diff --git a/.gitignore b/.gitignore index 357fded9..215c618b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,11 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index e328ce51..508e28d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,18 +108,18 @@ movement queries. ## Current state -**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. +**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. 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 diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 80581f8a..1d365fcb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,15 +46,409 @@ 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 ~1–2 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). 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). +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 14–30, 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 14–30 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the +#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`). **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 @@ -887,7 +1281,19 @@ 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:** OPEN — **explains user-observed "dungeons are broken"** +**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 **1–17 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" **Severity:** HIGH (blocks all dungeon navigation visually) **Filed:** 2026-05-21 **Component:** rendering, visibility, EnvCell portal traversal diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b7a710c0..903a682b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 14 rows +## 1. Intentional architecture (IA) — 17 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -55,15 +55,18 @@ 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) — 27 rows +## 2. Adaptation (AD) — 28 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: 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-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-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) | @@ -89,10 +92,12 @@ 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) — 34 rows +## 3. Documented approximation (AP) — 42 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -111,7 +116,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 | 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-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-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 | @@ -130,10 +135,21 @@ 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) — 30 rows +## 4. Temporary stopgap (TS) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -166,7 +182,9 @@ 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 | 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 | +| 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) | --- @@ -183,6 +201,7 @@ 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 | --- @@ -213,8 +232,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). D.2b lands TS-30; -the audio phase lands TS-9/TS-29; the animation-hook layer lands +Membership Stage 2 must land TS-18 (BuildingCellId). +The audio phase lands TS-9/TS-29; the animation-hook layer lands TS-10/TS-11/TS-12/TS-13/TS-14. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 79c7f4e5..fffb102c 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -424,10 +424,19 @@ behavior. Estimated 17–26 days focused work, 3–5 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. -- **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/`. +- **✓ 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-38–40 / TS-30–31; 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.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.)** -- **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.)** +- **✓ 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.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 1–9** (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. diff --git a/docs/research/2026-06-15-chat-window-redrive-handoff.md b/docs/research/2026-06-15-chat-window-redrive-handoff.md new file mode 100644 index 00000000..33d12e92 --- /dev/null +++ b/docs/research/2026-06-15-chat-window-redrive-handoff.md @@ -0,0 +1,135 @@ +# 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 [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`). diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md new file mode 100644 index 00000000..e3fb8b45 --- /dev/null +++ b/docs/research/2026-06-15-layoutdesc-format.md @@ -0,0 +1,491 @@ +# 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 2–6. 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` | named states (e.g. `HideDetail`, `ShowDetail`) | +| `Children` | **field** | `Dictionary` | 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` | keyed by property-id (uint); see §3 | +| `Media` | **field** | `List` | polymorphic list of media items | + +### States dictionary key type + +`ElementDesc.States` is `Dictionary`. 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`. 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` | 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` | | + +### 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 108459–108668. + +**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`. 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 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 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 | +| 0x13–0x19 | `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` (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 (0x13–0x19), all `gm*UI` custom types +- DrawModes: `Overlay` (2), any future additions +- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc. diff --git a/docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md b/docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md new file mode 100644 index 00000000..3834ba94 --- /dev/null +++ b/docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md @@ -0,0 +1,115 @@ +# 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 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. diff --git a/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md b/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md new file mode 100644 index 00000000..de3e30de --- /dev/null +++ b/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md @@ -0,0 +1,191 @@ +# 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, )` 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 197054–197560): + +| 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 **0–11**, **use** (arg3=1). (decomp 197576–197591) +- `0x1000004E..0x10000059` → `UseShortcut(this, msg-0x1000004E, 0)` → slots **0–11**, **select** (arg3=0). (decomp 197592–197606) +- `0x10000132..0x10000137` → `UseShortcut(this, 0xC..0x11, 1)` → slots **12–17**, **use**. (decomp 197616–197645) +- `0x10000138..0x1000013D` → `UseShortcut(this, 0xC..0x11, 0)` → slots **12–17**, **select**. (decomp 197646–197674) + +The slot count `18` is independently confirmed by the header struct `ShortCutManager::shortCuts_[18]` (`acclient.h` line 36492–36494: `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 198307–198310) 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 198179–198303). `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 196268–196300): +- 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 196412–196421) +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` 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 198879–198893). 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 197974–197976) +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 198031–198056) — 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 198007–198018) + - **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 198020–198027) + +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 196928–196930). Else (slot unspecified) it scans for a home (the loop at 196954+, with a "no empty slot" `DisplayStringInfo` notice when full, decomp 196945–196949). 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 196836–196848). Then `ItemList_Flush(slot); ItemList_AddItem(slot, objId); SetShortcutNum(weenie, slot)` (or `SetDelayedShortcutNum` if the weenie isn't loaded yet, decomp 196861–196867). If `send`, build `CShortCutData(slot, objId, 0)` → `Event_AddShortCut` (wire) + `PlayerModule::AddShortCut` (local model) (decomp 196873–196876). +- **`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 196471–196496) +- **`RemoveShortcutInSlotNum(slot, send)`** (decomp 196502): read the `UIItem` objId at `+0x5FC`, `RemoveShortcut(objId, send)`, return the evicted objId. (decomp 196519–196524) +- **`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 196539–196569) +- **`FlushShortcuts()`** (decomp 196442): `ItemList_Flush` every slot (visual clear; does NOT touch the server). Used by login restore. (decomp 196451–196457) + +## 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`. diff --git a/docs/research/2026-06-16-equipment-paperdoll-deep-dive.md b/docs/research/2026-06-16-equipment-paperdoll-deep-dive.md new file mode 100644 index 00000000..1d6f5a56 --- /dev/null +++ b/docs/research/2026-06-16-equipment-paperdoll-deep-dive.md @@ -0,0 +1,416 @@ +# 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 +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`** 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. diff --git a/docs/research/2026-06-16-inventory-deep-dive.md b/docs/research/2026-06-16-inventory-deep-dive.md new file mode 100644 index 00000000..614932d4 --- /dev/null +++ b/docs/research/2026-06-16-inventory-deep-dive.md @@ -0,0 +1,391 @@ +# 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 (176240–176259): 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 (176600–176629): 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 (176565–176573): 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 176576–176583: +`floor(arg2 * 300.0)` → `SetText(m_burdenText, "%d%%")`). So the bar is FULL +at 100% load and the number reads 0–300% (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:405–432`). [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 176269–176277)** +— 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, 980257–980562) 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:13–76` — +`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:10–13` 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:13–26` writes `Guid, count, {guid, + containerType}×n`; holtburger `events.rs:20` (+ fixture `events.rs:195`). +- `0x0023`: `GameEventWieldItem.cs:11–12` writes `objectId, (int)newLocation`. +- `0x019A`: `GameEventItemServerSaysMoveItem.cs:11` writes only `Guid`. +- `0x00A0`: `GameEventInventoryServerSaveFailed.cs` (error code present; + holtburger reads it). +- `SetStackSize`: `GameMessageSetStackSize.cs:12–15` (`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` 229271–229277, 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` 229738–229740 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` 229190–229208 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 `…54f–558`) 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. diff --git a/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md b/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md new file mode 100644 index 00000000..ba815855 --- /dev/null +++ b/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md @@ -0,0 +1,557 @@ +# 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. diff --git a/docs/research/2026-06-16-ui-panels-synthesis.md b/docs/research/2026-06-16-ui-panels-synthesis.md new file mode 100644 index 00000000..3dd77de1 --- /dev/null +++ b/docs/research/2026-06-16-ui-panels-synthesis.md @@ -0,0 +1,407 @@ +# 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). diff --git a/docs/research/2026-06-17-stateful-icon-RESOLVED.md b/docs/research/2026-06-17-stateful-icon-RESOLVED.md new file mode 100644 index 00000000..c1d1023e --- /dev/null +++ b/docs/research/2026-06-17-stateful-icon-RESOLVED.md @@ -0,0 +1,142 @@ +# 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 = ) + +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 0–12 + `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. diff --git a/docs/research/2026-06-17-stateful-icon-system-handoff.md b/docs/research/2026-06-17-stateful-icon-system-handoff.md new file mode 100644 index 00000000..e1c952d5 --- /dev/null +++ b/docs/research/2026-06-17-stateful-icon-system-handoff.md @@ -0,0 +1,127 @@ +# 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 1–4 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`; 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 1–4 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 1–4 + 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. diff --git a/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md b/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md new file mode 100644 index 00000000..7695a1b0 --- /dev/null +++ b/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md @@ -0,0 +1,239 @@ +# 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` (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` + (`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. diff --git a/docs/research/2026-06-18-item-object-model-handoff.md b/docs/research/2026-06-18-item-object-model-handoff.md new file mode 100644 index 00000000..63fd4ce6 --- /dev/null +++ b/docs/research/2026-06-18-item-object-model-handoff.md @@ -0,0 +1,120 @@ +# 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* (0x02CD–0x02DA)` | 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. diff --git a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md new file mode 100644 index 00000000..381860de --- /dev/null +++ b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md @@ -0,0 +1,140 @@ +# 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 = (1−d/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 1–2): 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 3–5 + (→ bake range ~3.9–6.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()` for floats, `dwo()` 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. diff --git a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md new file mode 100644 index 00000000..e221a636 --- /dev/null +++ b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md @@ -0,0 +1,152 @@ +# 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. diff --git a/docs/research/2026-06-20-indoor-lighting-regime-handoff.md b/docs/research/2026-06-20-indoor-lighting-regime-handoff.md new file mode 100644 index 00000000..6864f4a7 --- /dev/null +++ b/docs/research/2026-06-20-indoor-lighting-regime-handoff.md @@ -0,0 +1,188 @@ +# 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. diff --git a/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md new file mode 100644 index 00000000..4391fca3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md @@ -0,0 +1,633 @@ +# 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 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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; + +/// Verdict from the per-frame readiness probe for a held teleport arrival. +public enum ArrivalReadiness +{ + /// Destination not yet hydrated; keep holding. + NotReady, + + /// Destination terrain + cell are ready; place now. + Ready, + + /// 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. + Impossible, +} + +/// Lifecycle of a single teleport arrival. +public enum TeleportArrivalPhase { Idle, Holding } + +/// +/// 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 GameWindow.OnLivePositionUpdated that resolved the +/// arrival against the resident (old) landblocks before the destination hydrated +/// and landed the player in ocean. +/// +/// 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 +/// PlayerState.PortalSpace until the placement delegate flips it back to +/// InWorld. +/// +/// 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. +/// +public sealed class TeleportArrivalController +{ + /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. + public const int DefaultMaxHoldFrames = 600; + + private readonly Func _readiness; + private readonly Action _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 readiness, + Action place, + int maxHoldFrames = DefaultMaxHoldFrames) + { + _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); + _place = place ?? throw new ArgumentNullException(nameof(place)); + _maxHoldFrames = maxHoldFrames; + } + + /// 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). + public void BeginArrival(Vector3 destPos, uint destCell) + { + _destPos = destPos; + _destCell = destCell; + _heldFrames = 0; + Phase = TeleportArrivalPhase.Holding; + } + + /// Per-frame: evaluate readiness and place when ready / impossible / timed out. + /// No-op when Idle. + 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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` + `Action` 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). diff --git a/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md new file mode 100644 index 00000000..5fff7b20 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md @@ -0,0 +1,1322 @@ +# D.2b Retail Panel Frame + Live Vitals — 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:** Render a retail-shaped Vitals window (8-piece dat-sprite frame + live HP/Stam/Mana bars) by wiring the dormant `AcDream.App/UI` retained-mode toolkit and adding a markup/stylesheet/sprite layer, gated behind `ACDREAM_RETAIL_UI=1`. + +**Architecture:** The retail UI is the **existing `UiRoot`/`UiElement` tree** driven by `UiHost` (dormant today) — a separate system from the ImGui devtools path. Spec 1 wires `UiHost` into `GameWindow`, extends the shared `TextRenderer` with a textured-sprite path, adds `UiNineSlicePanel` (chrome) + `UiMeter` (bar) widgets, a `MarkupDocument` that instantiates a `UiElement` subtree from XML, and a `controls.ini` stylesheet loader. Render-only (input integration deferred). Spec: [`docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`](../specs/2026-06-14-d2b-retail-panel-frame-design.md). + +**Tech Stack:** C# / .NET 10, Silk.NET OpenGL, xUnit 2.9.3. Dat assets via the existing `TextureCache` + `SurfaceDecoder`. + +--- + +## File Structure + +**New files:** +- `src/AcDream.App/UI/UiNineSlicePanel.cs` — `UiPanel` subclass drawing the 8-piece dat-sprite frame + center fill. +- `src/AcDream.App/UI/UiMeter.cs` — `UiElement` vital bar (bg + partial fill). +- `src/AcDream.App/UI/RetailChromeSprites.cs` — confirmed chrome sprite DataIDs + sizes + insets (filled by Step 0). +- `src/AcDream.App/UI/ControlsIni.cs` — flat INI stylesheet parser (`#AARRGGBB`, `font://`). +- `src/AcDream.App/UI/MarkupDocument.cs` — XML → `UiElement` subtree builder + `{Binding}` resolution. +- `src/AcDream.App/UI/assets/vitals.xml` — the first-party vitals markup (copied to output). +- `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` — plugin-facing UI registration surface. +- `src/AcDream.App/Plugins/BufferedUiRegistry.cs` — buffers `AddMarkupPanel` until `UiHost` exists. +- `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`, `MarkupDocumentTests.cs`, `UiMeterTests.cs`, `UiNineSlicePanelTests.cs` +- `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` +- `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` + +**Modified files:** +- `src/AcDream.App/RuntimeOptions.cs` — add `RetailUi`, `AcDir`. +- `src/AcDream.App/Rendering/Shaders/ui_text.frag` — add `uUseTexture==2` RGBA branch. +- `src/AcDream.App/Rendering/TextRenderer.cs` — add `DrawSprite` + per-texture batch + `DepthMask`. +- `src/AcDream.App/Rendering/TextureCache.cs` — add `GetOrUpload(id, out w, out h)` size overload. +- `src/AcDream.App/UI/UiRenderContext.cs` — add `DrawSprite` forwarder. +- `src/AcDream.App/Rendering/GameWindow.cs` — wire `UiHost` + vitals subtree (render-only). +- `src/AcDream.Plugin.Abstractions/IPluginHost.cs` + `src/AcDream.App/Plugins/AppPluginHost.cs` — add `Ui`. +- `src/AcDream.App/Program.cs` — construct `BufferedUiRegistry`, pass to host + window. +- `docs/architecture/retail-divergence-register.md` — delete TS-30, add IA row (in the chrome commit). + +--- + +## Task 1: RuntimeOptions — add RetailUi + AcDir toggles + +**Files:** +- Modify: `src/AcDream.App/RuntimeOptions.cs` +- Test: `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.App; + +namespace AcDream.App.Tests; + +public class RuntimeOptionsRetailUiTests +{ + [Fact] + public void Parse_ReadsRetailUiAndAcDir() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI"] = "1", + ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUi); + Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); + } + + [Fact] + public void Parse_DefaultsRetailUiOffAndAcDirNull() + { + var opts = RuntimeOptions.Parse("dats", _ => null); + Assert.False(opts.RetailUi); + Assert.Null(opts.AcDir); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` +Expected: FAIL to **compile** — `RetailUi` / `AcDir` are not members of `RuntimeOptions`. + +- [ ] **Step 3: Add the fields** + +In `src/AcDream.App/RuntimeOptions.cs`, add two parameters at the **end** of the record (line 42, after `int? LegacyStreamRadius`): + +```csharp + int? LegacyStreamRadius, + bool RetailUi, + string? AcDir) +``` + +And in `Parse` (after the `LegacyStreamRadius:` line, before the closing `);`): + +```csharp + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), + RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); +``` + +- [ ] **Step 4: Fix any positional construction sites** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +If any `new RuntimeOptions(...)` positional call site fails to compile (missing 2 args), append `, RetailUi: false, AcDir: null` to it. (`Program.cs` uses `FromEnvironment`→`Parse` with named args and is unaffected.) + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/RuntimeOptions.cs tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +git commit -m "feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles" +``` + +--- + +## Task 2: Dat-sprite render capability + +GL code — verified by build + the Step-3 visual, not unit tests. + +**Files:** +- Modify: `src/AcDream.App/Rendering/Shaders/ui_text.frag` +- Modify: `src/AcDream.App/Rendering/TextRenderer.cs` +- Modify: `src/AcDream.App/Rendering/TextureCache.cs` +- Modify: `src/AcDream.App/UI/UiRenderContext.cs` + +- [ ] **Step 1: Add the RGBA branch to the fragment shader** + +In `src/AcDream.App/Rendering/Shaders/ui_text.frag`, replace the `main()` body's branch: + +```glsl +void main() { + if (uUseTexture == 1) { + // 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; + } + if (FragColor.a < 0.005) discard; +} +``` + +- [ ] **Step 2: Add a size-returning overload to TextureCache** + +In `src/AcDream.App/Rendering/TextureCache.cs`, add a size cache field next to `_handlesBySurfaceId` (top-of-class field region): + +```csharp + private readonly Dictionary _sizeBySurfaceId = new(); +``` + +And add this method directly after `GetOrUpload(uint surfaceId)` (after line 81): + +```csharp + /// + /// Like but also returns the decoded + /// pixel dimensions. UI 9-slice geometry needs the source size to + /// compute slice UVs. Cached alongside the handle. + /// + 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; + } +``` + +- [ ] **Step 3: Add the textured-sprite path to TextRenderer** + +In `src/AcDream.App/Rendering/TextRenderer.cs`, add a per-texture sprite buffer field (next to `_textBuf`/`_rectBuf`, ~line 31): + +```csharp + private readonly Dictionary> _spriteBufs = new(); +``` + +Clear it in `Begin` (inside the existing `Begin`, after `_rectBuf.Clear();`): + +```csharp + foreach (var b in _spriteBufs.Values) b.Clear(); +``` + +Add the public draw method (after `DrawString`, ~line 130): + +```csharp + /// + /// 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). + /// + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + { + if (!_spriteBufs.TryGetValue(texture, out var buf)) + { + buf = new List(256); + _spriteBufs[texture] = buf; + } + AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); + } +``` + +In `Flush`, (a) change the early-out so sprites alone still draw, (b) set `DepthMask(false)` + restore, (c) draw the sprite batches. Replace the existing `Flush` body's guard and state block down through the text draw: + +Replace: +```csharp + if (_textVerts == 0 && _rectVerts == 0) return; +``` +with: +```csharp + bool hasSprites = false; + foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } + if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; +``` + +Replace the state-save block: +```csharp + bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); + bool wasBlend = _gl.IsEnabled(EnableCap.Blend); + bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` +with (adds DepthMask off; restored to true below): +```csharp + bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); + bool wasBlend = _gl.IsEnabled(EnableCap.Blend); + bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.DepthMask(false); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` + +Add the sprite-draw block immediately **after** the text-glyph block (after the `if (_textVerts > 0 && font is not null) { ... }` block, before "Restore GL state"): + +```csharp + // RGBA dat sprites — one draw call per distinct GL texture. + if (hasSprites) + { + _shader.SetInt("uUseTexture", 2); + _gl.ActiveTexture(TextureUnit.Texture0); + _shader.SetInt("uTex", 0); + foreach (var kv in _spriteBufs) + { + if (kv.Value.Count == 0) continue; + _gl.BindTexture(TextureTarget.Texture2D, kv.Key); + UploadBuffer(kv.Value); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); + } + } +``` + +Add DepthMask restore in the "Restore GL state" block (after the existing three restores). Restore to `true` — the next frame's depth *clear* requires depth writes enabled, so `true` is the correct (and only safe) post-UI value: +```csharp + _gl.DepthMask(true); +``` + +- [ ] **Step 4: Add the DrawSprite forwarder to UiRenderContext** + +In `src/AcDream.App/UI/UiRenderContext.cs`, after the `DrawRectOutline` forwarder (line 54): + +```csharp + 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, tint); +``` + +- [ ] **Step 5: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/Shaders/ui_text.frag src/AcDream.App/Rendering/TextRenderer.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/UI/UiRenderContext.cs +git commit -m "feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite" +``` + +--- + +## Task 3: Step-0 chrome sprite prove-out (HUMAN-IN-THE-LOOP) + +Resolves the unverified chrome sprite IDs empirically (spec §6). Requires the user to run the client and eyeball candidates. + +**Files:** +- Create: `src/AcDream.App/UI/RetailChromeSprites.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (temporary prove-out block) + +- [ ] **Step 1: Create the constants file (empty placeholders to be filled by the run)** + +Create `src/AcDream.App/UI/RetailChromeSprites.cs`: + +```csharp +namespace AcDream.App.UI; + +/// +/// Confirmed retail window-chrome RenderSurface DataIDs + decoded sizes + +/// 9-slice insets. Values are filled by the Step-0 prove-out run (see +/// docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md, Task 3) +/// — do NOT trust pre-run values. Candidates dumped by the prove-out harness. +/// +public static class RetailChromeSprites +{ + // Candidate IDs to try in the Step-0 prove-out. Edit this list as needed. + public static readonly uint[] Candidates = + { + 0x06004CC2, 0x060074BF, 0x060074C0, 0x060074C1, 0x060074C2, + 0x060074C3, 0x060074C4, 0x060074C5, 0x060074C6, 0x0600129C, + }; + + // === FILLED BY STEP 0 (placeholder = magenta until confirmed) === + /// The single 9-sliceable frame sprite (or the body/center fill). + public static uint FrameSurfaceId = 0; // TODO Step 0: set to confirmed id + /// Corner inset in pixels (left/top/right/bottom assumed equal until LayoutDesc parse). + public static int Inset = 6; // TODO Step 0: tune to the real bevel +} +``` + +- [ ] **Step 2: Add a temporary prove-out block to OnRender** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in `OnRender` after the 3D passes (just before the ImGui block at ~line 8158), add: + +```csharp + // Step-0 prove-out (D.2b Task 3): draw candidate chrome sprites in a + // labelled row so we can eyeball which decode to frame art. Gated by + // ACDREAM_RETAIL_UI_PROVEOUT=1. TEMPORARY — delete after Step 0. + if (System.Environment.GetEnvironmentVariable("ACDREAM_RETAIL_UI_PROVEOUT") == "1" + && _textureCache is not null && _textRenderer is not null) + { + _textRenderer.Begin(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + float px = 20f; + foreach (var id in AcDream.App.UI.RetailChromeSprites.Candidates) + { + uint tex = _textureCache.GetOrUpload(id, out int tw, out int th); + _textRenderer.DrawSprite(tex, px, 60f, 96f, 96f, 0, 0, 1, 1, + System.Numerics.Vector4.One); + if (_debugFont is not null) + _textRenderer.DrawString(_debugFont, $"0x{id:X8}\n{tw}x{th}", px, 160f, + System.Numerics.Vector4.One); + px += 110f; + } + _textRenderer.Flush(_debugFont); + } +``` + +- [ ] **Step 3: Build + run the prove-out (manual)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Then launch with the prove-out flag (PowerShell): + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_RETAIL_UI_PROVEOUT = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath proveout.log +``` + +**Manual:** the user reports which candidate IDs render as frame/border art (vs magenta vs unrelated sprites) and their printed sizes. If the frame is a single 9-sliceable sprite, note that ID + size. If it's separate corner/edge sprites, note each. Tune `Candidates` and re-run if none match (widen the `0x0600xxxx` range near `0x060074xx`). + +- [ ] **Step 4: Record the confirmed values** + +Edit `RetailChromeSprites.cs`: set `FrameSurfaceId` to the confirmed id and `Inset` to the eyeballed bevel thickness. Add a comment with the decoded `WxH` and the date. + +- [ ] **Step 5: Remove the temporary prove-out block** + +Delete the `ACDREAM_RETAIL_UI_PROVEOUT` block from `GameWindow.cs` (it was scaffolding). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/RetailChromeSprites.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): Step-0 chrome sprite prove-out + confirmed RetailChromeSprites ids" +``` + +--- + +## Task 4: UiNineSlicePanel + +**Files:** +- Create: `src/AcDream.App/UI/UiNineSlicePanel.cs` +- Test: `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs` + +- [ ] **Step 1: Write the failing geometry test** + +Create `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiNineSlicePanelTests +{ + [Fact] + public void ComputeSliceRects_ProducesNinePatchesCoveringTheFrame() + { + // 100x80 frame, 32x32 source texture, 8px inset. + var rects = UiNineSlicePanel.ComputeSliceRects( + frameW: 100, frameH: 80, texW: 32, texH: 32, inset: 8); + + Assert.Equal(9, rects.Length); + + // Top-left corner: dst (0,0,8,8); src uv (0,0)-(8/32, 8/32). + var tl = rects[0]; + Assert.Equal(0f, tl.dstX); Assert.Equal(0f, tl.dstY); + Assert.Equal(8f, tl.dstW); Assert.Equal(8f, tl.dstH); + Assert.Equal(0f, tl.u0); Assert.Equal(0f, tl.v0); + Assert.Equal(8f / 32f, tl.u1, 5); Assert.Equal(8f / 32f, tl.v1, 5); + + // Center: dst (8,8, 100-16, 80-16); src uv inset..(tex-inset). + var center = rects[4]; + Assert.Equal(8f, center.dstX); Assert.Equal(8f, center.dstY); + Assert.Equal(84f, center.dstW); Assert.Equal(64f, center.dstH); + Assert.Equal(8f / 32f, center.u0, 5); + Assert.Equal(24f / 32f, center.u1, 5); + + // Bottom-right corner dst origin at (100-8, 80-8). + var br = rects[8]; + Assert.Equal(92f, br.dstX); Assert.Equal(72f, br.dstY); + Assert.Equal(8f, br.dstW); Assert.Equal(8f, br.dstH); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` +Expected: FAIL to compile — `UiNineSlicePanel` does not exist. + +- [ ] **Step 3: Implement UiNineSlicePanel** + +Create `src/AcDream.App/UI/UiNineSlicePanel.cs`: + +```csharp +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A whose background is a 9-sliced dat RenderSurface: +/// 4 fixed corners, 4 stretched edges, 1 stretched center. Retires the flat +/// translucent rect (divergence row TS-30). Insets come from +/// until the LayoutDesc importer supplies +/// per-panel metrics. +/// +public sealed class UiNineSlicePanel : UiPanel +{ + /// One slice patch: destination rect (local px) + source UVs (0..1). + public readonly record struct Slice( + float dstX, float dstY, float dstW, float dstH, + float u0, float v0, float u1, float v1); + + private readonly System.Func _resolve; + private readonly uint _surfaceId; + private readonly int _inset; + + /// Surface id → (GL handle, decoded width, height). + /// In production: id => { var t = cache.GetOrUpload(id, out var w, out var h); return (t, w, h); }. + public UiNineSlicePanel(System.Func resolve, + uint surfaceId, int inset) + { + _resolve = resolve; + _surfaceId = surfaceId; + _inset = inset; + BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill + BorderColor = Vector4.Zero; + } + + /// + /// Compute the 9 patches for a frame of x + /// from a x + /// source with a uniform . + /// Order: TL, TC, TR, ML, MC, MR, BL, BC, BR (index 4 = center). + /// + public static Slice[] ComputeSliceRects( + float frameW, float frameH, int texW, int texH, int inset) + { + float i = inset; + // destination column/row edges + float[] dx = { 0, i, frameW - i, frameW }; + float[] dy = { 0, i, frameH - i, frameH }; + // source UV column/row edges (0..1) + float[] ux = { 0, i / texW, (texW - i) / texW, 1f }; + float[] uy = { 0, i / texH, (texH - i) / texH, 1f }; + + var slices = new Slice[9]; + int n = 0; + for (int row = 0; row < 3; row++) + for (int col = 0; col < 3; col++) + slices[n++] = new Slice( + dx[col], dy[row], dx[col + 1] - dx[col], dy[row + 1] - dy[row], + ux[col], uy[row], ux[col + 1], uy[row + 1]); + return slices; + } + + protected override void OnDraw(UiRenderContext ctx) + { + var (tex, tw, th) = _resolve(_surfaceId); + if (tex == 0 || tw == 0 || th == 0) return; + foreach (var s in ComputeSliceRects(Width, Height, tw, th, _inset)) + ctx.DrawSprite(tex, s.dstX, s.dstY, s.dstW, s.dstH, + s.u0, s.v0, s.u1, s.v1, Vector4.One); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiNineSlicePanel.cs tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs +git commit -m "feat(D.2b): UiNineSlicePanel (9-slice dat chrome) + geometry tests" +``` + +--- + +## Task 5: UiMeter + +**Files:** +- Create: `src/AcDream.App/UI/UiMeter.cs` +- Test: `tests/AcDream.App.Tests/UI/UiMeterTests.cs` + +- [ ] **Step 1: Write the failing fill-geometry test** + +Create `tests/AcDream.App.Tests/UI/UiMeterTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiMeterTests +{ + [Fact] + public void ComputeFillRect_HalfFillIsHalfWidth() + { + var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); + Assert.Equal(0f, x); Assert.Equal(0f, y); + Assert.Equal(100f, w); Assert.Equal(12f, h); + } + + [Theory] + [InlineData(-1f, 0f)] // clamps below 0 + [InlineData(2f, 200f)] // clamps above 1 + [InlineData(0f, 0f)] + [InlineData(1f, 200f)] + public void ComputeFillRect_ClampsFraction(float pct, float expectedW) + { + var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); + Assert.Equal(expectedW, w); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` +Expected: FAIL to compile — `UiMeter` does not exist. + +- [ ] **Step 3: Implement UiMeter** + +Create `src/AcDream.App/UI/UiMeter.cs`: + +```csharp +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A horizontal vital bar: an empty background rect with a partial-width +/// fill. returns 0..1 (or null = no data → empty bar). +/// Solid-color for Spec 1; the retail orb sprite + scissor crop is a later +/// sub-phase. +/// +public sealed class UiMeter : UiElement +{ + /// Fill fraction provider; null result draws an empty bar. + public System.Func Fill { get; set; } = () => 0f; + public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); + public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + + public UiMeter() { ClickThrough = true; } + + /// Clamp to [0,1] and return the fill + /// rect (local px) for a bar of x . + 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) + { + ctx.DrawRect(0, 0, Width, Height, BgColor); + float? pct = Fill(); + if (pct is float p) + { + var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); + if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterTests.cs +git commit -m "feat(D.2b): UiMeter vital bar + fill-geometry tests" +``` + +--- + +## Task 6: Wire UiHost + hand-built vitals subtree (render-only) + retire TS-30 + +Visual-acceptance task. First on-screen retail panel. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` + +- [ ] **Step 1: Add the UiHost field** + +In `GameWindow.cs`, next to `_vitalsVm` (~line 614): + +```csharp + // Phase D.2b — retail-look UI tree. Null unless ACDREAM_RETAIL_UI=1. + private AcDream.App.UI.UiHost? _uiHost; +``` + +- [ ] **Step 2: Construct UiHost + the vitals subtree in OnLoad** + +In `GameWindow.cs` OnLoad, **after** `_textureCache` is constructed (after line 1724) and after `_vitalsVm` is available, add. Note: `_vitalsVm` is built today only inside the DevTools block (line 1330). Hoist its construction so it exists for the retail path too — change line 1330's block so the VM is created when `DevToolsEnabled || _options.RetailUi`. Concretely, ensure this runs regardless of DevTools: + +```csharp + _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); +``` + +**Also ungate the GUID setter:** the `_vitalsVm.SetLocalPlayerGuid(...)` call at EnterWorld (~line 1984) must run whenever `_vitalsVm` is non-null — not only under DevTools — or retail-only mode reads HP=1.0 forever. Change any `if (DevToolsEnabled)` guard around that call to `if (_vitalsVm is not null)` (use the null-conditional `_vitalsVm?.SetLocalPlayerGuid(guid);` if simpler). Verify the exact guard at the call site before editing. + +Then add the retail wiring (after `_textureCache` exists): + +```csharp + if (_options.RetailUi) + { + string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); + _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + + var cache = _textureCache!; + (uint, int, int) Resolve(uint id) + { + uint t = cache.GetOrUpload(id, out int w, out int h); + return (t, w, h); + } + + var panel = new AcDream.App.UI.UiNineSlicePanel( + Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset) + { Left = 10, Top = 30, Width = 220, Height = 96 }; + + var title = new AcDream.App.UI.UiLabel + { Text = "Vitals", Left = 8, Top = 4, + TextColor = new System.Numerics.Vector4(1, 1, 1, 1) }; + panel.AddChild(title); + + var vm = _vitalsVm!; + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 24, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(1f, 0f, 0f, 1f), + Fill = () => vm.HealthPercent }); + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 44, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(0.063f, 0.94f, 0.94f, 1f), + Fill = () => vm.StaminaPercent }); + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 64, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(0f, 0f, 1f, 1f), + Fill = () => vm.ManaPercent }); + + _uiHost.Root.AddChild(panel); + } +``` + +(`UiLabel` draws via the stb `BitmapFont` `_debugFont`; if `_debugFont` is null the title simply doesn't draw — acceptable for Spec 1.) + +- [ ] **Step 3: Draw the retail UI each frame** + +In `GameWindow.cs` OnRender, after the 3D passes and near the ImGui block (~line 8233, after `_imguiBootstrap` block or before it — order is deterministic either way; place it just before the ImGui `if` at line 8158 so ImGui composites on top in dev): + +```csharp + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + if (_options.RetailUi && _uiHost is not null) + { + _uiHost.Tick(deltaSeconds); + _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } +``` + +- [ ] **Step 4: Dispose UiHost on shutdown** + +In `GameWindow.cs`'s dispose/shutdown path (near where `_textRenderer`/`_debugFont` are disposed, ~line 12043): + +```csharp + _uiHost?.Dispose(); +``` + +- [ ] **Step 5: Build + visual verify (manual)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Launch with `ACDREAM_RETAIL_UI=1` (+ the live-connection env from CLAUDE.md). **User confirms:** the Vitals window renders with the dat-sprite frame + three bars that track HP/Stam/Mana as the character takes damage/regens. Also launch with `ACDREAM_DEVTOOLS=1` (retail off) and confirm the ImGui panels are unchanged. + +- [ ] **Step 6: Retire TS-30 + add the IA row** + +In `docs/architecture/retail-divergence-register.md`: delete the **TS-30** row (line ~166). Add one new **IA** row (next sequential IA number) for the markup/serialization layer: + +``` +| IA-NN | D.2b retail UI is our own UiRoot tree + XML markup + controls.ini stylesheet, not a byte-port of keystone.dll's LayoutDesc binary tree (keystone.dll has no PDB/decomp) | src/AcDream.App/UI/UiNineSlicePanel.cs + MarkupDocument.cs | keystone.dll is outside decomp coverage — a byte-port is impossible by definition; we mirror retail's LayoutDesc/ElementDesc field model + controls.ini token vocabulary | Layout semantics the research under-specifies (anchor resolution at non-800x600, controls.ini cascade corners) differ silently with no oracle | LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; keystone.dll layout evaluation (no PDB) | +``` + +(Replace `IA-NN` with the actual next number; verify against the register head — there were 14 IA rows at the 2026-06-12 count, so likely `IA-15`.) + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(D.2b): wire UiHost + live Vitals panel (render-only); retire TS-30, add IA row" +``` + +--- + +## Task 7: controls.ini stylesheet loader + +**Files:** +- Create: `src/AcDream.App/UI/ControlsIni.cs` +- Test: `tests/AcDream.App.Tests/UI/ControlsIniTests.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (apply title color/font tokens) + +- [ ] **Step 1: Write the failing parser tests** + +Create `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`: + +```csharp +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class ControlsIniTests +{ + [Fact] + public void Parse_ReadsSectionTokens() + { + var ini = ControlsIni.Parse( + "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); + + Assert.Equal("19", ini.Get("title", "height")); + Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); + Assert.Null(ini.Get("title", "missing")); + Assert.Null(ini.Get("nosuch", "height")); + } + + [Fact] + public void TryColor_ParsesAlphaFirstHex() + { + var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); + Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); + Assert.Equal(0xFF / 255f, c.W, 5); // alpha + Assert.Equal(0x4F / 255f, c.X, 5); // red + Assert.Equal(0x65 / 255f, c.Y, 5); // green + Assert.Equal(0x7D / 255f, c.Z, 5); // blue + } + + [Fact] + public void Parse_MissingFileReturnsEmptyNotThrow() + { + var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); + Assert.Null(ini.Get("title", "height")); // empty, no throw + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` +Expected: FAIL to compile — `ControlsIni` does not exist. + +- [ ] **Step 3: Implement ControlsIni** + +Create `src/AcDream.App/UI/ControlsIni.cs`: + +```csharp +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Minimal reader for retail's controls.ini — a flat INI with one +/// [section] per element type. Colors are #AARRGGBB (alpha +/// first). Optional: a missing file yields an empty sheet (callers fall +/// back to hardcoded defaults). See spec §7. +/// +public sealed class ControlsIni +{ + private readonly Dictionary> _sections; + + private ControlsIni(Dictionary> s) => _sections = s; + + /// Load from disk; returns an empty sheet if the file is absent. + 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>(System.StringComparer.OrdinalIgnoreCase); + Dictionary? 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(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; + + /// Parse a #AARRGGBB token into an RGBA . + 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; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Apply the stylesheet to the title label** + +In `GameWindow.cs`'s retail wiring (Task 6 Step 2), before building `title`, load the sheet and use the `[title]` color with a fallback: + +```csharp + string? acDir = _options.AcDir; + var controls = acDir is not null + ? AcDream.App.UI.ControlsIni.Load(Path.Combine(acDir, "controls", "controls.ini")) + : AcDream.App.UI.ControlsIni.Parse(string.Empty); + var titleColor = controls.TryColor("title", "color", out var tc) + ? tc : new System.Numerics.Vector4(1, 1, 1, 1); +``` + +Then set `TextColor = titleColor` on the `title` label. + +- [ ] **Step 6: Build + commit** + +```bash +dotnet build src/AcDream.App/AcDream.App.csproj +git add src/AcDream.App/UI/ControlsIni.cs tests/AcDream.App.Tests/UI/ControlsIniTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): controls.ini stylesheet loader (optional) + apply title color" +``` + +--- + +## Task 8: MarkupDocument — XML → UiElement subtree + +**Files:** +- Create: `src/AcDream.App/UI/MarkupDocument.cs` +- Create: `src/AcDream.App/UI/assets/vitals.xml` +- Test: `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs` +- Modify: `src/AcDream.App/AcDream.App.csproj` (copy `UI/assets/*.xml` to output) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (build the subtree from markup) + +- [ ] **Step 1: Write the failing parser test** + +Create `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class MarkupDocumentTests +{ + private sealed class FakeBinding + { + public float HealthPercent => 0.5f; + public float? ManaPercent => null; + } + + [Fact] + public void Build_CreatesPanelWithMeterChildrenAndGeometry() + { + const string xml = + "" + + " " + + ""; + + var resolve = (uint id) => ((uint)1, 32, 32); + var panel = MarkupDocument.Build(xml, new FakeBinding(), resolve, + frameSurfaceId: 0x06000000, inset: 8); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + + // One UiMeter child whose fill resolves to the binding's 0.5. + Assert.Single(panel.Children); + var meter = Assert.IsType(panel.Children[0]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + } + + [Fact] + public void Build_NullBindingPropertyYieldsNullFill() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), + id => ((uint)1, 32, 32), 0x06000000, 8); + var meter = Assert.IsType(panel.Children[0]); + Assert.Null(meter.Fill()); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` +Expected: FAIL to compile — `MarkupDocument` does not exist. + +- [ ] **Step 3: Implement MarkupDocument** + +Create `src/AcDream.App/UI/MarkupDocument.cs`: + +```csharp +using System; +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} attribute +/// values resolve against a supplied object by property name (reflection). +/// This is the format the future LayoutDesc importer will emit. See spec §7. +/// +public static class MarkupDocument +{ + /// Surface id → (GL handle, width, height). + public static UiNineSlicePanel Build( + string xml, object binding, Func resolve, + uint frameSurfaceId, int inset) + { + var root = XDocument.Parse(xml).Root + ?? throw new FormatException("empty markup"); + if (root.Name.LocalName != "panel") + throw new FormatException($"root must be , got <{root.Name.LocalName}>"); + + var panel = new UiNineSlicePanel(resolve, frameSurfaceId, inset) + { + Left = F(root, "x"), Top = F(root, "y"), + Width = F(root, "w"), Height = F(root, "h"), + }; + + string? title = (string?)root.Attribute("title"); + if (!string.IsNullOrEmpty(title)) + panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4 }); + + foreach (var el in root.Elements()) + { + switch (el.Name.LocalName) + { + case "meter": + 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), + }); + break; + // future: case "label", "button", "image" ... + } + } + 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; + + 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; + } + + /// Resolve "{Prop}" to a live getter against the binding; "" → constant 0. + private static Func BindFloat(string? expr, object binding) + { + if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') + return () => 0f; + string prop = expr[1..^1]; + var pi = binding.GetType().GetProperty(prop); + if (pi is null) return () => null; + return () => + { + object? v = pi.GetValue(binding); + return v switch + { + float f => f, + null => (float?)null, + _ => Convert.ToSingle(v, CultureInfo.InvariantCulture), + }; + }; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the vitals markup asset + copy-to-output** + +Create `src/AcDream.App/UI/assets/vitals.xml`: + +```xml + + + + + +``` + +In `src/AcDream.App/AcDream.App.csproj`, add an `ItemGroup` to copy UI assets to output: + +```xml + + + +``` + +- [ ] **Step 6: Replace the hand-built subtree with the markup build** + +In `GameWindow.cs`'s retail wiring (Task 6 Step 2), replace the hand-built `panel`/`title`/`UiMeter` block with: + +```csharp + string vitalsXmlPath = Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"); + var panel = AcDream.App.UI.MarkupDocument.Build( + System.IO.File.ReadAllText(vitalsXmlPath), + _vitalsVm!, Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset); + _uiHost.Root.AddChild(panel); +``` + +(The `controls.ini` title color from Task 7 can be applied by setting the title-`UiLabel`'s color after the build, or deferred — the markup path owns the title now.) + +- [ ] **Step 7: Build + visual verify + commit** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Launch with `ACDREAM_RETAIL_UI=1`; **user confirms** the markup-built panel renders identically to the hand-built one (frame + 3 live bars). + +```bash +git add src/AcDream.App/UI/MarkupDocument.cs src/AcDream.App/UI/assets/vitals.xml src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): MarkupDocument (XML -> UiElement tree) + vitals.xml; build panel from markup" +``` + +--- + +## Task 9: Plugin UI registry (capstone — designed-now, first consumer first-party) + +**Files:** +- Create: `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` +- Modify: `src/AcDream.Plugin.Abstractions/IPluginHost.cs` +- Create: `src/AcDream.App/Plugins/BufferedUiRegistry.cs` +- Modify: `src/AcDream.App/Plugins/AppPluginHost.cs`, `src/AcDream.App/Program.cs`, `src/AcDream.App/Rendering/GameWindow.cs` +- Test: `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` + +- [ ] **Step 1: Define the registry interface** + +Create `src/AcDream.Plugin.Abstractions/IUiRegistry.cs`: + +```csharp +namespace AcDream.Plugin.Abstractions; + +/// +/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +/// + a binding object exposing the data properties the markup binds to, and +/// registers it here from Enable(). Registrations made before the GL +/// window opens are buffered and drained once the UI host exists. +/// +public interface IUiRegistry +{ + /// Absolute path to the plugin's panel markup. + /// Object whose properties the markup's {Bindings} read. + void AddMarkupPanel(string markupPath, object binding); +} +``` + +- [ ] **Step 2: Add `Ui` to IPluginHost** + +In `src/AcDream.Plugin.Abstractions/IPluginHost.cs`: + +```csharp +public interface IPluginHost +{ + IPluginLogger Log { get; } + IGameState State { get; } + IEvents Events { get; } + IUiRegistry Ui { get; } +} +``` + +- [ ] **Step 3: Write the failing buffered-registry test** + +Create `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs`: + +```csharp +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public class BufferedUiRegistryTests +{ + [Fact] + public void Drain_YieldsBufferedRegistrationsOnce() + { + var reg = new BufferedUiRegistry(); + reg.AddMarkupPanel("a.xml", new object()); + reg.AddMarkupPanel("b.xml", new object()); + + var drained = reg.Drain(); + Assert.Equal(2, drained.Count); + Assert.Equal("a.xml", drained[0].MarkupPath); + + // Second drain is empty (consumed). + Assert.Empty(reg.Drain()); + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` +Expected: FAIL to compile — `BufferedUiRegistry` does not exist. + +- [ ] **Step 5: Implement BufferedUiRegistry** + +Create `src/AcDream.App/Plugins/BufferedUiRegistry.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Buffers plugin calls (which run in +/// Program.cs before the GL window opens) until GameWindow drains them into +/// the UiHost tree after construction. +/// +public sealed class BufferedUiRegistry : IUiRegistry +{ + public readonly record struct Pending(string MarkupPath, object Binding); + + private readonly List _pending = new(); + + public void AddMarkupPanel(string markupPath, object binding) + => _pending.Add(new Pending(markupPath, binding)); + + /// Return + clear all buffered registrations. + public IReadOnlyList Drain() + { + var copy = _pending.ToArray(); + _pending.Clear(); + return copy; + } +} +``` + +- [ ] **Step 6: Wire it through AppPluginHost + Program + GameWindow** + +`src/AcDream.App/Plugins/AppPluginHost.cs` — add the `Ui` member: + +```csharp + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) + { + Log = log; State = state; Events = events; Ui = ui; + } + + public IPluginLogger Log { get; } + public IGameState State { get; } + public IEvents Events { get; } + public IUiRegistry Ui { get; } +``` + +`src/AcDream.App/Program.cs` — construct the registry and pass it to host + window (replace lines 26 + 59): + +```csharp +var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); +``` +```csharp + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); +``` + +`GameWindow` — add a constructor parameter `AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null`, store it in a field, and in the retail wiring (after `_uiHost.Root.AddChild(panel)`), drain it: + +```csharp + if (_uiRegistry is not null) + { + foreach (var p in _uiRegistry.Drain()) + { + var pluginPanel = AcDream.App.UI.MarkupDocument.Build( + System.IO.File.ReadAllText(p.MarkupPath), p.Binding, Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset); + _uiHost.Root.AddChild(pluginPanel); + } + } +``` + +(Fix the `StubHost` in `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs:28` to implement the new `Ui` member — return a throwaway `BufferedUiRegistry` or a stub.) + +- [ ] **Step 7: Run tests + build** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` +Expected: PASS. Fix any compile breaks in plugin-host implementors surfaced by the new interface member. + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.Plugin.Abstractions/IUiRegistry.cs src/AcDream.Plugin.Abstractions/IPluginHost.cs src/AcDream.App/Plugins/BufferedUiRegistry.cs src/AcDream.App/Plugins/AppPluginHost.cs src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +git commit -m "feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost" +``` + +--- + +## Final verification + +- [ ] `dotnet build` green (whole solution: `dotnet build AcDream.slnx`). +- [ ] `dotnet test` green (all test projects). +- [ ] `ACDREAM_RETAIL_UI=1`: retail Vitals window (frame + 3 live bars) renders; bars track damage/regen. +- [ ] `ACDREAM_DEVTOOLS=1` (retail off): ImGui panels unchanged. +- [ ] TS-30 deleted; one new IA row present. +- [ ] Update the roadmap: mark D.2b Spec 1 (retail panel frame + vitals) shipped in [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md). diff --git a/docs/superpowers/plans/2026-06-15-chat-window-redrive.md b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md new file mode 100644 index 00000000..ab96b033 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md @@ -0,0 +1,1484 @@ +# Chat-window re-drive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font. + +**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`. + +**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`). + +**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +--- + +## File Structure + +**Create:** +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse` → `Publish(SendChatCmd)`). Pure, no GL. +- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL. +- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path). +- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`. +- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue). +- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`, + `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. + +**Modify:** +- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration. +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block. +- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`. +- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows. +- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed. + +--- + +## Task A: `ChatCommandRouter` (shared submit pipeline) + +Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection). + +**Files:** +- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` +- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs` +- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`: + +```csharp +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + // Minimal in-memory command bus capturing the last published SendChatCmd. + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} +``` + +> Verify the `ChatLog` / `ICommandBus` / `ChatVM` APIs used above match the real +> types before running (`ChatLog.OnSystemMessage(string, int)`, `ChatLog.Snapshot()`, +> `ChatLog.Clear()`, `ICommandBus.Publish`). Adjust the fixture if signatures differ. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: FAIL — `ChatCommandRouter` / `SubmitOutcome` do not exist. + +- [ ] **Step 3: Implement `ChatCommandRouter`** + +Create `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`. Move the +client-command + unknown-verb + parse + publish logic out of `ChatPanel` +(`ChatPanel.TryHandleClientCommand` + the submit block at `ChatPanel.cs:191-242`): + +```csharp +using System; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + // A '/' prefix is a command, never speech — unknown ones get local feedback + // instead of leaking to the server as chat. (@ verbs pass through to ACE.) + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; // e.g. "/t Name" with no message + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: PASS (5 tests). + +- [ ] **Step 5: Repoint `ChatPanel` at the router** + +In `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`, replace the submit body +(`ChatPanel.cs:194-241`, the `var trimmed = submitted.Trim();` block through +`_input = string.Empty;`) with a single call, and delete the now-dead +`TryHandleClientCommand` / `EqAny` / `BuildHelpText` helpers (they moved to the router): + +```csharp +if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) + && submitted is not null) +{ + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); + _input = string.Empty; + renderer.EndChild(); + renderer.End(); + return; +} +``` + +- [ ] **Step 6: Verify the full suite still passes** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests` +Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \ + src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \ + tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task B: `UiChatView` dat-font seam + 1-line wheel + +Make the transcript render in the dat font and scroll one line per wheel notch +(retail `HandleMouseWheel @0x471450`), keeping bottom-pin, drag-select, Ctrl+C. + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. `UiChatView.CharIndexAt` +is already a pure static taking a `Func` advance lookup — assert the +dat-font advance (`UiDatFont.GlyphAdvance`) drives caret hit-testing: + +```csharp +using AcDream.App.UI; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatViewDatFontTests +{ + // Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2). + private static FontCharDesc Glyph(char c) => new() + { + Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, + OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, + }; + + [Fact] + public void CharIndexAt_UsesDatGlyphAdvance() + { + // "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1). + float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); + Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + } + + [Fact] + public void GlyphAdvance_MatchesRetailFormula() + { + // HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10. + Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes-trivially** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if +`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the +real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The +first test should already pass since `CharIndexAt` is font-agnostic; this test pins +the dat-font advance as the lookup. + +- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`** + +In `src/AcDream.App/UI/UiChatView.cs`: + +1. Add a property next to `Font`: +```csharp +/// 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. +public UiDatFont? DatFont { get; set; } +``` +2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`): +```csharp +private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3) +``` +3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each + line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the + selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont` + branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so + `HitChar` uses the same advance source it drew with. +4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it: +```csharp +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); +``` +5. In the `Scroll` event, use the dat-font line height when present: +```csharp +float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS. + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs +git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C: `UiScrollable` (pixel-scroll coordinator) + +Port `UIElement_Scrollable`'s pixel-scroll math: a pure, GL-free coordinator the +transcript and scrollbar both read. No `UiElement` inheritance — it is held by +`UiChatView` and queried by `UiChatScrollbar`. + +**Files:** +- Create: `src/AcDream.App/UI/UiScrollable.cs` +- Test: `tests/AcDream.App.Tests/UI/UiScrollableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); // over max + Assert.Equal(200, s.ScrollY); // max = 300-100 + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); // content <= view => no scroll + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400 + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); // half of max(200) + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); // 0.5 * max(200) + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); // retail: negative = toward older/top + Assert.Equal(0, s.ScrollY); // already at top, clamped + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); // 50 + 2*16 + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); // 200 + view(100) + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: FAIL — `UiScrollable` does not exist. + +- [ ] **Step 3: Implement `UiScrollable`** + +Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable` +(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`, +`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`): + +```csharp +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) 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 (UiChatView) and the scrollbar (UiChatScrollbar). +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs +git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C2: Wire `UiScrollable` into `UiChatView` + +Replace `UiChatView`'s ad-hoc `_scroll` float with a `UiScrollable`, so the +transcript's content/view height + bottom-pin + line-scroll flow through the +shared model (and the scrollbar in Task D can read the same instance). + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` + +- [ ] **Step 1: Hold a `UiScrollable` + expose it** + +Add to `UiChatView`: +```csharp +/// The scroll model — also read by the linked UiChatScrollbar. +public UiScrollable Scroll { get; } = new(); +``` + +- [ ] **Step 2: Drive it from `OnDraw`** + +In `OnDraw`, after computing `lh`, `contentH`, `innerH`, set the model and read back +the offset instead of the local `_scroll`: +```csharp +Scroll.LineHeight = (int)MathF.Round(lh); +Scroll.ContentHeight = (int)MathF.Ceiling(contentH); +Scroll.ViewHeight = (int)MathF.Floor(innerH); +// Bottom-pin: if the user was at the end before content grew, stay pinned. +if (_pinBottom) Scroll.ScrollToEnd(); +float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content +``` +Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up +(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and +true again when they return to the end. + +> The existing `ClampScroll` static + `_scroll` field are superseded by +> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it +> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`. + +- [ ] **Step 3: Route the wheel through the model** + +In the `Scroll` event handler: +```csharp +case UiEventType.Scroll: +{ + // Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch. + Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); + _pinBottom = Scroll.AtEnd; + return true; +} +``` + +- [ ] **Step 4: Build + run the App tests** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView` +Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that +referenced the removed `_scroll`/`ClampScroll` to use `Scroll`. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs +git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task D: `UiChatScrollbar` (track + thumb + up/down) + +A `UiElement` that renders the right-side scrollbar and drives a `UiScrollable`. +Follows the `UiMeter` sprite pattern (`SpriteResolve` + `ctx.DrawSprite`). + +**Files:** +- Create: `src/AcDream.App/UI/UiChatScrollbar.cs` + +> **First, locate the scroll up/down button ids in the dat.** Run +> `dotnet run --project src/AcDream.Cli -- dump-vitals-layout "" 0x21000006` +> and inspect the children of track `0x10000012` (and the gold caps seen at the +> top/bottom of the scrollbar in the retail screenshot). Record the up-button and +> down-button element ids + their sprite ids in a comment. If the track has no +> button children, the up/down are part of the track sprite and clicks are handled +> by hit-region (top 16px = up, bottom 16px = down). + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChatScrollbar.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the +/// content/view ratio, and up/down step buttons. Drives a linked +/// . Ports retail UIElement_Scrollbar::UpdateLayout +/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from +/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// +public sealed class UiChatScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + public uint TrackSprite { get; set; } // 0x10000012 face + public uint ThumbSprite { get; set; } // 0x1000048c face + public uint UpSprite { get; set; } + public uint DownSprite { get; set; } + + private const float MinThumb = 8f; // retail attribute 0x89 floor + private const float ButtonH = 16f; // up/down button square + private bool _draggingThumb; + private float _dragOffsetY; + + public UiChatScrollbar() { CapturesPointerDrag = true; } + + /// Thumb rect in local space (between the two end buttons). + 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 fills the full height; buttons cap top/bottom; thumb floats between. + DrawSprite(ctx, resolve, TrackSprite, 0, 0, Width, Height); + DrawSprite(ctx, resolve, UpSprite, 0, 0, Width, ButtonH); + DrawSprite(ctx, resolve, DownSprite, 0, Height - ButtonH, Width, ButtonH); + if (m.HasOverflow) + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + DrawSprite(ctx, resolve, ThumbSprite, 0, ty, Width, th); + } + } + + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); + } + + public override bool OnEvent(in UiEvent e) + { + if (Model is not { } m) return false; + switch (e.Type) + { + case UiEventType.MouseDown: + { + float ly = e.Data2; // local Y (UiRoot delivers target-local) + if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // up button + if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // down button + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + if (ly >= ty && ly <= ty + th) { _draggingThumb = true; _dragOffsetY = ly - ty; } + else m.ScrollByPage(ly < ty ? -1 : 1); // click in track half = page + return true; + } + case UiEventType.MouseMove when _draggingThumb: + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = MathF.Max(1f, trackLen - h); + m.SetPositionRatio((e.Data2 - _dragOffsetY - trackTop) / travel); + return true; + } + case UiEventType.MouseUp: _draggingThumb = false; return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChatScrollbar.cs +git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task E: `UiChatInput` (editable one-line field) + +Port the `UIElement_Text` edit path: caret, insert/delete, 100-entry history, +focus sprite, dat-font draw, submit callback. Caret math reuses `UiDatFont`. + +**Files:** +- Create: `src/AcDream.App/UI/UiChatInput.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatInputTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`. The pure, testable seams are +text editing + history navigation (no GL). The widget exposes them as instance state: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatInputTests +{ + [Fact] + public void InsertChar_AdvancesCaret() + { + var input = new UiChatInput(); + input.InsertChar('h'); input.InsertChar('i'); + Assert.Equal("hi", input.Text); + Assert.Equal(2, input.CaretPos); + } + + [Fact] + public void Backspace_DeletesBeforeCaret() + { + var input = new UiChatInput(); + foreach (var c in "abc") input.InsertChar(c); + input.MoveCaret(-1); // caret between 'b' and 'c' + input.Backspace(); // deletes 'b' + Assert.Equal("ac", input.Text); + Assert.Equal(1, input.CaretPos); + } + + [Fact] + public void Submit_FiresCallback_ClearsText_PushesHistory() + { + string? sent = null; + var input = new UiChatInput { OnSubmit = t => sent = t }; + foreach (var c in "hello") input.InsertChar(c); + input.Submit(); + Assert.Equal("hello", sent); + Assert.Equal("", input.Text); + Assert.Equal(0, input.CaretPos); + } + + [Fact] + public void EmptySubmit_DoesNotFire() + { + int n = 0; + var input = new UiChatInput { OnSubmit = _ => n++ }; + input.Submit(); + Assert.Equal(0, n); + } + + [Fact] + public void History_UpDownBrowsesPreviousSubmissions() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + foreach (var c in "first") input.InsertChar(c); input.Submit(); + foreach (var c in "second") input.InsertChar(c); input.Submit(); + input.HistoryPrev(); // most recent + Assert.Equal("second", input.Text); + input.HistoryPrev(); + Assert.Equal("first", input.Text); + input.HistoryNext(); + Assert.Equal("second", input.Text); + input.HistoryNext(); // back to live (empty) + Assert.Equal("", input.Text); + } + + [Fact] + public void History_CapsAt100() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } + Assert.True(input.HistoryCount <= 100); + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: FAIL — `UiChatInput` does not exist. + +- [ ] **Step 3: Implement `UiChatInput`** + +Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode +(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) + +`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`, +sentinel `-1` = live): + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Editable one-line chat input. Port of retail UIElement_Text in editable +/// one-line mode + ChatInterface's 100-entry command history. Caret is a +/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. +/// Submit (Enter / Send) fires , clears, and pushes history. +/// +public sealed class UiChatInput : UiElement +{ + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + public float Padding { get; set; } = 4f; + public int MaxCharacters { get; set; } = 0xFFFF; // retail m_nMaxCharacters default + + /// Called on Enter/Send with the (non-empty) text. The widget clears after. + public Action? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _history = new(); + private int _historyIndex = -1; // -1 = live line (not browsing) + public int HistoryCount => _history.Count; + + public UiChatInput() + { + AcceptsFocus = true; + IsEditControl = true; + CapturesPointerDrag = true; + } + + // ── Pure editing seams (unit-tested) ───────────────────────────────── + public void InsertChar(char c) + { + if (c < 0x20 || c == 0x7F) return; // skip controls (retail CharacterHandler) + if (_text.Length >= MaxCharacters) return; + _text = _text.Insert(_caret, c.ToString()); + _caret++; + _historyIndex = -1; // editing returns to the live line + } + + public void Backspace() + { + if (_caret == 0) return; + _text = _text.Remove(_caret - 1, 1); + _caret--; + } + + public void DeleteForward() + { + if (_caret >= _text.Length) return; + _text = _text.Remove(_caret, 1); + } + + public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); + public void CaretHome() => _caret = 0; + public void CaretEnd() => _caret = _text.Length; + + 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; _historyIndex = -1; } + + private void PushHistory(string t) + { + _history.Add(t); + if (_history.Count > 100) _history.RemoveAt(0); // retail cap 100, drop oldest + _historyIndex = -1; + } + + public void HistoryPrev() // Up arrow — toward older + { + if (_history.Count == 0) return; + _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); + SetTextFromHistory(); + } + + public void HistoryNext() // Down arrow — toward newer, then live + { + if (_historyIndex < 0) return; + _historyIndex++; + if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } + SetTextFromHistory(); + } + + private void SetTextFromHistory() + { + _text = _history[_historyIndex]; + _caret = _text.Length; + } + + /// Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret. + public float CaretPixelX() + => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) + : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + + // ── Rendering + input ──────────────────────────────────────────────── + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + float ty = (Height - (DatFont?.LineHeight ?? Font?.LineHeight ?? 14f)) * 0.5f; + if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); + else if (Font is not null || ctx.DefaultFont is not null) ctx.DrawString(_text, Padding, ty, TextColor, Font); + + // Caret: 1px vertical line at the caret X (blink left to a follow-up; draw solid for now). + if (HasKeyboardFocus()) + { + float cx = Padding + CaretPixelX(); + float ch = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + ctx.DrawRect(cx, ty, 1f, ch, TextColor); + } + } + + private bool HasKeyboardFocus() + => (Parent is not null) && FindRoot()?.KeyboardFocus == this; + + private UiRoot? FindRoot() + { + UiElement? e = this; + while (e is not null) { if (e is UiRoot r) return r; e = e.Parent; } + return null; + } + + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.Char: + InsertChar((char)e.Data0); + return true; + case UiEventType.KeyDown: + { + var key = (Silk.NET.Input.Key)e.Data0; + switch (key) + { + case Silk.NET.Input.Key.Enter: + case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; + case Silk.NET.Input.Key.Backspace: Backspace(); return true; + case Silk.NET.Input.Key.Delete: DeleteForward(); return true; + case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; + case Silk.NET.Input.Key.Right: MoveCaret(1); return true; + case Silk.NET.Input.Key.Home: CaretHome(); return true; + case Silk.NET.Input.Key.End: CaretEnd(); return true; + case Silk.NET.Input.Key.Up: HistoryPrev(); return true; + case Silk.NET.Input.Key.Down: HistoryNext(); return true; + } + return false; + } + } + return false; + } +} +``` + +> **Note on focus access:** the snippet walks to the `UiRoot` to read `KeyboardFocus`. +> If `UiRoot.KeyboardFocus` is not reachable that way at runtime, add a +> `bool Focused` flag set from `UiEventType.FocusGained`/`FocusLost` in `OnEvent` +> instead (the `UiElement` event model delivers both — see `UiRoot.SetKeyboardFocus`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: PASS (6 tests). + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (If `e.Data0` for `Char` is the codepoint per `UiRoot.OnChar`, +the `(char)e.Data0` cast is correct.) + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs +git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task F: `UiChannelMenu` (channel selector) + +The `Chat ▸` selector: a button showing the active channel; clicking opens a popup +list of channels; selecting one fires a channel-changed callback. Ports +`UIElement_Menu` minimally (a button + a popup item list). + +**Files:** +- Create: `src/AcDream.App/UI/UiChannelMenu.cs` + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChannelMenu.cs`. The 13 channels map to +`ChatChannelKind` (retail `InitTalkFocusMenu @0x4cdc50` enum: 1=Say, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, …). The popup is a vertical list drawn on click; +selection updates `Selected` + fires `OnChannelChanged`. + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Chat channel selector (the "Chat ▸" button). Port of retail +/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: +/// a button whose label is the active channel; clicking opens a popup of channels; +/// selecting one calls SetTalkFocus (here: ). +/// +public sealed class UiChannelMenu : UiElement +{ + public readonly record struct Item(string Label, ChatChannelKind Channel); + + /// Retail talk-focus channels (subset acdream's ChatInputParser routes). + public static readonly Item[] Channels = + { + new("Say", ChatChannelKind.Say), + new("General", ChatChannelKind.General), + new("Trade", ChatChannelKind.Trade), + new("LFG", ChatChannelKind.Lfg), + new("Fellowship", ChatChannelKind.Fellowship), + new("Allegiance", ChatChannelKind.Allegiance), + new("Patron", ChatChannelKind.Patron), + new("Vassals", ChatChannelKind.Vassals), + new("Monarch", ChatChannelKind.Monarch), + new("Roleplay", ChatChannelKind.Roleplay), + new("Society", ChatChannelKind.Society), + new("Olthoi", ChatChannelKind.Olthoi), + }; + + public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; + public Action? OnChannelChanged { get; set; } + + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Func? SpriteResolve { get; set; } + public uint NormalSprite { get; set; } // 0x06004D65 + public uint PressedSprite { get; set; } // 0x06004D66 + public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + + private bool _open; + private const float ItemH = 16f; + + public UiChannelMenu() { CapturesPointerDrag = true; } + + private string Label => FindLabel(Selected); + private static string FindLabel(ChatChannelKind k) + { + foreach (var it in Channels) if (it.Channel == k) return it.Label; + return "Chat"; + } + + protected override void OnDraw(UiRenderContext ctx) + { + // Button face. + if (SpriteResolve is { } resolve) + { + var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + + // Popup list above the button (chat is at screen bottom). + if (_open) + { + float h = Channels.Length * ItemH; + float top = -h; + ctx.DrawRect(0, top, MathF.Max(Width, 90f), h, new(0f, 0f, 0f, 0.85f)); + for (int i = 0; i < Channels.Length; i++) + DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + } + } + + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) + { + if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); + else ctx.DrawString(s, x, y, TextColor, Font); + } + + protected override bool OnHitTest(float lx, float ly) + => _open ? (lx >= 0 && lx < MathF.Max(Width, 90f) && ly >= -Channels.Length * ItemH && ly < Height) + : base.OnHitTest(lx, ly); + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) + { + float ly = e.Data2; + if (_open && ly < 0) // clicked an item in the popup + { + int idx = (int)((ly + Channels.Length * ItemH) / ItemH); + if (idx >= 0 && idx < Channels.Length) + { + Selected = Channels[idx].Channel; + OnChannelChanged?.Invoke(Selected); + } + _open = false; + return true; + } + _open = !_open; // toggle on button click + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (Verify `ChatChannelKind` has the members used; adjust the +`Channels` table to the real enum names if any differ.) + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChannelMenu.cs +git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task G: `ChatWindowController` (import + bind + route) + +The `ChatInterface`/`gmMainChatUI::PostInit` analogue: import `0x21000006`, bind by +id, swap the transcript/input placeholders for the behavioral widgets, wire the +scrollbar/menu/send/max-min, and route inbound (`ChatVM`) + outbound +(`ChatCommandRouter`). + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ChatWindowController.cs` + +- [ ] **Step 1: Implement the controller** + +Create `src/AcDream.App/UI/Layout/ChatWindowController.cs`: + +```csharp +using System; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit. It +/// FindElement(id)s each role, swaps the transcript/input placeholders for the +/// behavioral widgets, wires the scrollbar/menu/send/max-min, and routes chat. +/// +public sealed class ChatWindowController +{ + public const uint LayoutId = 0x21000006u; + public const uint TranscriptId = 0x10000011u; + public const uint InputId = 0x10000016u; + public const uint TrackId = 0x10000012u; + public const uint ThumbId = 0x1000048Cu; + public const uint MenuId = 0x10000014u; + public const uint SendId = 0x10000019u; + public const uint MaxMinId = 0x1000046Fu; + + public UiChatView Transcript { get; private set; } = null!; + public UiChatInput Input { get; private set; } = null!; + public UiChatScrollbar Scrollbar { get; private set; } = null!; + public UiChannelMenu Menu { get; private set; } = null!; + + /// Bind an imported chat layout. Returns the controller, or null if the + /// required role elements are missing. + public static ChatWindowController? Bind( + ImportedLayout layout, ChatVM vm, ICommandBus bus, + UiDatFont? datFont, BitmapFont? debugFont, + Func resolve) + { + var transcriptPh = layout.FindElement(TranscriptId); + var inputPh = layout.FindElement(InputId); + if (transcriptPh is null || inputPh is null) return null; + + var c = new ChatWindowController(); + + // Transcript — swap placeholder for UiChatView at the same rect/anchors. + c.Transcript = new UiChatView + { + Left = transcriptPh.Left, Top = transcriptPh.Top, + Width = transcriptPh.Width, Height = transcriptPh.Height, + Anchors = transcriptPh.Anchors, + DatFont = datFont, Font = debugFont, + LinesProvider = () => BuildLines(vm), + }; + ReplaceInParent(transcriptPh, c.Transcript); + + // Input — swap placeholder for UiChatInput. + c.Input = new UiChatInput + { + Left = inputPh.Left, Top = inputPh.Top, + Width = inputPh.Width, Height = inputPh.Height, + Anchors = inputPh.Anchors, + DatFont = datFont, Font = debugFont, + }; + ReplaceInParent(inputPh, c.Input); + + // Menu — swap placeholder for UiChannelMenu (label tracks the active channel). + var menuPh = layout.FindElement(MenuId); + c.Menu = new UiChannelMenu { DatFont = datFont, Font = debugFont, SpriteResolve = resolve }; + if (menuPh is not null) + { + c.Menu.Left = menuPh.Left; c.Menu.Top = menuPh.Top; + c.Menu.Width = menuPh.Width; c.Menu.Height = menuPh.Height; + c.Menu.Anchors = menuPh.Anchors; + ReplaceInParent(menuPh, c.Menu); + } + + // Scrollbar — swap the track placeholder for the scrollbar widget driving the + // transcript's UiScrollable. + var trackPh = layout.FindElement(TrackId); + c.Scrollbar = new UiChatScrollbar { Model = c.Transcript.Scroll, SpriteResolve = resolve }; + if (trackPh is not null) + { + c.Scrollbar.Left = trackPh.Left; c.Scrollbar.Top = trackPh.Top; + c.Scrollbar.Width = trackPh.Width; c.Scrollbar.Height = trackPh.Height; + c.Scrollbar.Anchors = trackPh.Anchors; + // Sprite ids: read from the imported track/thumb nodes (TrackSprite, ThumbSprite). + ReplaceInParent(trackPh, c.Scrollbar); + } + + // Routing: input submit -> ChatCommandRouter with the menu's active channel. + c.Input.OnSubmit = text => + ChatCommandRouter.Submit(text, vm, bus, c.Menu.Selected); + c.Menu.OnChannelChanged = _ => { /* active channel read live from Menu.Selected */ }; + + // Send button -> submit (alternate trigger, retail ListenToElementMessage 0x10000019). + var send = layout.FindElement(SendId); + if (send is not null) send.ClickThrough = false; // ensure it receives clicks + // (wire send click -> c.Input.Submit() in the controller's event hook or via a + // small click handler subclass; if FindElement returns a UiDatElement, attach + // an OnClick delegate — add one to UiDatElement if absent.) + + return c; + } + + private static void ReplaceInParent(UiElement placeholder, UiElement widget) + { + var parent = placeholder.Parent; + if (parent is null) return; + parent.RemoveChild(placeholder); + parent.AddChild(widget); + } + + private static System.Collections.Generic.IReadOnlyList BuildLines(ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + var result = new UiChatView.Line[detailed.Count]; + for (int i = 0; i < detailed.Count; i++) + result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + return result; + } + + // Per-ChatKind palette (moved from GameWindow.RetailChatColor in Task H). + private static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch + { + AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), + AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), + AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), + AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), + AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), + AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), + AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), + _ => new(0.9f, 0.9f, 0.9f, 1f), + }; +} +``` + +> **Send-button + max/min click wiring:** `LayoutImporter` builds those as +> `UiDatElement` sprite nodes. If `UiDatElement` has no click hook, add an +> `Action? OnClick` invoked from `OnEvent(UiEventType.Click)` (small change, generic +> + reusable). Wire `send.OnClick = () => Input.Submit();` and +> `maxmin.OnClick = ToggleMaximize;`. The max/min toggle ports +> `gmMainChatUI::HandleMaximizeButton @0x4cce50` (swap between authored height and +> full-parent height, storing old Y/height). If that grows large, file it as a +> follow-up and leave the button inert this pass (note in a divergence row). + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. Resolve the sprite-id reads for the scrollbar (`TrackSprite`/ +`ThumbSprite`) by pulling them from the imported track/thumb `ElementInfo.StateMedia` +(or `UiDatElement`), following the `DatWidgetFactory.SliceIds` pattern. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ChatWindowController.cs +git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task H: `GameWindow` cutover + register + roadmap + +Replace the hand-authored chat block with the controller; default placement; remove +dead code; add divergence rows; mark the work landed. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Modify: `docs/plans/2026-04-11-roadmap.md` + +- [ ] **Step 1: Swap the chat block in `GameWindow`** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in the `if (_options.RetailUi)` block, +replace the "Retail chat window" section (`GameWindow.cs:1836-1887`, the +`retailChatVm` + `UiNineSlicePanel` + `UiChatView` + `BuildRetailChatLines` + +`RetailChatColor` block) with: + +```csharp +// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), +// the same importer path as vitals. ChatWindowController binds the transcript, +// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. +var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); +AcDream.App.UI.Layout.ImportedLayout? chatLayout; +lock (_datLock) + chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont); +if (chatLayout is not null) +{ + var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( + chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome); + if (chatController is not null) + { + var chatRoot = chatLayout.Root; + chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + chatRoot.Draggable = true; + chatRoot.Resizable = true; + chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f; + _uiHost.Root.AddChild(chatRoot); + Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); + } + else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); +} +else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); +``` + +> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is +> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` / +> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the +> chat window root needs `vitalsDatFont` loaded first, this block already runs after +> the vitals block where `vitalsDatFont` is created — keep that ordering. + +- [ ] **Step 2: Build + run the full suite** + +Run: `dotnet build && dotnet test` +Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in +`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics). + +- [ ] **Step 3: Add divergence-register rows** + +In `docs/architecture/retail-divergence-register.md`, add one row each (cite +`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element +word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs +render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap]; +(6) single default opacity, default font face/size [Approximation]. + +- [ ] **Step 4: Visual verification (user)** + +Launch live and confirm against the retail screenshot: +```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_RETAIL_UI="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log +``` +Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type + +Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame. + +- [ ] **Step 5: Update the roadmap + commit** + +Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer +Plan 2 — chat). Commit: +```bash +git add src/AcDream.App/Rendering/GameWindow.cs \ + docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md +git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review checklist (done while writing) + +- **Spec coverage:** §4 components ↔ Tasks A–H (router→A, transcript dat-font→B, + scrollable→C/C2, scrollbar→D, input→E, menu→F, controller→G, cutover→H). Deferred + items (§2/§6) → register rows in H Step 3. ✓ +- **Placeholders:** the two forward-discoveries (scroll up/down button ids in D; send/ + max-min click hook in G) are explicit, scoped implementation tasks with a fallback, + not hand-waves. ✓ +- **Type consistency:** `UiScrollable` API (`ScrollY`, `ThumbRatio`, `PositionRatio`, + `SetPositionRatio`, `ScrollByLines/Page`) used consistently in C, C2, D. `UiChatView.Scroll` + exposed in C2, consumed in D/G. `ChatCommandRouter.Submit(raw, vm, bus, channel)` defined + in A, called in E-wiring/G. `UiChatInput.OnSubmit`/`Submit()` consistent E↔G. ✓ diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md new file mode 100644 index 00000000..33afb841 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -0,0 +1,760 @@ +# 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 1–6 (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; + +/// 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. +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 StateMedia = new(); +} + +public static class ElementReader +{ + /// Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned + /// to that side"; any other value = not pinned. Left+Right ⇒ width stretches. + 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; + } + + /// Merge a base element with a derived override: start from base, apply any + /// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId. + 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(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; + +/// 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". +public sealed class UiDatElement : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + public string ActiveState { get; set; } = ""; + + public UiDatElement(ElementInfo info, Func 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(e); + } + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(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; + +/// 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). +public static class DatWidgetFactory +{ + /// RenderSurface id → (GL tex, w, h). + /// Retail UI font for text elements (may be null pre-load). + public static UiElement Create(ElementInfo info, + Func 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 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(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; + +/// Reads a retail LayoutDesc into a UiElement tree. Pure tree-building +/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell. +public sealed class ImportedLayout +{ + public required UiElement Root { get; init; } + private readonly Dictionary _byId; + public ImportedLayout(UiElement root, Dictionary byId) { Root = root; _byId = byId; } + public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null; +} + +public static class LayoutImporter +{ + /// Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving + /// inheritance), then BuildFromInfos. Returns null if the layout is missing. + public static ImportedLayout? Import(DatCollection dats, uint layoutId, + Func resolve, UiDatFont? datFont) + { + var ld = dats.Get(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(); + var nested = new Dictionary(); + 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); + } + + /// 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.) + public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable children, + Func resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + 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 resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + 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 resolve, UiDatFont? datFont, Dictionary 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); + } + + /// ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance. + private static ElementInfo Resolve(DatCollection dats, ElementDesc d) + { + var self = ToInfo(d); + if (d.BaseElement != 0 && d.BaseLayoutId != 0) + { + var baseLd = dats.Get(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; + } + + /// Read the verified ElementDesc fields into ElementInfo (no inheritance). + 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(); + 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; + +/// 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. +public static class VitalsController +{ + public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE; + + public static void Bind(ImportedLayout layout, + Func healthPct, Func staminaPct, Func manaPct, + Func healthText, Func staminaText, Func 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 pct, Func 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(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 7–8). +- **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. diff --git a/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md b/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md new file mode 100644 index 00000000..1a083dbd --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md @@ -0,0 +1,1104 @@ +# D.5.1 Toolbar (action bar) — Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the retail action bar (`gmToolbarUI`, `LayoutDesc 0x21000016`) as acdream's first data-driven game panel: 18 shortcut slots populated from the persisted `PlayerDescription` shortcut block, each pinned item rendering its real composited icon, with click-to-use. + +**Architecture:** Reuse the shipped D.2b assembly pattern (dat `LayoutDesc` → `LayoutImporter` → `DatWidgetFactory` → thin find-by-id controller). Two new shared widgets (`UiItemSlot`, `UiItemList`) + a CPU icon-composite pipeline (`IconComposer`) + the wire plumbing to carry `IconId` from `CreateObject` into `ItemRepository` and to persist the shortcut list. The 18 toolbar slots already resolve to `UIElement_ItemList` (class `0x10000031`) through the dat `BaseElement`/`BaseLayoutId` chain (slot → `0x100001B2` → `0x10000339`@`0x2100003D`, Type `0x10000031`), so one `DatWidgetFactory` branch makes them `UiItemList`s automatically; the item cell is created procedurally by the list. + +**Tech Stack:** C# .NET 10, Silk.NET OpenGL, the in-tree `AcDream.App/UI` retained-mode toolkit, `DatCollection` for RenderSurface decode, xUnit. + +**Spec:** [`docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md`](../specs/2026-06-16-d2b-toolbar-phase1-design.md). +**Research anchors:** [`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), [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md). + +**Spec deltas discovered during planning (elaboration, not contradiction):** +- Spec §4.4 assumed "just capture IconId." Reality: acdream's `CreateObject.TryParse` discards IconId (`CreateObject.cs:516`) AND there is **no** `CreateObject`→`ItemRepository` wiring at all — the repo is populated only from `PlayerDescription` with stub `ItemInstance`s (`ObjectId`+`WeenieClassId`). So Tasks 2–4 add: capture IconId, enrich the repo from the spawn event, and persist `Parsed.Shortcuts` (currently parsed then discarded in `GameEventWiring`). +- IconId source is CONFIRMED to be `CreateObject` for contained pack items (ACE `WorldObject_Networking.cs:79` writes `WritePackedDwordOfKnownType(IconId, 0x6000000)` unconditionally; Chorizite `PublicWeenieDesc` reads `Icon` with no flag gate). No fallback needed. +- Phase-1 `IconComposer` scope: CPU-composite the layers whose source data `ItemInstance` already exposes (custom underlay `IconUnderlayId` + base `IconId` + custom overlay `IconOverlayId`, alpha-over). The retail `IconData::RenderIcons` (decomp 407524) `GetByEnum` type-default-underlay, the overlay `ReplaceColor` tint, and the effect overlay need wire data not yet parsed (overlay tint color, `IconEffects`) — DEFERRED with divergence rows (Task 12). This keeps Approach A (faithful CPU pre-composite) while scoping to available data. + +--- + +## Task 0: Register D.5.1 in the roadmap + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` (the D.5 entry, ~line 433) + +- [ ] **Step 1: Add the D.5.1 sub-phase entry under D.5** + +In `docs/plans/2026-04-11-roadmap.md`, immediately after the `D.5 — Core panels` bullet (the one at ~line 433), add: + +```markdown +- **D.5.1 — Toolbar (action bar) [IN PROGRESS].** First D.5 sub-phase. `gmToolbarUI` (`LayoutDesc 0x21000016`) as the first data-driven game panel: 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real composited icons, click-to-use. New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory-registered) + `IconComposer` (CPU 5-layer composite, `IconData::RenderIcons` @407524) + the `CreateObject`→`ItemRepository` IconId wiring. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`. Deferred to later D.5 sub-phases: drag/reorder, the AddShortcut/RemoveShortcut mutate wire, meters/slider, spell shortcuts, faithful window manager, inventory, paperdoll. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/plans/2026-04-11-roadmap.md +git commit -m "docs(D.5.1): register toolbar phase-1 in the roadmap" +``` + +--- + +## Task 1: Capture `IconId` in `CreateObject.Parsed` + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (the `Parsed` struct ~lines 105-142; the parse at lines 515-516) +- Test: `tests/AcDream.Core.Net.Tests/CreateObjectTests.cs` (add a test; create the file if no CreateObject test exists — verify with `Glob tests/AcDream.Core.Net.Tests/*reate*bject*`) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/AcDream.Core.Net.Tests/CreateObjectTests.cs` (mirror an existing CreateObject test's byte-buffer construction; if none exists, build a minimal body using the same field order as `TryParse`). The assertion that matters: + +```csharp +[Fact] +public void TryParse_capturesIconId() +{ + // A CreateObject body for a simple contained item. Build the bytes with the + // exact field order TryParse reads (guid, ... name, packed WeenieClassId, + // packed-of-known-type IconId 0x06xxxxxx, u32 itemType, ...). Reuse the helper + // that an existing CreateObject test uses to assemble a body; the new assertion: + var parsed = CreateObject.TryParse(BuildContainedItemBody(iconId: 0x06001234u)); + + Assert.NotNull(parsed); + Assert.Equal(0x06001234u, parsed!.Value.IconId); +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests --filter TryParse_capturesIconId` +Expected: FAIL — `Parsed` has no member `IconId` (compile error), or `IconId` is 0. + +- [ ] **Step 3: Add `IconId` to the `Parsed` struct and capture it** + +In `src/AcDream.Core.Net/Messages/CreateObject.cs`, add a field to the `Parsed` struct (the readonly struct around lines 105-142): + +```csharp +public uint IconId; // 0x06xxxxxx RenderSurface id of the item icon (0 = none) +``` + +In `TryParse`, change the discard at line 516 to capture, and assign it into the returned `Parsed`. Replace: + +```csharp +_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); +``` +with: +```csharp +uint iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); +``` +Then add `IconId = iconId,` to the object/struct initializer where `Parsed` is constructed (the `return new Parsed { ... }` near the end of `TryParse`). Leave the `WeenieClassId` discard at line 515 as-is for now (the spawn event already carries it separately; capturing it is out of phase-1 scope). + +- [ ] **Step 4: Run the test, verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests --filter TryParse_capturesIconId` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/CreateObjectTests.cs +git commit -m "feat(D.5.1): capture IconId in CreateObject.Parsed (was discarded at cs:516)" +``` + +--- + +## Task 2: `ItemRepository.EnrichItem` (icon enrichment, enrich-existing) + +**Files:** +- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (add a method; events already exist at lines 49-59) +- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs` (verify the dir with `Glob tests/AcDream.Core.Tests/**/*ItemRepository*`; if absent, create it) + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public void EnrichItem_updatesIconOnExistingStub_andRaisesUpdated() +{ + var repo = new ItemRepository(); + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 42u }); // stub from PlayerDescription + ItemInstance? updated = null; + repo.ItemPropertiesUpdated += i => updated = i; + + bool hit = repo.EnrichItem(0x5001u, iconId: 0x06001234u, name: "Mana Stone", type: ItemType.Misc); + + Assert.True(hit); + Assert.Equal(0x06001234u, repo.GetItem(0x5001u)!.IconId); + Assert.Equal("Mana Stone", repo.GetItem(0x5001u)!.Name); + Assert.NotNull(updated); +} + +[Fact] +public void EnrichItem_returnsFalse_whenItemUnknown() +{ + var repo = new ItemRepository(); + Assert.False(repo.EnrichItem(0x9999u, 0x06001234u, "x", ItemType.Misc)); +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests --filter EnrichItem` +Expected: FAIL — `EnrichItem` not defined. + +- [ ] **Step 3: Implement `EnrichItem`** + +Add to `src/AcDream.Core/Items/ItemRepository.cs` (near `AddOrUpdate`): + +```csharp +/// +/// Enrich an already-known item (a stub created from PlayerDescription) with the +/// fuller data carried by its CreateObject (icon, name, type). Returns false if the +/// item isn't tracked yet — phase 1 enriches existing items only; full +/// CreateObject ingestion of newly-acquired items is the inventory phase. +/// Raises ItemPropertiesUpdated on success so bound widgets (the toolbar) re-render. +/// +public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type) +{ + 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; + ItemPropertiesUpdated?.Invoke(item); + return true; +} +``` + +- [ ] **Step 4: Run the test, verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests --filter EnrichItem` +Expected: PASS (both). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs +git commit -m "feat(D.5.1): ItemRepository.EnrichItem (icon/name/type from CreateObject)" +``` + +--- + +## Task 3: Thread `IconId` through the spawn event into `ItemRepository` + +This is integration wiring (no new pure unit; covered by Task 1/2 units + the visual gate). Three edits. + +**Files:** +- Modify: the `EntitySpawn` record (locate: `Grep "record EntitySpawn" src/AcDream.Core.Net` — likely `src/AcDream.Core.Net/WorldSession.cs` or a sibling) +- Modify: `src/AcDream.Core.Net/WorldSession.cs:701-719` (the `EntitySpawned?.Invoke(new EntitySpawn(...))`) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `OnLiveEntitySpawned` handler — subscribed at line 2216) + +- [ ] **Step 1: Add `IconId` to the `EntitySpawn` record** + +Run: `Grep "record EntitySpawn" src/AcDream.Core.Net -n`. Add a `uint IconId` parameter to the record's positional parameter list (append it at the end to minimize call-site churn; note the one constructor call in WorldSession is updated next). + +- [ ] **Step 2: Pass `parsed.Value.IconId` at the invoke site** + +In `src/AcDream.Core.Net/WorldSession.cs`, in the `EntitySpawned?.Invoke(new EntitySpawn(...))` block (lines 701-719), add `parsed.Value.IconId` as the final constructor argument (matching the new record parameter position). + +- [ ] **Step 3: Enrich the repo in the spawn handler** + +In `src/AcDream.App/Rendering/GameWindow.cs`, find `OnLiveEntitySpawned` (the handler subscribed at line 2216). Add, near the top of the handler body (after the `EntitySpawn` arg is in scope, call it `e`): + +```csharp +// D.5.1: enrich a known inventory/equipped item (stubbed from PlayerDescription) +// with the icon/name/type its CreateObject carries, so the toolbar can render it. +Items.EnrichItem(e.Guid, e.IconId, e.Name, e.ItemType); +``` + +(`Items` is the `ItemRepository` field at `GameWindow.cs:598`. `EnrichItem` is a no-op returning false for non-item spawns — players, NPCs, furniture — because they aren't in the repo, so this is safe to call unconditionally.) + +- [ ] **Step 4: Build + run the full suite** + +Run: `dotnet build` then `dotnet test` +Expected: green (no behavior regression; the new arg threads through). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/WorldSession.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.5.1): thread CreateObject IconId into ItemRepository via spawn event" +``` + +--- + +## Task 4: Persist `Parsed.Shortcuts` (the durable holder) + +`Parsed.Shortcuts` is parsed in `GameEventWiring.WireAll`'s PlayerDescription handler then discarded. Surface it to a durable holder the toolbar reads. + +**Files:** +- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `WireAll` signature + the PlayerDescription lambda) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add a `Shortcuts` field; pass a callback at the `WireAll(...)` call ~line 2269) +- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` + +- [ ] **Step 1: Write the failing test** + +In `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`, add a test that feeds a `PlayerDescription` message carrying a SHORTCUT block through `WireAll` and asserts the new `onShortcuts` callback receives the parsed list. Mirror an existing `GameEventWiringTests` PlayerDescription test for the message-construction + dispatch harness: + +```csharp +[Fact] +public void WireAll_PlayerDescription_invokesOnShortcuts() +{ + IReadOnlyList? got = null; + // ... build the same harness an existing PD test uses, but pass the new + // onShortcuts callback into WireAll: onShortcuts: list => got = list + // then dispatch a PD message whose SHORTCUT block has one entry (idx=0, guid=0x5001, spell=0, layer=0). + + Assert.NotNull(got); + Assert.Single(got!); + Assert.Equal(0x5001u, got![0].ObjectGuid); +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests --filter WireAll_PlayerDescription_invokesOnShortcuts` +Expected: FAIL — `WireAll` has no `onShortcuts` parameter. + +- [ ] **Step 3: Add the callback to `WireAll` and invoke it** + +In `src/AcDream.Core.Net/GameEventWiring.cs`: +- Add a parameter to `WireAll`: `Action>? onShortcuts = null` (optional, so existing callers/tests compile unchanged). +- In the PlayerDescription handler lambda (where `Parsed` is in scope, ~lines 281-433), after the existing inventory population, add: + +```csharp +onShortcuts?.Invoke(parsed.Shortcuts); +``` + +- [ ] **Step 4: Run the test, verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests --filter WireAll_PlayerDescription_invokesOnShortcuts` +Expected: PASS. + +- [ ] **Step 5: Store the shortcuts in GameWindow** + +In `src/AcDream.App/Rendering/GameWindow.cs`: +- Add a field near `Items` (line 598): + +```csharp +/// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). +public IReadOnlyList Shortcuts { get; private set; } + = System.Array.Empty(); +``` +- At the `GameEventWiring.WireAll(...)` call (~line 2269), pass `onShortcuts: list => Shortcuts = list`. + +- [ ] **Step 6: Build + commit** + +Run: `dotnet build` then `dotnet test` +Expected: green. + +```bash +git add src/AcDream.Core.Net/GameEventWiring.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +git commit -m "feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded)" +``` + +--- + +## Task 5: `IconComposer` — CPU icon composite + cache + +**Files:** +- Create: `src/AcDream.App/UI/IconComposer.cs` +- Modify: `src/AcDream.App/Rendering/TextureCache.cs` (add a public `UploadRgba8` wrapper — it's currently private) +- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs` + +The pure compositing core is testable; the dat-decode + GL-upload is a thin shell exercised by the visual gate. + +- [ ] **Step 1: Write the failing test (pure composite)** + +`tests/AcDream.App.Tests/UI/IconComposerTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +public class IconComposerTests +{ + private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a) + { + var px = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; } + return px; + } + + [Fact] + public void Compose_alphaOver_topOpaqueLayerWins() + { + var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque + var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque + var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top }); + Assert.Equal(2, w); Assert.Equal(2, h); + Assert.Equal(0, rgba[0]); // R + Assert.Equal(0, rgba[1]); // G + Assert.Equal(255, rgba[2]); // B — top layer won + Assert.Equal(255, rgba[3]); // A + } + + [Fact] + public void Compose_alphaOver_transparentTopKeepsBottom() + { + var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1); + var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue + var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top }); + Assert.Equal(255, rgba[0]); // bottom red preserved + Assert.Equal(0, rgba[2]); + } +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests --filter IconComposer` +Expected: FAIL — `IconComposer` not defined. + +- [ ] **Step 3: Implement `IconComposer`** + +`src/AcDream.App/UI/IconComposer.cs`: + +```csharp +using System; +using System.Collections.Generic; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI; + +/// +/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32×32 +/// texture, mirroring retail IconData::RenderIcons (decomp 407524). Each layer is a +/// 0x06 RenderSurface decoded DIRECTLY (the D.2b RenderSurface-vs-Surface rule). +/// Phase 1 composites the layers ItemInstance exposes (custom underlay + base + +/// custom overlay); the GetByEnum type-default underlay, the overlay ReplaceColor +/// tint, and the effect overlay are deferred (see plan Task 12 / divergence rows). +/// Composited textures are cached by their layer-id tuple. +/// +public sealed class IconComposer +{ + private readonly DatCollection _dats; + private readonly TextureCache _cache; + private readonly Dictionary<(uint, uint, uint), uint> _byTuple = new(); + + public IconComposer(DatCollection dats, TextureCache cache) + { + _dats = dats; + _cache = cache; + } + + /// Pure alpha-over composite, bottom→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 32×32 in practice). + public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers) + { + if (layers.Count == 0) return (Array.Empty(), 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); + } + + /// Resolve (and cache) the composited GL texture for an item's icon + /// layers. Returns 0 if no base icon is available. + public uint GetIcon(uint iconId, uint underlayId, uint overlayId) + { + if (iconId == 0) return 0; + var key = (iconId, underlayId, overlayId); + if (_byTuple.TryGetValue(key, out var tex)) return tex; + + var layers = new List<(byte[] rgba, int w, int h)>(); + AddLayer(layers, underlayId); + AddLayer(layers, iconId); + AddLayer(layers, overlayId); + 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(renderSurfaceId, out var rs) && + !_dats.HighRes.TryGet(renderSurfaceId, out rs)) + return; + var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + layers.Add((decoded.Rgba8, decoded.Width, decoded.Height)); + } +} +``` + +- [ ] **Step 4: Add the public `UploadRgba8` wrapper to `TextureCache`** + +In `src/AcDream.App/Rendering/TextureCache.cs`, expose the existing private upload (the one `GetOrUploadRenderSurface` calls). Add: + +```csharp +/// Upload raw RGBA8 bytes as a GL texture (used by IconComposer for +/// CPU-composited icons). Returns the GL handle. +public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest) + => UploadRgba8Internal(rgba, width, height, nearest); // rename the existing private method to *Internal if needed, or call it directly if it already has this shape +``` + +(Verify the existing private upload's name/signature with `Grep "UploadRgba8" src/AcDream.App/Rendering/TextureCache.cs`; if it already takes `(byte[], int, int, bool)`, just change its accessibility to `public` instead of adding a wrapper.) + +- [ ] **Step 5: Run the tests, verify they pass; build** + +Run: `dotnet test tests/AcDream.App.Tests --filter IconComposer` then `dotnet build` +Expected: PASS + green build. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/Rendering/TextureCache.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs +git commit -m "feat(D.5.1): IconComposer — CPU alpha-over icon composite + cache" +``` + +--- + +## Task 6: `UiItemSlot` widget (the item cell) + +**Files:** +- Create: `src/AcDream.App/UI/UiItemSlot.cs` +- Test: `tests/AcDream.App.Tests/UI/UiItemSlotTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using AcDream.App.UI; +using Xunit; + +public class UiItemSlotTests +{ + [Fact] + public void IsLeafWidget() + => Assert.True(new UiItemSlot().ConsumesDatChildren); + + [Fact] + public void DefaultEmptySprite_isToolbarBorder() + => Assert.Equal(0x060074CFu, new UiItemSlot().EmptySprite); + + [Fact] + public void Empty_whenNoItem() + { + var s = new UiItemSlot(); + Assert.Equal(0u, s.ItemId); + Assert.Equal(0u, s.IconTexture); + } + + [Fact] + public void SetItem_setsIdAndTexture() + { + var s = new UiItemSlot(); + s.SetItem(0x5001u, 0x99u); + Assert.Equal(0x5001u, s.ItemId); + Assert.Equal(0x99u, s.IconTexture); + } +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiItemSlot` +Expected: FAIL — `UiItemSlot` not defined. + +- [ ] **Step 3: Implement `UiItemSlot`** + +`src/AcDream.App/UI/UiItemSlot.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// 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). +/// +public sealed class UiItemSlot : UiElement +{ + public UiItemSlot() { ClickThrough = false; } + + public override bool ConsumesDatChildren => true; + + /// Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID. + public uint ItemId { get; private set; } + + /// Pre-composited icon GL texture for the bound item (0 = none). + public uint IconTexture { get; private set; } + + /// 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. + public uint EmptySprite { get; set; } = 0x060074CFu; + + /// RenderSurface id → (GL texture, w, h). Set by the factory/controller. + public Func? SpriteResolve { get; set; } + + public void SetItem(uint itemId, uint iconTexture) + { + ItemId = itemId; + IconTexture = iconTexture; + } + + public void Clear() { ItemId = 0; IconTexture = 0; } + + protected override void OnDraw(UiRenderContext ctx) + { + if (ItemId != 0 && IconTexture != 0) + { + ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + return; + } + 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); + } + } +} +``` + +- [ ] **Step 4: Run the tests, verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiItemSlot` +Expected: PASS (all four). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +git commit -m "feat(D.5.1): UiItemSlot widget (UIElement_UIItem cell port)" +``` + +--- + +## Task 7: `UiItemList` widget + `DatWidgetFactory` branch + +**Files:** +- Create: `src/AcDream.App/UI/UiItemList.cs` +- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (the `Create` switch, lines 63-71) +- Test: `tests/AcDream.App.Tests/UI/UiItemListTests.cs` + `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` (verify the factory-test file exists with `Glob tests/AcDream.App.Tests/**/*WidgetFactory*`; if absent, create it) + +- [ ] **Step 1: Write the failing tests** + +`tests/AcDream.App.Tests/UI/UiItemListTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +public class UiItemListTests +{ + [Fact] + public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren); + + [Fact] + public void StartsWithOneCell_forSingleCellSlot() + { + var list = new UiItemList(); + Assert.Equal(1, list.GetNumUIItems()); + Assert.NotNull(list.GetItem(0)); + } + + [Fact] + public void Cell_returnsTheFirstSlot() + { + var list = new UiItemList(); + Assert.Same(list.GetItem(0), list.Cell); + } +} +``` + +Add to the factory test file: + +```csharp +[Fact] +public void Create_buildsUiItemList_forItemListClassId() +{ + var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 }; + var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null); + Assert.IsType(w); +} +``` + +- [ ] **Step 2: Run the tests, verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter "UiItemList|UiItemList_forItemListClassId"` +Expected: FAIL — `UiItemList` not defined / factory returns `UiDatElement`. + +- [ ] **Step 3: Implement `UiItemList`** + +`src/AcDream.App/UI/UiItemList.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI; + +/// +/// 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. +/// +public sealed class UiItemList : UiElement +{ + private readonly List _cells = new(); + + public UiItemList(Func? 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? SpriteResolve { get; set; } + + /// Convenience for single-cell slots (the toolbar): the first cell. + public UiItemSlot Cell => _cells[0]; + + 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; + } + } +} +``` + +Note: `ConsumesDatChildren` stops the IMPORTER from adding dat children, but the list still draws its own `UiItemSlot` children (added via `AddChild`) through the normal `DrawSelfAndChildren` traversal — `ConsumesDatChildren` only gates the importer, not runtime children. The cell's `Width`/`Height` are synced in the list's `OnDraw` (which runs before the children pass), so the cell is correctly sized + hit-testable from the first rendered frame. + +- [ ] **Step 4: Add the factory branch** + +In `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`, add to the `Create` switch (lines 63-71), before the `_` fallback: + +```csharp +0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots +``` + +(The item *cell* class `0x10000032` is created procedurally by `UiItemList`, not via a static dat element in the toolbar, so it needs no factory branch this phase.) + +- [ ] **Step 5: Run the tests, verify they pass; build** + +Run: `dotnet test tests/AcDream.App.Tests --filter "UiItemList|ItemListClassId"` then `dotnet build` +Expected: PASS + green. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiItemList.cs src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/UiItemListTests.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +git commit -m "feat(D.5.1): UiItemList widget + factory branch for class 0x10000031" +``` + +--- + +## Task 8: `ToolbarController` (the `gmToolbarUI::PostInit` analogue) + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ToolbarController.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using System; +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using Xunit; + +public class ToolbarControllerTests +{ + private static readonly uint[] Row1 = + { 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF }; + private static readonly uint[] Row2 = + { 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF }; + + private static (ImportedLayout layout, Dictionary slots) FakeToolbar() + { + var dict = new Dictionary(); + var slots = new Dictionary(); + var root = new UiPanel(); + foreach (var id in Row1) AddSlot(id); + foreach (var id in Row2) AddSlot(id); + return (new ImportedLayout(root, dict), slots); + + void AddSlot(uint id) + { + var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 }; + dict[id] = list; slots[id] = list; root.AddChild(list); + } + } + + [Fact] + public void Populate_bindsShortcutToCorrectSlot() + { + var (layout, slots) = FakeToolbar(); + var repo = new ItemRepository(); + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_) => 0x77u, useItem: _ => { }); + + Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); + Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture); + Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty + } + + [Fact] + public void DeferredRebind_whenItemArrivesLate() + { + var (layout, slots) = FakeToolbar(); + var repo = new ItemRepository(); // item NOT present yet + var shortcuts = new List + { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_) => 0x88u, useItem: _ => { }); + Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet + + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); + + Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded + } + + [Fact] + public void Click_emitsUseForBoundItem() + { + var (layout, slots) = FakeToolbar(); + var repo = new ItemRepository(); + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + uint used = 0; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_) => 0x77u, useItem: g => used = g); + slots[Row1[0]].Cell.OnEvent(new UiEvent { Type = UiEventType.MouseDown }); + + Assert.Equal(0x5001u, used); + } +} +``` + +(Adapt `UiEvent` construction + the click-emit seam to the toolkit's actual event shape — see Step 3; if `UiItemSlot` needs a `Clicked` callback rather than handling `OnEvent`, wire that in Step 3 and update this assertion to invoke it.) + +- [ ] **Step 2: Run the tests, verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter ToolbarController` +Expected: FAIL — `ToolbarController` not defined. + +- [ ] **Step 3: Implement `ToolbarController`** + +`src/AcDream.App/UI/Layout/ToolbarController.cs`: + +```csharp +using System; +using System.Collections.Generic; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.UI.Layout; + +/// +/// 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). +/// +public sealed class ToolbarController +{ + // Slot element ids, in slot-index order (toolbar pre-dump 0x21000016). + private static readonly uint[] SlotIds = + { + 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF, + 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF, + }; + // Hidden-by-default elements (gmToolbarUI::PostInit): selected-object meters + stack slider. + private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 }; + + private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; + private readonly ItemRepository _repo; + private readonly Func> _shortcuts; + private readonly Func _iconIds; // (iconId, underlay, overlay) → GL texture + private readonly Action _useItem; + + private ToolbarController(ImportedLayout layout, ItemRepository repo, + Func> shortcuts, + Func iconIds, Action useItem) + { + _repo = repo; _shortcuts = shortcuts; _iconIds = iconIds; _useItem = useItem; + + for (int i = 0; i < SlotIds.Length; i++) + { + _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; + if (_slots[i] is { } list) + WireClick(list); + } + foreach (var id in HiddenIds) + if (layout.FindElement(id) is { } e) e.Visible = false; + + repo.ItemAdded += _ => Populate(); + repo.ItemPropertiesUpdated += _ => Populate(); + } + + public static ToolbarController Bind(ImportedLayout layout, ItemRepository repo, + Func> shortcuts, + Func iconIds, Action useItem) + { + var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem); + c.Populate(); + return c; + } + + /// Port of gmToolbarUI::UpdateFromPlayerDesc — flush then bind each shortcut. + public void Populate() + { + foreach (var list in _slots) list?.Cell.Clear(); + + foreach (var sc in _shortcuts()) + { + if (sc.ObjectGuid == 0) continue; // spell shortcuts — deferred phase + if (sc.Index >= _slots.Length) continue; + var list = _slots[(int)sc.Index]; + if (list is null) continue; + var item = _repo.GetItem(sc.ObjectGuid); + if (item is null) continue; // SetDelayedShortcutNum: re-bound on ItemAdded + uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId); + list.Cell.SetItem(sc.ObjectGuid, tex); + } + } + + private void WireClick(UiItemList list) + { + list.Cell.Clicked = () => + { + if (list.Cell.ItemId != 0) _useItem(list.Cell.ItemId); + }; + } +} +``` + +This requires a `Clicked` callback on `UiItemSlot`. Add to `UiItemSlot` (Task 6 file) and have `OnEvent` invoke it on mouse-down: + +```csharp +public Action? Clicked { get; set; } + +public override bool OnEvent(in UiEvent e) +{ + if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; } + return false; +} +``` + +(If `UiEvent`/`UiEventType` member names differ, match the toolkit's actual definitions — `Grep "enum UiEventType" src/AcDream.App/UI`.) + +- [ ] **Step 4: Run the tests, verify they pass; build** + +Run: `dotnet test tests/AcDream.App.Tests --filter ToolbarController` then `dotnet build` +Expected: PASS (all three) + green. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +git commit -m "feat(D.5.1): ToolbarController — bind 18 slots, populate, deferred rebind, click-to-use" +``` + +--- + +## Task 9: Wire the toolbar into `GameWindow` + +Integration (covered by the visual gate). Mirror the vitals import + mount. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `if (_options.RetailUi)` block, ~lines 1761-1898, after the chat block) + +- [ ] **Step 1: Construct the IconComposer once** + +In the `if (_options.RetailUi)` block (after `_uiHost` + the sprite resolver `ResolveChrome` exist, ~line 1778), add: + +```csharp +var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache); +``` + +- [ ] **Step 2: Import the toolbar layout + bind the controller** + +After the chat block (~line 1898), add (mirroring the vitals import at 1800-1828): + +```csharp +AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; +lock (_datLock) + toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x21000016u, ResolveChrome, vitalsDatFont); +if (toolbarLayout is not null) +{ + AcDream.App.UI.Layout.ToolbarController.Bind( + toolbarLayout, Items, + () => Shortcuts, + iconIds: (icon, under, over) => iconComposer.GetIcon(icon, under, over), + useItem: guid => UseItemByGuid(guid)); // existing use-item path (see Step 3) + + var toolbarRoot = toolbarLayout.Root; + toolbarRoot.Left = 10; toolbarRoot.Top = 300; // initial position; user-draggable + toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; + toolbarRoot.Draggable = true; + _uiHost.Root.AddChild(toolbarRoot); +} +``` + +- [ ] **Step 3: Provide `UseItemByGuid`** + +acdream already builds + sends use-item at `GameWindow.cs:11577-11579` (`InteractRequests.BuildUse(seq, guid)` → `_liveSession.SendGameAction`). Extract that into a small helper if it isn't already callable by guid: + +```csharp +private void UseItemByGuid(uint guid) +{ + if (_liveSession is null) return; + var seq = _liveSession.NextGameActionSequence(); + var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); + _liveSession.SendGameAction(body); +} +``` + +(If a guid-based use helper already exists near line 11577, call it instead of duplicating.) + +- [ ] **Step 4: Build** + +Run: `dotnet build` +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.5.1): mount the toolbar window under ACDREAM_RETAIL_UI" +``` + +--- + +## Task 10: Full suite + manual smoke gate + +- [ ] **Step 1: Build + full test suite** + +Run: `dotnet build` then `dotnet test` +Expected: all green. + +- [ ] **Step 2: Launch + visual verification (the user's gate)** + +Launch per CLAUDE.md (PowerShell, background, Tee to `launch.log`): + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_RETAIL_UI = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" +``` + +Acceptance (user confirms by looking): +- An 18-slot action bar (2 rows of 9) renders with the dat chrome + empty-slot sprites. +- Any persisted `+Acdream` shortcuts show their real composited item icons. +- Clicking a pinned item uses it (observe server-side / in-world effect). +- The bar drags as a whole window. + +(If `+Acdream` has no persisted shortcuts, the empty-slot render is still a valid gate; pinning real items to test icons may need the inventory phase or a server-side pre-pin — note this to the user.) + +--- + +## Task 11: Bookkeeping — divergence register, roadmap shipped, memory + +**Files:** +- Modify: `docs/architecture/retail-divergence-register.md` +- Modify: `docs/plans/2026-04-11-roadmap.md` +- Modify: `claude-memory/project_d2b_retail_ui.md` (durable lesson, if any) + +- [ ] **Step 1: Add divergence rows** + +Add rows to `docs/architecture/retail-divergence-register.md` for the phase-1 icon deferrals + the empty-sprite constant: + +- Icon composite omits the retail `GetByEnum` type-default underlay (`IconData::RenderIcons` 407524, enum 0x10000004), the overlay `ReplaceColor` tint, and the effect overlay (enum 0x10000005) — their source data (overlay tint color, `IconEffects`) isn't parsed yet. Risk: items with a material/effect overlay render without it. Retire when the inventory phase parses the full `PublicWeenieDesc`. +- `UiItemSlot.EmptySprite` defaults to the constant `0x060074CF` instead of importing the empty-slot state from the uiitem template `0x21000037`. Risk: paperdoll equip-slot silhouettes need per-slot empty sprites (already configurable). Retire when the cell imports its template states. +- (Reuse the existing IA-12 row for whole-window-drag — no new row.) + +- [ ] **Step 2: Flip the roadmap entry to shipped** + +In `docs/plans/2026-04-11-roadmap.md`, change the D.5.1 entry from `[IN PROGRESS]` to `✓ SHIPPED` with the commit range, mirroring the other D.2b shipped entries. + +- [ ] **Step 3: 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.1): divergence rows + roadmap shipped + memory for the toolbar" +``` + +--- + +## Task 12 (FOLLOW-UP, optional within phase): faithful icon layers + +Deferred from Task 5 to keep phase 1 shippable; do only if the toolbar icons visibly lack the standard background. NOT required for the phase-1 acceptance gate. + +- Port `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)` (the type-default underlay) — first confirm what `0x10000004` maps to (dump an EnumMapper DBObj) to decide whether it's the universal icon background. Add it as the bottom layer in `IconComposer.GetIcon`. +- Parse `IconEffects`/`IconOverlay` tint from `CreateObject` (extend `CreateObject.Parsed` + `ItemInstance`), then add the `ReplaceColor` overlay tint + the effect overlay (`GetByEnum 0x10000005`). +- Delete the corresponding divergence rows from Task 11 Step 1 as each layer lands. + +--- + +## Self-review notes (author) + +- **Spec coverage:** spec §2 widgets → Tasks 6,7; §4.3 icon → Task 5 (+12); §4.4 CreateObject/ItemInstance → Tasks 1,2,3; §4.5 ToolbarController → Task 8; §4.6 wiring/gating → Task 9; §5 testing → per-task TDD + Task 10; §6 acceptance → Task 10; §8 bookkeeping → Tasks 0,11. The shortcut-holder (Task 4) was implicit in §4.5's "reads Parsed.Shortcuts" and is made explicit here. +- **Type consistency:** `UiItemSlot.SetItem(uint,uint)` / `.Clear()` / `.ItemId` / `.IconTexture` / `.EmptySprite` / `.Clicked`; `UiItemList.Cell` / `.GetItem(int)` / `.GetNumUIItems()` / `.AddItem(UiItemSlot)` / `.Flush()`; `IconComposer.Compose(IReadOnlyList<(byte[],int,int)>)` / `.GetIcon(uint,uint,uint)`; `ItemRepository.EnrichItem(uint,uint,string,ItemType)`; `ToolbarController.Bind(ImportedLayout, ItemRepository, Func>, Func, Action)`. These match across Tasks 5-9. +- **Known executor confirmations (grep-to-confirm, not placeholders):** the exact name/signature of `TextureCache`'s private RGBA upload (Task 5 Step 4); the `EntitySpawn` record location (Task 3 Step 1); the `UiEvent`/`UiEventType` member shape (Tasks 6/8); whether a guid-based use helper already exists near `GameWindow.cs:11577` (Task 9 Step 3). Each step names the grep + the change. diff --git a/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md new file mode 100644 index 00000000..e68c745f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md @@ -0,0 +1,992 @@ +# 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 2–7; 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~"`. +- **Commit style:** `feat(D.2b): ` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) ` 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 2–6). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 2–7). +- `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 5–6). +- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 2–6). +- `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; + +/// +/// 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. +/// +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 span = bytes; + if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; + return JsonSerializer.Deserialize(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(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; + +/// +/// 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. +/// +public sealed class UiButton : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + + public Action? OnClick { get; set; } + public string? Label { get; set; } + public UiDatFont? LabelFont { get; set; } + public Vector4 LabelColor { get; set; } = Vector4.One; + + /// Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized). + public string ActiveState { get; set; } = ""; + + public UiButton(ElementInfo info, Func 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(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 + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); + + /// 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. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). + /// Null ⇒ all rows enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? 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(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 + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + public Func? 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(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(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(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 + /// 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). + private static UiText BuildText(ElementInfo info, Func 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/`` 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` return type, the `Array.Empty()`, 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(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 2–6, `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 2–7 (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. diff --git a/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md b/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md new file mode 100644 index 00000000..63b76929 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md @@ -0,0 +1,973 @@ +# 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) `. 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 + /// + /// 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. + /// + 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 + /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield. + public const uint UiEffectsPropertyId = 18u; + + /// + /// 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) → . Fires + /// ItemPropertiesUpdated so bound widgets re-composite. Extensible hook for future + /// typed PropertyInts (StackSize, Structure, …). False if the item is unknown. + /// + 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; + +/// +/// Inbound PublicUpdatePropertyInt (0x02CE) — the server updates one +/// PropertyInt on a visible object (carries the object guid). Standalone +/// GameMessage, dispatched like / CreateObject. +/// +/// +/// The companion PrivateUpdatePropertyInt (0x02CD) targets the player's OWN +/// object (no guid) and is not parsed here — it has no item-icon impact. +/// +/// +/// Wire layout (ACE GameMessagePublicUpdatePropertyInt, size hint 17): +/// +/// u32 opcode = 0x02CE +/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital +/// u32 guid +/// u32 property // PropertyInt enum; UiEffects = 18 +/// i32 value +/// +/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4). +/// +public static class PublicUpdatePropertyInt +{ + public const uint Opcode = 0x02CEu; + + public readonly record struct Parsed(uint Guid, uint Property, int Value); + + /// Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan 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 + /// + /// Payload for : 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). + /// + public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value); + + /// + /// 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. + /// + public event Action? 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 _effectDidByIndex = new(); +``` + +- [ ] **Step 4: Add `ResolveEffectDid` + `EnsureEffectSubMap`** + +In `IconComposer.cs`, after `EnsureUnderlaySubMap`: + +```csharp + /// + /// Resolve the effect-overlay DID for 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.) + /// + 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(masterDid, out var master)) return; + if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 + if (_dats.Portal.TryGet(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 + /// + /// Retail SurfaceWindow::ReplaceColor (0x00441530) with the icon-composite's + /// fixed source color: replace pixels exactly equal to pure-white-opaque + /// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with . Mutates in place. + /// + 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 _effectColorByDid = new(); +``` + +And the methods (after `ResolveEffectDid`): + +```csharp + /// + /// The effect tint color for : 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. + /// + 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(renderSurfaceId, out var rs) && + !_dats.HighRes.TryGet(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 + /// + /// 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. + /// + 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 _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex +``` + +- Change the constructor parameter type (the `Func iconIds` param): + +```csharp + Func iconIds, +``` + +- Change the `Bind` parameter type to match (same `Func 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` 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. diff --git a/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md b/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md new file mode 100644 index 00000000..6110f3e3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md @@ -0,0 +1,603 @@ +# 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 3–5 → Range 3.9–6.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(); + + 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(); + 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; + +/// +/// Packs a point-light snapshot into the flat float layout the bindless mesh +/// shader reads at SSBO binding=4 (mesh_modern.vert GlobalLight gLights[]): +/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity, +/// coneAngleEtc. Pure (no GL), so both WbDrawDispatcher and +/// EnvCellRenderer share ONE layout and cannot drift. +/// +public static class GlobalLightPacker +{ + public const int FloatsPerLight = 16; + + /// + /// Fill (grown + zero-cleared as needed) with the + /// packed snapshot; returns the light count n. The buffer always has at + /// least floats (so a zero-light frame still + /// uploads a non-empty SSBO). Callers upload max(n,1) * FloatsPerLight floats. + /// + public static int Pack(IReadOnlyList? 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) " +``` + +--- + +## 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; + +/// +/// 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. +/// +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(); + 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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? _pointSnapshot; + private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); +``` + +Near `SetClipRouting` (~262) add the per-frame setter: + +```csharp + /// + /// 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. + /// + public void SetPointSnapshot( + System.Collections.Generic.IReadOnlyList? 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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. diff --git a/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md b/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md new file mode 100644 index 00000000..61af1469 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md @@ -0,0 +1,46 @@ +# 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). diff --git a/docs/superpowers/plans/2026-06-18-d54-object-item-model.md b/docs/superpowers/plans/2026-06-18-d54-object-item-model.md new file mode 100644 index 00000000..080f8988 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-d54-object-item-model.md @@ -0,0 +1,1344 @@ +# D.5.4 Client Object/Item Data Model — 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 `CreateObject (0xF745)` the canonical create-or-update for every server object, holding the data side of all objects in one guid-keyed table (retail's `weenie_object_table` shape), so the UI resolves items by guid and the Coldeve blank-icon bug is fixed at the root. + +**Architecture:** Two guid-keyed tables (render/physics `WorldEntity` unchanged; data/UI `ClientObjectTable` broadened to all objects). `CreateObject` field-level **merge upsert** into `ClientObjectTable`; `DeleteObject` evicts; `PlayerDescription`/shortcuts are references; a live container-membership index; ingestion wired in `AcDream.Core.Net` (off `GameWindow`); `_liveEntityInfoByGuid` retired. + +**Tech Stack:** C# / .NET 10, xUnit (hand-built byte fixtures, no Moq), `AcDream.slnx` solution. Build `dotnet build`; test `dotnet test`. + +**Spec:** [`docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md`](../specs/2026-06-18-d54-object-item-model-design.md) + +**Canonical name map (used throughout this plan):** + +| Old | New | +|---|---| +| `ItemInstance` (type) | `ClientObject` | +| `ItemRepository` (type) | `ClientObjectTable` | +| `ItemRepository.GetItem` | `ClientObjectTable.Get` | +| `ItemRepository.ItemCount` | `ClientObjectTable.ObjectCount` | +| `ItemRepository.Items` (IEnumerable) | `ClientObjectTable.Objects` | +| event `ItemAdded` | `ObjectAdded` | +| event `ItemMoved` | `ObjectMoved` | +| event `ItemRemoved` | `ObjectRemoved` | +| event `ItemPropertiesUpdated` | `ObjectUpdated` | +| `GameWindow.Items` (field) | `GameWindow.Objects` | + +Unchanged member names (object-agnostic / container-specific): `AddOrUpdate`, `MoveItem`, `Remove`, `UpdateProperties`, `UpdateIntProperty`, `Clear`, `AddContainer`, `GetContainer`, `Containers`, `ContainerCount`, `UiEffectsPropertyId`. (`EnrichItem` is kept temporarily and deleted in Task 9.) + +--- + +## Task 1: Mechanical rename — `ItemInstance`→`ClientObject`, `ItemRepository`→`ClientObjectTable` + +Pure refactor, no behavior change. Do this first so every later task uses the new names. + +**Files:** +- Rename: `src/AcDream.Core/Items/ItemInstance.cs` → `ClientObject.cs` +- Rename: `src/AcDream.Core/Items/ItemRepository.cs` → `ClientObjectTable.cs` +- Rename: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs` → `ClientObjectTableTests.cs` +- Modify (consumers): `src/AcDream.Core.Net/GameEventWiring.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `src/AcDream.App/UI/Layout/ToolbarController.cs`, plus anything the grep in Step 1 surfaces. + +- [ ] **Step 1: Enumerate every reference (bound the rename)** + +Run (Grep tool or shell): +```bash +grep -rn -E "ItemInstance|ItemRepository|\.GetItem\(|\.ItemAdded|\.ItemMoved|\.ItemRemoved|\.ItemPropertiesUpdated|\.ItemCount\b" src tests +``` +Expected: hits in the files listed above (ItemInstance.cs, ItemRepository.cs, GameEventWiring.cs, GameWindow.cs, ToolbarController.cs, ItemRepositoryTests.cs). Record any *additional* files (e.g. plugin abstractions) and include them in the edits below. `CreateObjectTests.cs` references only `ItemType` (not renamed) — leave it. + +- [ ] **Step 2: git mv the three files** + +```bash +git mv src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ClientObject.cs +git mv src/AcDream.Core/Items/ItemRepository.cs src/AcDream.Core/Items/ClientObjectTable.cs +git mv tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +``` + +- [ ] **Step 3: Rename the types + members in `ClientObject.cs`** + +In `src/AcDream.Core/Items/ClientObject.cs`: `public sealed class ItemInstance` → `public sealed class ClientObject`. (The `ItemType`, `EquipMask`, `PropertyBundle`, `Container`, `BurdenMath` types in this file keep their names.) Update the XML-doc summary on the class from "Per-item live state" to "Per-object live state (the data side of every server object — items and creatures alike). Retail `ACCWeenieObject`." + +- [ ] **Step 4: Rename the type + members in `ClientObjectTable.cs`** + +In `src/AcDream.Core/Items/ClientObjectTable.cs`, apply (replace_all per token): +- `public sealed class ItemRepository` → `public sealed class ClientObjectTable` +- every `ItemInstance` → `ClientObject` (field types, event generic args, params) +- `event Action? ItemAdded` → `event Action? ObjectAdded` +- `event Action? ItemMoved` → `event Action? ObjectMoved` +- `event Action? ItemRemoved` → `event Action? ObjectRemoved` +- `event Action? ItemPropertiesUpdated` → `event Action? ObjectUpdated` +- `public int ItemCount` → `public int ObjectCount` +- `public IEnumerable Items` → `public IEnumerable Objects` +- `public ItemInstance? GetItem(uint objectId)` → `public ClientObject? Get(uint objectId)` +- update every internal `ItemAdded?.Invoke`/`ItemPropertiesUpdated?.Invoke`/`ItemMoved?.Invoke`/`ItemRemoved?.Invoke` to the new event names. +- Update the class XML-doc summary to "the client's table of every server object (retail `weenie_object_table` / `CObjectMaint`)." + +- [ ] **Step 5: Fix consumers** + +In `src/AcDream.Core.Net/GameEventWiring.cs`: `ItemRepository items` → `ClientObjectTable items`; `new ItemInstance` → `new ClientObject`; `items.GetItem` → `items.Get`. (Leave the PD seeding body as-is for now — Task 8 rewrites it.) + +In `src/AcDream.App/Rendering/GameWindow.cs`: +- `public readonly AcDream.Core.Items.ItemRepository Items = new();` → `public readonly AcDream.Core.Items.ClientObjectTable Objects = new();` +- every other `Items.` in this file → `Objects.` (e.g. `Items.EnrichItem`, `Items.UpdateIntProperty`); every `ItemRepository.UiEffectsPropertyId` → `ClientObjectTable.UiEffectsPropertyId`. +- the `WireAll(_liveSession.GameEvents, Items, ...)` arg → `Objects`. + +In `src/AcDream.App/UI/Layout/ToolbarController.cs`: `ItemRepository` → `ClientObjectTable` (field `_repo`, ctor param); `repo.ItemAdded` → `repo.ObjectAdded`; `repo.ItemPropertiesUpdated` → `repo.ObjectUpdated`; `_repo.GetItem` → `_repo.Get`. + +In `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`: `ItemRepository` → `ClientObjectTable`; `ItemInstance` → `ClientObject`; `repo.GetItem` → `repo.Get`; event names; `ItemCount` → `ObjectCount`. (The `MakeItem` helper keeps its name; it returns a `ClientObject`.) + +Apply the same renames in any extra files Step 1 surfaced. + +- [ ] **Step 6: Build + test green (no behavior change)** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds; full suite PASS (same count as before, just renamed). + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor(D.5.4): rename ItemRepository->ClientObjectTable, ItemInstance->ClientObject + +Broaden naming to the data side of every server object (retail weenie_object_table +shape). Pure rename; no behavior change. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Capture the full item field set in the `CreateObject` parser + +The wire-cursor walk already exists (`CreateObject.cs:558-806`); turn the `pos += N` skips into reads, capture `WeenieClassId` from the fixed prefix, and surface all fields on `Parsed`. + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` +- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs` + +- [ ] **Step 1: Write the failing test (full-field capture + cursor integrity)** + +Add to `CreateObjectTests.cs`. First extend the builder so the new fields are parameterizable — add these parameters to `BuildMinimalCreateObjectWithWeenieHeader` and write them in their correct slots (insert next to the existing matching `if ((weenieFlags & ...))` lines): + +```csharp +// add to the BuildMinimalCreateObjectWithWeenieHeader parameter list: + uint weenieClassId = 0x1234, + uint? maxStackSize = null, + byte? itemsCapacity = null, + byte? containersCapacity = null, + uint? container = null, + uint? wielder = null, + uint? validLocations = null, + uint? currentWieldedLocation = null, + uint? priority = null, + float? workmanship = null, +``` + +Replace the corresponding writer lines in the builder body with value-carrying versions: +```csharp + WritePackedDword(bytes, weenieClassId); // WeenieClassId (was hardcoded 0x1234) + // ... + if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8 + if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8 + if ((weenieFlags & 0x00002000u) != 0) WriteU16(bytes, (ushort)(maxStackSize ?? 0)); // MaxStackSize u16 + if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, container ?? 0); // Container u32 + if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, wielder ?? 0); // Wielder u32 + if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations + if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation + if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority + if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 + { + Span tmp = stackalloc byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(tmp, workmanship ?? 0f); + bytes.AddRange(tmp.ToArray()); + } +``` +(Leave `WritePackedDword(bytes, 0x1234)` → now `weenieClassId`; keep the `value`/`structure`/`maxStructure`/`stackSize`/`burden` lines already parameterized.) + +Then add the tests: +```csharp +[Fact] +public void TryParse_WeenieClassId_Surfaced() +{ + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000020u, name: "Sword", itemType: (uint)ItemType.MeleeWeapon, + weenieClassId: 0xABCDu); + var parsed = CreateObject.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal(0xABCDu, parsed!.Value.WeenieClassId); +} + +[Fact] +public void TryParse_FullItemFields_Captured() +{ + // Set every capture flag and assert every value round-trips. + uint flags = + 0x00000008u | // Value + 0x00001000u | // StackSize + 0x00002000u | // MaxStackSize + 0x00200000u | // Burden + 0x00000002u | // ItemsCapacity + 0x00000004u | // ContainersCapacity + 0x00004000u | // Container + 0x00008000u | // Wielder + 0x00010000u | // ValidLocations + 0x00020000u | // CurrentlyWieldedLocation + 0x00040000u | // Priority + 0x00000400u | // Structure + 0x00000800u | // MaxStructure + 0x01000000u; // Workmanship + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000021u, name: "Pack", itemType: (uint)ItemType.Container, + weenieFlags: flags, + value: 250u, stackSize: 7, maxStackSize: 100u, burden: 42, + itemsCapacity: 24, containersCapacity: 7, + container: 0x50000099u, wielder: 0x5000009Au, + validLocations: 0x02000000u, currentWieldedLocation: 0x02000000u, + priority: 8u, structure: 5, maxStructure: 10, workmanship: 7.5f); + var parsed = CreateObject.TryParse(body); + Assert.NotNull(parsed); + var p = parsed!.Value; + Assert.Equal(250, p.Value); + Assert.Equal(7, p.StackSize); + Assert.Equal(100, p.StackSizeMax); + Assert.Equal(42, p.Burden); + Assert.Equal(24, p.ItemsCapacity); + Assert.Equal(7, p.ContainersCapacity); + Assert.Equal(0x50000099u, p.ContainerId); + Assert.Equal(0x5000009Au, p.WielderId); + Assert.Equal(0x02000000u, p.ValidLocations); + Assert.Equal(0x02000000u, p.CurrentWieldedLocation); + Assert.Equal(8u, p.Priority); + Assert.Equal(5, p.Structure); + Assert.Equal(10, p.MaxStructure); + Assert.Equal(7.5f, p.Workmanship); +} + +[Fact] +public void TryParse_MidTailFieldsSet_StillReachesIconOverlay() +{ + // Cursor-integrity guard: setting fields BEFORE IconOverlay must not + // desync the IconOverlay read. + uint flags = + 0x00001000u | // StackSize (mid-tail) + 0x00004000u | // Container + 0x40000000u; // IconOverlay + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000022u, name: "Ring", itemType: (uint)ItemType.Jewelry, + weenieFlags: flags, stackSize: 1, container: 0x500000F0u, + iconOverlayId: 0x4321u); + var parsed = CreateObject.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal(0x06004321u, parsed!.Value.IconOverlayId); + Assert.Equal(0x500000F0u, parsed.Value.ContainerId); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests" +``` +Expected: FAIL — `Parsed` has no `WeenieClassId`/`Value`/`StackSize`/… members (compile error). + +- [ ] **Step 3: Extend the `Parsed` record** + +In `CreateObject.cs`, append these parameters to the `Parsed` record (after `UiEffects = 0`, before the closing `)`; bump the `UiEffects = 0` to `UiEffects = 0,`): +```csharp + // D.5.4 (2026-06-18): full item field set from the WeenieHeader tail — + // previously walked-past. Nullable = the gated flag was absent (don't + // clobber on merge); WeenieClassId is the fixed-prefix class id (was + // discarded at cs:538). Wire bits per r06 §4 / PublicWeenieDesc. + 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); +``` + +- [ ] **Step 4: Capture the values in `TryParse`** + +In `CreateObject.cs`, declare the new locals beside `iconId` (before the fixed-prefix `try`): +```csharp + uint weenieClassId = 0; + int? wValue = null; int? wStackSize = null; uint? 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; +``` +Change the fixed-prefix WeenieClassId read: +```csharp + weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded) +``` +In the optional-tail `try`, change these skips to reads (keep the bounds-check throw on each): +```csharp + if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8 + { + if (body.Length - pos < 1) throw new FormatException("trunc ItemCap"); + wItemsCapacity = body[pos]; pos += 1; + } + if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8 + { + if (body.Length - pos < 1) throw new FormatException("trunc ContCap"); + wContainersCapacity = body[pos]; pos += 1; + } + if ((weenieFlags & 0x00000008u) != 0) // Value u32 + { + if (body.Length - pos < 4) throw new FormatException("trunc Value"); + wValue = (int)ReadU32(body, ref pos); + } + // ... (Usable/UseRadius/TargetType/UiEffects/CombatUse unchanged) ... + 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); + } + // ... (RadarBlipColor/RadarBehavior/PScript unchanged) ... + 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; + } +``` +(Leave every other field — Usable, UseRadius, UiEffects, CombatUse, RadarBlipColor, RadarBehavior, PScript, Spell, HouseOwner, HouseRestrictions, HookItemTypes, Monarch, HookType, IconOverlay, IconUnderlay — exactly as-is.) + +- [ ] **Step 5: Pass the new fields to both `Parsed` construction sites** + +Append to the final `return new Parsed(...)` (after `UiEffects: uiEffects`): +```csharp + 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); +``` +`PartialResult()` does not reach the weenie tail, so it needs no change (its new fields default to null/0). + +- [ ] **Step 6: Run to verify pass** + +```bash +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests" +``` +Expected: PASS (all existing + 3 new tests). + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): capture full item field set in CreateObject parser + +WeenieClassId + Value/StackSize/MaxStackSize/Burden/capacities/Container/Wielder/ +ValidLocations/CurrentWieldedLocation/Priority/Structure/Workmanship. Nullable = +flag absent (don't clobber on merge). Cursor walk unchanged; +cursor-integrity test. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Plumb the new fields through `WorldSession.EntitySpawn` + +Pure plumbing (record + the single `EntitySpawned.Invoke`). Verified by build + existing tests (no easy unit test for an event record). + +**Files:** +- Modify: `src/AcDream.Core.Net/WorldSession.cs` + +- [ ] **Step 1: Extend the `EntitySpawn` record** + +Append to the `EntitySpawn` record (after `uint UiEffects = 0`, change it to `,`): +```csharp + uint UiEffects = 0, + // D.5.4 (2026-06-18): full item field set, forwarded to the object table. + 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); +``` + +- [ ] **Step 2: Forward the fields at the `EntitySpawned.Invoke` site** + +In the `0xF745` dispatch (after `parsed.Value.UiEffects` in the `new EntitySpawn(...)` call): +```csharp + 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)); +``` +(Replace the existing closing `parsed.Value.UiEffects));` with the block above.) + +- [ ] **Step 3: Build + test green** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds; full suite PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): forward full item field set through WorldSession.EntitySpawn + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Add the new fields to `ClientObject` + define the `WeenieData` ingest DTO + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObject.cs` +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` + +- [ ] **Step 1: Write the failing test** + +Add to `ClientObjectTableTests.cs`: +```csharp + [Fact] + public void ClientObject_NewFields_DefaultAndSettable() + { + var o = new ClientObject + { + ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7, + Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, + }; + o.WeenieClassId = 0xABCDu; // now settable + Assert.Equal(0x42u, o.WielderId); + Assert.Equal(24, o.ItemsCapacity); + Assert.Equal(7, o.ContainersCapacity); + Assert.Equal(8u, o.Priority); + Assert.Equal(5, o.Structure); + Assert.Equal(10, o.MaxStructure); + Assert.Equal(7.5f, o.Workmanship); + Assert.Equal(0xABCDu, o.WeenieClassId); + } + + [Fact] + public void WeenieData_Construct() + { + var d = new WeenieData(Guid: 1, Name: "x", Type: ItemType.Misc, WeenieClassId: 2, + IconId: 0x06001234u, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0, + Value: 5, StackSize: 1, StackSizeMax: 1, Burden: 10, + ContainerId: 0x99u, WielderId: null, ValidLocations: null, + CurrentWieldedLocation: null, Priority: null, + ItemsCapacity: null, ContainersCapacity: null, + Structure: null, MaxStructure: null, Workmanship: null); + Assert.Equal(0x99u, d.ContainerId); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" +``` +Expected: FAIL — `WielderId`/`Priority`/… and `WeenieData` don't exist; `WeenieClassId` is init-only. + +- [ ] **Step 3: Add fields to `ClientObject`** + +In `ClientObject.cs`, change `public uint WeenieClassId { get; init; }` → `public uint WeenieClassId { get; set; }`. After the `Bonded` property (before `Properties`), add: +```csharp + 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) +``` + +- [ ] **Step 4: Add the `WeenieData` DTO** + +Append to `ClientObject.cs` (same namespace), after the `ClientObject` class: +```csharp +/// +/// The wire-delivered patch from a CreateObject (0xF745). Nullable fields +/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert +/// () leaves the existing value untouched +/// for those, matching retail's SetWeenieDesc (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. +/// +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); +``` + +- [ ] **Step 5: Run to verify pass** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): add item fields to ClientObject + WeenieData ingest DTO + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: `Ingest` merge-upsert + `RecordMembership` (with the D.5.2 effects contract) + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `ClientObjectTableTests.cs` (these port the D.5.2 effects contract onto `Ingest` + lock the merge + the Coldeve fix): +```csharp + private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u, + string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0, + int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) => + new WeenieData(guid, name, type, wcid, icon, 0, 0, effects, + value, stack, StackSizeMax: 1, Burden: 10, ContainerId: container, + WielderId: null, ValidLocations: null, CurrentWieldedLocation: null, + Priority: null, ItemsCapacity: null, ContainersCapacity: null, + Structure: null, MaxStructure: null, Workmanship: null); + + [Fact] + public void Ingest_NewItemWithNoPriorStub_Creates_AndFiresAdded() // the Coldeve bug + { + var table = new ClientObjectTable(); + ClientObject? added = null; + table.ObjectAdded += o => added = o; + var obj = table.Ingest(FullWeenie(0x500000B0u)); + Assert.NotNull(added); + Assert.Equal(0x06001234u, table.Get(0x500000B0u)!.IconId); + Assert.Equal(0xABCDu, obj.WeenieClassId); + } + + [Fact] + public void Ingest_Existing_PatchesInPlace_PreservesPropertyBundle() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B1u)); + // Simulate an appraise having populated Properties. + table.Get(0x500000B1u)!.Properties.Ints[999u] = 7; + ClientObject? updated = null; + table.ObjectUpdated += o => updated = o; + table.Ingest(FullWeenie(0x500000B1u, name: "Renamed")); + Assert.NotNull(updated); + Assert.Equal("Renamed", table.Get(0x500000B1u)!.Name); + Assert.Equal(7, table.Get(0x500000B1u)!.Properties.Ints[999u]); // NOT clobbered + } + + [Fact] + public void Ingest_AbsentNullableField_DoesNotClobber() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B2u, value: 100)); + // Re-send with Value absent (null) — prior 100 must stay. + var noValue = FullWeenie(0x500000B2u) with { Value = null }; + table.Ingest(noValue); + Assert.Equal(100, table.Get(0x500000B2u)!.Value); + } + + [Fact] + public void Ingest_Effects_AssignedUnconditionally_ClearsToZero() // D.5.2 contract + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B3u, effects: 0x1u)); + Assert.Equal(0x1u, table.Get(0x500000B3u)!.Effects); + table.Ingest(FullWeenie(0x500000B3u, effects: 0u)); // now inert + Assert.Equal(0u, table.Get(0x500000B3u)!.Effects); + } + + [Fact] + public void RecordMembership_CreatesEntry_AndSetsEquip() + { + var table = new ClientObjectTable(); + table.RecordMembership(0x500000B4u, equip: EquipMask.MeleeWeapon); + var o = table.Get(0x500000B4u); + Assert.NotNull(o); + Assert.Equal(EquipMask.MeleeWeapon, o!.CurrentlyEquippedLocation); + Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it + } + + [Fact] + public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject + { + var table = new ClientObjectTable(); + table.RecordMembership(0x500000B5u); + table.Ingest(FullWeenie(0x500000B5u)); + Assert.Equal(1, table.ObjectCount); + Assert.Equal(0x06001234u, table.Get(0x500000B5u)!.IconId); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" +``` +Expected: FAIL — `Ingest`/`RecordMembership` don't exist. + +- [ ] **Step 3: Implement `Ingest` + `RecordMembership`** + +In `ClientObjectTable.cs`, rename the backing field `_items` → `_objects` (and update existing references in the file), then add (the `Reindex` call is a no-op stub here; Task 6 fills it): +```csharp + /// + /// 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. + /// + public ClientObject Ingest(WeenieData d) + { + bool existed = _objects.TryGetValue(d.Guid, out var obj); + if (!existed || obj is null) + { + 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; + 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; + } + + /// + /// PlayerDescription manifest: record that this guid is the player's + /// (in inventory or equipped at ), creating an + /// empty entry if CreateObject hasn't arrived yet. Never touches + /// icon/name/type/effects — that data comes from CreateObject. + /// + 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) + { + 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; + } + + // Filled in Task 6 (container index). No-op until then. + private void Reindex(ClientObject obj, uint oldContainerId) { } +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" +``` +Expected: PASS (existing + 6 new). `dotnet build` still green (EnrichItem unchanged, still called by GameWindow). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): ClientObjectTable.Ingest merge-upsert + RecordMembership + +Field-level merge (retail SetWeenieDesc): create-if-absent else patch present +fields, preserve PropertyBundle. Effects unconditional (D.5.2 contract). +RecordMembership = PD manifest. Locks the Coldeve no-prior-stub fix + out-of-order. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: Container membership index + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp + [Fact] + public void ContainerIndex_IngestThenContents_OrderedBySlot() + { + var table = new ClientObjectTable(); + // two items into container 0xC0, slots set via MoveItem after ingest + table.Ingest(FullWeenie(0x510u, container: 0xC0u)); + table.Ingest(FullWeenie(0x511u, container: 0xC0u)); + table.MoveItem(0x510u, 0xC0u, newSlot: 1); + table.MoveItem(0x511u, 0xC0u, newSlot: 0); + Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u)); + } + + [Fact] + public void ContainerIndex_Move_ReparentsBetweenContainers() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x520u, container: 0xC1u)); + table.MoveItem(0x520u, 0xC2u, newSlot: 0); + Assert.Empty(table.GetContents(0xC1u)); + Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u)); + } + + [Fact] + public void ContainerIndex_Remove_DropsFromContents() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x530u, container: 0xC3u)); + table.Remove(0x530u); + Assert.Empty(table.GetContents(0xC3u)); + } + + [Fact] + public void GetContents_UnknownContainer_Empty() + { + var table = new ClientObjectTable(); + Assert.Empty(table.GetContents(0xDEADu)); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" +``` +Expected: FAIL — `GetContents` doesn't exist; `Reindex` is a no-op. + +- [ ] **Step 3: Implement the index** + +In `ClientObjectTable.cs`, add the field beside `_objects`: +```csharp + private readonly Dictionary> _containerIndex = new(); +``` +Replace the `Reindex` no-op stub with: +```csharp + 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(); + 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; + + /// Ordered item guids in a container (retail object_inventory_table). + public IReadOnlyList GetContents(uint containerId) => + _containerIndex.TryGetValue(containerId, out var l) + ? l : (IReadOnlyList)System.Array.Empty(); +``` +In `MoveItem`, add a `Reindex` call before firing the event (and rename the event to `ObjectMoved`): +```csharp + uint oldContainer = item.ContainerId; + item.ContainerId = newContainerId; + item.ContainerSlot = newSlot; + item.CurrentlyEquippedLocation = newEquipLocation; + Reindex(item, oldContainer); + ObjectMoved?.Invoke(item, oldContainer, newContainerId); +``` +In `Remove`, drop from the index before firing `ObjectRemoved`: +```csharp + 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; +``` +In `Clear`, also clear the index: add `_containerIndex.Clear();`. + +- [ ] **Step 4: Run to verify pass** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): live container membership index (object_inventory_table) + +Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: `ObjectTableWiring` + rewire `GameWindow` ingestion off `EnrichItem` + +Move CreateObject/DeleteObject/0x02CE ingestion into `AcDream.Core.Net`; `GameWindow` stops calling `EnrichItem`. + +**Files:** +- Create: `src/AcDream.Core.Net/ObjectTableWiring.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Test: `tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs`. Since `EntitySpawned`/`EntityDeleted` are `WorldSession` events, test the mapping function directly by exposing it as a static. Test the translation `EntitySpawn → WeenieData`: +```csharp +using AcDream.Core.Items; +using AcDream.Core.Net; +using Xunit; + +namespace AcDream.Core.Net.Tests; + +public sealed class ObjectTableWiringTests +{ + [Fact] + public void ToWeenieData_CopiesFieldsFromSpawn() + { + var spawn = new WorldSession.EntitySpawn( + Guid: 0x600u, Position: null, SetupTableId: null, + AnimPartChanges: System.Array.Empty(), + TextureChanges: System.Array.Empty(), + SubPalettes: System.Array.Empty(), + BasePaletteId: null, ObjScale: null, Name: "Gem", ItemType: (uint)Items.ItemType.Gem, + MotionState: null, MotionTableId: null) + { + // positional record — use 'with' for the optional tail + } with { IconId = 0x06001111u, UiEffects = 0x2u, WeenieClassId = 0x10u, + Value = 50, StackSize = 3, ContainerId = 0xC9u }; + + var d = ObjectTableWiring.ToWeenieData(spawn); + Assert.Equal(0x600u, d.Guid); + Assert.Equal(0x06001111u, d.IconId); + Assert.Equal(0x2u, d.Effects); + Assert.Equal(0x10u, d.WeenieClassId); + Assert.Equal(50, d.Value); + Assert.Equal(3, d.StackSize); + Assert.Equal(0xC9u, d.ContainerId); + Assert.Equal(Items.ItemType.Gem, d.Type); + } + + [Fact] + public void Wire_CreateObject_Ingests() + { + var table = new ClientObjectTable(); + var session = WorldSessionTestFactory.Create(); // see note below + ObjectTableWiring.Wire(session, table); + session.RaiseEntitySpawnedForTest(new WorldSession.EntitySpawn( + 0x601u, null, null, + System.Array.Empty(), + System.Array.Empty(), + System.Array.Empty(), + null, null, "Coin", (uint)Items.ItemType.Money, null, null) + { } with { IconId = 0x06002222u }); + Assert.Equal(0x06002222u, table.Get(0x601u)!.IconId); + } +} +``` +> NOTE: if `WorldSession` cannot be constructed/raised directly in a test, drop `Wire_CreateObject_Ingests` and keep only `ToWeenieData_CopiesFieldsFromSpawn` (the pure mapping is the load-bearing logic; the `Wire` subscription is verified by build + the live run). Do NOT invent a `WorldSessionTestFactory`/`RaiseEntitySpawnedForTest` if no equivalent test seam exists — check `tests/AcDream.Core.Net.Tests` for how `WorldSession` is exercised first. + +- [ ] **Step 2: Run to verify it fails** + +```bash +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ObjectTableWiringTests" +``` +Expected: FAIL — `ObjectTableWiring` doesn't exist. + +- [ ] **Step 3: Create `ObjectTableWiring`** + +`src/AcDream.Core.Net/ObjectTableWiring.cs`: +```csharp +using AcDream.Core.Items; + +namespace AcDream.Core.Net; + +/// +/// 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). +/// +public static class ObjectTableWiring +{ + public static void Wire(WorldSession session, ClientObjectTable table) + { + System.ArgumentNullException.ThrowIfNull(session); + System.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); + }; + } + + /// Translate the wire spawn into the table's merge patch. + 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); +} +``` + +- [ ] **Step 4: Rewire `GameWindow`** + +In `GameWindow.cs`: +- In `WireLiveSessionEvents` (the `_liveSession.EntitySpawned += OnLiveEntitySpawned;` block), add right after assigning `_liveSession`: +```csharp + AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); +``` +- In `OnLiveEntitySpawned`, **delete** the `Objects.EnrichItem(...)` call (the whole 4-line `D.5.1: enrich...` block) — ingestion now happens in `ObjectTableWiring`. Leave the `lock (_datLock) { OnLiveEntitySpawnedLocked(spawn); }` render path. +- Delete the inline `_liveSession.ObjectIntPropertyUpdated += u => { ... Objects.UpdateIntProperty ... };` block (now in `ObjectTableWiring`). + +- [ ] **Step 5: Run to verify pass + build** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds; full suite PASS (the new `ObjectTableWiringTests` + all existing). The toolbar now gets its icons via `Ingest` → `ObjectAdded`/`ObjectUpdated`. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): ObjectTableWiring (CreateObject=upsert, Delete=evict, 0x02CE) off GameWindow + +CreateObject ingestion moves to Core.Net; GameWindow drops the EnrichItem call + +inline 0x02CE handler. Fixes the Coldeve blank-icon root cause: items with no PD +stub are now created, not dropped. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: `PlayerDescription` → membership manifest (fix the `WeenieClassId` misuse) + +**Files:** +- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` +- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` + +- [ ] **Step 1: Write the failing test** + +Add to `GameEventWiringTests.cs` (match the file's existing dispatch-test style; adapt names to its helpers): +```csharp + [Fact] + public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse() + { + var table = new ClientObjectTable(); + // ... build + dispatch a PlayerDescription with one inventory guid 0x700 + // (ContainerType=1) and one equipped guid 0x701 (EquipLocation=MeleeWeapon), + // using the same harness the existing PlayerDescription test uses ... + + Assert.NotNull(table.Get(0x700u)); + Assert.Equal(0u, table.Get(0x700u)!.WeenieClassId); // NOT the ContainerType (1) + Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x701u)!.CurrentlyEquippedLocation); + } +``` +> If the existing PlayerDescription test already builds a parser fixture, reuse its builder; otherwise model this test on the existing `PlayerDescription` registration test in the file. + +- [ ] **Step 2: Run to verify it fails** + +```bash +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests" +``` +Expected: FAIL — current code sets `WeenieClassId = inv.ContainerType` (1, not 0). + +- [ ] **Step 3: Replace the PD seeding block** + +In `GameEventWiring.cs`, replace the inventory/equipped seeding loops (the `foreach (var inv ...)` and `foreach (var eq ...)` blocks) with: +```csharp + // 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.) + foreach (var inv in p.Value.Inventory) + items.RecordMembership(inv.Guid); + foreach (var eq in p.Value.Equipped) + items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation); +``` +(`items` is now a `ClientObjectTable` after Task 1; `RecordMembership` from Task 5.) + +- [ ] **Step 4: Run to verify pass + build** + +```bash +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests" +dotnet build +``` +Expected: PASS; build green. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(D.5.4): PlayerDescription = membership manifest; drop WeenieClassId=ContainerType misuse + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: Delete `EnrichItem` + migrate its tests to `Ingest` + +`EnrichItem` is now unused (Task 7 removed its only caller). Remove it and port its remaining contract tests. + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` +- Modify: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` + +- [ ] **Step 1: Confirm no callers** + +```bash +grep -rn "EnrichItem" src tests +``` +Expected: hits only in `ClientObjectTable.cs` (definition) and `ClientObjectTableTests.cs` (the old tests). If any *production* caller remains, stop and rewire it to `Ingest` first. + +- [ ] **Step 2: Delete `EnrichItem`** + +Remove the entire `EnrichItem` method from `ClientObjectTable.cs` (the `public bool EnrichItem(...)` block). + +- [ ] **Step 3: Delete/port the EnrichItem tests** + +In `ClientObjectTableTests.cs`, delete `EnrichItem_updatesIconOnExistingStub_andRaisesUpdated`, `EnrichItem_returnsFalse_whenItemUnknown`, `EnrichItem_carriesEffects`, and `EnrichItem_effectsZero_clearsPriorEffects`. Their contracts are already covered by Task 5's `Ingest_*` tests (`Ingest_NewItemWithNoPriorStub_*`, `Ingest_Effects_AssignedUnconditionally_ClearsToZero`, `Ingest_Existing_PatchesInPlace_*`). Keep `UpdateIntProperty_*` tests (the 0x02CE path is unchanged). + +- [ ] **Step 4: Build + test green** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds (no `EnrichItem` references); full suite PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(D.5.4): delete EnrichItem (superseded by Ingest merge-upsert) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 10: Retire `_liveEntityInfoByGuid` → resolve from `ClientObjectTable` + +All objects are now in the table, so the redundant Name+ItemType dictionary can go. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` + +- [ ] **Step 1: Add resolve helpers** + +In `GameWindow.cs`, add two private helpers (near `DescribeLiveEntity`): +```csharp + private AcDream.Core.Items.ItemType LiveItemType(uint guid) => + Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None; + + private string? LiveName(uint guid) => Objects.Get(guid)?.Name; +``` + +- [ ] **Step 2: Migrate every `_liveEntityInfoByGuid` read** + +Replace each read site (verified locations) with the table lookup: +- target-indicator `entityResolver` (~line 1308-1316): `if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) rawItemType = (uint)info.ItemType;` → `rawItemType = (uint)LiveItemType(guid);` +- door-cycle diagnostic (~3821): `_liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo) && IsDoorName(doorInfo.Name)` → `IsDoorName(LiveName(update.Guid))` +- picker diagnostic (~11604): same pattern → `rawItemType = (uint)LiveItemType(guid);` +- `isCreature` for SendUse (~11663): `_liveEntityInfoByGuid.TryGetValue(sel, out var info) && (info.ItemType & ...Creature) != 0` → `(LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0` +- use-radius heuristics #1/#2 (~11905, ~11933): same Creature-bit check → `(LiveItemType(targetGuid) & ...Creature) != 0` +- `IsLiveCreatureTarget` (~12009): keep the `_entitiesByServerGuid.ContainsKey` guard; replace the info lookup with `return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;` +- useability creature fallback (~12185): `(LiveItemType(guid) & ...Creature) != 0` +- `DescribeLiveEntity` (~12294): `var name = LiveName(guid); if (!string.IsNullOrWhiteSpace(name)) return name!;` + +Ensure `IsDoorName` tolerates a null arg (it takes `string?`; if it doesn't, guard: `LiveName(...) is { } dn && IsDoorName(dn)`). + +- [ ] **Step 3: Delete the dictionary, its record, and its write/remove** + +- Delete `private readonly Dictionary _liveEntityInfoByGuid = new();` (~840). +- Delete the `LiveEntityInfo` record (~857-859). +- Delete the write in `OnLiveEntitySpawnedLocked` (~2720-2724, the `_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(...)` block). +- Delete `_liveEntityInfoByGuid.Remove(serverGuid);` in `RemoveLiveEntityByServerGuid` (~3731). + +- [ ] **Step 4: Build + test green** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds (no `_liveEntityInfoByGuid`/`LiveEntityInfo` references remain — grep to confirm: `grep -rn "_liveEntityInfoByGuid\|LiveEntityInfo" src` returns nothing); full suite PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(D.5.4): retire _liveEntityInfoByGuid; selection resolves from ClientObjectTable + +The one weenie table now holds every object's name+type, so the redundant +Name+ItemType dictionary is gone (retail: one weenie_object_table). + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 11: `ToolbarController` — guid-filtered re-bind + `ObjectRemoved` + +With all objects ingested, every creature spawn fires `ObjectAdded`. Filter so only shortcut-guid changes re-`Populate`, and clear a slot when its item is removed. + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` +- Test: `tests/AcDream.App.Tests/...` if an App test project exists; otherwise verify by build + the live run (note in commit). + +- [ ] **Step 1: Add a shortcut-guid filter + replace the subscriptions** + +In `ToolbarController.cs`, replace: +```csharp + repo.ItemAdded += _ => Populate(); // (already renamed to ObjectAdded in Task 1) + repo.ItemPropertiesUpdated += _ => Populate(); +``` +with: +```csharp + // D.5.4: the table now holds ALL objects, so filter to our shortcut guids + // (else every creature spawn re-populates the bar). + repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; + repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; + repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; +``` +Add the helper: +```csharp + private bool IsShortcutGuid(uint guid) + { + foreach (var sc in _shortcuts()) + if (sc.ObjectGuid == guid) return true; + return false; + } +``` + +- [ ] **Step 2: Build + test green** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds; full suite PASS. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "perf(D.5.4): toolbar re-binds only on shortcut-guid object changes; clear on remove + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 12: Bookkeeping + final verification + live run + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md`, `docs/architecture/retail-divergence-register.md`, `claude-memory/` (+ `MEMORY.md` index). + +- [ ] **Step 1: Roadmap — mark D.5.4 shipped** + +In `docs/plans/2026-04-11-roadmap.md`, change the `☐ D.5.4` ledger line to `✓ SHIPPED — D.5.4` with a one-paragraph summary (CreateObject canonical merge-upsert, all-objects table, container index, `_liveEntityInfoByGuid` retired, Coldeve blank-icon root fix) and the commit range. + +- [ ] **Step 2: Divergence register** + +In `docs/architecture/retail-divergence-register.md`: delete the enrich-only stopgap row(s) (the behavior is gone). Add a row for the global-event-with-guid-filter consumer model vs. retail's per-object `NoticeRegistrar`, and a row noting the deferred `null_object_table` parent/child pre-queue. + +- [ ] **Step 3: Memory digest** + +If there's a durable lesson (e.g. "retail is two tables, not one — keep render/data split"), add/update a `claude-memory/` note + a one-line `MEMORY.md` index entry. Keep the index line under ~200 chars. + +- [ ] **Step 4: Full build + test** + +```bash +dotnet build +dotnet test +``` +Expected: build succeeds; entire suite PASS. + +- [ ] **Step 5: Live run (visual gate — user confirms)** + +Launch against the local ACE server (per CLAUDE.md "Running the client"): +```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_RETAIL_UI = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "d54.log" +``` +Acceptance: the toolbar/hotbar now renders icons for items that were NOT in the login inventory snapshot (the Coldeve repro — previously 4/6 blank). The user confirms visually. + +- [ ] **Step 6: Commit bookkeeping** + +```bash +git add -A +git commit -m "docs(D.5.4): roadmap shipped + divergence register + memory + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-review notes (author) + +- **Spec coverage:** §4 in-scope items each map to a task — rename (T1), field capture (T2/T3/T4), merge-upsert + RecordMembership (T5), container index (T6), wiring off GameWindow + DeleteObject evict (T7), PD manifest + WeenieClassId fix (T8), EnrichItem delete (T9), `_liveEntityInfoByGuid` retire (T10), toolbar guid-filter (T11), bookkeeping (T12). Out-of-scope items (panels, ViewContents, drag-drop wire, ShortCutManager, null_object_table) are untouched. +- **Type consistency:** `ClientObject`/`ClientObjectTable`/`WeenieData`/`Ingest`/`RecordMembership`/`GetContents`/`ObjectAdded`/`ObjectUpdated`/`ObjectMoved`/`ObjectRemoved`/`Objects`/`Get` are used identically across tasks. +- **Known soft spots flagged inline:** the `ObjectTableWiring` `Wire` test depends on a `WorldSession` test seam that may not exist (Task 7 Step 1 note — fall back to the pure `ToWeenieData` test); `GameEventWiringTests` PD fixture should reuse the file's existing harness (Task 8 Step 1 note). The executor verifies these against the real test files before writing. diff --git a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md new file mode 100644 index 00000000..95129126 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md @@ -0,0 +1,455 @@ +# 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 **3–5 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. diff --git a/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md new file mode 100644 index 00000000..70b8e20f --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md @@ -0,0 +1,392 @@ +# 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 (~3–10 MB vs CEF's 150–300 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` 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>`), 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` 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 + + + + + +``` + +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 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 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. diff --git a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md new file mode 100644 index 00000000..342ed53d --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md @@ -0,0 +1,267 @@ +# 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 (1–4) — 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` | +| `0x10000522–525` | 0 | **numbered chat tabs 1–4** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` | + +> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the +> four `0x10000522–525` 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/(content−view)` +(`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 1–13 = channels carrying attr +`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, 8–0xD=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? 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` 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). diff --git a/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md new file mode 100644 index 00000000..1fb36f07 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md @@ -0,0 +1,216 @@ +# 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). diff --git a/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md b/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md new file mode 100644 index 00000000..ade2dd7e --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md @@ -0,0 +1,279 @@ +# 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 (1–0x12); 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` → `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 2–3, `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. diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md new file mode 100644 index 00000000..8c61043b --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md @@ -0,0 +1,410 @@ +# 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`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` | +| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func>`; 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 1–6. + +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 + 1–6**; 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 1–6, 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 1–6 (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). diff --git a/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md new file mode 100644 index 00000000..af66b641 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md @@ -0,0 +1,215 @@ +# 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=) + 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` + (+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. diff --git a/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md new file mode 100644 index 00000000..1ad1a645 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md @@ -0,0 +1,211 @@ +# 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 | 3–5 | **(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. diff --git a/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md b/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md new file mode 100644 index 00000000..482670b4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md @@ -0,0 +1,292 @@ +# 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? 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? 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> subscribeSelectionChanged, // hands the controller its handler to register + Func isHealthTarget, // IsLiveCreatureTarget proxy + Func name, // ClientObjectTable.Get(g)?.Name + Func healthPercent, // CombatState.GetHealthPercent + Func stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state) + Action 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 +/// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). +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. diff --git a/docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md b/docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md new file mode 100644 index 00000000..ee5c7118 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md @@ -0,0 +1,336 @@ +# 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`, `weenie_object_table : LongHash`, + matching `null_object_table` / `null_weenie_object_table` placeholders (for out-of-order + create), `visible_object_table`, and `object_inventory_table : LongHash` + (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>` keyed by containerGuid, kept + ordered by slot; updated on upsert / `MoveItem` / `Remove`; exposed via + `IReadOnlyList 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. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index d50c6b46..64eac77a 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -50,6 +50,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 2916724e..5b06e67e 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,14 +4,16 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log, IGameState state, IEvents events) + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) { Log = log; State = state; Events = events; + Ui = ui; } public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } + public IUiRegistry Ui { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs new file mode 100644 index 00000000..bcab04fb --- /dev/null +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Buffers plugin calls (which run in +/// Program.cs before the GL window opens) until GameWindow drains them into the +/// UiHost tree after construction. +/// +public sealed class BufferedUiRegistry : IUiRegistry +{ + public readonly record struct Pending(string MarkupPath, object Binding); + + private readonly List _pending = new(); + + public void AddMarkupPanel(string markupPath, object binding) + => _pending.Add(new Pending(markupPath, binding)); + + /// Return + clear all buffered registrations. + public IReadOnlyList Drain() + { + var copy = _pending.ToArray(); + _pending.Clear(); + return copy; + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index bc43997b..b3aebd5a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); -var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents); +var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); @@ -56,7 +57,7 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents); + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2365ca14..95191d49 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -595,7 +595,10 @@ public sealed class GameWindow : IDisposable // SpellTable.Empty if the file is missing (e.g. tooling contexts). public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable(); public readonly AcDream.Core.Spells.Spellbook SpellBook = null!; - public readonly AcDream.Core.Items.ItemRepository Items = new(); + public readonly AcDream.Core.Items.ClientObjectTable Objects = new(); + /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). + public IReadOnlyList Shortcuts { get; private set; } + = System.Array.Empty(); // Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from // PlayerDescription so the Vitals HUD can render those bars. // Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment @@ -612,6 +615,14 @@ public sealed class GameWindow : IDisposable // when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; + // Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1. + private AcDream.App.UI.UiHost? _uiHost; + // Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern). + private AcDream.App.UI.Layout.ToolbarController? _toolbarController; + // Phase D.5.3a — selected-object strip controller (name, overlay state, health meter). + private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController; + // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. + private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; // Phase I.2: ImGui debug panel ViewModel. Lives for as long as // _panelHost does. Self-subscribes to CombatState in its ctor, so // disposing isn't required (panel host holds the only ref). @@ -828,7 +839,6 @@ public sealed class GameWindow : IDisposable /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _entitiesByServerGuid = new(); - private readonly Dictionary _liveEntityInfoByGuid = new(); /// /// Latest for each /// guid. Captured at the end of so @@ -838,6 +848,21 @@ public sealed class GameWindow : IDisposable private readonly Dictionary _lastSpawnByGuid = new(); // Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn. private uint? _selectedGuid; + /// Fires when the selected world object changes (retail gmToolbarUI selection-change event, + /// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes. + private event Action? SelectionChanged; + /// Currently-selected world object guid. The setter fires only on + /// an actual change (dedup), so all writes go through here; reads may use the field directly. + private uint? SelectedGuid + { + get => _selectedGuid; + set + { + if (_selectedGuid == value) return; + _selectedGuid = value; + SelectionChanged?.Invoke(value); + } + } // B.6/B.7 (2026-05-16): pending close-range action that will be fired // once the local auto-walk overlay reports arrival (body has finished @@ -845,9 +870,6 @@ public sealed class GameWindow : IDisposable // far-range sends fire the wire packet immediately at SendUse/SendPickUp // time. Cleared before the deferred send fires — single-fire, no retry. private (uint Guid, bool IsPickup)? _pendingPostArrivalAction; - private readonly record struct LiveEntityInfo( - string? Name, - AcDream.Core.Items.ItemType ItemType); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private const double ServerControlledVelocityStaleSeconds = 0.60; private int _liveSpawnReceived; // diagnostics @@ -862,12 +884,14 @@ public sealed class GameWindow : IDisposable private int _liveAnimRejectSingleFrame; private int _liveAnimRejectPartFrames; - public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents) + public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents, + AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; + _uiRegistry = uiRegistry; SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); } @@ -972,8 +996,10 @@ public sealed class GameWindow : IDisposable _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); _mouseSource = new AcDream.App.Input.SilkMouseSource( firstMouse, - wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, - wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); + wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + || (_uiHost?.Root.WantsMouse ?? false), + wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard) + || (_uiHost?.Root.WantsKeyboard ?? false)); _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); @@ -1007,21 +1033,36 @@ public sealed class GameWindow : IDisposable // integrates gravity against an empty world and free-falls // the player into the void (retail loads cells synchronously; // this is the async-streaming equivalent of that invariant). - isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) - && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null - // #107 gate-2 extension (2026-06-10): an INDOOR spawn claim - // additionally waits for the claimed cell's hydration so the - // entry snap's AdjustPosition validation can act (retail loads - // the cell synchronously before SetPosition; this is the - // async-streaming equivalent). Claims that can never hydrate - // (id outside the landblock's NumCells range per the dat) - // don't hold the gate — the Resolve-head safety net demotes - // them loudly. - && (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) - || sp.Position is not { } spawnClaim - || spawnClaim.LandblockId == 0 - || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId) - || IsSpawnClaimUnhydratable(spawnClaim.LandblockId)), + isSpawnGroundReady: () => + { + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false; + + // #107 / #135: spawn-ground readiness is spawn-claim aware. For an + // INDOOR claim (sealed dungeon / building interior) the ground the + // player lands on is the EnvCell FLOOR (its BSP), so gate on the + // cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap. + // A dungeon's cells sit in their landblock at an arbitrary (often + // negative) offset, so the spawn's WORLD position can fall in a + // NEIGHBOUR terrain landblock that the #135 dungeon collapse + // deliberately does not load; requiring terrain there hangs login + // forever (cellReady true, SampleTerrainZ null). Retail loads the + // cell synchronously and places the player on the cell floor — + // cellReady is the faithful indoor equivalent (#106/#107, AD-2). + // (Before #135 this only passed by accident: the 25×25 window + // happened to stream the neighbour terrain.) + if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) + && sp.Position is { } spawnClaim + && spawnClaim.LandblockId != 0 + && (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u + && !IsSpawnClaimUnhydratable(spawnClaim.LandblockId)) + return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId); + + // Outdoor spawn, OR an unhydratable indoor claim that will demote to + // an outdoor position: hold until the terrain under the spawn streams + // (the original #106 gate — entering against an empty world free-falls + // the player into the void). + return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null; + }, enterPlayerMode: EnterPlayerModeFromAutoEntry); } @@ -1039,7 +1080,8 @@ public sealed class GameWindow : IDisposable // K.1b §E: explicit WantCaptureMouse defense-in-depth on the // surviving direct-mouse handler. Suppresses RMB orbit / // FlyCamera look while ImGui has the mouse focus. - if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + || (_uiHost?.Root.WantsMouse ?? false)) { _lastMouseX = pos.X; _lastMouseY = pos.Y; @@ -1273,7 +1315,7 @@ public sealed class GameWindow : IDisposable // live state from this GameWindow instance every frame: // - selected guid → _selectedGuid (set by PickAndStoreSelection) // - entity resolver → position from _entitiesByServerGuid + - // itemType / PWD bits from cached LiveEntityInfo + last spawn + // itemType from ClientObjectTable (Objects) + last spawn // - camera → _cameraController.Active or (zero) when not // yet ready, in which case the panel bails on viewport==0. _targetIndicator = new AcDream.App.UI.TargetIndicatorPanel( @@ -1282,9 +1324,7 @@ public sealed class GameWindow : IDisposable { if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return null; - uint rawItemType = 0; - if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) - rawItemType = (uint)info.ItemType; + uint rawItemType = (uint)LiveItemType(guid); uint pwdBits = 0; uint? useability = null; if (_lastSpawnByGuid.TryGetValue(guid, out var spawn)) @@ -1729,6 +1769,329 @@ public sealed class GameWindow : IDisposable // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. _samplerCache = new SamplerCache(_gl); + // Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing + // UiHost retained-mode tree (dormant until now) + a first vitals panel. + // Render-only: UiHost input is NOT yet bridged to the InputDispatcher + // (next sub-phase), so the close button + window drag are inert. Coexists + // with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged. + if (_options.RetailUi) + { + _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); + _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + + // Feed Silk input to the UiRoot tree so windows drag / close / select. + // UiRoot consumes UI events; the game InputDispatcher (subscribed to the + // same devices) is gated off via WantCaptureMouse/Keyboard above when the + // pointer is over a widget — no double-handling. + foreach (var m in _input!.Mice) _uiHost.WireMouse(m); + foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb); + + var cache = _textureCache!; + (uint, int, int) ResolveChrome(uint id) + { + uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h); + return (t, w, h); + } + + // Phase D.5.1 — icon composer for the toolbar shortcut slots. + // Constructed once here so the closure below can capture it; needs + // the same cache reference that ResolveChrome uses above. + var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache); + + // Phase D.2b — optional retail stylesheet. controls.ini lives under + // the AC install (ACDREAM_AC_DIR); absent → source-verified fallback. + var controls = _options.AcDir is { } acDir + ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) + : AcDream.App.UI.ControlsIni.Parse(string.Empty); + // Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000, + // Latin-1, 16px, outline atlas). Passed into the importer so the meter + // number overlay renders through the dat-font two-pass blit; falls back to + // the debug font only if it fails to load. Under _datLock like other reads. + AcDream.App.UI.UiDatFont? vitalsDatFont; + lock (_datLock) + vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!); + Console.WriteLine(vitalsDatFont is not null + ? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay." + : "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font."); + + // Phase D.2b — the vitals window is data-driven from the dat LayoutDesc + // (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml + // markup path was retired after the importer proved pixel-identical at the + // 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels. + AcDream.App.UI.Layout.ImportedLayout? imported; + lock (_datLock) + imported = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); + if (imported is not null) + { + AcDream.App.UI.Layout.VitalsController.Bind(imported, + healthPct: () => _vitalsVm!.HealthPercent, + staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, + manaPct: () => _vitalsVm!.ManaPercent ?? 0f, + healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", + staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", + manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); + // Top-level retail window: user-positioned (Anchors.None so the per-frame + // anchor pass doesn't reset it), movable, and horizontally resizable like + // retail. On a width change the dat edge-anchors reflow the pieces + // (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges + + // the three bars stretch, corners stay 5px, the right edge/corners track + // the right side. Vertical resize is off (the layout has no vertical stretch). + var vitalsRoot = imported.Root; + vitalsRoot.Left = 10; vitalsRoot.Top = 30; + vitalsRoot.ClickThrough = false; + vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + vitalsRoot.Draggable = true; + vitalsRoot.Resizable = true; + vitalsRoot.ResizeX = true; + vitalsRoot.ResizeY = false; + vitalsRoot.MinWidth = 40f; + _uiHost.Root.AddChild(vitalsRoot); + Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); + } + else + { + Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable."); + } + + // Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), + // the same importer path as vitals. ChatWindowController binds the transcript, + // input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. + var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); + AcDream.App.UI.Layout.ElementInfo? chatRootInfo; + AcDream.App.UI.Layout.ImportedLayout? chatLayout; + lock (_datLock) + { + chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos( + _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId); + chatLayout = chatRootInfo is null ? null + : AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont); + } + if (chatRootInfo is not null && chatLayout is not null) + { + var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( + chatRootInfo, chatLayout, retailChatVm, + () => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance, + vitalsDatFont, _debugFont, ResolveChrome); + if (chatController is not null) + { + // Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the + // keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard + // is set by WireKeyboard above — it is non-null here. + chatController.Transcript.Keyboard = _uiHost.Keyboard; + chatController.Input.Keyboard = _uiHost.Keyboard; + // Wrap the dat content in the universal 8-piece beveled window chrome — + // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat + // layout only carries flat background sprites, so without this the window + // has no retail-style border (the user asked for the vitals border). The + // nine-slice IS the movable/resizable window; the dat content fills its + // interior, inset by the border. The gmMainChatUI content is authored 490 + // wide (its transcript/input panels) — KEEP that width + the dat-authored + // HEIGHT so the content's child anchors (input-bar-at-bottom, transcript- + // fills) capture correct margins on first layout; resizing the frame reflows + // them correctly from there. + const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border; + var chatRoot = chatController.Root; + float contentW = 490f, contentH = chatRoot.Height; // dat-authored height + var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { + Left = 10, Top = 440, + Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder, + MinWidth = 200f, MinHeight = 90f, + // Retail chat is translucent — fade the window's backgrounds/chrome + // (text stays opaque). Configurable opacity is a later step; 0.75 reads + // as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0) + Opacity = 0.75f, + }; + chatRoot.Left = chatBorder; chatRoot.Top = chatBorder; + chatRoot.Width = contentW; chatRoot.Height = contentH; + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + chatRoot.Draggable = false; chatRoot.Resizable = false; + chatFrame.AddChild(chatRoot); + _uiHost.Root.AddChild(chatFrame); + // Tab / Enter enters "write mode" by focusing this input (retail's chat + // activation); a focused input suppresses character movement (see the + // WantsKeyboard gate in the movement poll). + _uiHost.Root.DefaultTextInput = chatController.Input; + Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); + } + else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); + } + else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); + + // Phase D.5.1 — toolbar window, data-driven from LayoutDesc 0x21000016 + // (gmToolbarUI). Mirrors the vitals/chat import+bind+mount pattern above. + + // Read the shortcut-slot digit sprite DID arrays from LayoutDesc 0x21000037 + // (the UIItem cell template): 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 → StateDesc.Properties[0x10000042] (peace) / [0x10000043] (war) + // empty → StateDesc.Properties[0x1000005e] (background digit, stance-independent) + uint[]? toolbarPeaceDigits = null; + uint[]? toolbarWarDigits = null; + uint[]? toolbarEmptyDigits = null; + lock (_datLock) + { + var uiItemLd = _dats!.Get(0x21000037u); + if (uiItemLd is not null + && uiItemLd.Elements.TryGetValue(0x10000346u, out var composite) + && composite.Children.TryGetValue(0x1000034Au, out var shortcutNumElem) + && shortcutNumElem.StateDesc is { } sd + && sd.Properties is { } props) + { + // Mirror LayoutImporter.ReadState: Properties[key] is ArrayBaseProperty + // containing DataIdBaseProperty entries. Each DataIdBaseProperty.Value is + // the RenderSurface DID for that digit. + // Peace: property 0x10000042; War: property 0x10000043. + if (props.TryGetValue(0x10000042u, out var rawPeace) + && rawPeace is DatReaderWriter.Types.ArrayBaseProperty arrPeace) + { + toolbarPeaceDigits = new uint[arrPeace.Value.Count]; + for (int i = 0; i < arrPeace.Value.Count; i++) + if (arrPeace.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) + toolbarPeaceDigits[i] = d.Value; + } + if (props.TryGetValue(0x10000043u, out var rawWar) + && rawWar is DatReaderWriter.Types.ArrayBaseProperty arrWar) + { + toolbarWarDigits = new uint[arrWar.Value.Count]; + for (int i = 0; i < arrWar.Value.Count; i++) + if (arrWar.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) + toolbarWarDigits[i] = d.Value; + } + } + else + { + Console.WriteLine("[D.5.1] digit arrays: element 0x1000034A/0x10000346 not found in LayoutDesc 0x21000037 — falling back to cited constants."); + } + + // Empty-slot BACKGROUND digit lives under a DIFFERENT cell composite: + // composite 0x10000341 (the UIElement_UIItem-typed variant) carries property + // 0x1000005e (plainer digits 0x060010FA..0x06001102 for 1-9, 0x060074CF for the + // bottom row); composite 0x10000346 (peace/war, read above) does NOT carry it. + // Confirmed by a live dat property dump. Retail: UIElement_UIItem::SetShortcutNum + // (decomp 229481/229493) — empty branch (m_elem_Icon->m_state == 0x1000001c) reads + // 0x1000005e, stance-independent. No fallback constants (safe: no digit if absent). + if (uiItemLd is not null + && uiItemLd.Elements.TryGetValue(0x10000341u, out var emptyComposite) + && emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyScn) + && emptyScn.StateDesc is { } emptySd + && emptySd.Properties is { } emptyProps + && emptyProps.TryGetValue(0x1000005Eu, out var rawEmpty) + && rawEmpty is DatReaderWriter.Types.ArrayBaseProperty arrEmpty) + { + toolbarEmptyDigits = new uint[arrEmpty.Value.Count]; + for (int i = 0; i < arrEmpty.Value.Count; i++) + if (arrEmpty.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) + toolbarEmptyDigits[i] = d.Value; + } + Console.WriteLine($"[D.5.1] empty digit array (0x10000341/0x1000005e): {toolbarEmptyDigits?.Length ?? 0} entries."); + } + + // Cited-constant fallback (UIElement_UIItem::SetShortcutNum, decomp 229465 + dat probe). + // Used when the dat navigation above fails (e.g. missing LayoutDesc in older dat). + if (toolbarPeaceDigits is null) + toolbarPeaceDigits = new uint[] + { 0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u, + 0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u }; + if (toolbarWarDigits is null) + toolbarWarDigits = new uint[] + { 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u, + 0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u }; + // Report the arrays actually used (after any fallback substitution). + Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits.Length}, empty={toolbarEmptyDigits?.Length ?? 0} entries."); + + AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; + lock (_datLock) + toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x21000016u, ResolveChrome, vitalsDatFont); + if (toolbarLayout is not null) + { + _toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind( + toolbarLayout, Objects, + () => Shortcuts, + iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), + useItem: guid => UseItemByGuid(guid), + combatState: Combat, + peaceDigits: toolbarPeaceDigits, + warDigits: toolbarWarDigits, + emptyDigits: toolbarEmptyDigits); + + // Phase D.5.3a — selected-object strip (name, overlay state, health meter). + // Analogue of retail gmToolbarUI::HandleSelectionChanged + // (acclient_2013_pseudo_c.txt:198635). + _selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind( + toolbarLayout, + subscribeSelectionChanged: h => SelectionChanged += h, + subscribeHealthChanged: h => Combat.HealthChanged += h, + isHealthTarget: IsHealthBarTarget, + name: g => Objects.Get(g)?.Name, + healthPercent: g => Combat.GetHealthPercent(g), + hasHealth: g => Combat.HasHealth(g), + stackSize: g => (uint)(Objects.Get(g)?.StackSize ?? 0), + sendQueryHealth: g => _liveSession?.SendQueryHealth(g), + datFont: vitalsDatFont); + + var toolbarRoot = toolbarLayout.Root; + // Wrap the dat content in the universal 8-piece beveled window chrome — + // the SAME UiNineSlicePanel used by the vitals and chat windows. The + // toolbar LayoutDesc (0x21000016) is 300×122; the frame adds one border + // thickness on every side, giving an outer window of 310×132. + const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border; + float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height; + var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { + Left = 10, Top = 300, + Width = toolbarContentW + 2 * toolbarBorder, + Height = toolbarContentH + 2 * toolbarBorder, + // The toolbar is fully opaque (not translucent like the chat window). + Opacity = 1.0f, + }; + // Content is offset by the border so it sits inside the chrome. + toolbarRoot.Left = toolbarBorder; + toolbarRoot.Top = toolbarBorder; + toolbarRoot.Width = toolbarContentW; + toolbarRoot.Height = toolbarContentH; + // Anchor content to all four edges so it reflows if the frame is resized. + toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + // The frame is the draggable window; the content itself is not. + toolbarRoot.ClickThrough = false; + toolbarRoot.Draggable = false; + toolbarRoot.Resizable = false; + toolbarFrame.AddChild(toolbarRoot); + _uiHost.Root.AddChild(toolbarFrame); + + Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); + } + else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found."); + + // Drain plugin-registered markup panels (buffered before the GL + // window opened) into the same UiRoot tree. A faulty plugin markup + // file is isolated — logged + skipped, never crashes the client. + if (_uiRegistry is not null) + { + foreach (var p in _uiRegistry.Drain()) + { + try + { + string pluginXml = System.IO.File.ReadAllText(p.MarkupPath); + var pluginPanel = AcDream.App.UI.MarkupDocument.Build( + pluginXml, p.Binding, ResolveChrome, controls); + _uiHost.Root.AddChild(pluginPanel); + Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}"); + } + } + } + } + // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher // always construct. @@ -1914,6 +2277,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities @@ -2019,6 +2383,10 @@ public sealed class GameWindow : IDisposable private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) { _liveSession = session; + // D.5.4: ingest CreateObject into the object table (upsert) and wire Delete + + // UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so + // the table is populated before the render handler runs. + AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; @@ -2073,7 +2441,7 @@ public sealed class GameWindow : IDisposable var skillTable = _dats?.Get(0x0E000004u); AcDream.Core.Net.GameEventWiring.WireAll( - _liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer, + _liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer, TurbineChat, resolveSkillFormulaBonus: (skillId, attrCurrents) => { @@ -2115,7 +2483,8 @@ public sealed class GameWindow : IDisposable _lastSeenRunSkill, _lastSeenJumpSkill); Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); } - }); + }, + onShortcuts: list => Shortcuts = list); // Phase I.7: subscribe to CombatState events and emit // retail-faithful "You hit X for Y damage" chat lines into @@ -2292,6 +2661,7 @@ public sealed class GameWindow : IDisposable LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current); _liveSession.VitalCurrentUpdated += v => LocalPlayer.OnVitalCurrent(v.VitalId, v.Current); + } /// @@ -2362,12 +2732,6 @@ public sealed class GameWindow : IDisposable $"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); } - _liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo( - spawn.Name, - spawn.ItemType is { } rawItemType - ? (AcDream.Core.Items.ItemType)rawItemType - : AcDream.Core.Items.ItemType.None); - // Target the statue specifically for full diagnostic dump: Name match // is cheap and gives us exactly one entity's worth of log regardless // of arrival order. @@ -2436,6 +2800,57 @@ public sealed class GameWindow : IDisposable // landblock; each neighbor landblock is offset by 192 units per step. int lbX = (int)((p.LandblockId >> 24) & 0xFFu); int lbY = (int)((p.LandblockId >> 16) & 0xFFu); + + // G.3 (#133): recenter streaming onto the player's spawn landblock at + // login. The streaming center (_liveCenterX/_liveCenterY) is pinned to + // the startup default (Holtburg, 0xA9B4) and is otherwise only moved by + // the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A + // character saved INSIDE a far dungeon spawns with that dungeon's + // landblock id, but the center never followed it, so the dungeon (tens + // of km away in world space) never streamed and the #107 auto-entry + // gate's SampleTerrainZ(pe.Position) waited forever — the player hung + // frozen at login. Mirror the teleport-arrival recenter HERE, for the + // PLAYER's spawn only, BEFORE the world-space translation below: when + // the spawn landblock differs from the current center, move the center + // onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the + // new center frame (identical to the teleport path's + // `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`), + // and the next StreamingController.Tick observes the new center and + // streams the spawn landblock. + // + // No-op for a normal Holtburg login: the saved spawn landblock equals + // the default center, so the guard is false and origin/worldPos are + // byte-identical to the pre-fix path. Gated on the player guid so NPC / + // object spawns never move the center. Idempotent + thrash-free: a + // re-sent CreateObject for the same spawn landblock leaves the center + // already-equal, so the guard is false on every repeat. + if (spawn.Guid == _playerServerGuid + && (lbX != _liveCenterX || lbY != _liveCenterY)) + { + Console.WriteLine( + $"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + + $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}"); + _liveCenterX = lbX; + _liveCenterY = lbY; + } + + // #135: the instant we know the player spawned into a SEALED dungeon, + // pre-collapse streaming to that single landblock — BEFORE the first + // StreamingController.Tick bootstraps the 25×25 ocean-grid window. The + // player isn't placed yet (physics CurrCell is null), so the per-frame + // insideDungeon gate stays false for the entire hydration window and + // NormalTick would otherwise load ~24 neighbor dungeons then unload them + // (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed- + // dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor + // surround. We hold _datLock here, and IsSealedDungeonCell re-takes it + // (reentrant); the controller call is render-thread-safe (Channel writes). + if (spawn.Guid == _playerServerGuid + && _streamingController is not null + && IsSealedDungeonCell(p.LandblockId)) + { + _streamingController.PreCollapseToDungeon(lbX, lbY); + } + var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, @@ -3322,11 +3737,10 @@ public sealed class GameWindow : IDisposable // clear using the same guid the next spawn/update would use. _remoteDeadReckon.Remove(serverGuid); _remoteLastMove.Remove(serverGuid); - _liveEntityInfoByGuid.Remove(serverGuid); _entitiesByServerGuid.Remove(serverGuid); _lastSpawnByGuid.Remove(serverGuid); if (_selectedGuid == serverGuid) - _selectedGuid = null; + SelectedGuid = null; if (logDelete) _lightingSink?.UnregisterOwner(existingEntity.Id); @@ -3414,8 +3828,7 @@ public sealed class GameWindow : IDisposable // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled - && _liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo) - && IsDoorName(doorInfo.Name)) + && IsDoorName(LiveName(update.Guid))) { Console.WriteLine(System.FormattableString.Invariant( $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); @@ -4449,10 +4862,18 @@ public sealed class GameWindow : IDisposable private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { - // Phase A.1: track the most recently updated entity's landblock so the - // streaming controller can follow the player. TODO: filter by our own - // character guid once we reliably know it from CharacterList. - _lastLivePlayerLandblockId = update.Position.LandblockId; + // Phase A.1 / #135: track the PLAYER's last server-known landblock so the + // streaming controller can follow the player in the fly-camera / pre-player-mode + // (login hold) views. Filtered to our OWN character guid — resolving the original + // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock + // must NOT move the streaming observer: during a dungeon-login hold (player not + // yet placed, so _playerController is null and the PortalSpace observer branch + // can't apply) that would drift the observer off the pre-collapsed dungeon + // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window + // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList + // (~line 1984) before world entry, so it is valid by the time updates arrive. + if (update.Guid == _playerServerGuid) + _lastLivePlayerLandblockId = update.Position.LandblockId; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; @@ -4874,7 +5295,7 @@ public sealed class GameWindow : IDisposable entity.Rotation = rmState.Body.Orientation; } - // Phase B.3: portal-space arrival detection. + // 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 @@ -4888,79 +5309,127 @@ public sealed class GameWindow : IDisposable bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); - // #107 (2026-06-10): ANY player position update while in PortalSpace - // IS the teleport arrival. Retail/holtburger exit portal space on the - // next position event unconditionally (holtburger messages.rs - // PlayerTeleport handler: log + LoginComplete; the destination applies - // through the normal position flow — no distance test). The old - // `differentLandblock || farAway(>100m)` arrival gate was an - // invention: ACE's same-landblock short-hop position corrections - // (e.g. right after an indoor login) matched neither condition, so - // PortalSpace never exited and movement input stayed frozen for the - // whole session (the #107 "input ignored" wedge shape — - // flood-fix-gate2.log: `teleport started (seq=1)` with no arrival). + 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) { - Console.WriteLine( - $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + - $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); + // 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); - System.Numerics.Vector3 newWorldPos; - if (differentLandblock) - { - // 1. Recenter the streaming controller on the new landblock. - _liveCenterX = lbX; - _liveCenterY = lbY; - - // Recompute worldPos with new center (it becomes local-to-center). - // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ) - // relative to the new origin — which maps to world-space (0,0,0) + local offset. - // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically. - newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); - // (after recentering, origin is (0,0,0) since lb == center) - } - else - { - // Same landblock: worldPos is already in the current center frame. - newWorldPos = worldPos; - } - - // 2. Resolve through physics for the correct ground Z. - uint newCellId = p.LandblockId; - var resolved = _physicsEngine.Resolve( - newWorldPos, newCellId, - System.Numerics.Vector3.Zero, _playerController.StepUpHeight); - var snappedPos = new System.Numerics.Vector3( - resolved.Position.X, resolved.Position.Y, resolved.Position.Z); - - // 3. Snap player entity + controller. - entity.SetPosition(snappedPos); - entity.ParentCellId = resolved.CellId; - entity.Rotation = rot; - _playerController.SetPosition(snappedPos, resolved.CellId); - - // 4. Recenter chase camera on the new position. - _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); - - // 5. Return to InWorld. - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - - // 5. Send LoginComplete to tell the server the client finished loading. - // Per holtburger's PlayerTeleport handler (client/messages.rs:434-440), - // retail clients call send_login_complete() after each portal transition. - // ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path - // doesn't also send one. We send directly here instead. - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + // #135: pre-collapse on teleport into a sealed dungeon too — same + // race as login. The destination isn't placed until it hydrates, so + // without this NormalTick loads the full neighbor window during the + // arrival hold. The PortalSpace observer branch (OnUpdate) keeps the + // observer pinned to _liveCenterX/Y while held, so the stale frozen + // player position can't drift the observer off the dungeon and re-expand. + if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId)) + _streamingController.PreCollapseToDungeon(lbX, lbY); } + 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); } } + // 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; + + // #135: an INDOOR destination (sealed dungeon / building interior) gates on the + // EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can + // place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load, + // so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on + // the cell floor. Outdoor: the terrain heightmap is the ground. + bool indoor = (destCell & 0xFFFFu) >= 0x0100u; + if (indoor) + return _physicsEngine.IsSpawnCellReady(destCell) + ? AcDream.App.World.ArrivalReadiness.Ready + : AcDream.App.World.ArrivalReadiness.NotReady; + + if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) + 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()); + } + /// /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). /// Freeze movement input by setting the player controller to PortalSpace. @@ -4972,6 +5441,7 @@ public sealed class GameWindow : IDisposable { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; + EnsureTeleportArrivalController(); Console.WriteLine($"live: teleport started (seq={sequence})"); } @@ -5598,26 +6068,56 @@ public sealed class GameWindow : IDisposable // Static objects inside the cell continue to flow through the dispatcher // as WorldEntity records below — they have real GfxObj MeshRefs that work // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list. + // Transforms — needed by the portal-visibility cell (unlifted) AND the + // render/physics path. Computed for EVERY cell with a valid cellStruct, + // not just drawable ones. Keep the small render lift out of physics; retail + // BSP contact planes use the EnvCell origin verbatim. The lift constant is + // shared with every draw-space consumer of portal polygons (OutsideView + // gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130). + 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); + + // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless + // of whether CellMesh.Build produced drawable sub-meshes. A portals-only + // pass-through connector (a ramp / stair / cellar mouth) yields 0 render + // sub-meshes but MUST be in the visibility graph so the flood can traverse it + // to the cells beyond — otherwise the flood lookup-misses the unregistered + // neighbour and the grey clear shows through the opening (#133: ramp + // neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier + // at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace] + // skip=lookup-miss). Retail keeps the whole landblock cell array resident + // before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT + // the render sub-meshes. The +0.02 m render lift is a DRAW concern only and + // is intentionally NOT fed into the visibility transform (#119-residual: the + // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + + // PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of + // drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer → + // sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to + // TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can + // never reach it and lags one cell behind the eye (#133 residual: the camera sat + // 1.32 m past the ramp portal's plane while the viewer cell stalled in + // 0x00070103 — the sweep transited every cached neighbour but NEVER the + // un-cached connector 0x014D — so the side test culled the on-screen connector + // portal and the grey clear showed through). Retail keeps the whole landblock + // cell array resident for the sweep; a portals-only connector has an empty + // collision BSP but its portals drive the transit. CacheCellStruct reads the + // cellStruct directly, not the render sub-meshes. + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); + var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; - // Keep the small render lift out of physics; retail BSP - // contact planes use the EnvCell origin verbatim. The lift - // constant is shared with every draw-space consumer of - // portal polygons (OutsideView gate, seal/punch fans) — - // see PortalVisibilityBuilder.ShellDrawLiftZ (#130). - 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); - // Phase A8: register the cell with EnvCellRenderer for rendering. // staticObjects is empty — cell stabs continue as separate WorldEntity // records via the dispatcher (see lines below for the unchanged stab path). @@ -5630,25 +6130,6 @@ public sealed class GameWindow : IDisposable cellWorldPosition: cellOrigin, cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); - - // Step 4: build LoadedCell for portal visibility — with the - // PHYSICS (unlifted) transform. The +0.02 m render lift above - // is a DRAW concern (shell z-fighting vs terrain); feeding it - // into the visibility graph shifted every HORIZONTAL portal - // plane 2 cm up, putting an eye standing on a deck/landing - // 10–20 mm BELOW the lifted plane — outside the side test's - // ±10 mm in-plane window — so the cell behind the portal was - // side-culled: the tower-top staircase vanish + roof flap - // (#119-residual; captured live at eye z=126.803 vs the - // 010A→0107 plane at 126.80, reproduced ONLY with the lift in - // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical - // doorways were immune (the lift slides their planes along - // themselves), which is why this hit exactly stairs, decks, - // and cellar mouths. - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - - // Cache CellStruct physics BSP for indoor collision (UNCHANGED). - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } @@ -5665,6 +6146,17 @@ public sealed class GameWindow : IDisposable .DumpEntitySourceIds.Contains(stab.Id); int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; + // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to + // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based + // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the + // WorldBuilder editor shows it at the origin. acdream's render path came from + // WB (no distance LOD), so without this skip it draws the marker forever (the + // red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs + // skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0). + if ((stab.Id & 0xFF000000u) == 0x01000000u + && AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id)) + continue; + var meshRefs = new List(); var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); if ((stab.Id & 0xFF000000u) == 0x01000000u) @@ -5698,6 +6190,12 @@ public sealed class GameWindow : IDisposable } foreach (var mr in flat) { + // #136: skip an editor-only marker PART (retail hides it at runtime + // distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole + // part GfxObj 0x010028CA is such a marker — skipping it empties + // meshRefs and the whole stab drops below. + if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId)) + continue; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) { @@ -6786,7 +7284,27 @@ public sealed class GameWindow : IDisposable int observerCx = _liveCenterX; int observerCy = _liveCenterY; - if (_playerMode && _playerController is not null) + if (_playerMode && _playerController is not null + && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace) + { + // Teleport hold (#135): the local player position is frozen at the + // PRE-teleport spot, expressed in the OLD center frame, but + // _liveCenterX/_liveCenterY were already recentered onto the + // destination landblock (OnLivePositionUpdated). Follow the + // destination directly — the stale position-derived offset + // (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off + // the dungeon and trip ExitDungeonExpand, re-streaming the very + // neighbor window the pre-collapse just suppressed. Correct for an + // outdoor teleport too: pre-load the destination during the hold. + // + // NOTE: these assignments equal the observerCx/Cy defaults initialized + // above — the LOAD-BEARING effect of this branch is INHIBITING the + // position-derived offset in the else-if below while the player position + // is frozen, not the (redundant) assignment. Kept explicit for clarity. + observerCx = _liveCenterX; + observerCy = _liveCenterY; + } + else if (_playerMode && _playerController is not null) { // Player mode: follow the physics-resolved player position. // The player walks via the local physics engine; the server @@ -6798,12 +7316,28 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } else if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld - && _lastLivePlayerLandblockId is { } lid) + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) { - // Live mode (fly camera): follow the server's last-known player position. - observerCx = (int)((lid >> 24) & 0xFFu); - observerCy = (int)((lid >> 16) & 0xFFu); + // Live, not yet in player mode: the login auto-entry hold, or a live + // fly-camera spectator. Follow the PLAYER's server-known landblock; if it + // hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is + // the spawn/teleport recenter (the dungeon landblock at a dungeon login). + // + // #135 regression fix (2026-06-14): this MUST NOT fall through to the + // fly-camera projection below. During a dungeon-login hold the streaming is + // pre-collapsed onto the spawn landblock; a camera-derived observer far from + // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate — + // the player is never placed and login hangs with no dungeon. Previously + // _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC + // kept this branch on the dungeon; once it was filtered to the player guid + // (line ~4507), a not-yet-arrived player UP dropped to the camera branch. + // The fly camera is the OFFLINE observer only. + if (_lastLivePlayerLandblockId is { } lid) + { + observerCx = (int)((lid >> 24) & 0xFFu); + observerCy = (int)((lid >> 16) & 0xFFu); + } + // else: keep the _liveCenterX/_liveCenterY default (the spawn recenter). } else { @@ -6817,7 +7351,37 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); } - _streamingController.Tick(observerCx, observerCy); + // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell + // (indoor cell that doesn't see outside — the same predicate that kills + // the sun/sky, playerInsideCell below), collapse streaming to the single + // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25 + // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building + // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated + // and keep their surrounding terrain. + // True only for a sealed indoor cell. Read the physics CurrCell's own + // SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather + // than the render registry: the registry lookup only succeeds AFTER the + // landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which + // delayed the collapse and let the full 25×25 neighbor window churn in + // first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the + // moment the player is placed, so the collapse now engages at the snap. + bool insideDungeon = false; + if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv + && !pcEnv.SeenOutside) + { + insideDungeon = true; + // Pin the collapse to the cell's OWN landblock (cell id high 16 bits), + // NOT the position-derived observer landblock. A dungeon's EnvCells sit + // at arbitrary world coords (the "ocean" placement) with negative local + // offsets, so floor(pp.Y/192) lands one landblock off — which collapses + // onto the WRONG landblock and unloads the real dungeon, nulling CurrCell + // and breaking the render (the Bug-A coordinate class). The cell id is the + // authoritative landblock. + uint cellLb = pcEnv.Id >> 16; + observerCx = (int)((cellLb >> 8) & 0xFFu); + observerCy = (int)(cellLb & 0xFFu); + } + _streamingController.Tick(observerCx, observerCy, insideDungeon); // Re-inject persistent entities rescued from unloaded landblocks // into the current center landblock (the one the observer is in). @@ -6837,11 +7401,21 @@ public sealed class GameWindow : IDisposable // Step 2: routed through the controller; functionally identical. _liveSessionController?.Tick(); + // 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(); + // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers // that actually consume the events. _inputDispatcher?.Tick(); + // Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse + // on selection, then revert). No-op when nothing is flashing. + _selectedObjectController?.Tick(dt); + // Phase K.2 — re-evaluate WantCaptureMouse for the MMB // mouse-look state machine. Detect rising/falling edges so the // state suspends correctly when ImGui claims the cursor while @@ -6875,6 +7449,11 @@ public sealed class GameWindow : IDisposable // this guard adds defense-in-depth for the per-frame IsActionHeld // movement poll below (typing "walk" into a chat field shouldn't // walk). + // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a + // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block + // below still runs so AUTORUN keeps driving the character while you type. Held WASD + // is silenced at the source instead — InputDispatcher.IsActionHeld returns false + // while WantCaptureKeyboard (which includes a focused chat input) is set. bool suppressGameInput = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; if (suppressGameInput) return; @@ -6975,10 +7554,24 @@ public sealed class GameWindow : IDisposable // so it doesn't get frustum-culled when the player walks away from // the spawn landblock. Without this, the entity stays in the spawn // landblock's entity list and disappears when that landblock is culled. - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + uint currentLb; + if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u) + { + // Indoor cell (dungeon/building EnvCell): the entity's landblock is + // the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean" + // world coords with negative local-Y, so floor(pp.Y/192) lands one + // landblock off (the Bug-A class) — relocating the player into the + // landblock the dungeon collapse unloaded, making the avatar + // invisible. The cell id is authoritative. + currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu; + } + else + { + var pp = _playerController.Position; + int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); + int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); + currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + } _worldState.RelocateEntity(pe, currentLb); } @@ -7528,6 +8121,17 @@ public sealed class GameWindow : IDisposable // frame — terrain, static mesh, instanced mesh, sky. UpdateSunFromSky(kf, playerInsideCell); Lighting.Tick(camPos); + + // Fix B (A7 #3): build this frame's point-light snapshot and hand it to + // the entity dispatcher for per-OBJECT light selection + // (minimize_object_lighting). Replaces the single global nearest-8-to- + // camera UBO set for point/spot lights so a wall's torches stay tied to + // the wall as the camera moves. The SUN + ambient still flow through the + // SceneLighting UBO built below (binding=1) — terrain/sky read those. + Lighting.BuildPointLightSnapshot(camPos); + _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); + _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) + var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); @@ -7554,6 +8158,25 @@ public sealed class GameWindow : IDisposable _sceneLightingUbo?.Upload(ubo); + // #133 A7 (2026-06-13): objective dungeon-lighting probe. One + // rate-limited [light] line — insideCell / ambient / sun / + // registered-point-lights / active-slot-count / player cell — so + // the dungeon-dim question is self-verifiable from launch.log + // without a screenshot. RegisteredCount is point/spot lights only + // (the sun lives in LightManager.Sun, never in the _all list); + // ubo.CellAmbient.W is the shader active-slot count, which counts + // the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1. + AcDream.Core.Rendering.RenderingDiagnostics.EmitLight( + insideCell: playerInsideCell, + ambientR: Lighting.CurrentAmbient.AmbientColor.X, + ambientG: Lighting.CurrentAmbient.AmbientColor.Y, + ambientB: Lighting.CurrentAmbient.AmbientColor.Z, + sunIntensity: Lighting.Sun?.Intensity ?? 0f, + registeredLights: Lighting.RegisteredCount, + activeLights: (int)ubo.CellAmbient.W, + playerCellId: playerRoot?.CellId ?? 0u, + lights: Lighting); + // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) @@ -8208,6 +8831,16 @@ public sealed class GameWindow : IDisposable SkipWorldGeometry: ; } + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own + // blend/depth state and restores. Drawn before ImGui so the devtools + // overlay composites on top during development. + if (_options.RetailUi && _uiHost is not null) + { + _uiHost.Tick(deltaSeconds); + _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } + // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws // so ImGui composites on top. ImGuiController save/restores the // GL state it touches (blend, scissor, VAO, shader, texture); any @@ -10499,6 +11132,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { if (_lightingSink is not null && @@ -10981,7 +11615,7 @@ public sealed class GameWindow : IDisposable if (picked is uint guid) { - _selectedGuid = guid; + SelectedGuid = guid; string label = DescribeLiveEntity(guid); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); // B.7 (2026-05-15): one-shot per-pick diagnostic so we can @@ -10989,9 +11623,7 @@ public sealed class GameWindow : IDisposable // RadarBlipColor are produced for the just-picked entity. // Helps verify whether a "green NPC" really is flagged as // Vendor server-side or whether our lookup is wrong. - uint rawItemType = 0; - if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) - rawItemType = (uint)info.ItemType; + uint rawItemType = (uint)LiveItemType(guid); uint pwdBits = 0; uint? pickUseability = null; float? pickUseRadius = null; @@ -11048,8 +11680,7 @@ public sealed class GameWindow : IDisposable // Retail string at acclient_2013_pseudo_c.txt:1033115 // (data_7e2a70): "The %s cannot be used". - bool isCreature = _liveEntityInfoByGuid.TryGetValue(sel, out var info) - && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0; + bool isCreature = (LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0; if (isCreature) { @@ -11135,6 +11766,20 @@ public sealed class GameWindow : IDisposable } } + // Phase D.5.1 — direct use-by-guid for toolbar shortcut clicks. + // Mirrors the B.4b far-range send path; no proximity / auto-walk needed + // for items already in the player's inventory. + private void UseItemByGuid(uint guid) + { + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + return; + var seq = _liveSession.NextGameActionSequence(); + var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); + _liveSession.SendGameAction(body); + Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); + } + private void SendPickUp(uint itemGuid) { if (_liveSession is null @@ -11276,8 +11921,7 @@ public sealed class GameWindow : IDisposable // Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic. float useRadius = 0.6f; - if (_liveEntityInfoByGuid.TryGetValue(targetGuid, out var info) - && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) + if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0) { useRadius = 3.0f; } @@ -11304,8 +11948,7 @@ public sealed class GameWindow : IDisposable // Per-type use radius — same heuristic as the picker's // radiusForGuid callback. float useRadius = 0.6f; - if (_liveEntityInfoByGuid.TryGetValue(targetGuid, out var info) - && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) + if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0) { useRadius = 3.0f; } @@ -11362,7 +12005,7 @@ public sealed class GameWindow : IDisposable bestGuid = guid; } - _selectedGuid = bestGuid; + SelectedGuid = bestGuid; if (bestGuid is { } selected) { string label = DescribeLiveEntity(selected); @@ -11386,10 +12029,42 @@ public sealed class GameWindow : IDisposable return false; if (!_entitiesByServerGuid.ContainsKey(guid)) return false; - if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info)) + + return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0; + } + + // PublicWeenieDesc _bitfield flags (acclient.h:6431-6463) — same bitfield RadarBlipColors reads. + private const uint BfPlayer = 0x8u; // BF_PLAYER (acclient.h:6434) + private const uint BfAttackable = 0x10u; // BF_ATTACKABLE (acclient.h:6437) + + /// + /// True if the selected-object strip should show a Health meter for . + /// Approximates retail's IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable() + /// gate (gmToolbarUI::HandleSelectionChanged :198754) using the server-provided PWD flags: + /// the BF_ATTACKABLE bit (monsters) or the BF_PLAYER bit (other players). + /// A friendly NPC (e.g. a vendor) has neither bit set → name-only, matching retail. + /// The full PK/faction logic of ObjectIsAttackable + the pet case are not ported (divergence AP-46). + /// + private bool IsHealthBarTarget(uint guid) + { + if (guid == _playerServerGuid) + return false; + if (!_entitiesByServerGuid.ContainsKey(guid)) return false; - return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0; + uint pwd = _lastSpawnByGuid.TryGetValue(guid, out var spawn) + && spawn.ObjectDescriptionFlags is { } odf ? odf : 0u; + + // Another player → health bar (retail IsPlayer branch). + if ((pwd & BfPlayer) != 0) + return true; + + // Attackable branch: retail ObjectIsAttackable requires the object to be a CREATURE + // first (InqType() & 0x10, acclient_2013_pseudo_c.txt:375406), THEN attackable. A Door + // carries the BF_ATTACKABLE bit but is ItemType Misc, so it is never a health-bar target — + // require the Creature flag here too (matches retail; excludes attackable doors/objects). + bool isCreature = (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0; + return isCreature && (pwd & BfAttackable) != 0; } @@ -11556,8 +12231,7 @@ public sealed class GameWindow : IDisposable // `ItemUseable = null`; without the fallback the M1 "click NPC" // flow regresses. The diagnostic line below lets us measure // how often this branch fires in real play. - if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) - && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) + if ((LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0) { if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled) Console.WriteLine(System.FormattableString.Invariant( @@ -11665,11 +12339,15 @@ public sealed class GameWindow : IDisposable return (it & SmallItemMask) != 0u; } + private AcDream.Core.Items.ItemType LiveItemType(uint guid) => + Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None; + + private string? LiveName(uint guid) => Objects.Get(guid)?.Name; + private string DescribeLiveEntity(uint guid) { - if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) - && !string.IsNullOrWhiteSpace(info.Name)) - return info.Name!; + var name = LiveName(guid); + if (!string.IsNullOrWhiteSpace(name)) return name!; return $"0x{guid:X8}"; } @@ -11747,6 +12425,35 @@ public sealed class GameWindow : IDisposable return unhydratable; } + // #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell + // (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes + // a real dungeon (collapse streaming to its single landblock) from a building + // interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from + // an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as + // the hydration path (BuildLoadedCell, ~line 5999) and as the physics + // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse + // decision matches the eventual gate decision exactly. Returns false when the dat + // lacks the cell (out-of-range index / missing record) so we never collapse on a + // guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may + // already hold it (the login spawn handler does). + private bool IsSealedDungeonCell(uint cellId) + { + // Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF + // structural shell ids (LandBlockInfo / LandBlock heightmap). A naive + // `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get on + // 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as + // an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport + // position never carries a shell id, but exclude them so the read is sound. + uint low = cellId & 0xFFFFu; + if (low < 0x0100u || low >= 0xFFFEu) return false; + if (_dats is null) return false; + DatReaderWriter.DBObjs.EnvCell? envCell; + lock (_datLock) + envCell = _dats.Get(cellId); + return envCell is not null + && !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside); + } + private void EnterPlayerModeFromAutoEntry() { _playerMode = true; @@ -12198,6 +12905,7 @@ public sealed class GameWindow : IDisposable _sceneLightingUbo?.Dispose(); _particleRenderer?.Dispose(); _debugLines?.Dispose(); + _uiHost?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); _dats?.Dispose(); diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index 7765a46a..f2e879ae 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -46,10 +46,12 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -// 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. +// 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. vec3 accumulateLights(vec3 N, vec3 worldPos) { vec3 lit = uCellAmbient.xyz; int activeLights = int(uCellAmbient.w); @@ -73,14 +75,19 @@ 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)); - float atten = 1.0; // retail: no attenuation inside Range + // 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); 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; } - lit += Lcol * ndl * atten; + // 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); } } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index bbcc9584..4f344369 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -4,6 +4,7 @@ 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; @@ -31,36 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -vec3 accumulateLights(vec3 N, vec3 worldPos) { - vec3 lit = uCellAmbient.xyz; - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - int kind = int(uLights[i].posAndKind.w); - vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; - if (kind == 0) { - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += Lcol * ndl; - } else { - vec3 toL = uLights[i].posAndKind.xyz - worldPos; - float d = length(toL); - float range = uLights[i].dirAndRange.w; - if (d < range && range > 1e-3) { - vec3 Ldir = toL / max(d, 1e-4); - float ndl = max(0.0, dot(N, Ldir)); - float atten = 1.0; - if (kind == 2) { - float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); - atten *= (cos_l > cos_edge) ? 1.0 : 0.0; - } - lit += Lcol * ndl * atten; - } - } - } - return lit; -} +// 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 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); @@ -106,8 +82,8 @@ void main() { if (color.a < 0.05) discard; } - vec3 N = normalize(vNormal); - vec3 lit = accumulateLights(N, vWorldPos); + // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights). + vec3 lit = vLit; // Lightning flash — additive scene bump (matches mesh_instanced.frag). lit += uFogParams.z * vec3(0.6, 0.6, 0.75); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index ce4378ac..78011f66 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -69,6 +69,33 @@ 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 @@ -95,10 +122,107 @@ 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; @@ -123,6 +247,7 @@ 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]; diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag index 7740ea11..75c9cd3d 100644 --- a/src/AcDream.App/Rendering/Shaders/ui_text.frag +++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag @@ -7,10 +7,13 @@ uniform sampler2D uTex; uniform int uUseTexture; void main() { - if (uUseTexture != 0) { + if (uUseTexture == 1) { // 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; } diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index ad04da1a..88592057 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -25,14 +25,39 @@ 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 _textBuf = new(8192); private readonly List _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 Verts = new(256); } + private readonly List _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 _overlayTextBuf = new(1024); + private readonly List _overlayRectBuf = new(256); + private readonly List _overlaySpriteSegs = new(); + private int _overlaySegUsed; + private int _overlayTextVerts; + private int _overlayRectVerts; + + /// 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. + public bool OverlayMode { get; set; } + public TextRenderer(GL gl, string shaderDir) { _gl = gl; @@ -56,6 +81,20 @@ 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 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); } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -64,17 +103,32 @@ 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; } /// Draw a filled rectangle in screen pixel space. public void DrawRect(float x, float y, float w, float h, Vector4 color) { - AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); - _rectVerts += 6; + 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; } } + /// 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 — 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. + public void DrawFill(float x, float y, float w, float h, Vector4 color) + => DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color); + /// Draw a 1-pixel-thick outline rect. public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) { @@ -119,16 +173,47 @@ public sealed unsafe class TextRenderer : IDisposable if (gw > 0 && gh > 0) { - AppendQuad(_textBuf, - gx, gy, gw, gh, - g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, - color); - _textVerts += 6; + 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; } } cursorX += g.Advance; } } + /// + /// 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). + /// + 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); + } + + /// Pick the sprite segment for : 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). + private static SpriteSeg NextSpriteSeg(List 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 buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) @@ -159,7 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - if (_textVerts == 0 && _rectVerts == 0) return; + bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0; + bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; + if (!anyNormal && !anyOverlay) return; _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); @@ -171,36 +258,85 @@ 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); - // Untextured rects first — they form panel backgrounds. - if (_rectVerts > 0) + // 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); + } + + /// 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 . + private void DrawLayer( + List spriteSegs, int segUsed, + List rectBuf, int rectVerts, + List textBuf, int textVerts, BitmapFont? font) + { + // 1. RGBA dat sprites — one draw call per distinct GL texture. + if (segUsed > 0) { - _shader.SetInt("uUseTexture", 0); - UploadBuffer(_rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); + _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)); + } } - // Textured text glyphs. - if (_textVerts > 0 && font is not null) + // 2. Untextured rects — widget fills on top of the chrome. + if (rectVerts > 0) + { + _shader.SetInt("uUseTexture", 0); + UploadBuffer(rectBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); + } + + // 3. Textured debug-font text glyphs on top. + 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 buf) @@ -223,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable public void Dispose() { + _gl.DeleteTexture(_whiteTex); _gl.DeleteBuffer(_vbo); _gl.DeleteVertexArray(_vao); _shader.Dispose(); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 056ec01f..bbc7d4b5 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly GL _gl; private readonly DatCollection _dats; private readonly Dictionary _handlesBySurfaceId = new(); + private readonly Dictionary _sizeBySurfaceId = new(); /// /// Composite cache for surface-with-override-origtex entries (Phase 5 /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), @@ -30,6 +31,18 @@ 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 _handlesByRenderSurfaceId = new(); + private readonly Dictionary _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 _adhocHandles = new(); + private readonly Wb.BindlessSupport? _bindless; // Bindless / Texture2DArray parallel caches. Keys mirror the legacy three @@ -80,6 +93,74 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return h; } + /// + /// Like but also returns the decoded + /// pixel dimensions. UI 9-slice geometry needs the source size to + /// compute slice UVs. Cached alongside the handle. + /// + 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; + } + + /// + /// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded + /// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the + /// Surface→SurfaceTexture chain that 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 + /// DefaultPaletteId (same starting palette + /// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns + /// a 1x1 magenta handle on miss. + /// + 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(renderSurfaceId, out var rs) + || _dats.HighRes.TryGet(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(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; + } + /// /// Alpha-channel histogram for one decoded texture. Used to diagnose /// "why are clouds not transparent" — if cloud textures come out with @@ -476,7 +557,19 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return composed; } - private uint UploadRgba8(DecodedTexture decoded) + /// Uploads a raw RGBA8 byte array as a Texture2D. Used by + /// to upload CPU-composited icon layers. + /// The returned handle is tracked in and deleted by + /// . Callers must NOT also store the handle in any of the + /// keyed caches — that would cause a double-delete on Dispose. + 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) { uint tex = _gl.GenTexture(); _gl.BindTexture(TextureTarget.Texture2D, tex); @@ -493,8 +586,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab PixelType.UnsignedByte, p); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + // 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.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); @@ -582,5 +678,17 @@ 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(); } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 2fe1a37a..bf80e9e0 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable private uint _clipSlotBuffer; private uint[] _clipSlotData = Array.Empty(); + // 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? _pointSnapshot; + private readonly System.Collections.Generic.Dictionary _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. @@ -231,6 +242,18 @@ 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); } @@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void SetClipRouting(IReadOnlyDictionary? cellIdToSlot) => _cellIdToSlot = cellIdToSlot; + /// + /// 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. + /// + public void SetPointSnapshot( + System.Collections.Generic.IReadOnlyList? snapshot) + => _pointSnapshot = snapshot; + // --------------------------------------------------------------------------- // GetEnvCellGeomId // Verbatim copy of WB EnvCellRenderManager.cs:94-103. @@ -843,6 +877,7 @@ 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 @@ -997,6 +1032,35 @@ 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). @@ -1016,6 +1080,15 @@ 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 — @@ -1213,6 +1286,35 @@ 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.) @@ -1228,6 +1330,8 @@ 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); @@ -1443,5 +1547,7 @@ 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) } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e266be8c..6b7ac0d6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2,6 +2,7 @@ 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; @@ -132,6 +133,24 @@ 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? _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 @@ -329,8 +348,21 @@ 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 } + /// + /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot + /// (). Call once per frame BEFORE + /// . The dispatcher uploads it to binding=4 and selects each + /// object's up-to-8 lights from it () + /// by the object's bounding sphere — camera-independent. Pass null/empty to + /// disable per-object point lights (only ambient + sun render). + /// + public void SetSceneLights(IReadOnlyList? pointSnapshot) + => _pointSnapshot = pointSnapshot; + /// /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// (binding=2) that created. The @@ -861,6 +893,9 @@ 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(); @@ -888,7 +923,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(); } + foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); } var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -1053,6 +1088,11 @@ 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. @@ -1350,6 +1390,13 @@ 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(); @@ -1375,6 +1422,13 @@ 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++; } @@ -1460,6 +1514,15 @@ 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); @@ -1743,6 +1806,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); } + /// + /// Fix B: pack into the binding=4 global light + /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, + /// matching mesh_modern.vert's GlobalLight). 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). + /// + 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)); + } + /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -1936,6 +2016,75 @@ 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 + } + + /// + /// 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 (), so + /// a static building's torches stay constant as the viewer moves. Fills + /// ; unused slots are -1. On the no-lights + /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. + /// + /// + /// 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 + /// (minimize_object_lighting, 0x0054d480) runs ONLY in the indoor stage: + /// RenderDeviceD3D::DrawMeshInternal (0x0059f398) calls it under + /// if (Render::useSunlight == 0), and the outdoor landscape stage runs + /// Render::useSunlightSet(1) (PView::DrawCells 0x005a485a, right + /// before LScape::draw which draws buildings/scenery). So a building + /// EXTERIOR shell (, + /// = 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 (UpdateSunFromSky). See the divergence register + /// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*. + /// + /// + 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); + } + + /// + /// Retail's useSunlight gate for per-object torch lighting, as a pure + /// predicate. An object receives the static wall torches (the indoor + /// minimize_object_lighting pass) ONLY when it is parented to an EnvCell + /// — an interior cell, by the AC convention (cellId & 0xFFFF) >= 0x0100. + /// Outdoor objects (building shells with null , + /// outdoor scenery in a land sub-cell 0x0001..0x00FF, outdoor creatures) + /// are sun-lit only and return false. Mirrors + /// RenderDeviceD3D::DrawMeshInternal (0x0059f398): torches enabled iff + /// Render::useSunlight == 0, which is true only in the indoor draw stage. + /// + internal static bool IndoorObjectReceivesTorches(uint? parentCellId) + => parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u; + + /// + /// Fix B: append the current entity's 8-slot light set to a group's + /// , parallel to its Matrices (one + /// 8-int block per instance), mirroring grp.Slots.Add. + /// + private void AppendCurrentLightSet(InstanceGroup grp) + { + for (int k = 0; k < LightManager.MaxLightsPerObject; k++) + grp.LightSets.Add(_currentEntityLightSet[k]); } private void ClassifyBatches( @@ -1993,6 +2142,7 @@ 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)); } } @@ -2072,6 +2222,8 @@ 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++) @@ -2257,5 +2409,13 @@ 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 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 LightSets = new(); } } diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index a1ceb4db..9be7601d 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -39,7 +39,9 @@ public sealed record RuntimeOptions( bool RetailCloseDegrades, bool DumpSceneryZ, bool DumpLiveSpawns, - int? LegacyStreamRadius) + int? LegacyStreamRadius, + bool RetailUi, + string? AcDir) { /// /// Build options from the process environment. Used by @@ -81,7 +83,9 @@ 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"))); + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), + RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index c5e36815..050c1265 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId) { public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); + + /// + /// Control job: drop every queued (not-yet-started) Load from the worker's + /// priority queues, keeping Unloads. Posted by + /// 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. + /// + public sealed record ClearLoads() : LandblockStreamJob(0); } /// diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index 19b2a94b..ffaa6de7 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); } + /// + /// Cancel every queued-but-not-started Load. Posts a + /// 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. + /// + public void ClearPendingLoads() + { + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + _inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads()); + } + /// /// Drain up to completed results. /// Non-blocking. Call from the render thread once per OnUpdate. @@ -180,7 +196,18 @@ 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; @@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable lowPriority.Enqueue(job); } + /// + /// Drop every from a priority queue, + /// preserving Unloads (and any other control jobs). Rotates the queue once + /// in place. Used by the path. + /// + private static void DropLoadJobs(Queue 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 queue, uint landblockId, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index f0bc0955..d6d00518 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,9 +22,24 @@ public sealed class StreamingController private readonly Func> _drainCompletions; private readonly Action _applyTerrain; private readonly Action? _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; + /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. @@ -71,13 +86,15 @@ public sealed class StreamingController GpuWorldState state, int nearRadius, int farRadius, - Action? removeTerrain = null) + Action? removeTerrain = null, + Action? clearPendingLoads = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; + _clearPendingLoads = clearPendingLoads; _state = state; NearRadius = nearRadius; FarRadius = farRadius; @@ -97,7 +114,76 @@ public sealed class StreamingController /// → enqueue full unload /// /// - public void Tick(int observerCx, int observerCy) + 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(); + } + + /// + /// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first + /// 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. + /// + /// The per-frame insideDungeon gate keys on the physics + /// CurrCell, 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 + /// 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. + /// + /// 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 _clearPendingLoads 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 . + /// + public void PreCollapseToDungeon(int cx, int cy) + { + uint centerId = StreamingRegion.EncodeLandblockId(cx, cy); + if (_collapsed && _collapsedCenter == centerId) return; + EnterDungeonCollapse(cx, cy, centerId); + } + + /// + /// Outdoor / building-interior streaming — the original two-tier model. + /// + private void NormalTick(int observerCx, int observerCy) { if (_region is null) { @@ -116,9 +202,88 @@ public sealed class StreamingController foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } + } - // Drain up to N completions per frame so a big diff doesn't spike - // GPU upload time. Remaining completions wait for the next frame. + /// + /// 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 LandblockManager.GetAdjacentIDs 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. + /// + 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); + } + + /// + /// While collapsed, unload any landblock that finished loading after the + /// collapse edge — a Load the worker had already dequeued before the + /// control job took + /// effect. At steady state only the dungeon landblock is resident, so this + /// is a no-op. + /// + 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); + } + + /// Chebyshev distance in landblock cells between two landblock ids. + 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)); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Drain up to N completions per frame so a big diff doesn't spike GPU + /// upload time. Remaining completions wait for the next frame. + /// + private void DrainAndApply() + { var drained = _drainCompletions(MaxCompletionsPerFrame); foreach (var result in drained) { diff --git a/src/AcDream.App/UI/ControlsIni.cs b/src/AcDream.App/UI/ControlsIni.cs new file mode 100644 index 00000000..2812d696 --- /dev/null +++ b/src/AcDream.App/UI/ControlsIni.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Minimal reader for retail's controls.ini — a flat INI with one +/// [section] per element type. Colors are #AARRGGBB (alpha +/// first). Optional: a missing file yields an empty sheet (callers fall back +/// to hardcoded defaults). See the D.2b spec §7. +/// +public sealed class ControlsIni +{ + private readonly Dictionary> _sections; + + private ControlsIni(Dictionary> s) => _sections = s; + + /// Load from disk; returns an empty sheet if the file is absent. + 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>(System.StringComparer.OrdinalIgnoreCase); + Dictionary? 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(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; + + /// Parse a #AARRGGBB token into an RGBA . + 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; + } +} diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs new file mode 100644 index 00000000..2ff95019 --- /dev/null +++ b/src/AcDream.App/UI/IconComposer.cs @@ -0,0 +1,271 @@ +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; + +/// +/// 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) — +/// 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, 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. +/// +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 0058d214–0058d22c; DBCache::GetDIDFromEnum 0x413940. + private EnumIDMap? _underlaySubMap; + private bool _underlayResolveTried; + private readonly Dictionary _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 _effectDidByIndex = new(); + private readonly Dictionary _effectTileByDid = new(); + + public IconComposer(DatCollection dats, TextureCache cache) + { + _dats = dats; + _cache = cache; + } + + /// + /// Resolve the type-default underlay DID for via the + /// two-level EnumIDMap chain (retail: IconData::RenderIcons 0058d214–0058d22c + + /// DBCache::GetDIDFromEnum 0x413940). + /// + /// index = LowestSetBit(itemType) + 1, or 0x21 when itemType has no bits set. + /// + /// 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). + /// + 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(masterDid, out var master)) return; + if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008 + if (_dats.Portal.TryGet(subDid, out var sub)) _underlaySubMap = sub; + } + + /// + /// Resolve the effect-overlay DID for 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.) + /// + 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(masterDid, out var master)) return; + if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 + if (_dats.Portal.TryGet(subDid, out var sub)) _effectSubMap = sub; + } + + /// + /// Retail SurfaceWindow::ReplaceColor SURFACE overload (0x004415b0): for every + /// pixel in 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 in place. + /// + 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]; + } + } + } + + /// + /// The decoded effect tile for (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. + /// + 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(renderSurfaceId, out var rs) && + !_dats.HighRes.TryGet(renderSurfaceId, out rs)) + return false; + decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + return true; + } + + /// Pure alpha-over composite, bottom->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). + public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers) + { + if (layers.Count == 0) return (Array.Empty(), 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); + } + + /// + /// 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. + /// + 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(renderSurfaceId, out var rs) && + !_dats.HighRes.TryGet(renderSurfaceId, out rs)) + return; + var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + layers.Add((decoded.Rgba8, decoded.Width, decoded.Height)); + } +} diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs new file mode 100644 index 00000000..7726b96a --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -0,0 +1,472 @@ +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; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130. +/// +/// +/// The transcript (0x10000011) is Type-12 and is built as a +/// by the factory; this controller binds its live data provider in place. The input +/// (0x10000016) is also Type-12, so the factory builds it as an invisible +/// placeholder; this controller removes that placeholder and adds +/// a at the same rect. The scrollbar track (0x10000012) is +/// built directly as a by the factory (Type 11) and bound in +/// place. The channel menu (0x10000014) is built as (Type 6) +/// and bound in place. +/// +/// +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 ───────────────────────────────────────────────────── + + /// Root element of the imported layout (the chat window chrome). + public UiElement Root { get; private set; } = null!; + + /// Live chat transcript widget. Null until succeeds. + public UiText Transcript { get; private set; } = null!; + + /// Editable chat input widget. Null until succeeds. + public UiField Input { get; private set; } = null!; + + /// Scrollbar widget, driven by 's scroll model. + public UiScrollbar Scrollbar { get; private set; } = null!; + + /// Channel-selector menu widget. + 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; + + /// Window height before maximize (stored to restore on un-maximize). + private float _normalHeight; + /// Window top before maximize. + private float _normalTop; + private bool _maximized; + + // ── Factory ──────────────────────────────────────────────────────────── + + /// + /// Bind an imported chat layout to live behavior. + /// + /// and must come from the + /// SAME pass (ImportInfos then Build) + /// so rects in the info tree match the widget geometry in the layout tree. + /// + /// Returns null if the essential transcript/input panels are missing from + /// the info tree or the widget tree (e.g. the layout dat is incomplete). + /// + /// Full tree from + /// . + /// Widget tree from . + /// Chat view-model (transcript data + command routing). + /// Factory that returns the live command bus at submit time. + /// Called on every chat submit so it resolves + /// even when the live session is established AFTER runs + /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). + /// Retail dat font for transcript + input rendering. + /// Fallback debug bitmap font (used when + /// is null). + /// Dat RenderSurface id → (GL tex handle, px width, px height). + /// Forwarded to and . + public static ChatWindowController? Bind( + ElementInfo rootInfo, + ImportedLayout layout, + ChatVM vm, + Func busProvider, + UiDatFont? datFont, + BitmapFont? debugFont, + Func 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 ───────────────────────────────────────────── + + /// + /// Toggle between the normal chat window height and an expanded 320px height. + /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0: + /// retail stores the pre-maximize height and restores it on a second click. + /// The 320px expanded size is the approximate retail maximized chat height. + /// + 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 ──────────────────────────────────────────────────────────── + + /// + /// Depth-first search for an node by id in the + /// raw info tree (which contains ALL elements, including the Type-12 skipped ones). + /// + 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; + } + + /// + /// Convert the ChatVM's detailed lines to the transcript's + /// record format, applying retail-faithful + /// per- colors. + /// + private static IReadOnlyList BuildLines( + ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont) + { + var detailed = vm.RecentLinesDetailed(); + if (detailed.Count == 0) return Array.Empty(); + + // 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 measure = + datFont is { } df ? s => df.MeasureWidth(s) + : debugFont is { } bf ? s => bf.MeasureWidth(s) + : s => s.Length * 7f; + + var result = new List(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; + } + + /// + /// Greedy word-wrap: split into fragments that each fit in + /// pixels (per ), 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). + /// + public static IEnumerable WrapText(string text, float maxW, Func 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(); + } + + /// + /// Per- text color — the EXACT retail RGBA values read from a + /// live retail client via cdb (the named RGBAColor constants at acclient + /// 0x81c4a8+, e.g. colorWhite/colorBrightPurple/colorLightBlue/ + /// colorGreen, used by ChatInterface::BuildChatColorLookupTable @0x4f31c0). + /// 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. + /// + 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) + }; +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs new file mode 100644 index 00000000..287971fe --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -0,0 +1,247 @@ +using System; +using System.Linq; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim +/// algorithm ports); everything else (and unknown Types) falls back to +/// . +/// +/// +/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12 +/// element is now built as a . Elements that carry their own +/// dat sprite media keep it as the . Pure +/// prototype elements (no state media, no controller binding) draw nothing because +/// defaults to transparent. +/// +/// +/// +/// The meter's back/front 3-slice sprite ids live on grandchild image elements, +/// NOT on the meter element itself (format doc §11). +/// walks two layers down to extract them: the two Type-3 container children +/// ordered by (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. +/// +/// +/// +/// The expand-detail overlay present in the front container carries ONLY named +/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the +/// TryGetValue("") filter in excludes it +/// automatically. +/// +/// +public static class DatWidgetFactory +{ + /// + /// Creates the for , sets its + /// rect (Left/Top/Width/Height) and Anchors, and returns it. + /// + /// Resolved, merged element snapshot from the LayoutDesc importer. + /// RenderSurface id → (GL tex handle, pixel width, pixel height). + /// Returns (0,0,0) when the texture is not yet uploaded. + /// 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. + /// The widget for this element. Never null — every type produces a widget. + public static UiElement? Create(ElementInfo info, + Func 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 ──────────────────────────────────────────────────────────────── + + /// + /// Builds a and populates its sprite ids from the meter's + /// child/grandchild elements (format doc §11). Two shapes are handled: + /// + /// + /// 3-slice shape (vitals meters — 2 Type-3 containers, each with 3 image grandchildren): + /// + /// 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) + /// + /// + /// + /// + /// Single-image shape (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 tiles + /// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction. + /// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328) + /// + /// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E] + /// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F] + /// + /// + /// + /// + /// and are NOT set here. + /// They are bound to the live stat providers by the controller (VitalsController / + /// SelectedObjectController). + /// + /// + private static UiMeter BuildMeter(ElementInfo info, + Func 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; + } + + /// + /// 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. + /// + /// + /// Children that carry ONLY named states (e.g. the expand-detail overlay with + /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically + /// because for "" returns + /// false. + /// + /// + 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 ───────────────────────────────────────────────────────────────── + + /// 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 defaults to transparent. + private static UiText BuildText(ElementInfo info, Func 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 }; + } +} diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs new file mode 100644 index 00000000..93a4eb30 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; + +namespace AcDream.App.UI.Layout; + +/// +/// GL-free, dat-free snapshot of a resolved layout element. +/// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc +/// after inheritance is applied. The pure transforms on +/// operate on this type so they can be unit-tested without the dats or OpenGL. +/// +/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without +/// updating the plan spec and downstream consumers. +/// +public sealed class ElementInfo +{ + /// Dat element id (e.g. 0x100000E6). + public uint Id; + + /// + /// Raw element class id as a uint. + /// Game-specific ids like 0x1000004D (gmVitalsUI root) and 0x10000009 + /// overflow int when treated as signed, so this stays uint. + /// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter, + /// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root. + /// + public uint Type; + + /// Position and size within the parent, in pixels (cast from dat uint fields). + public float X, Y, Width, Height; + + /// + /// Raw edge-anchor flag values from the dat (LeftEdge, TopEdge, + /// RightEdge, BottomEdge fields of ElementDesc). + /// Values 0–4; map to bit-flags via + /// . + /// + public uint Left, Top, Right, Bottom; + + /// Draw order within the parent (lower = drawn first / behind). + public uint ReadOrder; + + /// + /// Font dat object id inherited from the base element's Properties[0x1A] + /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited. + /// + public uint FontDid; + + /// + /// Sprite per state: state name → (RenderSurface file id, DrawMode int). + /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). + /// Named states use the UIStateId.ToString() value as the key + /// (e.g. "HideDetail", "ShowDetail"). + /// + public Dictionary StateMedia = new(); + + /// + /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString(). + /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set). + /// Used by to pick which state's sprite to render initially. + /// Examples: "Normal" (Send button), "Minimized" (max/min button), "" (DirectState). + /// + public string DefaultStateName = ""; + + /// + /// Resolved child elements (populated by the importer in Task 5). + /// Children come from the derived element's own tree, not the base element's. + /// + public List Children = new(); +} + +/// +/// Pure, GL-free, dat-free transforms for the LayoutDesc importer. +/// All methods are static and operate on POCOs. +/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond +/// the bit-flag enum from AcDream.App.UI. +/// +public static class ElementReader +{ + /// 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). + /// LeftEdge dat field value (0–4). + /// TopEdge dat field value (0–4). + /// RightEdge dat field value (0–4). + /// BottomEdge dat field value (0–4). + 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; + } + + /// + /// Merges a base element snapshot with a derived element snapshot, mirroring + /// the BaseElement / BaseLayoutId inheritance chain in the dat. + /// + /// + /// Rules: + /// + /// + /// Scalar fields (, , + /// , , + /// ): derived wins if non-zero; otherwise + /// inherited from base. + /// + /// + /// Position (, ) and + /// edge flags ( etc.) and + /// : always taken from the derived element + /// (derived placement, not the base prototype's geometry). + /// + /// + /// : base entries are the default; derived + /// entries override (or add) per state name key. + /// + /// + /// : come from the derived element's own tree only. + /// + /// + /// + /// + 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(derived.Children), + }; + // Start with base StateMedia as defaults, then let derived entries override. + m.StateMedia = new Dictionary(base_.StateMedia); + foreach (var kv in derived.StateMedia) + m.StateMedia[kv.Key] = kv.Value; + return m; + } +} diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs new file mode 100644 index 00000000..6a3cdd1e --- /dev/null +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// +/// The result of importing a retail LayoutDesc: a tree with +/// an O(1) lookup table for finding any element by its dat id. +/// +public sealed class ImportedLayout +{ + /// Root widget of the imported tree. + public UiElement Root { get; } + + private readonly Dictionary _byId; + + public ImportedLayout(UiElement root, Dictionary byId) + { + Root = root; + _byId = byId; + } + + /// Find a widget by its dat element id (e.g. 0x100000E6). + /// Returns null if the id was skipped (Type-12 prototype) or not present. + public UiElement? FindElement(uint id) + => _byId.TryGetValue(id, out var e) ? e : null; +} + +/// +/// Two-layer layout importer for retail LayoutDesc dat objects. +/// +/// +/// Pure layer ( / ): +/// converts a pre-resolved tree into a +/// tree via . Testable without dats or OpenGL — all tests +/// in LayoutImporterTests.cs exercise this layer only. +/// +/// +/// +/// Dat shell (): reads a , +/// converts each top-level to a fully resolved +/// (applying BaseElement / BaseLayoutId +/// inheritance with a cycle guard), then delegates to . +/// +/// +/// +/// Meter elements (Type 7) consume their own dat-children: +/// reads the grandchild slice-sprite ids during construction, so the +/// children must NOT be added as separate nodes in the tree. +/// Every other element type recurses its children generically. +/// +/// +public static class LayoutImporter +{ + // ── Pure layer ──────────────────────────────────────────────────────────── + + /// + /// Convenience for tests: attach to + /// , then call . + /// The children list is set directly on ; + /// any existing children are replaced. + /// + public static ImportedLayout BuildFromInfos( + ElementInfo rootInfo, + IEnumerable children, + Func resolve, + UiDatFont? datFont) + { + rootInfo.Children = new List(children); + return Build(rootInfo, resolve, datFont); + } + + /// + /// Pure builder: produce the widget tree from a fully resolved + /// tree (children already attached). + /// + public static ImportedLayout Build( + ElementInfo rootInfo, + Func resolve, + UiDatFont? datFont) + { + var byId = new Dictionary(); + // 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 resolve, + UiDatFont? datFont, + Dictionary 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 ───────────────────────────────────────────────────────────── + + /// + /// 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. + /// + /// The dat collection to read the LayoutDesc from. + /// The LayoutDesc dat id to read. + public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId) + { + var ld = dats.Get(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), 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(); + foreach (var kv in ld.Elements) + CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase); + + var tops = new List(); + 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 }; + } + + /// + /// 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. + /// + public static ImportedLayout? Import( + DatCollection dats, + uint layoutId, + Func resolve, + UiDatFont? datFont) + { + var rootInfo = ImportInfos(dats, layoutId); + if (rootInfo is null) return null; + return Build(rootInfo, resolve, datFont); + } + + // ── Inheritance resolution ──────────────────────────────────────────────── + + /// + /// Converts an to a resolved : + /// reads own fields + media, applies the BaseElement / BaseLayoutId chain + /// (cycle-guarded by ), then resolves + attaches children. + /// + 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(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; + } + + /// + /// Read an 's own scalar fields + state media into a + /// fresh . No inheritance is applied; children are not + /// attached (the caller handles those). + /// + 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; + } + + /// + /// Read the first from into + /// info.StateMedia[name] and extract the font DID from property 0x1A + /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. + /// + 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 ─────────────────────────────────────────── + + /// + /// Recursively walks and all its children, adding to + /// the BaseElement of every descriptor that + /// references this layout (BaseLayoutId == layoutId). Used by + /// to identify pure prototype/template elements that + /// should not be instantiated as live widgets. + /// + private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet result) + { + if (d.BaseElement != 0 && d.BaseLayoutId == layoutId) + result.Add(d.BaseElement); + foreach (var kv in d.Children) + CollectBaseRefsInDesc(kv.Value, layoutId, result); + } + + /// + /// Returns true when carries no own state media — i.e. its + /// StateDesc (DirectState) and States (named states) yield no + /// entries with a non-zero file id. + /// Such elements are pure inheritance templates with no rendering content. + /// + 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 ─────────────────────────────────────────────────── + + /// + /// Find an by id anywhere in the top-level tree of + /// (depth-first). Returns null if not found. + /// + 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; + } +} diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs new file mode 100644 index 00000000..74dfe76e --- /dev/null +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -0,0 +1,268 @@ +using System; +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A1). +/// Analogue of retail gmToolbarUI::HandleSelectionChanged +/// (docs/research/named-retail/acclient_2013_pseudo_c.txt:198635) + +/// RecvNotice_UpdateObjectHealth (:196213). +/// +/// +/// 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 QueryHealth (0x01BF) request. The Health meter +/// becomes visible only when the server actually reports health for the selected guid — +/// either an UpdateHealth (0x01C0) arrives (retail +/// RecvNotice_UpdateObjectHealthSetVisible(1)) 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. +/// +/// +/// +/// Retail element roles (PostInit, :198119): m_pSelObjectField +/// is the container 0x1000019E whose SetState(0x1000000b/0c) drives a +/// 0.25s Pause→Normal 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 0x100001A0 directly and reverts it after the same +/// to reproduce the brief flash. The name element +/// 0x1000019F 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). +/// +/// +/// +/// Divergence — health-target gate approximation. +/// Retail sends Event_QueryHealth for IsPlayer() || pet_owner || ObjectIsAttackable() +/// (:198754). acdream uses IsLiveCreatureTarget (the ItemType.Creature +/// 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. +/// +/// +public sealed class SelectedObjectController +{ + // ── Element ids (toolbar LayoutDesc 0x21000016) ───────────────────────── + /// Selected-object container / field element id (retail m_pSelObjectField). + public const uint ContainerId = 0x1000019E; + /// Selected-object name element id (retail m_pSelObjectName, UIElement_Text). + public const uint NameId = 0x1000019F; + /// Selected-object overlay element id (states: ObjectSelected / StackedItemSelected). + public const uint OverlayId = 0x100001A0; + /// Selected-object health meter element id (retail m_pSelObjectHealthMeter). + public const uint HealthMeterId = 0x100001A1; + + /// Selection-overlay flash duration — retail's container ObjectSelected state is a + /// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E). + private const double FlashSeconds = 0.25; + + /// Z-order for the name so it draws OVER the overlay frame + health bar. + /// The strip's other children sit at ReadOrder 1–4; this floats the name to the top. + private const int NameZOrderOnTop = 1_000_000; + + /// 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). + private const int OverlayZOrder = NameZOrderOnTop - 1; + + /// 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. + 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 _isHealthTarget; + private readonly Func _resolveName; + private readonly Func _healthPercent; + private readonly Func _hasHealth; + private readonly Func _stackSize; + private readonly Action _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 + + /// White label color for the name line. + private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f); + + private SelectedObjectController( + ImportedLayout layout, + Action> subscribeSelectionChanged, + Action> subscribeHealthChanged, + Func isHealthTarget, + Func name, + Func healthPercent, + Func hasHealth, + Func stackSize, + Action 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() + : 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); + } + + /// + /// Create and bind a to . + /// Port of retail gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth. + /// + /// Imported toolbar layout (LayoutDesc 0x21000016). + /// Called once with + /// (typical host: h => SelectionChanged += h). + /// Called once with + /// (typical host: h => Combat.HealthChanged += h) — drives meter visibility. + /// Returns true for guids that may show a health meter + /// (proxy for retail's IsPlayer() || pet_owner || ObjectIsAttackable()). + /// Returns the display name for a given guid (or null if unknown). + /// Returns the health fill fraction [0..1] for a given guid. + /// Returns true if real health has been received for a guid + /// (so a re-selected, already-known target shows its bar immediately). + /// Returns the stack size for a guid (0 or 1 = non-stacked). + /// Sends retail QueryHealth (0x01BF); may be a no-op offline. + /// Dat font for the name label; null = debug bitmap font fallback. + public static SelectedObjectController Bind( + ImportedLayout layout, + Action> subscribeSelectionChanged, + Action> subscribeHealthChanged, + Func isHealthTarget, + Func name, + Func healthPercent, + Func hasHealth, + Func stackSize, + Action sendQueryHealth, + UiDatFont? datFont) + => new SelectedObjectController( + layout, subscribeSelectionChanged, subscribeHealthChanged, + isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont); + + /// + /// Port of gmToolbarUI::HandleSelectionChanged (:198635): + /// clear-then-populate the selected-object strip on any selection change. + /// + 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; + } + } + + /// + /// Port of gmToolbarUI::RecvNotice_UpdateObjectHealth (:196213): 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 provider. + /// + public void OnHealthChanged(uint guid, float percent) + { + if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null) + _healthMeter.Visible = true; + } + + /// Per-frame tick: reverts the selection overlay after the brief flash window. + 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; + } +} diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs new file mode 100644 index 00000000..1279328a --- /dev/null +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.UI.Layout; + +/// +/// 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). +/// +/// +/// Retail reference: gmToolbarUI::PostInit grabs each slot widget by its +/// id, calls UpdateFromPlayerDesc to flush-and-bind shortcuts from the +/// PlayerDescription trailer, and hooks OnEvent for the Click case to fire +/// UseShortcut. The deferred-rebind path matches +/// gmToolbarUI::SetDelayedShortcutNum which re-tries binding after +/// CreateObject resolves a formerly-unknown guid. +/// +/// +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> _shortcuts; + private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex + private readonly Action _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> shortcuts, + Func iconIds, + Action 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(); }; + } + + /// + /// Returns true if is one of the currently-active shortcut guids. + /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. + /// + private bool IsShortcutGuid(uint guid) + { + foreach (var sc in _shortcuts()) + if (sc.ObjectGuid == guid) return true; + return false; + } + + /// + /// Create and bind a to . + /// Calls immediately (binds whatever items are in the repo now). + /// Returns the controller so the caller can call again + /// if the shortcut list is refreshed outside the repo-event path. + /// + /// Imported toolbar layout (LayoutDesc 0x21000016). + /// Live item repository — must stay alive for the controller's lifetime. + /// Provider for the current shortcut bar list. + /// Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle. + /// Callback fired when a bound slot is clicked; receives the item guid. + /// + /// Optional live combat state — when provided, the toolbar subscribes to + /// 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). + /// + /// + /// 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). + /// + /// War-mode digit DID array (property 0x10000043, same element). + /// + /// 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). + /// + public static ToolbarController Bind( + ImportedLayout layout, + ClientObjectTable repo, + Func> shortcuts, + Func iconIds, + Action 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; + } + + /// + /// Port of gmToolbarUI::UpdateFromPlayerDesc: 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 + /// ObjectAdded event re-fires this method when the item arrives + /// (matching retail's SetDelayedShortcutNum deferred-rebind path). + /// + 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(); + } + + /// + /// Port of gmToolbarUI::RecvNotice_SetCombatMode + /// (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 (the player + /// always starts in peace mode) and subsequently whenever + /// fires. + /// + 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(); + } + + /// + /// Push digit-array references and shortcut-number state into every slot cell. + /// Top row (indices 0–8): SetShortcutNum(i, _peace) — numbers 1–9 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 9–17): 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). + /// + 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 1–9 always shown + else + cell.ClearShortcutNum(); // bottom row: no slot labels + } + } + + /// + /// Wire the callback on a slot cell so that + /// clicking a bound item fires with the slot's current guid. + /// Mirrors retail's gmToolbarUI click → UseShortcut dispatch. + /// + private void WireClick(UiItemList list) + { + list.Cell.Clicked = () => + { + if (list.Cell.ItemId != 0) + _useItem(list.Cell.ItemId); + }; + } +} diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs new file mode 100644 index 00000000..5f6ea79c --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -0,0 +1,122 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// 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". +/// +/// +/// 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. +/// +/// +/// +/// DrawModeType (DatReaderWriter.Enums), stored as int in to +/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6: +/// Undefined=0, Normal=1, Overlay=2, Alphablend=3. There is no Stretch mode. +/// +/// +/// +/// Tiling uses UV-repeat on BOTH axes (Width/tw, Height/th) so vertical +/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too. +/// sets +/// GL_REPEAT on both S and T, so vertical tiling is always active. +/// +/// +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 _resolve; + + /// Which state name to render. "" = the unnamed DirectState. + /// Falls back to DirectState if the named state is absent. + public string ActiveState { get; set; } = ""; + + /// Merged for this element. + /// Dat file-id → (GL texture handle, native px width, native px height). + /// Returns (0,0,0) when the texture is not yet uploaded. + public UiDatElement(ElementInfo info, Func 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) + } + + /// + /// Returns the (File, DrawMode) for the current , + /// falling back to the DirectState ("" key) if the named state is absent. + /// Returns (0, 0) if neither exists. + /// + // 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); + + /// Optional click handler. Set by a controller for interactive dat + /// elements (e.g. the chat Send / max-min buttons). Requires + /// = false to receive click events. + 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; + } + + /// Optional centered text label drawn over the sprite (e.g. the "Send" + /// button face whose dat sprite is a blank frame). Null = sprite only. + public string? Label { get; set; } + /// Dat font for . Required for the label to draw. + public UiDatFont? LabelFont { get; set; } + /// Label color (default white). + 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); + } + } +} diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs new file mode 100644 index 00000000..39f2f396 --- /dev/null +++ b/src/AcDream.App/UI/Layout/VitalsController.cs @@ -0,0 +1,98 @@ +using System; +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C). +/// Mirrors retail gmVitalsUI::PostInit: 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. +/// +/// The slice sprites + dat font on each are already +/// set by during tree construction; this controller +/// only binds the dynamic vitals data. Do not touch meter rendering fields here. +/// +/// Element ids confirmed from +/// docs/research/2026-06-15-layoutdesc-format.md §11 +/// (vitals window 0x2100006C dump). +/// +public static class VitalsController +{ + /// Dat element id for the Health meter (0x100000E6). + public const uint Health = 0x100000E6; + /// Dat element id for the Stamina meter (0x100000EC). + public const uint Stamina = 0x100000EC; + /// Dat element id for the Mana meter (0x100000EE). + public const uint Mana = 0x100000EE; + + /// + /// Bind live vitals data providers to the Health, Stamina, and Mana meter + /// elements found in . Any meter whose id is absent + /// from the layout is silently skipped — partial layouts (e.g. test fakes) + /// do not cause errors. + /// + /// Imported vitals layout tree. + /// Provider returning Health fill fraction [0..1]. + /// Provider returning Stamina fill fraction [0..1]. + /// Provider returning Mana fill fraction [0..1]. + /// Provider returning Health "cur/max" overlay text. + /// Provider returning Stamina "cur/max" overlay text. + /// Provider returning Mana "cur/max" overlay text. + public static void Bind( + ImportedLayout layout, + Func healthPct, + Func staminaPct, + Func manaPct, + Func healthText, + Func staminaText, + Func manaText) + { + BindMeter(layout, Health, healthPct, healthText); + BindMeter(layout, Stamina, staminaPct, staminaText); + BindMeter(layout, Mana, manaPct, manaText); + } + + /// White cur/max numbers — matches the former UiMeter.LabelColor default. + private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f); + + private static void BindMeter( + ImportedLayout layout, uint id, + Func pct, + Func 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() + : new[] { new UiText.Line(s, NumberColor) }; + }, + }; + m.AddChild(number); + } +} diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs new file mode 100644 index 00000000..1132479b --- /dev/null +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -0,0 +1,159 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Reflection; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} 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. +/// +public static class MarkupDocument +{ + /// Raw XML markup for a single panel. + /// Object whose public properties are bound to {PropName} attributes. + /// Surface id → (GL handle, width, height) for chrome sprites. + /// Optional controls.ini stylesheet for the title color. + public static UiNineSlicePanel Build( + string xml, object binding, Func 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 , 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; + + /// + /// Parses #AARRGGBB → RGBA (alpha first, matching + /// controls.ini convention). Falls back to opaque white on bad input. + /// + 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 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 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; + } +} diff --git a/src/AcDream.App/UI/RetailChromeSprites.cs b/src/AcDream.App/UI/RetailChromeSprites.cs new file mode 100644 index 00000000..f2a80fd7 --- /dev/null +++ b/src/AcDream.App/UI/RetailChromeSprites.cs @@ -0,0 +1,66 @@ +namespace AcDream.App.UI; + +/// +/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0 +/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded +/// DIRECTLY (), NOT +/// through the Surface→SurfaceTexture chain. +/// +/// +/// The universal floating-window bevel is an 8-piece border (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). +/// +/// +/// +/// 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. +/// +/// +public static class RetailChromeSprites +{ + /// Tiled interior fill — the shared panel background (48×48). + public const uint CenterFill = 0x06004CC2; + + /// Horizontal top edge (10×5, tiled across the top span). + public const uint TopEdge = 0x060074BF; + /// Horizontal bottom edge (10×5). + public const uint BottomEdge = 0x060074C1; + /// Vertical left edge (5×10). + public const uint LeftEdge = 0x060074C0; + /// Vertical right edge (5×10). + public const uint RightEdge = 0x060074C2; + + /// Top-left corner (5×5). + public const uint CornerTL = 0x060074C3; + /// Top-right corner (5×5). + public const uint CornerTR = 0x060074C4; + /// Bottom-left corner (5×5). + public const uint CornerBL = 0x060074C5; + /// Bottom-right corner (5×5). + public const uint CornerBR = 0x060074C6; + + /// Border thickness in pixels = the corner/edge sprite size (5px). + 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 0x1000063B–0x10000642): 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. + /// Corner grip stud, all four corners (5×5). + public const uint GripCorner = 0x06006129; + /// Top edge grip (10×5, tiled across). + public const uint GripTop = 0x0600612A; + /// Left edge grip (5×10, tiled down). + public const uint GripLeft = 0x0600612B; + /// Bottom edge grip (10×5). + public const uint GripBottom = 0x0600612C; + /// Right edge grip (5×10). + public const uint GripRight = 0x0600612D; +} diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs new file mode 100644 index 00000000..6c31797d --- /dev/null +++ b/src/AcDream.App/UI/UiButton.cs @@ -0,0 +1,115 @@ +using System; +using System.Numerics; +using AcDream.App.UI.Layout; + +namespace AcDream.App.UI; + +/// +/// 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). +/// +/// +/// Draws per-state sprite media exactly like (same +/// ActiveState defaulting, same ActiveMedia() fallback chain, same tiled +/// DrawSprite call with UV-repeat so chrome edges tile correctly) plus an +/// optional centered text label. The click behavior mirrors +/// one-for-one so the chat Send and Max/Min buttons that previously bound through +/// UiDatElement.OnClick continue to work without behavioral change. +/// +/// +/// +/// State selection: picks if set, then +/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed +/// DirectState ("" key) — identical to . +/// +/// +/// +/// Built by for Type-1 elements (chat Send 0x10000019, +/// Max/Min 0x1000046F). NOT the same as , which is an +/// earlier dev-scaffold widget with no dat sprites. +/// +/// +public sealed class UiButton : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + + /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). + public Action? OnClick { get; set; } + + /// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame). + public string? Label { get; set; } + + /// Dat font for . Required for the label to draw. + public UiDatFont? LabelFont { get; set; } + + /// Label color (default white). + public Vector4 LabelColor { get; set; } = Vector4.One; + + /// + /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). + /// Matches . + /// + public string ActiveState { get; set; } = ""; + + /// Merged for this element. + /// Dat file-id → (GL texture handle, native px width, native px height). + /// Returns (0,0,0) when the texture is not yet uploaded. + public UiButton(ElementInfo info, Func 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) + } + + /// 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. + public override bool ConsumesDatChildren => true; + + /// + /// Returns the File id for the current , falling back to + /// the DirectState ("" key) if the named state is absent. + /// Returns 0 if neither exists. + /// Mirrors . + /// + 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; + } +} diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs new file mode 100644 index 00000000..400ccf0f --- /dev/null +++ b/src/AcDream.App/UI/UiDatFont.cs @@ -0,0 +1,162 @@ +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; + +/// +/// 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 can blit each glyph +/// as two textured quads exactly the way the retail client does. +/// +/// +/// Retail render model — SurfaceWindow::DrawCharacter +/// (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 +/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter (the function's +/// return value, accumulated by the string loop at 0x00467ed4 +/// edi_3 += var_98), and each glyph is drawn starting at +/// penX + HorizontalOffsetBefore. +/// +/// +/// +/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is +/// PFID_A8 — alpha-only. Our SurfaceDecoder expands A8 to RGBA as +/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag, +/// uUseTexture==2) MULTIPLIES the sampled texel by the per-vertex tint +/// (texture(uTex,vUv) * vColor), 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. +/// +/// +public sealed class UiDatFont +{ + /// Retail UI font id (Latin-1, 16x16 max, with outline atlas). + public const uint DefaultFontId = 0x40000000u; + + /// Foreground (glyph pixels) GL texture handle + atlas pixel size. + public uint ForegroundTexture { get; } + public int ForegroundWidth { get; } + public int ForegroundHeight { get; } + + /// Background (outline/shadow) GL texture handle + atlas pixel size. + /// 0 when the font has no background atlas (then the outline pass is skipped). + public uint BackgroundTexture { get; } + public int BackgroundWidth { get; } + public int BackgroundHeight { get; } + + /// Vertical advance between lines (retail MaxCharHeight). + public float LineHeight { get; } + + /// Distance from a line's top to its baseline (retail BaselineOffset). + public float BaselineOffset { get; } + + private readonly Dictionary _glyphs; + + private UiDatFont( + uint fgTex, int fgW, int fgH, + uint bgTex, int bgW, int bgH, + float lineHeight, float baselineOffset, + Dictionary glyphs) + { + ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH; + BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH; + LineHeight = lineHeight; + BaselineOffset = baselineOffset; + _glyphs = glyphs; + } + + /// True if this font carries a separate outline/shadow atlas + /// (retail's m_pBackgroundSurface). When false the outline pass is + /// skipped and only the foreground (fill) glyphs are drawn. + public bool HasBackground => BackgroundTexture != 0; + + /// Look up a glyph descriptor for a character. Returns false for + /// characters not present in the font's table (callers skip them). + public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!); + + /// + /// Load Font 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. + /// + public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(cache); + + if (!dats.TryGet(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(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); + } + + /// + /// Total pen advance (in pixels) for , summing each + /// glyph's retail advance. Characters not in the font contribute nothing. + /// + public float MeasureWidth(string text) + => MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null); + + /// + /// Pure pen-advance summation seam: total width of + /// given a 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. + /// + public static float MeasureWidth(string? text, Func 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; + } + + /// + /// The retail per-glyph horizontal advance: + /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter. This is the + /// value SurfaceWindow::DrawCharacter 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. + /// + public static float GlyphAdvance(FontCharDesc g) + => g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; +} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index ae9a0a7c..b22da24e 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -4,6 +4,11 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which parent edges a child keeps a fixed margin to on resize. +/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches. +[System.Flags] +public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 } + /// /// Base class for every UI widget in the retained-mode tree. /// @@ -88,6 +93,39 @@ public abstract class UiElement /// Painter's-algorithm z-order within siblings. Higher = on top. public int ZOrder { get; set; } + /// 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. + public float Opacity { get; set; } = 1f; + + /// 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). + public bool Draggable { get; set; } + + /// If true, a left-drag starting near this element's edge/corner + /// resizes it (window resize). Intended for top-level panels. + public bool Resizable { get; set; } + + /// 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. + public bool CapturesPointerDrag { get; set; } + + /// Minimum size enforced while resizing. + public float MinWidth { get; set; } = 40f; + public float MinHeight { get; set; } = 40f; + + /// Allow horizontal (width) resize. Ignored unless . + public bool ResizeX { get; set; } = true; + /// Allow vertical (height) resize. Ignored unless . + public bool ResizeY { get; set; } = true; + + /// Edges this element anchors to in its parent. Default Left|Top + /// (pinned top-left, fixed size — no reflow). Left|Right stretches width. + public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } @@ -108,6 +146,19 @@ public abstract class UiElement return true; } + /// + /// 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 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 true; the generic container + /// () and panels return false + /// and recurse their children normally. Mirrors retail, where each + /// UIElement_X::DrawSelf owns its internal structure. + /// + public virtual bool ConsumesDatChildren => false; + // ── Virtual overrides ─────────────────────────────────────────────── /// @@ -116,6 +167,25 @@ public abstract class UiElement /// protected virtual void OnDraw(UiRenderContext ctx) { } + /// + /// Draw AFTER this element's own children, but still within this element's + /// transform/alpha (NOT a global pass like ). 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 . Default: nothing. + /// + protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { } + + /// + /// 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 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. + /// + protected virtual void OnDrawOverlay(UiRenderContext ctx) { } + /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -146,12 +216,18 @@ public abstract class UiElement { if (!Visible) return; - // Translate into our local space. + // Translate into our local space + push this window's opacity (multiplies into + // descendants' sprite/rect draws; text bypasses the alpha so it stays sharp). 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) { @@ -161,9 +237,42 @@ 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(); + } + } + + /// Second draw traversal: re-walks the tree applying the same + /// transform/alpha as and calls + /// 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). + 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(); } } @@ -183,9 +292,14 @@ public abstract class UiElement /// internal UiElement? HitTest(float localX, float localY) { - if (!Visible || !Enabled || ClickThrough) return null; + if (!Visible || !Enabled) return null; - // Children first, in reverse Z-order (topmost first). + // 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. if (_children.Count > 0) { var ordered = _children.ToArray(); @@ -198,6 +312,70 @@ 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; + + /// Reposition/resize this element per , keeping + /// the margins captured (at first layout / design size) to each anchored edge. + /// Called by the parent each frame before drawing children. + 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; + } + + /// Forget the captured anchor margins so the next + /// 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. + internal void ResetAnchorCapture() => _anchorCaptured = false; + + /// Walk up to the owning (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. + internal UiRoot? FindRoot() + { + UiElement e = this; + while (e.Parent is not null) e = e.Parent; + return e as UiRoot; + } + + /// Compute an anchored child rect. Left&Right ⇒ stretch width + /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise + /// pin left at fixed width. Same logic vertically. + 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); + } } diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs new file mode 100644 index 00000000..9bc7ef32 --- /dev/null +++ b/src/AcDream.App/UI/UiField.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Generic editable one-line field widget. Port of retail UIElement_Field +/// (RegisterElementClass(3) @ acclient_2013_pseudo_c.txt:126190). Carries +/// retail Field's drag-drop hooks (CatchDroppedItem/MouseOverTop) +/// as stubs for future item-window use. +/// +/// +/// 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 , clears, and pushes history (100-entry cap, +/// sentinel 0xFFFFFFFF — port of ChatInterface::ProcessCommand @0x4f5100). +/// +/// +/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40. +/// +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); + /// Selected-span highlight (translucent blue, behind the text). + 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; + + /// Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift). + /// Wired by the host from . + public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + + /// Dat sprite resolver (id → GL texture + size) for the focused-field + /// background. Null = fall back to the flat rect. + public Func? SpriteResolve { get; set; } + /// Gold "lit" field background drawn when focused (retail Normal_focussed + /// state, RenderSurface 0x060011AB). 0 = no focus sprite. + public uint FocusFieldSprite { get; set; } + + public Action? 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 _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 + } + + /// 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. + 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; + } + + /// Move the caret left (negative) or right (positive) by + /// glyph positions without extending a selection. Public for test access. + 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) : ""; + } + + /// Remove the selected span (if any). Returns true if it removed anything. + 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 ───────────────────────────────────────────────────────── + + /// Pixel-X of the caret (Σ glyph advances to ). + 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); + + /// Map a local X (click) to the nearest caret index — retail + /// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset. + 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; + } +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 5f697cfb..718d5cbd 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable public UiRoot Root { get; } = new(); public TextRenderer TextRenderer { get; } public BitmapFont? DefaultFont { get; set; } + + /// The last wired keyboard. Exposed so widgets that need clipboard + /// access () or modifier-key state + /// () — e.g. 's + /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. + public IKeyboard? Keyboard { get; private set; } + private long _startTicks = System.Environment.TickCount64; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) @@ -82,6 +89,7 @@ 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); diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs new file mode 100644 index 00000000..761d4db3 --- /dev/null +++ b/src/AcDream.App/UI/UiItemList.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI; + +/// +/// 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. +/// +public sealed class UiItemList : UiElement +{ + private readonly List _cells = new(); + + public UiItemList(Func? 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? SpriteResolve { get; set; } + + /// Convenience for single-cell slots (the toolbar): the first cell. + /// Valid only while the list has at least one cell; after + /// (the inventory-phase rebuild path) the list is empty until + /// runs, so use there instead. + /// the list has no cells (e.g. after Flush). + 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; + } + } +} diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs new file mode 100644 index 00000000..d3ff3b7d --- /dev/null +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -0,0 +1,143 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// 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). +/// +public sealed class UiItemSlot : UiElement +{ + public UiItemSlot() { ClickThrough = false; } + + public override bool ConsumesDatChildren => true; + + /// Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID. + public uint ItemId { get; private set; } + + /// Pre-composited icon GL texture for the bound item (0 = none). + public uint IconTexture { get; private set; } + + /// 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. + public uint EmptySprite { get; set; } = 0x060074CFu; + + /// RenderSurface id -> (GL texture, w, h). Set by the factory/controller. + public Func? 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. + + /// 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. + public int ShortcutNum { get; private set; } = -1; + + /// True = draw peace digit set; false = war digit set. + public bool ShortcutPeace { get; private set; } = true; + + /// 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. + public uint[]? PeaceDigits { get; set; } + + /// War digit DID array. Same layout as PeaceDigits. + /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance. + public uint[]? WarDigits { get; set; } + + /// 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). + public uint[]? EmptyDigits { get; set; } + + /// 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. + public void SetShortcutNum(int index, bool peace) + { + ShortcutNum = index; + ShortcutPeace = peace; + } + + /// Clear the shortcut number label (hides the digit). + public void ClearShortcutNum() { ShortcutNum = -1; } + + /// + /// 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. + /// + internal uint[]? ActiveDigitArray() + { + bool occupied = ItemId != 0; + return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits; + } + + // ── Events / draw ───────────────────────────────────────────────────────── + + /// Invoked by when a left-button-down lands on + /// a bound slot. Wired by ToolbarController to the use-item callback. + public Action? Clicked { get; set; } + + /// + 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); + } + } + } + } +} diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs new file mode 100644 index 00000000..c10bd419 --- /dev/null +++ b/src/AcDream.App/UI/UiMenu.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Generic dropdown menu. Ports retail UIElement_Menu +/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163) + +/// UIElement_Menu::MakePopup @0x46d310: 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 for Type-6 elements. +/// +public sealed class UiMenu : UiElement +{ + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); + + /// 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. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? 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 (~x4–20 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? 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); + /// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus + /// enabled state). Confirmed via decomp: enabled items render white. + public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f); + /// 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. + 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; } + + /// 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). + 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 + } + + /// 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. + 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 + } + + /// 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. + 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(); } + } + + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + + /// tiled centre fill) framing the rect (,, + /// ,). Reuses the same geometry + + /// ids as ; no resize + /// grips (a menu popup is not resizable). + private void DrawBevel(UiRenderContext ctx, Func 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 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; + } +} diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs new file mode 100644 index 00000000..057402c7 --- /dev/null +++ b/src/AcDream.App/UI/UiMeter.cs @@ -0,0 +1,176 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// 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. returns 0..1 (null = no data → empty bar); +/// returns the overlay text (null = no number). +/// +/// +/// 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. +/// +/// +public sealed class UiMeter : UiElement +{ + + /// Fill fraction provider; a null result draws an empty bar. + public Func Fill { get; set; } = () => 0f; + /// Centered overlay text provider (e.g. "291/291"); null = none. + public Func 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); + + /// 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 bitmap font + /// is used instead. Set by the host when the retail UI is active. + public UiDatFont? DatFont { get; set; } + + /// 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. + public Func? 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. + /// Empty-track left-cap RenderSurface id. + public uint BackLeft { get; set; } + /// Empty-track middle (tiled gradient) RenderSurface id. + public uint BackTile { get; set; } + /// Empty-track right-cap RenderSurface id. + public uint BackRight { get; set; } + /// Coloured-fill left-cap RenderSurface id. + public uint FrontLeft { get; set; } + /// Coloured-fill middle (tiled gradient) RenderSurface id. + public uint FrontTile { get; set; } + /// Coloured-fill right-cap RenderSurface id. + public uint FrontRight { get; set; } + + public UiMeter() { ClickThrough = true; } + + /// The meter draws its own 3-slice bars; the importer must not build its + /// grandchild slice/text elements as separate widgets. + public override bool ConsumesDatChildren => true; + + /// Clamp to [0,1] and return the fill rect + /// (local px) for a bar of x . + 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); + } + } + } + + /// + /// 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 (local px from the left) is drawn. + /// The back track passes clipW = Width; the front fill passes + /// clipW = Width * fraction. 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. + /// + private void DrawHBar( + UiRenderContext ctx, Func 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); + } + + /// Draw a slice over local [, + /// pieceX+], with the texture repeating every + /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). + /// Clipped so nothing past 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. + 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); + } +} diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs new file mode 100644 index 00000000..f407f07b --- /dev/null +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -0,0 +1,112 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A whose background is the retail 8-piece window bevel +/// (): 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: +/// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. +/// +public sealed class UiNineSlicePanel : UiPanel +{ + /// A placed chrome piece: destination rect in local pixel space. + public readonly record struct Rect(float X, float Y, float W, float H); + + /// The nine destination rects for an 8-piece border + center. + 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 _resolve; + + public UiNineSlicePanel(System.Func 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; + } + + /// + /// Destination rects (local px) for a frame of (, + /// ) with border thickness : + /// 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. + /// + 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 (0x1000063B–0x10000642). + 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); + } +} diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 9f941da1..b6a2085f 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -57,14 +57,17 @@ public class UiLabel : UiElement /// callback. Retail equivalent is Keystone's button widget, driven by /// a StateDesc per UIStateId (normal / hot / pressed / /// disabled) from the panel layout. +/// Note: the dat-widget button (Type 1 / UIElement_Button) is +/// in UiButton.cs — that is the production widget used by D.2b panels. +/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites). /// -public class UiButton : UiPanel +public class UiSimpleButton : UiPanel { public string Text { get; set; } = string.Empty; public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public event System.Action? Click; - public UiButton() + public UiSimpleButton() { BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f); diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 51ce7b83..ebf6fc69 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -22,6 +22,29 @@ public sealed class UiRenderContext private readonly System.Collections.Generic.List _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 _alphaStack = new(); + private float _alpha = 1f; + + /// Current cumulative opacity multiplier applied to sprite + rect draws. + public float AlphaMod => _alpha; + + /// Multiply into the running opacity. Pair with . + public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } + + /// Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays + /// that must stay opaque even inside a translucent window. Pair with . + 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; @@ -45,13 +68,33 @@ public sealed class UiRenderContext public Vector2 CurrentOrigin => _current; + /// 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 . + 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, color); + => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); + + /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for + /// a panel BACKGROUND that text draws on top of. composites after + /// all sprites and would cover the text — use this for backgrounds, that for foreground + /// fills (carets, vital bars). + 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)); 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, color, thickness); + => 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)); + + /// Multiply the current window opacity into a draw color's alpha. + private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha); public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null) { @@ -59,4 +102,101 @@ public sealed class UiRenderContext if (f is null) return; TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color); } + + /// + /// Draw a single line of text with a retail dat font (), + /// at , = the top-left of the + /// typographic block (in this element's local space). Mirrors retail's + /// SurfaceWindow::DrawCharacter (acclient 0x00442bd0): for each glyph + /// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline), + /// then the FOREGROUND atlas sub-rect tinted (the + /// fill). The pen advances by + /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each + /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis + /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the + /// glyph's OffsetY into the atlas. + /// + /// gates the black outline pass. Retail decides + /// this PER text element: UIElement_Text::DrawSelf (acclient 0x00467aa0) + /// runs the outline pass only when m_bitField & 0x10 is set — i.e. the + /// element called SetOutline(true) (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 + /// outline:true only for elements retail outlines. + /// + 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); + } + } + + /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to + /// normalized UVs for an atlas of x + /// . Guards against a zero-sized atlas. + 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); + } } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 7df41739..91fd219d 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -4,6 +4,10 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which edges of a window a resize-drag is affecting (corners combine two). +[System.Flags] +public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 } + /// /// Top-level UI container. Implements the retail "Device" responsibilities /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, @@ -40,6 +44,10 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } + /// 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. + public UiElement? DefaultTextInput { get; set; } + /// /// Single modal overlay; while set, mouse clicks outside its rect /// are ignored. Retail sets this via Device vtable +0x48. @@ -49,12 +57,30 @@ public sealed class UiRoot : UiElement /// Widget with mouse capture (during click-drag). public UiElement? Captured { get; private set; } + /// + /// 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. + /// + public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null; + + /// True when a widget holds keyboard focus (e.g. a focused chat input). + public bool WantsKeyboard => KeyboardFocus is not null; + /// Current drag source (set between drag-begin and drop/cancel). 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. @@ -109,6 +135,13 @@ 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) ── @@ -120,6 +153,26 @@ 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) @@ -155,19 +208,68 @@ public sealed class UiRoot : UiElement if (Modal is not null && !ContainsAbsolute(Modal, x, y)) return; - var (target, lx, ly) = HitTestTopDown(x, y); + var (target, _, _) = 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; } - // Set keyboard focus if target accepts it. - if (target.AcceptsFocus) SetKeyboardFocus(target); + // 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); - // Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold). SetCapture(target); - _dragCandidate = true; + + // 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; + } // Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201). int rawType = btn switch @@ -177,8 +279,13 @@ 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)lx, Data2: (int)ly); + Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); BubbleEvent(target, in e); } @@ -187,6 +294,20 @@ 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); @@ -251,6 +372,18 @@ 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) { @@ -436,6 +569,48 @@ 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; + } + + /// Which edges of 's screen rect the point + /// (,) is within px of. + /// None if the point is outside the grip-expanded box entirely. + 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; + } + + /// Compute a resized rect from a start rect + drag delta + which edges, + /// clamping to (,). Left/Top edges + /// move the origin so the opposite edge stays put. + 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; diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs new file mode 100644 index 00000000..f9e78a12 --- /dev/null +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -0,0 +1,57 @@ +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) 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. +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (scroll quantum). InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs new file mode 100644 index 00000000..d574b597 --- /dev/null +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -0,0 +1,210 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Generic scrollbar. Ports retail UIElement_Scrollbar +/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); +/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line. +/// +/// +/// 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. +/// +public sealed class UiScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). + public uint TrackSprite { get; set; } + + /// Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps. + public uint ThumbSprite { get; set; } + + /// Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall). + public uint ThumbTopSprite { get; set; } + + /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). + public uint ThumbBotSprite { get; set; } + + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). + public uint UpSprite { get; set; } + + /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). + public uint DownSprite { get; set; } + + /// Retail attribute 0x89 floor: minimum thumb height in pixels. + private const float MinThumb = 8f; + + /// Thumb cap height (native sprite height from base layout 0x2100003E). + private const float CapH = 3f; + + /// Up/down button height in pixels. Matches element height 16px from + /// the up/down button children in base layout 0x2100003E. + private const float ButtonH = 16f; + + private bool _draggingThumb; + private float _dragOffsetY; + + public UiScrollbar() { CapturesPointerDrag = true; } + + /// The scrollbar draws its own track/thumb/arrows; its dat up/down button + /// children are reproduced procedurally, so the importer must not build them. + public override bool ConsumesDatChildren => true; + + /// + /// Computes the thumb rectangle (local y origin and height) within the track area + /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout + /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top + /// offset = trackTop + (trackLen - thumbH) * PositionRatio. + /// + /// The scroll model. + /// Y of the top of the usable track area (below up-button). + /// Pixel length of the usable track area (between up and down buttons). + /// Local Y of the thumb's top edge, and its pixel height. + 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); + } + } + } + + /// Draw a sprite stretched 1:1 to the dest rect. + private void DrawSprite(UiRenderContext ctx, Func 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); + } + + /// Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point + /// the top scroll button's (down-art) arrow upward. + private void DrawSpriteFlipV(UiRenderContext ctx, Func 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); + } + + /// 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. + private void DrawTiled(UiRenderContext ctx, Func 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; + } +} diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs new file mode 100644 index 00000000..c89f4ae7 --- /dev/null +++ b/src/AcDream.App/UI/UiText.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Text; +using AcDream.App.Rendering; + +namespace AcDream.App.UI; + +/// +/// Scrollable text view for retail UIElement_Text elements +/// (RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). +/// Renders the lines from bottom-pinned (newest at the bottom, +/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps +/// text inside the window. +/// +/// +/// Supports Windows-like text selection: a left-click-drag inside the transcript +/// selects characters (the 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. +/// +/// +public sealed class UiText : UiElement +{ + /// One display line: pre-formatted text + its colour. + public readonly record struct Line(string Text, Vector4 Color); + + /// 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). + public readonly record struct Pos(int Line, int Col); + + /// Provider of the lines to show, oldest-first. Polled each frame. + public Func> LinesProvider { get; set; } = static () => Array.Empty(); + + /// Font for the transcript; falls back to the context default. + public BitmapFont? Font { get; set; } + + /// 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. + public UiDatFont? DatFont { get; set; } + + /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by + /// the host from . + public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + + /// 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. ChatWindowController). + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); + + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + + /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). + /// Required when is non-zero. + public Func? SpriteResolve { get; set; } + + /// Highlight colour painted behind a selected character span. + public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); + + /// Inner text inset from the view edges, px. + public float Padding { get; set; } = 4f; + + /// Static centered single-line mode (retail UIElement_Text 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 + /// 's former number overlay so those numbers stay pixel-identical + /// after the rewire. Pair with ClickThrough = true for non-interactive labels. + public bool Centered { get; set; } + + /// The scroll model — also read by the linked UiScrollbar. + public UiScrollable Scroll { get; } = new(); + + /// True while the view is pinned to the newest line (auto-scrolls as content grows). + 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 _lastLines = Array.Empty(); + 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 + } + + /// The text view draws its own lines + background; any dat sub-elements + /// (scroll indicators, caps) are not built as separate widgets by the importer. + public override bool ConsumesDatChildren => true; + + /// + /// 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. + /// + 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 ──────────────────────────────────────────────── + + /// Select the entire cached transcript (Ctrl+A). + 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); + } + + /// Normalise (anchor, caret) into ordered (start, end). False if no + /// selection or it is empty (anchor == caret). + 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); + } + + /// The currently-selected text against the cached lines. Empty when + /// nothing is selected. + 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) ─────────────────── + + /// Order two caret positions so the first is <= the second (by line, + /// then column). + 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); + } + + /// + /// Assemble the selected substring spanning .. + /// (inclusive of start.Col, exclusive of end.Col) from + /// . 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. + /// + public static string SelectedText(IReadOnlyList 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(); + } + + /// + /// Convert a local-space point to a caret against the cached + /// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped + /// to the line range; col via . + /// + 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); + } + + /// + /// The caret column for a horizontal position (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 falls on — natural + /// Windows-like caret placement. Pure — unit-testable with a synthetic advance. + /// + /// The line text. + /// Per-character advance (pixels) lookup. + /// Horizontal position relative to the text's left edge. + public static int CharIndexAt(string text, Func 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 + } +} diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs new file mode 100644 index 00000000..096f0cce --- /dev/null +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -0,0 +1,105 @@ +using System; +using System.Numerics; + +namespace AcDream.App.World; + +/// Verdict from the per-frame readiness probe for a held teleport arrival. +public enum ArrivalReadiness +{ + /// Destination not yet hydrated; keep holding. + NotReady, + + /// Destination terrain + cell are ready; place now. + Ready, + + /// 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. + Impossible, +} + +/// Lifecycle of a single teleport arrival. +public enum TeleportArrivalPhase { Idle, Holding } + +/// +/// 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 GameWindow.OnLivePositionUpdated that resolved the +/// arrival against the resident (old) landblocks before the destination hydrated +/// and landed the player in ocean. +/// +/// 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 +/// PlayerState.PortalSpace until the placement delegate flips it back to +/// InWorld. +/// +/// 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. +/// +public sealed class TeleportArrivalController +{ + /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. + public const int DefaultMaxHoldFrames = 600; + + private readonly Func _readiness; + private readonly Action _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 readiness, + Action place, + int maxHoldFrames = DefaultMaxHoldFrames) + { + _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); + _place = place ?? throw new ArgumentNullException(nameof(place)); + _maxHoldFrames = maxHoldFrames; + } + + /// 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). + public void BeginArrival(Vector3 destPos, uint destCell) + { + _destPos = destPos; + _destCell = destCell; + _heldFrames = 0; + Phase = TeleportArrivalPhase.Holding; + } + + /// Per-frame: evaluate readiness and place when ready / impossible / timed out. + /// No-op when Idle. + 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); + } +} diff --git a/src/AcDream.Cli/AcDream.Cli.csproj b/src/AcDream.Cli/AcDream.Cli.csproj index 7d30223e..e964e5cb 100644 --- a/src/AcDream.Cli/AcDream.Cli.csproj +++ b/src/AcDream.Cli/AcDream.Cli.csproj @@ -9,6 +9,14 @@ + + + + + + diff --git a/src/AcDream.Cli/FontAtlasDump.cs b/src/AcDream.Cli/FontAtlasDump.cs new file mode 100644 index 00000000..f9f49161 --- /dev/null +++ b/src/AcDream.Cli/FontAtlasDump.cs @@ -0,0 +1,182 @@ +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; + +/// +/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes: +/// • <out>-fg.png — foreground (fill) atlas, alpha→luminance (white on black) +/// • <out>-bg.png — background (outline) atlas, alpha→luminance +/// • <out>-sample.png — a sample string composited EXACTLY the way +/// UiRenderContext.DrawStringDat 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. +/// +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(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(); + 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; + } + + /// Composite the sample string with the two-pass outline+fill model, + /// blitting atlas sub-rects 1:1. adds a fractional + /// line origin; selects the FIX (snap the line baseline + /// to a whole pixel once) vs the BUG (round each glyph's Y independently). + private static Image RenderSample( + string text, Dictionary 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(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 native, string outBase) + { + using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor)); + zoom.SaveAsPng($"{outBase}-6x.png"); + } + + /// Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha + /// as coverage, tinted by . 1:1 (no scaling), so this is the + /// pixel-exact result GL_NEAREST + native-size quad produces. + private static void BlitGlyph(Image 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); + } + } + } + + /// 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. + private static Image AlphaLuma(DecodedTexture t) + { + var img = new Image(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(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; + } +} diff --git a/src/AcDream.Cli/LayoutIndexDump.cs b/src/AcDream.Cli/LayoutIndexDump.cs new file mode 100644 index 00000000..5276486c --- /dev/null +++ b/src/AcDream.Cli/LayoutIndexDump.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Read-only research diagnostic: index EVERY UI in the +/// dat by its root element's Type + size + an element-Type histogram, so a +/// panel re-drive can locate its layout from the decomp-registered class id +/// (e.g. gmMainChatUI registers type 0x10000041 → 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. +/// +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().OrderBy(x => x)) + { + var l = dats.Get(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(); + 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 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}", + }; +} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index a4c290ee..4bb7cba8 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -1,10 +1,139 @@ 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 "); + 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 [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 [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 [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 <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 [0xFontId] [sample] [outBase]"); + return 2; + } + return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut); +} + +if (args.Length >= 1 && args[0] == "probe") +{ + // probe + if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe "); 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 + if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop "); 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 <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 [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 <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. @@ -160,3 +289,146 @@ static (string Name, Func Count)[] CountCellByLow16(DatCollection dats) ("Region", () => dats.GetAllIdsOfType().Count()), }; } + +/// +/// 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. +/// +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()) + { + var ld = dats.Get(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(" "); continue; } + + var sprites = new List<(string Role, uint DataId, string DrawMode)>(); + VbCollectSprites(meterElem, sprites, 0); + + if (sprites.Count == 0) + { + Console.WriteLine(" "); + } + 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())); + } +} diff --git a/src/AcDream.Cli/VitalsLayoutDump.cs b/src/AcDream.Cli/VitalsLayoutDump.cs new file mode 100644 index 00000000..675f671b --- /dev/null +++ b/src/AcDream.Cli/VitalsLayoutDump.cs @@ -0,0 +1,152 @@ +using System.Collections; +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// 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. +/// +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()) + { + var l = dats.Get(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(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 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()}"); + } + } + + /// Enumerate public properties AND public fields (the DatReaderWriter + /// generated types expose geometry/file ids as fields, not properties). + 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; + } +} diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs new file mode 100644 index 00000000..312c97d1 --- /dev/null +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -0,0 +1,324 @@ +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; + +/// +/// 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). +/// +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(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 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 0x1000063B–0x10000642). 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); + } + } + + /// Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED + /// per ), native-width right-cap; clipped to clipW. + private static void DrawHBar( + Image canvas, Image left, Image mid, Image 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) + } + + /// Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at + /// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW. + private static void TileMiddle( + Image canvas, Image 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)); + } + } + + /// Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped + /// (proportionally) so nothing past clipW shows. + private static void DrawClippedPiece( + Image canvas, Image 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 canvas, Image 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)); + } + + /// 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. + 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(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; + } + + /// + /// 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). + /// + 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(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; + } + + /// Print the RGB of a rectangular block of pixels from a PNG (framebuffer probe). + 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(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; + } + + /// Crop a region of a PNG and upscale (nearest) — for zooming into a framebuffer dump. + 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(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; + } + + /// 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). + 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(id); + if (rs is null) { Console.Error.WriteLine($"no RenderSurface 0x{id:X8}"); return 1; } + var pal = rs.DefaultPaletteId != 0 ? dats.Get(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 Load(DatCollection dats, uint id) + { + var rs = dats.Get(id); + if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image(1, 1); } + var dt = SurfaceDecoder.DecodeRenderSurface(rs); + return Image.LoadPixelData(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; + } +} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index c5f61e32..dba723c6 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -11,7 +11,7 @@ namespace AcDream.Core.Net; /// /// Central registration point that wires every parsed GameEvent from /// into the appropriate Core state -/// class (, , +/// class (, , /// , ). /// /// @@ -32,7 +32,7 @@ public static class GameEventWiring { public static void WireAll( GameEventDispatcher dispatcher, - ItemRepository items, + ClientObjectTable items, CombatState combat, Spellbook spellbook, ChatLog chat, @@ -61,7 +61,11 @@ public static class GameEventWiring // (matching ACE's CreatureSkill.Current minus // augs/multipliers/vitae which we still don't model). Action? onSkillsUpdated = null, - Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null) + Func /*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>? onShortcuts = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -247,7 +251,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.GetItem(p.Value.Guid) is not null) + if (items.Get(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 @@ -396,40 +400,19 @@ public static class GameEventWiring Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}"); } - // 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. + // 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.) foreach (var inv in p.Value.Inventory) - { - if (items.GetItem(inv.Guid) is null) - { - items.AddOrUpdate(new ItemInstance - { - ObjectId = inv.Guid, - WeenieClassId = inv.ContainerType, - }); - } - } + items.RecordMembership(inv.Guid); foreach (var eq in p.Value.Equipped) - { - 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); - } + 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); }); } } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 48b678d3..b79546ed 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -127,6 +127,12 @@ 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. (Useability & USEABLE_REMOTE @@ -139,7 +145,45 @@ public static class CreateObject // a sizing hint for selection indicators on entities that // publish it. uint? Useability = null, - float? UseRadius = 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); /// /// The relevant subset of the server-sent MovementData / @@ -506,14 +550,30 @@ 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); - _ = ReadPackedDword(body, ref pos); // WeenieClassId - _ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); + weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded) + iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); if (body.Length - pos >= 4) itemType = ReadU32(body, ref pos); if (body.Length - pos >= 4) @@ -532,31 +592,63 @@ public static class CreateObject catch { /* truncated name — partial result is still useful */ } } - // --- WeenieHeader optional tail (2026-05-15): walk the - // conditional fields up through Useability + UseRadius. + // --- WeenieHeader optional tail: walk every conditional field + // in EXACT ACE write order (WorldObject_Networking.cs:87-219) + // so the cursor reaches IconOverlay + IconUnderlay. // - // 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. + // 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. // - // Field bit width decoded? - // ------- ------ -------- -------- - // weenieFlags2 conditional on objDescFlags & 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 + // 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 // - // 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. + // 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. 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 @@ -564,23 +656,28 @@ 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. + // offsets. Now correct. We CAPTURE weenieFlags2 now (instead + // of skipping) so we can gate IconUnderlay from bit 0x01. bool hasSecondHeader = objectDescriptionFlags.HasValue && (objectDescriptionFlags.Value & 0x04000000u) != 0; - if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2 + if (hasSecondHeader) + { + if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2"); + weenieFlags2 = ReadU32(body, ref pos); + } if ((weenieFlags & 0x00000001u) != 0) // PluralName _ = ReadString16L(body, ref pos); - if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity + if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8 { if (body.Length - pos < 1) throw new FormatException("trunc ItemCap"); - pos += 1; + wItemsCapacity = body[pos]; pos += 1; } - if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity + if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8 { if (body.Length - pos < 1) throw new FormatException("trunc ContCap"); - pos += 1; + wContainersCapacity = body[pos]; pos += 1; } if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16 { @@ -590,9 +687,9 @@ public static class CreateObject if ((weenieFlags & 0x00000008u) != 0) // Value u32 { if (body.Length - pos < 4) throw new FormatException("trunc Value"); - pos += 4; + wValue = (int)ReadU32(body, ref pos); } - if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP + if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP { if (body.Length - pos < 4) throw new FormatException("trunc Useability"); useability = ReadU32(body, ref pos); @@ -603,6 +700,147 @@ 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. */ } @@ -611,7 +849,17 @@ public static class CreateObject instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, physicsState, objectDescriptionFlags, friction, elasticity, - useability, useRadius); + 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); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). diff --git a/src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs b/src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs new file mode 100644 index 00000000..35d466a6 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs @@ -0,0 +1,44 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound PublicUpdatePropertyInt (0x02CE) — the server updates one +/// PropertyInt on a visible object (carries the object guid). Standalone +/// GameMessage, dispatched like / CreateObject. +/// +/// +/// The companion PrivateUpdatePropertyInt (0x02CD) targets the player's OWN +/// object (no guid) and is not parsed here — it has no item-icon impact. +/// +/// +/// Wire layout (ACE GameMessagePublicUpdatePropertyInt, size hint 17): +/// +/// u32 opcode = 0x02CE +/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital +/// u32 guid +/// u32 property // PropertyInt enum; UiEffects = 18 +/// i32 value +/// +/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4). +/// +public static class PublicUpdatePropertyInt +{ + public const uint Opcode = 0x02CEu; + + public readonly record struct Parsed(uint Guid, uint Property, int Value); + + /// Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation. + public static Parsed? TryParse(ReadOnlySpan 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); + } +} diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs new file mode 100644 index 00000000..463d7d9e --- /dev/null +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -0,0 +1,57 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Net; + +/// +/// 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). +/// +public static class ObjectTableWiring +{ + /// + /// Subscribe to the three object-lifecycle events + /// on . Call this BEFORE the render handler subscribes + /// to EntitySpawned so the table is populated before the render path runs. + /// + 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); + }; + } + + /// Translate the wire spawn into the table's merge patch. + 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); +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 8b4e0f74..6508084d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -80,7 +80,35 @@ 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); + 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); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -153,6 +181,20 @@ public sealed class WorldSession : IDisposable /// public event Action? StateUpdated; + /// + /// Payload for : 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). + /// + public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value); + + /// + /// 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. + /// + public event Action? ObjectIntPropertyUpdated; + /// /// Fires when the server sends a PlayerTeleport (0xF751) game message, /// signalling that the player is entering portal space. The uint payload @@ -716,7 +758,26 @@ public sealed class WorldSession : IDisposable parsed.Value.Friction, parsed.Value.Elasticity, parsed.Value.Useability, - parsed.Value.UseRadius)); + 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)); } } else if (op == DeleteObject.Opcode) @@ -890,6 +951,13 @@ 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 @@ -1072,6 +1140,18 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). + /// + /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 + /// (docs/research/named-retail/acclient_2013_pseudo_c.txt). + /// + public void SendQueryHealth(uint targetGuid) + { + uint seq = NextGameActionSequence(); + byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid); + SendGameAction(body); + } + /// Send retail TargetedMeleeAttack (0x0008). public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) { diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 15018b0f..1143115e 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -92,6 +92,16 @@ public sealed class CombatState public float GetHealthPercent(uint guid) => _healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f; + /// + /// 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 + /// 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). + /// + public bool HasHealth(uint guid) => _healthByGuid.ContainsKey(guid); + public int TrackedTargetCount => _healthByGuid.Count; // ── Inbound handlers (wired from WorldSession.GameEvents) ──────────────── diff --git a/src/AcDream.Core/Items/ItemInstance.cs b/src/AcDream.Core/Items/ClientObject.cs similarity index 77% rename from src/AcDream.Core/Items/ItemInstance.cs rename to src/AcDream.Core/Items/ClientObject.cs index d1b5685f..04d9c873 100644 --- a/src/AcDream.Core/Items/ItemInstance.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -121,14 +121,13 @@ public sealed class PropertyBundle } /// -/// Per-item live state. The server owns item identity (ObjectId); -/// acdream mirrors properties here on CreateObject and updates -/// via UpdateProperty* messages. +/// Per-object live state (the data side of every server object — items and creatures alike). +/// Retail ACCWeenieObject. /// -public sealed class ItemInstance +public sealed class ClientObject { public uint ObjectId { get; init; } - public uint WeenieClassId { get; init; } // "blueprint" + public uint WeenieClassId { get; set; } // "blueprint" public string Name { get; set; } = ""; public ItemType Type { get; set; } public EquipMask ValidLocations { get; set; } @@ -136,6 +135,13 @@ public sealed class ItemInstance public uint IconId { get; set; } // 0x06xxxxxx public uint IconUnderlayId{ get; set; } // "magic" underlay public uint IconOverlayId { get; set; } // "enchanted" overlay + /// + /// 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. + /// + 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 @@ -144,9 +150,49 @@ public sealed class ItemInstance 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(); } +/// +/// The wire-delivered patch from a CreateObject (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 SetWeenieDesc (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?. +/// +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); + /// /// Container = inventory pack. Hierarchy is strictly 2-deep: character /// → side packs; a side pack cannot hold another side pack (r06 §7). @@ -157,7 +203,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 Items { get; } = new(); + public List Items { get; } = new(); public List SidePacks { get; } = new(); // empty for side-pack public bool IsSidePack => SideCapacity == 0; } diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs new file mode 100644 index 00000000..94fa574f --- /dev/null +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace AcDream.Core.Items; + +/// +/// The client's table of every server object (retail weenie_object_table / +/// CObjectMaint). Resolve by guid via Get. +/// +/// +/// Retail semantics (r06): +/// +/// +/// Every object is a with a unique +/// ObjectId. CreateObject seeds it when the server tells us +/// the item exists (in our inventory, on the ground, in a +/// vendor's list, etc). +/// +/// +/// Moves happen via -carrying messages: +/// WieldObject, InventoryPutObjInContainer, +/// InventoryPutObjectIn3D, ViewContents, +/// CloseGroundContainer. +/// +/// +/// InventoryServerSaveFailed reverts a speculative local +/// state change (e.g. when a drag-drop was rejected server-side). +/// +/// +/// +/// +/// +/// Thread safety: designed for single-threaded use from the render +/// thread; the event delegates run synchronously on the caller's +/// thread. A backs the +/// map so plugin code can look up items from any thread without +/// corrupting state. +/// +/// +public sealed class ClientObjectTable +{ + private readonly ConcurrentDictionary _objects = new(); + private readonly ConcurrentDictionary _containers = new(); + private readonly Dictionary> _containerIndex = new(); + + /// Fires when an object is first added to the session. + public event Action? ObjectAdded; + + /// + /// 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". + /// + public event Action? ObjectMoved; + + /// Fires when an object is removed from the session. + public event Action? ObjectRemoved; + + /// Fires when an object's properties are updated (typically after Appraise). + public event Action? ObjectUpdated; + + /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield; + /// the typed mirror maintains on + /// . + public const uint UiEffectsPropertyId = 18u; + + public int ObjectCount => _objects.Count; + public int ContainerCount => _containers.Count; + + public IEnumerable Objects => _objects.Values; + public IEnumerable Containers => _containers.Values; + + /// + /// Look up an object by its server-assigned ObjectId. + /// + public ClientObject? Get(uint objectId) => + _objects.TryGetValue(objectId, out var item) ? item : null; + + /// + /// 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). + /// + public Container? GetContainer(uint objectId) => + _containers.TryGetValue(objectId, out var c) ? c : null; + + /// + /// 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. + /// + 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); + } + + /// + /// Register a container. Idempotent. + /// + public void AddContainer(Container container) + { + ArgumentNullException.ThrowIfNull(container); + _containers[container.ObjectId] = container; + } + + /// + /// Handle a server-driven move — called from + /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) + /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation + /// and fires ObjectMoved. + /// + 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; + } + + /// + /// Handle a server-driven remove (destroyed item, dropped into 3D + /// space, stolen, etc). + /// + 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; + } + + /// + /// Apply a patch (e.g. from an + /// IdentifyObjectResponse) to an existing object. Individual + /// keys in the incoming bundle overwrite existing values; keys not + /// present are left untouched. + /// + 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; + } + + /// + /// 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) → . Fires + /// ObjectUpdated so bound widgets re-composite. Extensible hook for future + /// typed PropertyInts (StackSize, Structure, …). False if the object is unknown. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + + /// + /// PlayerDescription manifest: record that this guid is the player's + /// (in inventory or equipped at ), creating an + /// empty entry if CreateObject hasn't arrived yet. Never touches + /// icon/name/type/effects — that data comes from CreateObject. + /// + 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(); + 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; + + /// + /// 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. + /// + public IReadOnlyList GetContents(uint containerId) => + _containerIndex.TryGetValue(containerId, out var l) + ? l.ToArray() : System.Array.Empty(); + + /// + /// Flush the table — typically called on logoff or teleport + /// that drops the session's object state. + /// + public void Clear() + { + _objects.Clear(); + _containers.Clear(); + _containerIndex.Clear(); + } +} diff --git a/src/AcDream.Core/Items/ItemRepository.cs b/src/AcDream.Core/Items/ItemRepository.cs deleted file mode 100644 index 02c864a2..00000000 --- a/src/AcDream.Core/Items/ItemRepository.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; - -namespace AcDream.Core.Items; - -/// -/// Live item-state mirror — the client-side view of every item the -/// server has spawned for this session. Owns -/// records, tracks which container holds each item, and fires events so -/// UI panels (inventory, paperdoll, hotbars) can redraw on change. -/// -/// -/// Retail semantics (r06): -/// -/// -/// Every item is a with a unique -/// ObjectId. CreateObject seeds it when the server tells us -/// the item exists (in our inventory, on the ground, in a -/// vendor's list, etc). -/// -/// -/// Moves happen via -carrying messages: -/// WieldObject, InventoryPutObjInContainer, -/// InventoryPutObjectIn3D, ViewContents, -/// CloseGroundContainer. -/// -/// -/// InventoryServerSaveFailed reverts a speculative local -/// state change (e.g. when a drag-drop was rejected server-side). -/// -/// -/// -/// -/// -/// Thread safety: designed for single-threaded use from the render -/// thread; the event delegates run synchronously on the caller's -/// thread. A backs the -/// map so plugin code can look up items from any thread without -/// corrupting state. -/// -/// -public sealed class ItemRepository -{ - private readonly ConcurrentDictionary _items = new(); - private readonly ConcurrentDictionary _containers = new(); - - /// Fires when an item is first added to the session. - public event Action? ItemAdded; - - /// - /// Fires when an item'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". - /// - public event Action? ItemMoved; - - /// Fires when an item is removed from the session. - public event Action? ItemRemoved; - - /// Fires when an item's properties are updated (typically after Appraise). - public event Action? ItemPropertiesUpdated; - - public int ItemCount => _items.Count; - public int ContainerCount => _containers.Count; - - public IEnumerable Items => _items.Values; - public IEnumerable Containers => _containers.Values; - - /// - /// Look up an item by its server-assigned ObjectId. - /// - public ItemInstance? GetItem(uint objectId) => - _items.TryGetValue(objectId, out var item) ? item : null; - - /// - /// 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). - /// - public Container? GetContainer(uint objectId) => - _containers.TryGetValue(objectId, out var c) ? c : null; - - /// - /// Register / refresh an item in the repository. Called on - /// CreateObject for item-typed weenies and on IdentifyObjectResponse - /// to fill in detail properties. - /// - public void AddOrUpdate(ItemInstance item) - { - ArgumentNullException.ThrowIfNull(item); - bool existed = _items.ContainsKey(item.ObjectId); - _items[item.ObjectId] = item; - if (!existed) ItemAdded?.Invoke(item); - else ItemPropertiesUpdated?.Invoke(item); - } - - /// - /// Register a container. Idempotent. - /// - public void AddContainer(Container container) - { - ArgumentNullException.ThrowIfNull(container); - _containers[container.ObjectId] = container; - } - - /// - /// Handle a server-driven move — called from - /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) - /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation - /// and fires ItemMoved. - /// - public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, - EquipMask newEquipLocation = EquipMask.None) - { - if (!_items.TryGetValue(itemId, out var item)) return false; - - uint oldContainer = item.ContainerId; - item.ContainerId = newContainerId; - item.ContainerSlot = newSlot; - item.CurrentlyEquippedLocation = newEquipLocation; - - ItemMoved?.Invoke(item, oldContainer, newContainerId); - return true; - } - - /// - /// Handle a server-driven remove (destroyed item, dropped into 3D - /// space, stolen, etc). - /// - public bool Remove(uint itemId) - { - if (!_items.TryRemove(itemId, out var item)) return false; - ItemRemoved?.Invoke(item); - return true; - } - - /// - /// Apply a patch (e.g. from an - /// IdentifyObjectResponse) to an existing item. Individual - /// keys in the incoming bundle overwrite existing values; keys not - /// present are left untouched. - /// - public bool UpdateProperties(uint itemId, PropertyBundle incoming) - { - if (!_items.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; - ItemPropertiesUpdated?.Invoke(item); - return true; - } - - /// - /// Flush the repository — typically called on logoff or teleport - /// that drops the session's item state. - /// - public void Clear() - { - _items.Clear(); - _containers.Clear(); - } -} diff --git a/src/AcDream.Core/Lighting/GlobalLightPacker.cs b/src/AcDream.Core/Lighting/GlobalLightPacker.cs new file mode 100644 index 00000000..9de709a5 --- /dev/null +++ b/src/AcDream.Core/Lighting/GlobalLightPacker.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Lighting; + +/// +/// Packs a point-light snapshot into the flat float layout the bindless mesh +/// shader reads at SSBO binding=4 (mesh_modern.vert GlobalLight gLights[]): +/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity, +/// coneAngleEtc. Pure (no GL), so both WbDrawDispatcher and +/// EnvCellRenderer share ONE layout and cannot drift. +/// +public static class GlobalLightPacker +{ + public const int FloatsPerLight = 16; + + /// + /// Fill (grown + zero-cleared as needed) with the + /// packed snapshot; returns the light count n. The buffer always has at + /// least floats (so a zero-light frame still + /// uploads a non-empty SSBO). Callers upload max(n,1) * FloatsPerLight floats. + /// + public static int Pack(IReadOnlyList? 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; + // posAndKind (xyz world pos, w kind) + buffer[o + 0] = L.WorldPosition.X; + buffer[o + 1] = L.WorldPosition.Y; + buffer[o + 2] = L.WorldPosition.Z; + buffer[o + 3] = (int)L.Kind; + // dirAndRange (xyz forward, w range) + buffer[o + 4] = L.WorldForward.X; + buffer[o + 5] = L.WorldForward.Y; + buffer[o + 6] = L.WorldForward.Z; + buffer[o + 7] = L.Range; // w = Range = Falloff × static_light_factor (1.3), pre-multiplied by LightInfoLoader — NOT the raw dat Falloff + // colorAndIntensity (xyz linear colour, w intensity) + buffer[o + 8] = L.ColorLinear.X; + buffer[o + 9] = L.ColorLinear.Y; + buffer[o + 10] = L.ColorLinear.Z; + buffer[o + 11] = L.Intensity; + // coneAngleEtc (x cone radians; yzw reserved) + buffer[o + 12] = L.ConeAngle; + } + return n; + } +} diff --git a/src/AcDream.Core/Lighting/LightBake.cs b/src/AcDream.Core/Lighting/LightBake.cs new file mode 100644 index 00000000..1ab52714 --- /dev/null +++ b/src/AcDream.Core/Lighting/LightBake.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Lighting; + +/// +/// Retail per-vertex static-light burn-in. Ported verbatim from +/// calc_point_light (acclient 0x0059c8b0), the function retail's +/// D3DPolyRender::SetStaticLightingVertexColors (0x0059cfe0) runs over +/// EVERY vertex of an EnvCell mesh × EVERY reaching static light, baking the +/// result into the vertex diffuse colour ONCE (then the rasteriser Gouraud- +/// interpolates it across each triangle and the texture stage modulates it). +/// +/// +/// This is the faithful answer to the dungeon "spotlight" look (#133 A7): our +/// old per-pixel nearest-8 path lit only the 8 torches nearest the CAMERA and +/// re-ranked them every frame (the sliding crescent). The retail bake sums ALL +/// reaching lights into the vertex once, keyed on light position not camera — +/// uniform, stable, and never blown out (each light is clamped to its own +/// colour, then the vertex sum is clamped to [0,1]). +/// +/// +/// Constants (decomp-cited, not guessed): +/// +/// static_light_factor = 1.3 (0x00820e24) — folded into +/// by LightInfoLoader, so +/// falloff_eff == light.Range here. +/// LIGHT_POINT_RANGE = 0.75 (0x007e5430) — the half-Lambert wrap +/// uses 2·LPR = 1.5 as the divisor and (2·LPR − 1) = 0.5 as the +/// distance bias, so even surfaces angled away from a torch receive some light. +/// +/// +public static class LightBake +{ + // calc_point_light literals. + private const float TwoLpr = 1.5f; // LIGHT_POINT_RANGE + LIGHT_POINT_RANGE + private const float WrapBias = 0.5f; // (2 · LIGHT_POINT_RANGE) − 1.0 + + /// + /// Accumulate one static light's contribution into a per-vertex RGB sum, + /// exactly as calc_point_light does. Returns the contribution to ADD + /// (already per-channel clamped to the light's own colour); the caller sums + /// over all reaching lights and clamps the total to [0,1]. + /// + public static Vector3 PointContribution( + Vector3 vtxWorldPos, Vector3 vtxWorldNormal, LightSource light) + { + // D = light − vertex (FROM vertex TO light), used un-normalised. + float dx = light.WorldPosition.X - vtxWorldPos.X; + float dy = light.WorldPosition.Y - vtxWorldPos.Y; + float dz = light.WorldPosition.Z - vtxWorldPos.Z; + + float distsq = dx * dx + dy * dy + dz * dz; + float dist = MathF.Sqrt(distsq); + float falloffEff = light.Range; // = Falloff × static_light_factor(1.3) + if (dist >= falloffEff || falloffEff <= 1e-4f) + return Vector3.Zero; + + // Half-Lambert wrap: (1/1.5)·(N·D + 0.5·dist), N un-normalised vertex normal. + float wrap = (1f / TwoLpr) * + (vtxWorldNormal.X * dx + vtxWorldNormal.Y * dy + vtxWorldNormal.Z * dz + + WrapBias * dist); + if (wrap <= 0f) + return Vector3.Zero; + + // norm branch — ported EXACTLY (changes the near-vs-far falloff shape). + float norm = distsq > 1f ? distsq * dist : dist; + float scale = (1f - dist / falloffEff) * light.Intensity * (wrap / norm); + + // Per channel: contribution clamped to the light's own colour (a single + // light can never push a channel past its colour — the no-blowout ceiling). + return new Vector3( + MathF.Min(scale * light.ColorLinear.X, light.ColorLinear.X), + MathF.Min(scale * light.ColorLinear.Y, light.ColorLinear.Y), + MathF.Min(scale * light.ColorLinear.Z, light.ColorLinear.Z)); + } + + /// + /// Bake the full per-vertex colour by summing every reaching lit point/spot + /// light, then clamping to [0,1] (the SetStaticLightingVertexColors + /// final clamp). Directional lights are skipped — they are handled by the + /// sun path, not the static burn-in. + /// + public static Vector3 ComputeVertexColor( + Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList reaching) + { + float r = 0f, g = 0f, b = 0f; + for (int i = 0; i < reaching.Count; i++) + { + var light = reaching[i]; + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + var c = PointContribution(vtxWorldPos, vtxWorldNormal, light); + r += c.X; g += c.Y; b += c.Z; + } + return new Vector3( + Math.Clamp(r, 0f, 1f), + Math.Clamp(g, 0f, 1f), + Math.Clamp(b, 0f, 1f)); + } +} diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index 63a250f4..671da599 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -79,7 +79,15 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - Range = info.Falloff, + // falloff_eff for the per-vertex point-light burn-in (calc_point_light + // 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor + // is the fixed global 1.3 (0x00820e24). That is the path that lights + // STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is + // about — so we match it, not the D3D-dynamic config_hardware_light + // rangeAdjust (1.5, a different path for moving objects). The shader ramp + // (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard + // disc edge that read as a spotlight. + Range = info.Falloff * 1.3f, ConeAngle = info.ConeAngle, OwnerId = ownerId, IsLit = true, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index a9ba8dfc..95ea1edf 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting; /// §12.2). /// /// -/// Active-light selection algorithm (r13 §12.2 "Tick" steps): +/// Active-light selection algorithm (r13 §12.2), as implemented by +/// : /// /// -/// Recompute DistSq from viewer to every registered -/// point/spot light. +/// Reserve slot 0 for the sun (directional, infinite range) when present. /// /// -/// Drop lights outside Range² * 1.1 (10% slack prevents -/// pop as we walk across the boundary). -/// -/// -/// Rank remaining lights by DistSq ascending. Pick top 7. -/// -/// -/// Reserve slot 0 for the sun (directional, infinite range). +/// For every registered lit point/spot light, recompute DistSq +/// from the viewer and keep the nearest (MaxActiveLights − sunSlot) +/// directly in the active window via an allocation-free insertion +/// partial-select (no per-frame list/sort). /// /// +/// There is deliberately NO viewer-range candidacy filter: each light's +/// own range cutoff is applied PER SURFACE in the shader +/// (mesh_modern.frag: d < range), so a torch the viewer +/// stands outside the range of must still light the wall it sits on. The +/// earlier Range² × 1.1 slack filter wrongly dropped exactly those +/// lights (the #133 "lighting off" report). /// /// /// @@ -37,7 +39,6 @@ namespace AcDream.Core.Lighting; public sealed class LightManager { public const int MaxActiveLights = 8; // D3D parity - private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff private readonly List _all = new(); private readonly LightSource?[] _active = new LightSource?[MaxActiveLights]; @@ -94,45 +95,187 @@ public sealed class LightManager /// public void Tick(Vector3 viewerWorldPos) { - // Pass 1: compute DistSq + filter out lights outside the slack radius. - var candidates = new List(_all.Count); - foreach (var light in _all) - { - if (!light.IsLit) continue; - if (light.Kind == LightKind.Directional) - { - // Directional lights don't participate in this ranking — - // the sun is always slot 0. - continue; - } - - Vector3 delta = light.WorldPosition - viewerWorldPos; - light.DistSq = delta.LengthSquared(); - - float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack; - if (light.DistSq > rangeSq) continue; - candidates.Add(light); - } - - // Pass 2: sort by DistSq ascending, take up to 7. - candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq)); - + // Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1) + // point lights (slot 0 is the sun) and applies each light's hard range cutoff + // PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`), + // NOT a viewer-range candidacy filter — a torch the viewer stands outside the + // range of must still light the wall it sits on. + // + // Allocation-free partial selection: the old path built `new List<>(N)` and + // ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches, + // so that allocated a large list per frame (GC pressure → FPS). Instead keep + // the nearest maxPoint directly in the _active window, maintained sorted by + // insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation. Array.Clear(_active); _activeCount = 0; - // Slot 0 = sun when present. + // Slot 0 = sun when present (directional; never ranked by distance). + int baseSlot = 0; if (Sun is not null) { _active[0] = Sun; - _activeCount = 1; + baseSlot = 1; } - int maxPoint = MaxActiveLights - _activeCount; - int pointCount = Math.Min(maxPoint, candidates.Count); - for (int i = 0; i < pointCount; i++) + int maxPoint = MaxActiveLights - baseSlot; + int filled = 0; + if (maxPoint > 0) { - _active[_activeCount + i] = candidates[i]; + foreach (var light in _all) + { + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + + Vector3 delta = light.WorldPosition - viewerWorldPos; + light.DistSq = delta.LengthSquared(); + + // Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by + // DistSq. Insert if there's room or this light is nearer than the + // current farthest (then the farthest falls off the end). + if (filled < maxPoint) + { + int j = baseSlot + filled; + while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq) + { + _active[j] = _active[j - 1]; + j--; + } + _active[j] = light; + filled++; + } + else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq) + { + int j = baseSlot + maxPoint - 1; + while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq) + { + _active[j] = _active[j - 1]; + j--; + } + _active[j] = light; + } + } } - _activeCount += pointCount; + + _activeCount = baseSlot + filled; + } + + // ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ── + // + // The single global nearest-8-to-VIEWER set above (Tick) is camera-relative: + // a wall's brightness changes as the camera moves because the wall's torches + // swap in/out of that global top-8. Retail instead picks up-to-8 lights PER + // OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480), + // so a torch always lights the wall it sits on, camera-independent. The two + // members below feed the per-instance light path in WbDrawDispatcher; Tick + // remains the source of the legacy single-UBO path + the sun slot. + + /// Max point/spot lights any one object can be lit by — retail's + /// D3D fixed-function 8-light cap (minimize_object_lighting). The sun + /// is global, not part of an object's per-object set, so all 8 are point/spot. + public const int MaxLightsPerObject = 8; + + /// Hard cap on the per-frame global point-light snapshot the shader + /// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128 + /// is generous. If exceeded, the nearest-to-camera are kept (cold path). + public const int MaxGlobalLights = 128; + + private readonly List _pointSnapshot = new(); + + /// + /// Per-frame snapshot of lit point/spot lights, stable-indexed for the global + /// shader light buffer and for per-object selection: the index of a light here + /// IS the index the per-instance light-set SSBO references. Built by + /// . + /// + public IReadOnlyList PointSnapshot => _pointSnapshot; + + /// + /// Rebuild from the registered lit point/spot + /// lights. The sun and unlit lights are excluded (the sun is global ambient- + /// path; unlit torches contribute nothing). When more than + /// qualify, keeps the nearest the camera so the + /// most relevant lights survive the cap. Call once per frame before + /// per-object selection. + /// + public void BuildPointLightSnapshot(Vector3 cameraWorldPos) + { + _pointSnapshot.Clear(); + foreach (var light in _all) + { + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared(); + _pointSnapshot.Add(light); + } + if (_pointSnapshot.Count > MaxGlobalLights) + { + _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); + _pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights); + } + } + + /// + /// Select up to point/spot lights from + /// that reach the object sphere + /// (, ), nearest-first. + /// Faithful to retail's minimize_object_lighting (0x0054d480): a light + /// is a candidate iff its falloff sphere overlaps the object sphere — + /// (light.pos − center)² < (light.Range + radius)² — and when more + /// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the + /// farthest fall off). already folds + /// static_light_factor (1.3), matching the per-vertex cutoff so a + /// selected light always actually contributes in the shader. + /// + /// Writes indices INTO to + /// (ascending by distance) and returns the count. + /// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a + /// static object's set is stable and may be computed once. Unit-testable + /// without GL. + /// + /// + public static int SelectForObject( + IReadOnlyList snapshot, + Vector3 center, + float radius, + Span outIndices) + { + int cap = Math.Min(outIndices.Length, MaxLightsPerObject); + if (cap <= 0) return 0; + + Span keptDistSq = stackalloc float[MaxLightsPerObject]; + int count = 0; + + for (int li = 0; li < snapshot.Count; li++) + { + var light = snapshot[li]; + float reach = light.Range + radius; + float dsq = (light.WorldPosition - center).LengthSquared(); + if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object + + if (count < cap) + { + int j = count; + while (j > 0 && keptDistSq[j - 1] > dsq) + { + keptDistSq[j] = keptDistSq[j - 1]; + outIndices[j] = outIndices[j - 1]; + j--; + } + keptDistSq[j] = dsq; + outIndices[j] = li; + count++; + } + else if (dsq < keptDistSq[cap - 1]) + { + int j = cap - 1; + while (j > 0 && keptDistSq[j - 1] > dsq) + { + keptDistSq[j] = keptDistSq[j - 1]; + outIndices[j] = outIndices[j - 1]; + j--; + } + keptDistSq[j] = dsq; + outIndices[j] = li; + } + } + return count; } } diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs index c8d38bf7..c9c2bedd 100644 --- a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -141,4 +141,53 @@ public static class GfxObjDegradeResolver resolvedGfxObj = closeGfxObj; return true; } + + /// + /// True when a GfxObj is an EDITOR-ONLY placement marker that retail's distance-based + /// degrade hides at any runtime distance. Such a marker's closest degrade slot is visible + /// ONLY at distance 0 (Degrades[0].MaxDist == 0) and the table degrades to GfxObj + /// id 0 (= nothing) at real distance. Retail + /// (CPhysicsPart::UpdateViewerDistance 0x0050E030 → Draw 0x0050D7A0 picks + /// gfxobj[deg_level] by viewer distance) therefore never draws it in the live + /// client — only WorldBuilder shows it at the editor origin. acdream has no per-frame + /// distance-LOD (the resolver above always returns slot 0), so without this check it + /// renders the marker mesh forever — the #136 dungeon "red/green cone" (Setup 0x02000C39 + /// / GfxObj 0x010028CA, whose degrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, + /// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at + /// distance > 0) skip such GfxObjs. + /// + public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId) + => IsRuntimeHiddenMarker( + id => dats.Get(id), + id => dats.Get(id), + gfxObjId); + + /// Loader-callback overload of . + public static bool IsRuntimeHiddenMarker( + Func getGfxObj, + Func getDegradeInfo, + uint gfxObjId) + { + var gfxObj = getGfxObj(gfxObjId); + if (gfxObj is null + || !gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) + || gfxObj.DIDDegrade == 0) + return false; + + var info = getDegradeInfo(gfxObj.DIDDegrade); + if (info is null || info.Degrades.Count == 0) + return false; + + // Closest slot visible only at distance exactly 0 = editor-only placement marker. + bool firstSlotEditorOnly = info.Degrades[0].MaxDist == 0f; + if (!firstSlotEditorOnly) + return false; + + // ...and the table degrades to NOTHING (id 0) at real distance — confirms it + // becomes invisible at runtime rather than LOD-swapping to a real mesh. + foreach (var d in info.Degrades) + if ((uint)d.Id == 0u) + return true; + return false; + } } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index deec7ed3..7218e016 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -26,8 +26,16 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _buildings = new(); /// - /// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches. - /// Consumed by nobody this stage (zero behavior change). + /// The unified cell graph (UCG): the active id->cell resolver and registry. + /// Populated unconditionally in — BEFORE the + /// idempotency + null-BSP guards, so BSP-less cells are registered too — and + /// consumed across the engine: the player render/lighting root + /// (CellGraph.CurrCell, written at the player chokepoint + /// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the + /// universal id->cell lookup (GetVisible), the 3rd-person camera cell + /// (FindVisibleChildCell), and the block-local terrain origin + /// (TryGetTerrainOrigin, read by CellTransit's pick + transit + /// paths). No longer inert. /// public UcgCellGraph CellGraph { get; } = new(); diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 80a76cf8..378afe92 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -638,9 +638,23 @@ public sealed class PhysicsEngine { Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); + // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id, + // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning + // resident landblocks for one whose [0,192) local bounds contain + // the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE + // (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon + // landblock fails the localY>=0 bounds test, so the loop matches a + // neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping + // the validated claim 0x00070143 -> 0xA9B30143. The client then + // mis-resolves the player into the wrong landblock and spams ACE with + // rejected moves. The validated claim's prefix is AUTHORITATIVE; a + // position falling in a neighbouring resident landblock must not + // re-stamp it. Byte-identical for the login case (the position lies in + // the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000); + // diverges only — and correctly — in the far-teleport dungeon case. return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), - lbPrefix | (cellId & 0xFFFFu), + cellId, IsOnGround: true); } } diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index ba081f71..872285e4 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -243,6 +243,34 @@ public static class RenderingDiagnostics public static bool ProbePhantomEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1"; + /// + /// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true, + /// the per-frame scene-lighting build emits ONE [light] line + /// roughly every second (wall-clock rate-limited like WB-DIAG) via + /// : + /// + /// [light] insideCell=<bool> ambient=(r,g,b) sun=<intensity> + /// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id> + /// + /// This is the self-verification signal for the dungeon-dim question: + /// + /// insideCell=true ambient=(0.20,0.20,0.20) sun=0 + /// confirms the indoor branch fired (retail flat ambient, sun killed). + /// registeredLights is the count of dat-baked + /// point/spot lights (Setup.Lights) registered with the + /// LightManager — if this is 0 in a dungeon, the cell's static + /// objects carry no baked torches (so the only illumination IS the + /// 0.2 ambient → dim). + /// activeLights is uCellAmbient.w — the + /// shader's active-slot count, which INCLUDES the (zeroed) sun slot + /// indoors. So activeLights=1 registeredLights=0 = "only the dead + /// sun slot, no torches in range". + /// + /// Output-only, inert when off. Initial state from ACDREAM_PROBE_LIGHT=1. + /// + public static bool ProbeLightEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; + // Cell-change gate for EmitVis. The probe fires once per distinct root cell // so launch.log stays readable under motion (the per-frame call is a no-op // when the root is unchanged). Sentinel 0 = "no root yet" — the first real @@ -336,6 +364,93 @@ public static class RenderingDiagnostics /// internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0; + // Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty — + // we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0 + // = "never emitted" so the first call always fires. + private static long _lastLightEmitTicks; + private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks + + /// + /// #133 A7 — emit ONE rate-limited [light] line describing the + /// current scene-lighting state, followed (when + /// is supplied) by up to three [light-detail] lines for the nearest + /// ACTIVE point/spot lights. Cheap no-op when + /// is false; otherwise fires at most + /// once per second. Pull the values from the spot where + /// GameWindow.UpdateSunFromSky set Lighting.CurrentAmbient + /// / Lighting.Sun and where SceneLightingUbo.Build computed + /// the active-slot count. + /// + /// The [light-detail] lines are the answer to the "candle-spotlight" + /// question — they expose each torch's REAL dat-derived runtime values + /// (range= Falloff metres, intensity=, cone= radians, + /// color=, distToViewer=) so it is visible in launch.log + /// whether dungeon torches are tiny-range points or wide cones and at what + /// intensity — without a screenshot: + /// + /// [light-detail] kind=Point range=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m> + /// + /// + /// + /// The playerInsideCell value driving the indoor branch. + /// Cell ambient red (xyz of uCellAmbient). + /// Cell ambient green. + /// Cell ambient blue. + /// The sun LightSource.Intensity (0 indoors). + /// Total point/spot lights registered with the LightManager. + /// uCellAmbient.w — shader active-slot count (includes the zeroed sun slot indoors). + /// The player's current cell id (0 if unresolved → outside). + /// The ticked LightManager (its Active list, sorted nearest-first by the + /// just-completed Tick). When non-null, drives the [light-detail] lines. Optional so existing call + /// sites / tests that only want the aggregate line keep compiling. + public static void EmitLight(bool insideCell, + float ambientR, float ambientG, float ambientB, + float sunIntensity, + int registeredLights, + int activeLights, + uint playerCellId, + AcDream.Core.Lighting.LightManager? lights = null) + { + if (!ProbeLightEnabled) return; + + long now = DateTime.UtcNow.Ticks; + if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks) + return; + _lastLightEmitTicks = now; + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}", + insideCell, ambientR, ambientG, ambientB, sunIntensity, + registeredLights, activeLights, playerCellId)); + + // #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble" + // question. Dump the actual runtime dat-derived values of the nearest + // ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle + // are visible in launch.log (are torch ranges 1m or 10m? points or + // spots? what intensity?). The sun (Directional, slot 0) is skipped — + // it carries no Range/cone meaning. DistSq is already cached by + // LightManager.Tick this frame, so the active list is sorted nearest- + // first; we just take the first few non-directional entries. + if (lights is null) return; + var active = lights.Active; + int shown = 0; + const int MaxDetail = 3; + for (int i = 0; i < active.Length && shown < MaxDetail; i++) + { + var ls = active[i]; + if (ls is null) continue; + if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue; + + float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f; + Console.WriteLine(string.Format(ci, + "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}", + ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle, + ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist)); + shown++; + } + } + private static bool _probeEnvCellEnabled = Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 49cfe199..f727a59c 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -80,6 +80,11 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency) { + // Malformed Base1Solid (or OrigTextureId==0) surface with no color value: + // signal undecodable (Magenta) instead of NRE. This method is called + // directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's + // try/catch, so it must be null-safe itself. + if (color is null) return DecodedTexture.Magenta; float opacity = Math.Clamp(1f - translucency, 0f, 1f); byte alpha = (byte)Math.Clamp(color.Alpha * opacity, 0f, 255f); return new DecodedTexture( diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index fb6269fd..00b19ce9 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -6,17 +6,26 @@ using AcDream.Core.Physics; // TerrainSurface namespace AcDream.Core.World.Cells; /// -/// The unified cell graph: the authoritative id->cell resolver and registry. -/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed -/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209). -/// Worker-thread populated; reads are concurrency-safe. +/// The unified cell graph: the active, authoritative id->cell resolver and registry. +/// Populated unconditionally from +/// (before its +/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across +/// the engine: resolves any cell id, is +/// the player render/lighting root, resolves the +/// 3rd-person camera cell, and supplies the block-local +/// terrain origin for the LandDefs lcoord math. Retail anchor: CObjCell::GetVisible +/// (pseudo_c:308209). Worker-thread populated; reads are concurrency-safe. /// public sealed class CellGraph { private readonly ConcurrentDictionary _envCells = new(); private readonly ConcurrentDictionary _terrain = new(); - /// Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer). + /// The player's current cell — the render/lighting root. Written ONLY at the + /// player chokepoint + /// (NPCs never touch it — a per-entity writer was the cottage-doorway "blue-hole" + /// cause); read by the renderer for the player root (GameWindow). Left unchanged when + /// the id isn't yet resolvable in the graph (stale beats null). public ObjCell? CurrCell { get; internal set; } public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 5acf2d39..0120e84a 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -74,22 +74,15 @@ public readonly record struct SkyKeyframe( /// (see ). /// /// - /// Why |sunVec| instead of DirBright directly: retail's - /// PrimD3DRender::UpdateLightsInternal at 0x0059b57c - /// (decomp line 424118-424119) computes - /// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²) - /// from the sun vector SkyDesc::GetLighting built at - /// 0x00500ac9 (decomp lines 261343-261353): - /// - /// sunVec.x = sin(H) × DirBright × cos(P) - /// sunVec.y = cos(P) // NOT scaled by DirBright - /// sunVec.z = DirBright × sin(P) - /// - /// Because Y is unscaled by DirBright, |sunVec| ≠ - /// DirBright in general — it varies with sun pitch and heading. - /// Using DirBright alone underweighted the warm directional - /// term, letting the cool ambient/fog dominate ⇒ acdream rendered - /// blue-white at keyframes where retail looked warm-gray. + /// |sunVec| is retail's D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²) + /// scaling (PrimD3DRender::UpdateLightsInternal 0x0059b57c, decomp + /// 424118-424119) of the WORLD-space sun vector (LScape::sunlight). + /// Because is now the + /// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified — + /// see that method), |sunVec| == DirBright, so this is effectively + /// SunColor = DirColor × DirBright. (A prior bug used the un-transformed + /// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~4–5× too bright at dawn/dusk; + /// [[reference-retail-ambient-values]].) /// /// public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length(); @@ -301,21 +294,35 @@ public sealed class SkyStateProvider } /// - /// Retail's raw sun vector (NOT normalized) — the same vector - /// SkyDesc::GetLighting writes at 0x00500ac9 - /// (decomp lines 261343, 261352, 261353): + /// Retail's world-space sun vector (NOT normalized): the standard + /// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by + /// DirBright: /// - /// sunVec.x = sin(H_rad) × DirBright × cos(P_rad) - /// sunVec.y = cos(P_rad) // NOT scaled by DirBright - /// sunVec.z = DirBright × sin(P_rad) + /// sunVec.x = DirBright × cos(P) × sin(H) + /// sunVec.y = DirBright × cos(P) × cos(H) + /// sunVec.z = DirBright × sin(P) /// - /// Y is unscaled by brightness on purpose — that's what makes - /// |sunVec|DirBright in general (the magnitude varies - /// with pitch/heading, which is the basis for retail's "sun is brighter - /// in some configurations than others" lighting behavior). The shader's - /// uSunDir uniform uses the NORMALIZED vector for N·L; the - /// magnitude feeds intensity and - /// the ambient brightness boost in . + /// so |sunVec| == DirBright exactly (cos²P·(sin²H+cos²H)+sin²P = 1). + /// + /// + /// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]): + /// retail's LScape::sunlight read at a dawn keyframe (H=90°, P=0.9°, + /// DirBright≈0.224) = (0.2238, ~0, 0.00352) — y≈0, magnitude 0.224 = + /// DirBright. That fed level = 0.2·|sunlight| + ambient_level = 0.2·0.224 + + /// 0.40 = 0.445, matching the captured SetWorldAmbientLight level. + /// + /// + /// PRIOR BUG: an earlier version returned y = cos(P) (≈1) — the raw + /// PRE-transform value the decomp's SkyDesc::GetLighting writes to its + /// arg5 (0x00500ac9, before LScape::set_sky_position's world + /// transform). Porting that un-transformed vector inflated |sunVec| to + /// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost + /// () AND the sun colour + /// () by ~30% vs retail. The world-space + /// form above is what LScape::sunlight actually holds at runtime. + /// + /// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright) + /// feeds the sun-colour intensity and the ambient brightness boost. /// public static Vector3 RetailSunVector(SkyKeyframe kf) { @@ -325,9 +332,9 @@ public sealed class SkyStateProvider float sinP = MathF.Sin(p); float B = kf.DirBright; return new Vector3( - MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P) - cosP, // y = cos(P) ← unscaled by B - B * sinP); // z = B × sin(P) + B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H) + B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H) + B * sinP); // z = DirBright × sin(P) } /// diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 7374ea91..dca64d7b 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -10,4 +10,5 @@ public interface IPluginHost IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } + IUiRegistry Ui { get; } } diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs new file mode 100644 index 00000000..1b724f1a --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -0,0 +1,14 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + +/// a binding object exposing the data properties the markup binds to, and +/// registers it from Enable(). Calls made before the GL window opens are +/// buffered and drained once the UI host exists. +/// +public interface IUiRegistry +{ + /// Absolute path to the plugin's panel markup file. + /// Object whose properties the markup's {Bindings} resolve against. + void AddMarkupPanel(string markupPath, object binding); +} diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 84bafce3..e62dc5e2 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -141,6 +141,12 @@ public sealed class InputDispatcher public bool IsActionHeld(InputAction action) { if (action == InputAction.None) return false; + // While a text field owns the keyboard ("write mode"), held game actions read as + // released: typing "swd" must not move the character. This is the polling-path twin + // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven + // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at + // the call site) keeps driving the character, so chat doesn't cancel autorun. + if (_mouse.WantCaptureKeyboard) return false; foreach (var b in _bindings.ForAction(action)) { if (IsChordHeld(b.Chord)) return true; diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs new file mode 100644 index 00000000..9158d2d0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -0,0 +1,78 @@ +using System; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index c8ece999..9cb8cb1f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -191,53 +191,7 @@ public sealed class ChatPanel : IPanel if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { - var trimmed = submitted.Trim(); - // Phase J follow-up: client-side commands intercepted before - // the server-bound parse path. Avoids the /help round-trip - // that produced "Unknown command: help" duplicates from - // ACE's command-error replies, AND gives users a discoverable - // local cheat-sheet of acdream's own slash prefixes. - if (TryHandleClientCommand(trimmed)) - { - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - - // Phase J Tier 4: any /-prefixed input that ISN'T one of our - // known verbs gets a local "Unknown command" message instead - // of being broadcast to the server as plain speech. The - // user reported "/ls" / "/mp /path" leaking out as chat — - // a / prefix is a command, never speech. (@-prefixed unknown - // verbs still pass through to ACE because ACE's - // CommandManager intercepts @ server-side and replies with - // its own "Unknown command" / valid command output.) - if (trimmed.Length > 0 && trimmed[0] == '/') - { - string verb = ChatInputParser.GetVerbToken(trimmed); - if (!ChatInputParser.IsKnownVerb(verb)) - { - _vm.ShowSystemMessage( - $"Unknown command: {verb}. Type /help for the list of supported commands."); - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - } - - var parsed = ChatInputParser.Parse( - trimmed, - ChatChannelKind.Say, - _vm.LastIncomingTellSender, - _vm.LastOutgoingTellTarget); - if (parsed is { } p) - { - ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); - } - // Defensive: if the backend ever forgot to clear on submit, - // do it here. Cheap; no harm if already empty. + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); _input = string.Empty; } @@ -258,79 +212,4 @@ public sealed class ChatPanel : IPanel _ => new Vector4(1f, 1f, 1f, 1f), }; - /// - /// Phase J follow-up: handle client-side slash commands before - /// the parser passes anything to the server bus. Returns true - /// when the input was consumed (and the caller should clear the - /// buffer + skip the SendChatCmd path); false otherwise. - /// - /// - /// Recognised client-side commands: - /// - /// /help, /?, /h — render the slash-prefix - /// cheat-sheet locally. Avoids the server's "Unknown command" - /// round-trip when the user just wants to know what they can - /// type. - /// /clear, /cls — drain the chat log so the - /// panel starts empty. - /// - /// - private bool TryHandleClientCommand(string trimmed) - { - if (trimmed.Length == 0) return false; - - // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { - _vm.ShowSystemMessage(BuildHelpText()); - return true; - } - - // /clear, /cls — also @clear, @cls. - if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { - _vm.Clear(); - return true; - } - - // /framerate — also @framerate. Prints current FPS to chat. - if (EqAny(trimmed, "/framerate", "@framerate")) - { - _vm.ShowFps(); - return true; - } - - // /loc — also @loc. Prints current player position to chat. - // ACE has a server-side @loc too; client-side wins here - // (instantaneous + uses our local interpolated position). - if (EqAny(trimmed, "/loc", "@loc")) - { - _vm.ShowLocation(); - return true; - } - - return false; - } - - /// Case-insensitive multi-string equality test. - private static bool EqAny(string s, params string[] options) - { - for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - /// - /// Multi-line cheat-sheet text rendered by /help. ImGui's - /// Text path flows embedded newlines naturally so this lands - /// as one ChatLog entry that visually wraps to several lines. - /// - private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + - " /patron /vassals /monarch /covassals\n" + - " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + - "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 5ab79928..272953e3 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -22,4 +22,10 @@ + + + PreserveNewest + + + diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs new file mode 100644 index 00000000..6e22e17f --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -0,0 +1,21 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public class BufferedUiRegistryTests +{ + [Fact] + public void Drain_YieldsBufferedRegistrationsOnceThenEmpty() + { + var reg = new BufferedUiRegistry(); + reg.AddMarkupPanel("a.xml", new object()); + reg.AddMarkupPanel("b.xml", new object()); + + var drained = reg.Drain(); + Assert.Equal(2, drained.Count); + Assert.Equal("a.xml", drained[0].MarkupPath); + Assert.Equal("b.xml", drained[1].MarkupPath); + + Assert.Empty(reg.Drain()); // consumed + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs new file mode 100644 index 00000000..5e5f1228 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes +/// WB-DIAG to ~9.1M instances/frame. Suspected cause: +/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding +/// (decomp:311878). A dungeon cell has seen_outside==0; retail's PVS for it is just the +/// cell's stab_list () — typically a small bounded +/// set. If our flood instead visits ~all cells of the landblock, that is the blowup. +/// +/// This is a DIAGNOSTIC, not a fix: it loads the real 0x0007 interior cells, runs the real +/// production flood from representative dungeon-cell roots, and PRINTS the ground-truth numbers — +/// flood visited-cell-set size () vs the +/// root's stab_list size (), plus how many visited cells +/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output. +/// +public class Issue95DungeonFloodDiagnosticTests +{ + private const uint TownNetwork = 0x00070000u; + + private readonly ITestOutputHelper _out; + public Issue95DungeonFloodDiagnosticTests(ITestOutputHelper output) => _out = output; + + // Production-ish projection (mirrors the sibling harnesses): FovY ~1.2, 1280x720, + // near 0.1, far 5000. The flood's clip is near-independent, so exactness is not + // load-bearing for cell-count measurement. + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); + return view * proj; + } + + [Fact] + public void Measure_DungeonFlood_VisibleCellCount() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dat dir did not resolve (ACDREAM_DAT_DIR unset and " + + "%USERPROFILE%\\Documents\\Asheron's Call absent). No numbers measured."); + // Diagnostic test: do not hard-fail when dats are absent (matches sibling harnesses). + return; + } + _out.WriteLine($"dat dir resolved: {datDir}"); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // 1) LandBlockInfo header — NumCells for 0x0007. + var lbi = dats.Get(TownNetwork | 0xFFFEu); + if (lbi is null) + { + _out.WriteLine($"SKIP: LandBlockInfo 0x{TownNetwork | 0xFFFEu:X8} not found in the dat " + + "(0x0007 may not exist in this client_cell_1.dat)."); + return; + } + _out.WriteLine($"=== 0x0007 (Town Network) LandBlockInfo ==="); + _out.WriteLine($"NumCells (DatLandBlockInfo.NumCells) = {lbi.NumCells}"); + + // 2) Load ALL interior cells (sparse ids tolerated — see LoadAllInteriorCells). + var loaded = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, TownNetwork); + _out.WriteLine($"cells actually loaded = {loaded.Count}"); + Assert.True(loaded.Count > 0, "no interior cells loaded for 0x0007 — cannot measure"); + + Func lookup = id => loaded.TryGetValue(id, out var c) ? c : null; + + // 3) Per-cell stab_list (VisibleCells) distribution across ALL loaded cells. + // This is the bounded retail PVS size we expect the flood to roughly match. + var stabSizes = loaded.Values.Select(c => c.VisibleCells.Count).ToList(); + int seenOutsideCount = loaded.Values.Count(c => c.SeenOutside); + int interiorCount = loaded.Count - seenOutsideCount; + _out.WriteLine(""); + _out.WriteLine("=== stab_list (LoadedCell.VisibleCells) distribution over ALL loaded cells ==="); + _out.WriteLine($"cells with SeenOutside==true (entrance/exterior-facing) = {seenOutsideCount}"); + _out.WriteLine($"cells with SeenOutside==false (interior dungeon) = {interiorCount}"); + if (stabSizes.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"VisibleCells.Count min={stabSizes.Min()} max={stabSizes.Max()} avg={stabSizes.Average():F1} sum={stabSizes.Sum()}")); + int emptyStab = stabSizes.Count(s => s == 0); + _out.WriteLine($"cells with EMPTY stab_list (no dat PVS) = {emptyStab}"); + + // 4) Pick representative DUNGEON roots: the first interior (SeenOutside==false) cells in + // ascending id order. If none exist, fall back to 0x00070100 and report that. + var interiorRoots = loaded + .Where(kv => !kv.Value.SeenOutside) + .OrderBy(kv => kv.Key) + .Select(kv => kv.Value) + .Take(5) + .ToList(); + + if (interiorRoots.Count == 0) + { + _out.WriteLine(""); + _out.WriteLine("NOTE: NO cell has SeenOutside==false (all cells see the exterior). " + + "Falling back to root 0x00070100 for the flood measurement."); + if (loaded.TryGetValue(TownNetwork | 0x0100u, out var fallback)) + interiorRoots.Add(fallback); + else + { + _out.WriteLine("WARN: 0x00070100 not loaded either; using the lowest-id loaded cell."); + interiorRoots.Add(loaded.OrderBy(kv => kv.Key).First().Value); + } + } + + _out.WriteLine(""); + _out.WriteLine("=== PER-ROOT FLOOD MEASUREMENT (PortalVisibilityBuilder.Build) ==="); + _out.WriteLine("property read for the visited-cell set: PortalVisibilityFrame.OrderedVisibleCells"); + _out.WriteLine("root | seenOut | stab(VisibleCells) | flood(OrderedVisibleCells) | crossLB | dir"); + + var floodSizes = new List(); + foreach (var root in interiorRoots) + { + // Eye at the root cell's world origin, looking toward its first portal (or +X if none), + // so the flood actually fires through an opening. Sweep all 6 axis directions and KEEP + // the maximum visited-set — the blowup is a worst-case-over-orientation quantity. + var eye = root.WorldPosition; + int bestFlood = -1; + string bestDir = "?"; + int bestCrossLb = -1; + List? bestVisited = null; + + // Direction candidates: toward each portal's polygon centroid (the natural look-through), + // plus the 6 cardinal axes as a fallback sweep. + var lookTargets = new List<(Vector3 target, string label)>(); + for (int pi = 0; pi < root.Portals.Count && pi < root.PortalPolygons.Count; pi++) + { + var poly = root.PortalPolygons[pi]; + if (poly is { Length: >= 1 }) + { + var cl = Vector3.Zero; + foreach (var v in poly) cl += v; + cl /= poly.Length; + lookTargets.Add((Vector3.Transform(cl, root.WorldTransform), + $"portal{pi}->0x{root.Portals[pi].OtherCellId:X4}")); + } + } + foreach (var (d, lbl) in new (Vector3, string)[] + { + (Vector3.UnitX, "+X"), (-Vector3.UnitX, "-X"), + (Vector3.UnitY, "+Y"), (-Vector3.UnitY, "-Y"), + (Vector3.UnitZ, "+Z"), (-Vector3.UnitZ, "-Z"), + }) + lookTargets.Add((eye + d * 5f, lbl)); + + foreach (var (target, label) in lookTargets) + { + if (Vector3.DistanceSquared(target, eye) < 1e-6f) continue; + var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, target)); + int floodN = frame.OrderedVisibleCells.Count; + if (floodN > bestFlood) + { + bestFlood = floodN; + bestDir = label; + bestVisited = frame.OrderedVisibleCells; + bestCrossLb = frame.OrderedVisibleCells.Count(id => (id & 0xFFFF0000u) != TownNetwork); + } + } + + floodSizes.Add(bestFlood); + _out.WriteLine(FormattableString.Invariant( + $"0x{root.CellId:X8} | {(root.SeenOutside ? "Y" : "N"),5} | {root.VisibleCells.Count,18} | {bestFlood,26} | {bestCrossLb,7} | {bestDir}")); + + // For the FIRST root, also print the actual visited set + stab set for eyeballing. + if (ReferenceEquals(root, interiorRoots[0]) && bestVisited is not null) + { + _out.WriteLine(" first-root visited (OrderedVisibleCells, low ids): " + + string.Join(" ", bestVisited.Select(id => $"{id & 0xFFFFu:X4}"))); + _out.WriteLine(" first-root stab_list (VisibleCells, low ids): " + + string.Join(" ", root.VisibleCells.Select(id => $"{id & 0xFFFFu:X4}"))); + } + } + + // 5) Aggregate flood-size stats across the sampled roots — the headline numbers. + _out.WriteLine(""); + _out.WriteLine("=== AGGREGATE over sampled roots ==="); + if (floodSizes.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"flood visited-set size (OrderedVisibleCells): min={floodSizes.Min()} max={floodSizes.Max()} avg={floodSizes.Average():F1} (NumCells={lbi.NumCells}, loaded={loaded.Count})")); + var sampledStab = interiorRoots.Select(r => r.VisibleCells.Count).ToList(); + if (sampledStab.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"sampled roots' stab_list size (VisibleCells): min={sampledStab.Min()} max={sampledStab.Max()} avg={sampledStab.Average():F1}")); + _out.WriteLine(""); + _out.WriteLine("INTERPRETATION: if flood max ~= loaded.Count (visits ~all cells) while stab " + + "is small, that is the #95 blowup — the flood is unbounded by the retail stab_list PVS."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs new file mode 100644 index 00000000..cb1ffd7c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs @@ -0,0 +1,42 @@ +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +/// +/// A7 Fix D round 2 — pins retail's useSunlight gate for per-object torch +/// lighting (WbDrawDispatcher.IndoorObjectReceivesTorches). Retail enables +/// the static wall-torches on an object ONLY in the indoor stage +/// (DrawMeshInternal 0x0059f398: if (useSunlight == 0) minimize_object_lighting()), +/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor +/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only +/// EnvCell-parented (indoor, low word >= 0x0100) objects receive torches. +/// +public sealed class WbDrawDispatcherTorchGateTests +{ + [Fact] + public void BuildingShell_NullParent_IsOutdoor_NoTorches() + { + // Building exterior shells are top-level landblock stabs with no + // ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent). + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null)); + } + + [Theory] + [InlineData(0xA9B4_0001u)] // outdoor land sub-cell + [InlineData(0xA9B4_0020u)] // outdoor land sub-cell + [InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40) + public void OutdoorLandCell_NoTorches(uint parentCellId) + { + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); + } + + [Theory] + [InlineData(0xA9B4_0100u)] // first EnvCell + [InlineData(0xA9B4_0164u)] // interior EnvCell + [InlineData(0x0007_0143u)] // dungeon EnvCell + public void IndoorEnvCell_GetsTorches(uint parentCellId) + { + Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs new file mode 100644 index 00000000..b18590ae --- /dev/null +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using AcDream.App; + +namespace AcDream.App.Tests; + +public class RuntimeOptionsRetailUiTests +{ + [Fact] + public void Parse_ReadsRetailUiAndAcDir() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI"] = "1", + ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUi); + Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); + } + + [Fact] + public void Parse_DefaultsRetailUiOffAndAcDirNull() + { + var opts = RuntimeOptions.Parse("dats", _ => null); + Assert.False(opts.RetailUi); + Assert.Null(opts.AcDir); + } +} diff --git a/tests/AcDream.App.Tests/UI/ControlsIniTests.cs b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs new file mode 100644 index 00000000..d4802e27 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs @@ -0,0 +1,38 @@ +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class ControlsIniTests +{ + [Fact] + public void Parse_ReadsSectionTokens() + { + var ini = ControlsIni.Parse( + "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); + + Assert.Equal("19", ini.Get("title", "height")); + Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); + Assert.Null(ini.Get("title", "missing")); + Assert.Null(ini.Get("nosuch", "height")); + } + + [Fact] + public void TryColor_ParsesAlphaFirstHex() + { + var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); + Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); + Assert.Equal(0xFF / 255f, c.W, 5); // alpha + Assert.Equal(0x4F / 255f, c.X, 5); // red + Assert.Equal(0x65 / 255f, c.Y, 5); // green + Assert.Equal(0x7D / 255f, c.Z, 5); // blue + } + + [Fact] + public void Load_MissingFileReturnsEmptyNotThrow() + { + var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); + Assert.Null(ini.Get("title", "height")); // empty, no throw + } +} diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs new file mode 100644 index 00000000..ba35d60d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -0,0 +1,197 @@ +using System; +using System.IO; +using AcDream.App.UI; +using AcDream.Core.Items; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI; + +public class IconComposerTests +{ + private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a) + { + var px = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; } + return px; + } + + [Fact] + public void Compose_alphaOver_topOpaqueLayerWins() + { + var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque + var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque + var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top }); + Assert.Equal(2, w); Assert.Equal(2, h); + Assert.Equal(0, rgba[0]); // R + Assert.Equal(0, rgba[1]); // G + Assert.Equal(255, rgba[2]); // B — top layer won + Assert.Equal(255, rgba[3]); // A + } + + [Fact] + public void Compose_alphaOver_transparentTopKeepsBottom() + { + var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1); + var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue + var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top }); + Assert.Equal(255, rgba[0]); // bottom red preserved + Assert.Equal(0, rgba[2]); + } + + /// + /// Dat-free: when an opaque type-default underlay is prepended as layer 0, + /// Compose yields a fully-opaque result even when the base icon is semi-transparent. + /// This validates the bottom-up ordering that makes filled toolbar slots non-transparent + /// (retail IconData::RenderIcons 407524: underlay is OPAQUE Blit_Normal first). + /// + [Fact] + public void Compose_opaqueUnderlayFirst_resultIsFullyOpaque() + { + var underlay = (Solid(2, 2, 128, 64, 32, 255), 2, 2); // opaque tawny + var baseIcon = (Solid(2, 2, 0, 0, 0, 128), 2, 2); // semi-transparent black + var (rgba, w, h) = IconComposer.Compose(new[] { underlay, baseIcon }); + Assert.Equal(2, w); Assert.Equal(2, h); + // All pixels fully opaque: underlay A=255, baseIcon blends over it. + for (int i = 0; i < w * h; i++) + Assert.Equal(255, rgba[i * 4 + 3]); + } + + // ── Dat-gated golden tests ──────────────────────────────────────────────── + // These tests open the real Asheron's Call dats (ACDREAM_DAT_DIR or the default + // Documents path) and verify the EnumIDMap 0x10000004 resolve chain against the + // known golden DIDs from the dat (confirmed 2026-06-17 research). + // Golden values: IconData::RenderIcons 0058d214 + DBCache::GetDIDFromEnum 0x413940. + + private static string? ResolveDatDir() + { + var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + var def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + [Fact] + public void ResolveUnderlayDid_goldenValues_matchDat() + { + var datDir = ResolveDatDir(); + if (datDir is null) + return; // dats absent (CI) — skip cleanly + + using var dats = new DatCollection(datDir, DatAccessType.Read); + // TextureCache is not needed for the resolve path; pass a null-safe stub + // via IconComposer — the underlay-resolve methods only touch _dats. + // We cannot construct TextureCache without GL, so use a bare IconComposer + // with a null cache guard: ResolveUnderlayDid is internal and pure-dat. + var composer = new IconComposer(dats, null!); + + // Golden values confirmed against C:/Users/erikn/Documents/Asheron's Call + // (IconData::RenderIcons decomp 407524; DBCache::GetDIDFromEnum 0x413940): + // MeleeWeapon (0x1) → index 1 → 0x060011CB + // Armor (0x2) → index 2 → 0x060011CF + // Clothing (0x4) → index 3 → 0x060011F3 + // Jewelry (0x8) → index 4 → 0x060011D5 + // None (0x0) → index 0x21 (fallback) → 0x060011D4 + Assert.Equal(0x060011CBu, composer.ResolveUnderlayDid(ItemType.MeleeWeapon)); + Assert.Equal(0x060011CFu, composer.ResolveUnderlayDid(ItemType.Armor)); + Assert.Equal(0x060011F3u, composer.ResolveUnderlayDid(ItemType.Clothing)); + Assert.Equal(0x060011D5u, composer.ResolveUnderlayDid(ItemType.Jewelry)); + Assert.Equal(0x060011D4u, composer.ResolveUnderlayDid(ItemType.None)); + } + + [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)); + } + + [Fact] + public void TryGetEffectTile_noEffectBlack_magicalTextured() + { + 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!); + + // effects==0 → 0x21 fallback → 0x060011C5, a 32x32 SOLID-BLACK tile. Copying it + // per-pixel blackens an icon's pure-white pixels (retail-faithful no-mana scroll edge). + Assert.True(composer.TryGetEffectTile(0u, out var black)); + Assert.Equal(32, black.Width); + Assert.Equal(32, black.Height); + Assert.True(black.Rgba8[0] <= 8 && black.Rgba8[1] <= 8 && black.Rgba8[2] <= 8); + + // Magical (0x1) → 0x060011CA, a TEXTURED blue tile (NOT a flat color) — this is the + // gradient retail copies per-pixel into the icon's white pixels (the Energy Crystal + // blue). Assert the tile is non-uniform so a future flat-color regression fails here. + Assert.True(composer.TryGetEffectTile(0x1u, out var magic)); + bool uniform = true; + for (int i = 4; i < magic.Width * magic.Height * 4 && uniform; i += 4) + if (magic.Rgba8[i] != magic.Rgba8[0] || magic.Rgba8[i + 1] != magic.Rgba8[1] || + magic.Rgba8[i + 2] != magic.Rgba8[2]) + uniform = false; + Assert.False(uniform); // textured → gradient, not flat + } + + [Fact] + public void ReplaceWhiteFromSurface_copiesSourcePixelForPureWhiteOpaque() + { + // 2x2 dest: [white-opaque, red-opaque, white-transparent, white-opaque] + var dst = new byte[] + { + 255,255,255,255, // pure white opaque → takes src(0,0) + 255, 0, 0,255, // red → untouched + 255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF) + 255,255,255,255, // pure white opaque → takes src(1,1) + }; + // 2x2 src — distinct per-pixel colors (a "gradient"). + var src = new byte[] + { + 10, 20, 30,255, // (0,0) + 40, 50, 60,255, // (1,0) + 70, 80, 90,255, // (0,1) + 100,110,120,255, // (1,1) + }; + IconComposer.ReplaceWhiteFromSurface(dst, 2, 2, src, 2, 2); + Assert.Equal(new byte[] { 10, 20, 30, 255 }, dst[0..4]); // copied src(0,0) + Assert.Equal(new byte[] { 255, 0, 0, 255 }, dst[4..8]); // untouched (not white) + Assert.Equal(new byte[] { 255, 255, 255, 0 }, dst[8..12]); // untouched (transparent) + Assert.Equal(new byte[] { 100, 110, 120, 255 }, dst[12..16]); // copied src(1,1) — per-pixel + } + + [Fact] + public void TwoStageWithEffect_copiesTilePixelBeforeUnderlay() + { + // drag = base (white pixel); copy the effect tile's pixel into the white; then over + // an opaque tawny underlay. The white pixel must become the tile's pixel in the final. + var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque + var drag = IconComposer.Compose(new[] { baseIcon }); + var tile = new byte[] { 0, 0, 255, 255 }; // 1x1 blue tile pixel + IconComposer.ReplaceWhiteFromSurface(drag.rgba, drag.w, drag.h, tile, 1, 1); + 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); // tile pixel on top + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs new file mode 100644 index 00000000..836adbdc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Dat-free conformance tests for the committed chat_21000006.json golden fixture. +/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement / +/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006). +/// +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(); + 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) + Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript) + Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs new file mode 100644 index 00000000..cdc89c5f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs @@ -0,0 +1,39 @@ +using System; +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; + +/// +/// 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. +/// +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"); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs new file mode 100644 index 00000000..aab080cd --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Smoke tests for — no dats, no GL. +/// +/// Building the Type-12 "skipped" elements via the pure +/// path is the correct approach: we build a synthetic info tree that reflects the +/// real chat layout hierarchy (root → transcript panel + input bar as Type-3 +/// containers, with Type-12 children for transcript + input, plus a Type-3 track +/// and menu), call to get the widget tree +/// (Type-12 children skipped, Type-3 parents created), then call +/// which reads rects from the info tree +/// and places behavioral widgets under the parent containers. +/// +public class ChatWindowControllerTests +{ + // ── Null-resolve helper (no GL needed) ───────────────────────────────── + private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + + // ── Capture bus — records every Publish call ──────────────────────────── + private sealed class CaptureBus : ICommandBus + { + public readonly List Published = new(); + public void Publish(T cmd) where T : notnull => Published.Add(cmd!); + } + + // ── Synthetic element tree matching the real chat layout topology ──────── + + /// + /// Build a minimal synthetic ElementInfo tree that mirrors the real chat + /// layout (0x21000006) with enough fidelity for Bind to succeed: + /// root (Type-3) + /// transcriptPanel (Type-3) [0x10000010] + /// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place + /// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind + /// inputBar (Type-3) [0x10000013] + /// menu (Type-6) [0x10000014] + /// input (Type-12, no media) [0x10000016] ← built as UiText by factory; Bind removes + replaces with UiField + /// send (Type-3) [0x10000019] + /// maxmin (Type-3) [0x1000046F] + /// + private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree() + { + var transcriptNode = new ElementInfo + { + Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory + X = 16, Y = 0, Width = 458, Height = 74, + }; + var trackNode = new ElementInfo + { + Id = 0x10000012u, Type = 3, + X = 474, Y = 6, Width = 16, Height = 68, + }; + var transcriptPanel = new ElementInfo + { + Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74, + }; + transcriptPanel.Children.Add(transcriptNode); + transcriptPanel.Children.Add(trackNode); + + var menuNode = new ElementInfo + { + Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17, + }; + var inputNode = new ElementInfo + { + Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory + X = 46, Y = 0, Width = 398, Height = 17, + }; + var sendNode = new ElementInfo + { + Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17, + }; + var inputBar = new ElementInfo + { + Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17, + }; + inputBar.Children.Add(menuNode); + inputBar.Children.Add(inputNode); + inputBar.Children.Add(sendNode); + + var maxMinNode = new ElementInfo + { + Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16, + }; + + var root = new ElementInfo + { + Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100, + }; + root.Children.Add(transcriptPanel); + root.Children.Add(inputBar); + root.Children.Add(maxMinNode); + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + return (root, layout, vm); + } + + // ── Test 1: Bind returns non-null with the minimal tree ────────────────── + + [Fact] + public void Bind_Returns_NonNull_OnValidTree() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + } + + // ── Test 2: Transcript is placed as a child of the transcript panel ────── + + [Fact] + public void Bind_Transcript_IsChildOfTranscriptPanel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + var panel = layout.FindElement(0x10000010u); + Assert.NotNull(panel); + // The transcript widget must be a child of the transcript panel. + Assert.Contains(ctrl!.Transcript, panel!.Children); + } + + // ── Test 3: Input is placed as a child of the input bar ───────────────── + + [Fact] + public void Bind_Input_IsChildOfInputBar() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + var bar = layout.FindElement(0x10000013u); + Assert.NotNull(bar); + Assert.Contains(ctrl!.Input, bar!.Children); + } + + // ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ───── + + [Fact] + public void Bind_InputSubmit_PublishesSendChatCmd() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + ctrl!.Input.OnSubmit!.Invoke("hello world"); + + // ChatCommandRouter.Submit should have published a SendChatCmd. + Assert.Single(bus.Published); + var cmd = Assert.IsType(bus.Published[0]); + Assert.Equal("hello world", cmd.Text); + } + + // ── Test 5: Channel change updates the channel used by subsequent submits ─ + + [Fact] + public void Bind_ChannelChange_UpdatesSubmitChannel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + // Switch channel to General via the generic OnSelect (payload is ChatChannelKind). + ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General); + ctrl.Input.OnSubmit!.Invoke("hey all"); + + Assert.Single(bus.Published); + var cmd = Assert.IsType(bus.Published[0]); + Assert.Equal(ChatChannelKind.General, cmd.Channel); + } + + // ── Test 6: Bind returns null when required elements are absent ────────── + + [Fact] + public void Bind_Returns_Null_WhenTranscriptPanelMissing() + { + // Build a layout that is missing the transcript panel entirely. + var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 }; + // No children → TranscriptPanelId and InputBarId are absent from the widget tree. + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex); + + Assert.Null(ctrl); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs new file mode 100644 index 00000000..67a3a10a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -0,0 +1,231 @@ +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); + + // ── Test 1: Type 7 → UiMeter ───────────────────────────────────────────── + + [Fact] + public void Type7_Meter_MakesUiMeter() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 2: Unknown type → UiDatElement fallback ───────────────────────── + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 3: Type 12 → UiText (behavioral text widget) ──────────────────── + + [Fact] + public void Type12_Text_MakesUiText() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── + + /// + /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have + /// its rect + anchors copied onto the returned widget. + /// Per UIElement::UpdateForParentSizeChange @0x00462640: + /// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top; + /// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither. + /// Combined: Left | Top | Right. + /// + [Fact] + public void RectAndAnchors_SetFromElementInfo() + { + var info = new ElementInfo + { + Type = 3, + X = 5, Y = 21, + Width = 150, Height = 16, + Left = 1, Top = 1, + Right = 1, Bottom = 0, + }; + var e = DatWidgetFactory.Create(info, NoTex, null)!; + Assert.Equal(5f, e.Left); + Assert.Equal(21f, e.Top); + Assert.Equal(150f, e.Width); + Assert.Equal(16f, e.Height); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); + } + + // ── Test 5: ReadOrder propagated to ZOrder ─────────────────────────────── + + [Fact] + public void Create_PropagatesReadOrderToZOrder() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null); + Assert.Equal(7, e!.ZOrder); + } + + // ── Test G1a: Type 12 always produces UiText (with or without own sprites) ── + + [Fact] + public void DatWidgetFactory_Type12_AlwaysMakesUiText() + { + var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, + StateMedia = { ["Normal"] = (0x00001234u, 1) } }; + Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); + } + + // ── Test 5c: Type 1 → UiButton ────────────────────────────────────────── + + [Fact] + public void Type1_Button_MakesUiButton() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── + + [Fact] + public void Type11_Scrollbar_MakesUiScrollbar() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ──── + // + // Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat + // bevel chrome + the transcript/input container panels) are inert sprite-bearing + // chrome, not editable fields. They stay on the UiDatElement fallback so their + // sprites render and they gain no spurious focus/edit affordance. The one true + // editable field (the chat input, 0x10000016) resolves to Type 12 and is + // controller-placed as a UiField. Register Type 3 → UiField only when a window + // carries a factory-built editable Type-3 field. + + [Fact] + public void Type3_NotRegistered_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 5d: Type 6 → UiMenu ───────────────────────────────────────────── + + [Fact] + public void Type6_Menu_MakesUiMenu() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 7: Type 0x10000031 → UiItemList ──────────────────────────────── + + [Fact] + public void Create_buildsUiItemList_forItemListClassId() + { + var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 }; + var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null); + Assert.IsType(w); + } + + // ── Test M1: Single-image meter (toolbar selected-object meters) ──────── + // + // The toolbar health/mana meters (0x100001A1 / 0x100001A2) use a DIFFERENT + // shape from the vitals 3-slice meters: the back-track sprite lives on the + // meter ELEMENT's own DirectState ("" key), and there is exactly ONE Type-3 + // child whose own DirectState ("" key) carries the fill sprite. That child + // has no image grandchildren, so SliceIds would return all-zero — the new + // Count==1 branch reads the StateMedia entries directly instead. + // The sprites go in the TILE slot (Back/FrontTile), NOT the cap slot: DrawMode=Normal + // tiles at native width across the full bar geometry (UIElement_Meter::DrawChildren), + // so the back spans all 140px and the fill clips to 140*fraction for any native width. + // Back/FrontLeft + Back/FrontRight must be 0 (no caps on a single-image bar). + + [Fact] + public void BuildMeter_SingleImageShape_ReadsDirectStateFromElementAndFillChild() + { + const uint BackFile = 0x0600193Eu; // health back-track (from toolbar dump) + const uint FillFile = 0x0600193Fu; // health fill (from toolbar dump) + + // Meter element: Type 7, own DirectState = back-track sprite. + var meter = new ElementInfo { Type = 7, Id = 0x100001A1u, Width = 140, Height = 31 }; + meter.StateMedia[""] = (BackFile, 1); + + // Single Type-3 fill container: own DirectState = fill sprite, no grandchildren. + var fillContainer = new ElementInfo { Type = 3, ReadOrder = 1 }; + fillContainer.StateMedia[""] = (FillFile, 1); + meter.Children.Add(fillContainer); + + var e = DatWidgetFactory.Create(meter, NoTex, null); + + var m = Assert.IsType(e); + // Back-track on the meter element's own DirectState, fill on the single child — + // both in the TILE slot so they tile across the full 140px bar (DrawMode=Normal). + Assert.Equal(BackFile, m.BackTile); + Assert.Equal(0u, m.BackLeft); + Assert.Equal(0u, m.BackRight); + Assert.Equal(FillFile, m.FrontTile); + Assert.Equal(0u, m.FrontLeft); + Assert.Equal(0u, m.FrontRight); + } + + // ── Test 6: Meter slice extraction (the important one) ─────────────────── + + /// + /// A meter (Type 7) whose two Type-3 containers each carry 3 image children + /// (ordered by X, bearing a DirectState "" sprite), plus the front container + /// has a fourth expand-overlay child with ONLY a named "ShowDetail" state — + /// that overlay must be excluded from the slice count. + /// + [Fact] + public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay() + { + // Slice ids sourced from format doc §11 — real health-bar ids. + const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u; + const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u; + const uint OverlayFile = 0x06007490u; + + // Back container (ReadOrder 0 — drawn first / behind) + var backChild = new ElementInfo { Type = 3, ReadOrder = 0 }; + backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } }); + backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } }); + backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } }); + + // Front container (ReadOrder 1 — drawn on top) + var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 }; + frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } }); + // Expand-detail overlay: named state only — NO DirectState "" — must be ignored. + frontChild.Children.Add(new ElementInfo + { + X = 0, + StateMedia = { ["ShowDetail"] = (OverlayFile, 3) } + }); + + var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backChild); + meter.Children.Add(frontChild); + + var e = DatWidgetFactory.Create(meter, NoTex, null); + + var m = Assert.IsType(e); + Assert.Equal(BackL, m.BackLeft); + Assert.Equal(BackT, m.BackTile); + Assert.Equal(BackR, m.BackRight); + Assert.Equal(FrontL, m.FrontLeft); + Assert.Equal(FrontT, m.FrontTile); + Assert.Equal(FrontR, m.FrontRight); + // Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot. + Assert.NotEqual(OverlayFile, m.FrontRight); + Assert.NotEqual(OverlayFile, m.FrontTile); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs new file mode 100644 index 00000000..9d79f58d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -0,0 +1,164 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class ElementReaderTests +{ + // ── ToAnchors (decomp-backed: UIElement::UpdateForParentSizeChange @0x00462640) ───────────── + + /// + /// Top edge (L=1,T=1,R=1,B=2): LeftEdge==1 → Left; RightEdge==1 → Right (stretch); + /// TopEdge==1 → Top; BottomEdge==2 (not 1/4, top≠2) → no Bottom. + /// This is the top chrome edge — it pins left, stretches width, pins top, fixed height. + /// Real vitals values from format doc §11 (0x10000634). + /// + [Fact] + public void ToAnchors_TopEdge_StretchesWidth() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 2); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// TL corner (L=1,T=1,R=2,B=2): LeftEdge==1 → Left; RightEdge==2 (not 1/4), left≠2 → no Right; + /// TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. Fixed size, pinned top-left. + /// Real vitals values from format doc §11 (0x10000633). + /// + [Fact] + public void ToAnchors_TlCorner_PinsTopLeftFixed() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 2); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// TR corner (L=2,T=1,R=1,B=2): LeftEdge==2 → triggers Right (track-right); RightEdge==1 → Right; + /// left≠1 → no Left; TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. + /// Fixed-width element whose left and right both track the parent's right edge. + /// Real vitals values from format doc §11 (0x10000635). + /// + [Fact] + public void ToAnchors_TrCorner_TracksRight() + { + var a = ElementReader.ToAnchors(left: 2, top: 1, right: 1, bottom: 2); + Assert.False(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// Left edge (L=1,T=1,R=2,B=1): LeftEdge==1 → Left; RightEdge==2, left≠2 → no Right; + /// TopEdge==1 → Top; BottomEdge==1 → Bottom. Pins left+top+bottom, fixed width, stretches height. + /// Real vitals values from format doc §11 (0x10000636). + /// + [Fact] + public void ToAnchors_LeftEdge_StretchesHeight() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// All-ones (L=1,T=1,R=1,B=1): all four flags fire — Left, Right, Top, Bottom. + /// A piece pinned to all four sides stretches both horizontally and vertically. + /// + [Fact] + public void ToAnchors_Meter_StretchesBoth() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// All-zero edge flags (prototype-only elements) fall back to Left|Top default. + /// + [Fact] + public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft() + { + var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a); + } + + /// + /// Value 3 on left and right axes contributes no Left/Right anchor; + /// TopEdge==1 → Top; BottomEdge==1 → Bottom. + /// left=3 (not 1/4) → no Left; right=3 (not 1/4), left≠2 → no Right; + /// top=1 → Top; bottom=1 → Bottom. Result: Top|Bottom. + /// + [Fact] + public void EdgeFlagsToAnchors_ValueThree_HorizAxes_YieldsTopBottom() + { + var a = ElementReader.ToAnchors(left: 3, top: 1, right: 3, bottom: 1); + Assert.False(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + // ── Merge ──────────────────────────────────────────────────────────────── + + [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 + } + + [Fact] + public void Merge_DerivedHasFontDid_OverridesBase() + { + var base_ = new ElementInfo { FontDid = 0x40000000, Width = 100, Height = 10 }; + var derived = new ElementInfo { FontDid = 0x40000001, Width = 100 }; + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(0x40000001u, merged.FontDid); + } + + [Fact] + public void Merge_DerivedStateMediaOverridesBase() + { + var base_ = new ElementInfo(); + base_.StateMedia[""] = (0x06001000u, 1); + base_.StateMedia["HideDetail"] = (0x06001001u, 1); + + var derived = new ElementInfo(); + derived.StateMedia[""] = (0x06002000u, 3); // overrides base default state + + var merged = ElementReader.Merge(base_, derived); + // derived's "" overrides base's "" + Assert.Equal((0x06002000u, 3), merged.StateMedia[""]); + // base's "HideDetail" is kept (derived didn't provide it) + Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]); + } + + [Fact] + public void Merge_ChildrenComeFromDerived() + { + var base_ = new ElementInfo(); + base_.Children.Add(new ElementInfo { Id = 0x1u }); + + var derived = new ElementInfo(); + derived.Children.Add(new ElementInfo { Id = 0x2u }); + + var merged = ElementReader.Merge(base_, derived); + // children must come from derived only + Assert.Single(merged.Children); + Assert.Equal(0x2u, merged.Children[0].Id); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs new file mode 100644 index 00000000..c7338ba1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -0,0 +1,75 @@ +using System.IO; +using System.Text.Json; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Loads the committed layout ElementInfo fixtures and builds widget trees — +/// no dats required. Fixtures were generated from the real portal.dat and +/// serialized with . +/// +public static class FixtureLoader +{ + private static readonly JsonSerializerOptions _opts = new() + { + IncludeFields = true, + }; + + /// + /// Deserializes the committed vitals_2100006C.json fixture (copied to + /// the test output directory via the csproj CopyToOutputDirectory item) + /// into an tree, then builds and returns the + /// using a null-returning sprite resolver and no + /// dat font — sufficient for conformance checks on tree structure and slice ids. + /// + public static ImportedLayout LoadVitals() + { + var root = LoadVitalsInfos(); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + /// + /// Deserializes the committed vitals_2100006C.json fixture into a raw + /// tree WITHOUT calling . + /// Use this when the test needs to inspect the resolved + /// tree directly (e.g. inheritance-resolution checks) without exercising the + /// widget factory. + /// + public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos() + => LoadInfos("vitals_2100006C.json"); + + /// + /// Deserializes the committed chat_21000006.json fixture into a raw + /// tree and builds the + /// using a null-returning sprite resolver and no dat font — sufficient for + /// conformance checks on tree structure and resolved types. + /// + public static ImportedLayout LoadChat() + => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); + + /// + /// Deserializes the committed chat_21000006.json fixture into a raw + /// tree WITHOUT calling . + /// Use this when the test needs to inspect the resolved + /// tree directly (e.g. resolved Type values per element id). + /// + public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() + => LoadInfos("chat_21000006.json"); + + // ── Shared loader ──────────────────────────────────────────────────────── + + 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); + // Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize(ReadOnlySpan) + // does not reject the first byte. + ReadOnlySpan span = bytes; + if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) + span = span[3..]; + return JsonSerializer.Deserialize(span, _opts) + ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs new file mode 100644 index 00000000..ba336aac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -0,0 +1,198 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Golden conformance tests for the vitals LayoutDesc importer. +/// Uses the committed JSON fixture (vitals_2100006C.json) — no dats, no GL. +/// +/// These tests lock the importer's tree-building (factory dispatch, meter slice +/// extraction, rects) against the real portal.dat values captured when the +/// fixture was generated. Any regression in , +/// , or will surface here. +/// +/// Sprite ids sourced from docs/research/2026-06-15-layoutdesc-format.md §11. +/// +[Trait("Category", "Conformance")] +public class LayoutConformanceTests +{ + // ── Test 1: Three meters at expected rects ──────────────────────────────── + + /// + /// The three vital bars must be UiMeters positioned at x=5, width=150, height=16, + /// at y=5 (health), y=21 (stamina), y=37 (mana). + /// + [Fact] + public void VitalsTree_HasThreeMetersAtExpectedRects() + { + var layout = FixtureLoader.LoadVitals(); + + (uint Id, float Y)[] expected = + [ + (0x100000E6u, 5f), // health + (0x100000ECu, 21f), // stamina + (0x100000EEu, 37f), // mana + ]; + + foreach (var (id, y) in expected) + { + var elem = layout.FindElement(id); + Assert.NotNull(elem); + var meter = Assert.IsType(elem); + Assert.Equal(5f, meter.Left); + Assert.Equal(y, meter.Top); + Assert.Equal(150f, meter.Width); + Assert.Equal(16f, meter.Height); + } + } + + // ── Test 2: All 18 slice ids ────────────────────────────────────────────── + + /// + /// The six back+front 3-slice sprite ids for each of the three meters must + /// match the values confirmed from the dat dump (format doc §11). + /// This proves the factory's grandchild slice extraction against committed data. + /// + [Fact] + public void VitalsTree_MetersHaveExpectedSliceIds() + { + var layout = FixtureLoader.LoadVitals(); + + // Columns: MeterId, then 6 slice ids in order: + // BackLeft, BackTile, BackRight, FrontLeft, FrontTile, FrontRight + (uint MeterId, uint[] Slices)[] cases = + [ + (0x100000E6u, [0x0600747Eu, 0x0600747Fu, 0x06007480u, 0x06007481u, 0x06007482u, 0x06007483u]), // health + (0x100000ECu, [0x06007484u, 0x06007485u, 0x06007486u, 0x06007487u, 0x06007488u, 0x06007489u]), // stamina + (0x100000EEu, [0x0600748Au, 0x0600748Bu, 0x0600748Cu, 0x0600748Du, 0x0600748Eu, 0x0600748Fu]), // mana + ]; + + foreach (var (meterId, s) in cases) + { + var m = Assert.IsType(layout.FindElement(meterId)); + Assert.Equal(s[0], m.BackLeft); Assert.Equal(s[1], m.BackTile); Assert.Equal(s[2], m.BackRight); + Assert.Equal(s[3], m.FrontLeft); Assert.Equal(s[4], m.FrontTile); Assert.Equal(s[5], m.FrontRight); + } + } + + // ── Test 3: Chrome TL corner sprite ─────────────────────────────────────── + // + // NOTE: Type 3 is retail UIElement_Field, but acdream's Type-3 elements here are + // sprite-bearing CHROME (the 8-piece bevel corners), so they stay on the generic + // UiDatElement fallback (NOT registered as UiField in the factory — see + // DatWidgetFactory.Create). This test guards that the chrome corner keeps drawing + // its dat sprite; if a future change routes Type 3 → UiField, the corner sprite + // would vanish and this assertion fails — which is the intended early warning. + + /// + /// The top-left chrome corner element (id 0x10000633) must be a + /// whose active media file id is 0x060074C3. + /// + [Fact] + public void VitalsTree_ChromeCornerHasExpectedSprite() + { + var layout = FixtureLoader.LoadVitals(); + + var elem = layout.FindElement(0x10000633u); + Assert.NotNull(elem); + var datElem = Assert.IsType(elem); + var (file, _) = datElem.ActiveMedia(); + Assert.Equal(0x060074C3u, file); + } + + // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ─── + + /// + /// Proves that Resolve()'s inheritance merge fired against real dat data: + /// at least one element in the fixture tree must have FontDid == 0x40000000 + /// (the vitals font), inherited from the base-layout prototype 0x10000376 + /// in 0x2100003F via the BaseElement / BaseLayoutId chain. + /// + /// + /// The three text labels (0x100000EB health, 0x100000ED stamina, + /// 0x100000EF mana) are Type=0 derived elements with no own font property. + /// The base element 0x10000376 carries Properties[0x1A] → + /// ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]. + /// propagates this via the "FontDid: derived wins + /// if non-zero, otherwise inherit" rule. + /// + /// + /// + /// This test verifies end-to-end inheritance resolution against the committed fixture + /// (format doc §10, docs/research/2026-06-15-layoutdesc-format.md). + /// It operates on the raw tree, NOT the widget tree, + /// so the factory dispatch (Type 12 → skip) does not interfere. + /// + /// + [Fact] + public void VitalsTree_TextLabel_InheritsFontDidFromBaseLayout() + { + var root = FixtureLoader.LoadVitalsInfos(); + + // Walk the full ElementInfo tree and collect all FontDid values. + var fontDids = new System.Collections.Generic.List(); + CollectFontDids(root, fontDids); + + // At least one element must carry FontDid == 0x40000000 (the vitals font). + // In practice, the three text labels (health/stamina/mana) all inherit it. + Assert.Contains(0x40000000u, fontDids); + } + + private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List acc) + { + if (node.FontDid != 0) acc.Add(node.FontDid); + foreach (var child in node.Children) + CollectFontDids(child, acc); + } + + // ── Test 5: Horizontal resize conformance (160→200) ────────────────────── + + /// + /// Proves end-to-end reflow for a 160→200 width change using the corrected + /// ToAnchors mapping (UIElement::UpdateForParentSizeChange @0x00462640). + /// + /// For each piece, margins are computed from the 160-wide design rect and then + /// is applied at parentW=200. + /// + /// Expected outcomes: + /// - TL corner (L=1,R=2): Left only → fixed at x=0, w=5 + /// - top edge (L=1,R=1): Left+Right → stretches to w=190 at x=5 + /// - TR corner (L=2,R=1): Right only → tracks right at x=195, w=5 + /// - meter (L=1,R=1): Left+Right → stretches to w=190 at x=5 + /// + [Fact] + public void HorizontalResize_160to200_ReflowsCorrectly() + { + const float designParentW = 160f; + const float newParentW = 200f; + const float parentH = 58f; + + // (piece, designX, designW, LeftEdge, RightEdge, expectedX, expectedW) + (string Piece, float DesignX, float DesignW, uint L, uint R, float ExpX, float ExpW)[] cases = + [ + ("TL corner", 0f, 5f, 1u, 2u, 0f, 5f ), + ("top edge", 5f, 150f, 1u, 1u, 5f, 190f), + ("TR corner", 155f, 5f, 2u, 1u, 195f, 5f ), + ("meter", 5f, 150f, 1u, 1u, 5f, 190f), + ]; + + foreach (var (piece, dX, dW, l, r, expX, expW) in cases) + { + // T/B values don't affect x/w; use real vitals values (top=1, bottom=2) + var anchors = ElementReader.ToAnchors(l, top: 1u, r, bottom: 2u); + + // Margins from the design rect at parentW=160 + float mL = dX; + float mR = designParentW - (dX + dW); + + // Reflow at parentW=200 (parentH irrelevant for x/w assertions) + var (x, _, w, _) = UiElement.ComputeAnchoredRect( + anchors, mL, mT: 0f, mR, mB: 0f, w0: dW, h0: 5f, parentW: newParentW, parentH); + + // xUnit 2.x Assert.Equal(float,float,int) = decimal-place precision + Assert.True(Math.Abs(x - expX) < 0.5f, $"{piece}: expected x={expX} got {x}"); + Assert.True(Math.Abs(w - expW) < 0.5f, $"{piece}: expected w={expW} got {w}"); + } + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs new file mode 100644 index 00000000..2025085c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -0,0 +1,154 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Pure unit tests for — no dats, no GL. +/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption. +/// +public class LayoutImporterTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + // ── Test 1: Health meter element → UiMeter with correct rect ───────────── + + /// + /// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter + /// that is findable by its id, positioned at Left=5, Width=150. + /// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>. + /// + [Fact] + public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() + { + 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 }, NoTex, null); + + var found = tree.FindElement(0x100000E6); + Assert.IsType(found); + Assert.Equal(5f, found!.Left); + Assert.Equal(150f, found.Width); + } + + // ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ── + + /// + /// A root with two children: one Type-12 UIElement_Text and one Type-3 container. + /// The Type-12 must appear as a in the tree (transparent, + /// draws nothing until a controller binds its LinesProvider); + /// the Type-3 must also be present. + /// + [Fact] + public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() + { + var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 160, Height = 58 }; + var prototype = new ElementInfo { Id = 0x20000001, Type = 12, Width = 0, Height = 0 }; + var container = new ElementInfo { Id = 0x20000002, Type = 3, Width = 100, Height = 20 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null); + + // Type-12 is now a UiText (transparent, no lines) — present in the tree. + Assert.IsType(tree.FindElement(0x20000001)); + // Type-3 must also be present. + Assert.NotNull(tree.FindElement(0x20000002)); + } + + // ── Test 3: Meter consumes its children — child ids not in byId ────────── + + /// + /// A meter (Type 7) whose children are the 3-slice back/front containers. + /// The meter itself must be findable; its direct children must NOT appear as + /// separate nodes in the tree (meters own their children, not the generic tree). + /// + [Fact] + public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree() + { + const uint MeterId = 0x100000E6u; + const uint BackLayerId = 0x100000E7u; + const uint FrontLayerId = 0x00000002u; + + // Build a minimal meter with back + front containers, each with 3 slice children. + var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0, + l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u); + var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1, + l: 0x06007481u, t: 0x06007482u, r: 0x06007483u); + + var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backContainer); + meter.Children.Add(frontContainer); + + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null); + + // The meter widget is present. + Assert.IsType(tree.FindElement(MeterId)); + // The meter's dat-children are NOT separate UiElement nodes. + Assert.Null(tree.FindElement(BackLayerId)); + Assert.Null(tree.FindElement(FrontLayerId)); + // The UiMeter itself has no Ui children (meters consume their children internally). + var uiMeter = (UiMeter)tree.FindElement(MeterId)!; + Assert.Empty(uiMeter.Children); + } + + // ── Test 4: Prototype-skip in BuildFromInfos ───────────────────────────── + + /// + /// When one top-level element is referenced as a BaseElement by a sibling + /// (mirroring the toolbar slot prototype pattern), and the prototype element + /// has no own state media, the importer must NOT produce a widget for the + /// prototype id (FindElement returns null), but MUST produce the derived element. + /// + /// NOTE: This test exercises (the pure + /// layer), where prototype detection is done by inspecting the pre-resolved + /// ElementInfo tree rather than the raw dat ElementDesc. The pure layer skips + /// an element if its Id is in a sibling's (or child's) Children chain + /// as a BaseElement — but actually the pure layer has no BaseElement knowledge + /// at this stage (that's resolved before Build). The prototype-skip in the real + /// world occurs in ImportInfos (the dat shell), BEFORE calling Build. + /// + /// This test verifies the INVARIANT that holds AFTER ImportInfos filters prototypes: + /// a pure template element that was skipped is absent from FindElement, while the + /// derived element (which inherited from it) IS present. + /// + /// We model this by simply NOT adding the prototype to the ElementInfo tree passed + /// to BuildFromInfos — as if ImportInfos already filtered it out. + /// + [Fact] + public void BuildFromInfos_PrototypeSkipped_DerivedPresent_PrototypeAbsent() + { + // Simulate what ImportInfos does AFTER filtering: the prototype 0xBBB00001 is + // absent (already skipped by ImportInfos), the derived element 0xCCC00001 is + // present with its own media inherited from the prototype. + var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 200, Height = 100 }; + // The derived element has its own size + media (prototype was merged into it already). + var derived = new ElementInfo + { + Id = 0xCCC00001u, + Type = 0x10000031u, // UIElement_ItemList (toolbar slot type) + X = 10, Y = 10, Width = 32, Height = 32, + }; + derived.StateMedia[""] = (0x06001234u, 1); + + // Only the derived element appears in the tree (prototype was filtered by ImportInfos). + var tree = LayoutImporter.BuildFromInfos(root, new[] { derived }, NoTex, null); + + // The derived element is present in the built tree. + Assert.NotNull(tree.FindElement(0xCCC00001u)); + // The prototype id is NOT in the tree (was never added). + Assert.Null(tree.FindElement(0xBBB00001u)); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r) + { + var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder }; + c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } }); + c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } }); + c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } }); + return c; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs new file mode 100644 index 00000000..cdefebc0 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Unit tests for — the +/// gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth +/// analogue (acclient_2013_pseudo_c.txt:198635 / :196213). +/// +/// +/// Key behavior under test: the Health meter is UpdateHealth-driven — it becomes +/// visible only when real health is known for the selected guid (a HealthChanged +/// fires for it, or it is already cached at select time via hasHealth). Selecting a +/// target does NOT show the meter on its own. This matches retail: a friendly NPC you have +/// not assessed shows name-only; a monster's bar appears after damage / assess. +/// +/// +public class SelectedObjectControllerTests +{ + // ── Shared layout ──────────────────────────────────────────────────────── + + private static ( + ImportedLayout layout, + UiPanel nameEl, + UiDatElement overlayEl, + UiMeter healthMeterEl) + FakeLayout() + { + var dict = new Dictionary(); + var root = new UiPanel(); + + var nameEl = new UiPanel { Width = 100, Height = 20 }; + dict[SelectedObjectController.NameId] = nameEl; + root.AddChild(nameEl); + + var overlayInfo = new ElementInfo + { + Id = SelectedObjectController.OverlayId, + Type = 3, + StateMedia = + { + [""] = (0x06000001u, 3), + ["ObjectSelected"] = (0x06001937u, 3), + ["StackedItemSelected"] = (0x06004CF4u, 3), + }, + }; + var overlayEl = new UiDatElement(overlayInfo, _ => (0u, 0, 0)); + dict[SelectedObjectController.OverlayId] = overlayEl; + root.AddChild(overlayEl); + + var healthMeterEl = new UiMeter { Width = 100, Height = 10, Visible = true }; + dict[SelectedObjectController.HealthMeterId] = healthMeterEl; + root.AddChild(healthMeterEl); + + return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl); + } + + // ── Recording delegates ────────────────────────────────────────────────── + + private sealed class Harness + { + public Action? SelectionHandler; + public Action? HealthHandler; + public readonly List QueryHealthCalls = new(); + + public readonly Dictionary HealthTargetMap = new(); + public readonly Dictionary NameMap = new(); + public readonly Dictionary HealthMap = new(); + public readonly Dictionary HasHealthMap = new(); + public readonly Dictionary StackMap = new(); + + public void FireSelection(uint? g) => SelectionHandler?.Invoke(g); + public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct); + + public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null) + => SelectedObjectController.Bind( + layout, + subscribeSelectionChanged: h => SelectionHandler = h, + subscribeHealthChanged: h => HealthHandler = h, + isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v, + name: g => NameMap.TryGetValue(g, out var v) ? v : null, + healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f, + hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v, + stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u, + sendQueryHealth: g => QueryHealthCalls.Add(g), + datFont: datFont); + } + + // ── B1: Bind initialisation ────────────────────────────────────────────── + + [Fact] + public void Bind_healthMeterHidden_nameTextChildAttached_nameFloatedOnTop() + { + var (layout, nameEl, _, healthMeterEl) = FakeLayout(); + new Harness().Bind(layout); + + Assert.False(healthMeterEl.Visible, "health meter must be Visible=false immediately after Bind"); + + var textChild = nameEl.Children.OfType().SingleOrDefault(); + Assert.NotNull(textChild); + Assert.True(textChild!.Centered, "name UiText must be Centered"); + Assert.True(textChild.ClickThrough, "name UiText must be ClickThrough"); + Assert.False(textChild.AcceptsFocus, "AcceptsFocus must be false on name label"); + Assert.False(textChild.IsEditControl, "IsEditControl must be false on name label"); + Assert.False(textChild.CapturesPointerDrag, "CapturesPointerDrag must be false on name label"); + + // The name element must be floated 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). + Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order"); + } + + [Fact] + public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected() + { + var (layout, nameEl, _, _) = FakeLayout(); + new Harness().Bind(layout); + + var textChild = nameEl.Children.OfType().Single(); + Assert.Empty(textChild.LinesProvider()); + } + + // ── H1: Select a health target — meter does NOT show on select alone ───── + + [Fact] + public void SelectHealthTarget_unknownHealth_meterStaysHidden_queryFired_nameAndOverlaySet() + { + const uint Guid = 0xAA01u; + const string ExpectedName = "Drudge Prowler"; + + var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = true; + h.NameMap[Guid] = ExpectedName; + h.StackMap[Guid] = 1u; // ObjectSelected + // HasHealthMap[Guid] not set → false (no health known yet) + h.Bind(layout); + + h.FireSelection(Guid); + + // Health not yet known → meter must stay hidden (retail: shows on UpdateHealth). + Assert.False(healthMeterEl.Visible, + "meter must stay hidden on select when no health is known yet"); + // But QueryHealth is sent (retail Event_QueryHealth on select for a health target). + Assert.Single(h.QueryHealthCalls); + Assert.Equal(Guid, h.QueryHealthCalls[0]); + Assert.Equal("ObjectSelected", overlayEl.ActiveState); + + var lines = nameEl.Children.OfType().Single().LinesProvider(); + Assert.Single(lines); + Assert.Equal(ExpectedName, lines[0].Text); + Assert.Equal(new Vector4(1f, 1f, 1f, 1f), lines[0].Color); + } + + // ── H1b: Health arrives for the selected guid → meter appears ─────────── + + [Fact] + public void HealthChanged_forSelectedGuid_showsMeter() + { + const uint Guid = 0xAA02u; + + var (layout, _, _, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = true; + h.NameMap[Guid] = "Drudge Slinker"; + h.Bind(layout); + + h.FireSelection(Guid); + Assert.False(healthMeterEl.Visible, "hidden until health arrives"); + + // Simulate UpdateHealth (0x01C0) for the selected guid. + h.FireHealth(Guid, 0.6f); + Assert.True(healthMeterEl.Visible, "meter must appear when health arrives for the selected guid"); + } + + [Fact] + public void HealthChanged_forOtherGuid_doesNotShowMeter() + { + const uint Sel = 0xAA03u, Other = 0xBB03u; + + var (layout, _, _, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Sel] = true; + h.HealthTargetMap[Other] = true; + h.NameMap[Sel] = "Selected"; + h.Bind(layout); + + h.FireSelection(Sel); + h.FireHealth(Other, 0.5f); // health for a DIFFERENT entity + + Assert.False(healthMeterEl.Visible, "health for a non-selected guid must not show the meter"); + } + + // ── H1c: Already-known health → meter shows immediately on select ─────── + + [Fact] + public void SelectHealthTarget_alreadyKnownHealth_meterVisibleImmediately() + { + const uint Guid = 0xAA04u; + + var (layout, _, _, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = true; + h.HasHealthMap[Guid] = true; // health already cached (e.g. previously assessed) + h.HealthMap[Guid] = 0.9f; + h.NameMap[Guid] = "Olthoi"; + h.Bind(layout); + + h.FireSelection(Guid); + Assert.True(healthMeterEl.Visible, + "meter must show immediately when health is already known for the target"); + } + + // ── H2: Stacked item ───────────────────────────────────────────────────── + + [Fact] + public void SelectStackedItem_overlayStackedItemSelected_meterHidden() + { + const uint Guid = 0xBB02u; + + var (layout, _, overlayEl, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = false; + h.NameMap[Guid] = "Heal Kits"; + h.StackMap[Guid] = 5u; // stackSize > 1 + h.Bind(layout); + + h.FireSelection(Guid); + + Assert.Equal("StackedItemSelected", overlayEl.ActiveState); + Assert.False(healthMeterEl.Visible); + } + + // ── H3: Non-health target (friendly NPC / scenery / Door) ─────────────── + + [Fact] + public void SelectNonHealthTarget_meterHidden_noQuery_nameSet() + { + const uint Guid = 0xCC03u; + const string ExpectedName = "Town Crier"; + + var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = false; + h.NameMap[Guid] = ExpectedName; + h.Bind(layout); + + h.FireSelection(Guid); + + Assert.False(healthMeterEl.Visible, "meter must stay hidden for a non-health target"); + Assert.Empty(h.QueryHealthCalls); + Assert.Equal("ObjectSelected", overlayEl.ActiveState); + + var lines = nameEl.Children.OfType().Single().LinesProvider(); + Assert.Single(lines); + Assert.Equal(ExpectedName, lines[0].Text); + } + + // ── H4: Deselect clears the strip ──────────────────────────────────────── + + [Fact] + public void SelectNull_clearsStrip() + { + const uint Guid = 0xDD04u; + + var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = true; + h.HasHealthMap[Guid] = true; // so the meter is shown on select + h.HealthMap[Guid] = 0.5f; + h.NameMap[Guid] = "Wolf"; + h.Bind(layout); + + h.FireSelection(Guid); + Assert.True(healthMeterEl.Visible); + + h.FireSelection(null); + + Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect"); + Assert.Equal("", overlayEl.ActiveState); + Assert.Empty(nameEl.Children.OfType().Single().LinesProvider()); + } + + // ── H5: Re-select a different guid ─────────────────────────────────────── + + [Fact] + public void ReSelect_differentGuid_clearsFirstThenAppliesSecond() + { + const uint GuidA = 0xEE05u, GuidB = 0xFF06u; + + var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[GuidA] = true; h.HealthTargetMap[GuidB] = false; + h.HasHealthMap[GuidA] = true; // A shows its bar on select + h.NameMap[GuidA] = "Bandit"; h.NameMap[GuidB] = "Chest"; + h.HealthMap[GuidA] = 1.0f; + h.Bind(layout); + + h.FireSelection(GuidA); + Assert.True(healthMeterEl.Visible); + Assert.Single(h.QueryHealthCalls); + + h.FireSelection(GuidB); + + Assert.False(healthMeterEl.Visible, "meter must clear when switching to a non-health target"); + Assert.Equal("ObjectSelected", overlayEl.ActiveState); + Assert.Single(h.QueryHealthCalls); // B is not a health target → no extra query + + var lines = nameEl.Children.OfType().Single().LinesProvider(); + Assert.Single(lines); + Assert.Equal("Chest", lines[0].Text); + } + + // ── H6: Overlay flash reverts after the flash window (Tick) ───────────── + + [Fact] + public void Tick_revertsOverlayFlash_afterDuration() + { + const uint Guid = 0xAB06u; + + var (layout, _, overlayEl, _) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = false; + h.NameMap[Guid] = "Lever"; + var c = h.Bind(layout); + + h.FireSelection(Guid); + Assert.Equal("ObjectSelected", overlayEl.ActiveState); + + // A small tick before the window elapses → still flashing. + c.Tick(0.1); + Assert.Equal("ObjectSelected", overlayEl.ActiveState); + + // Tick past the 0.25s window → overlay reverts to blank. + c.Tick(0.2); + Assert.Equal("", overlayEl.ActiveState); + } + + // ── H7: Partial layout (missing elements) ──────────────────────────────── + + [Fact] + public void PartialLayout_noElements_doesNotThrow() + { + var root = new UiPanel(); + var layout = new ImportedLayout(root, new Dictionary()); + + var h = new Harness(); + h.HealthTargetMap[0x12345678u] = true; + h.NameMap[0x12345678u] = "Something"; + var c = h.Bind(layout); + + Assert.NotNull(h.SelectionHandler); + Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u))); + Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f))); + Assert.Null(Record.Exception(() => c.Tick(0.5))); + Assert.Null(Record.Exception(() => h.FireSelection(null))); + + Assert.Single(h.QueryHealthCalls); + Assert.Equal(0x12345678u, h.QueryHealthCalls[0]); + } + + // ── H8: Fill reflects live health; returns 0 when nothing selected ────── + + [Fact] + public void HealthMeterFill_reflectsLiveHealthPercent() + { + const uint Guid = 0xAA07u; + + var (layout, _, _, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = true; + h.NameMap[Guid] = "Arwic Banderling"; + h.HealthMap[Guid] = 0.5f; + h.Bind(layout); + + h.FireSelection(Guid); + Assert.Equal(0.5f, healthMeterEl.Fill()); + + h.HealthMap[Guid] = 0.25f; // server updates health + Assert.Equal(0.25f, healthMeterEl.Fill()); + } + + [Fact] + public void HealthMeterFill_returnsZero_whenNothingSelected() + { + const uint Guid = 0xAA08u; + + var (layout, _, _, healthMeterEl) = FakeLayout(); + var h = new Harness(); + h.HealthTargetMap[Guid] = true; + h.NameMap[Guid] = "Spider"; + h.HealthMap[Guid] = 0.8f; + h.Bind(layout); + + h.FireSelection(Guid); + Assert.Equal(0.8f, healthMeterEl.Fill()); + + h.FireSelection(null); + Assert.Equal(0f, healthMeterEl.Fill() ?? 0f); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs new file mode 100644 index 00000000..9668a586 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +public class ToolbarControllerTests +{ + private static readonly uint[] Row1 = + { 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF }; + private static readonly uint[] Row2 = + { 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF }; + + // The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list). + private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; + + private static (ImportedLayout layout, Dictionary slots, + Dictionary indicators) FakeToolbar() + { + var dict = new Dictionary(); + var slots = new Dictionary(); + var indicators = new Dictionary(); + var root = new UiPanel(); + foreach (var id in Row1) AddSlot(id); + foreach (var id in Row2) AddSlot(id); + // Add combat indicator elements as plain UiPanels keyed by id. + foreach (var id in CombatIds) + { + var e = new UiPanel { Visible = true }; + dict[id] = e; indicators[id] = e; root.AddChild(e); + } + return (new ImportedLayout(root, dict), slots, indicators); + + void AddSlot(uint id) + { + var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 }; + dict[id] = list; slots[id] = list; root.AddChild(list); + } + } + + [Fact] + public void Populate_bindsShortcutToCorrectSlot() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }); + + Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); + Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture); + Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty + } + + [Fact] + public void DeferredRebind_whenItemArrivesLate() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); // item NOT present yet + var shortcuts = new List + { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); + Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet + + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); + + Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded + } + + [Fact] + public void Click_emitsUseForBoundItem() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + uint used = 0; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g); + // UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload) + slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown)); + + Assert.Equal(0x5001u, used); + } + + // ── C1: combat-mode indicator tests ───────────────────────────────────── + + /// + /// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible; + /// the melee/missile/magic indicators (0x10000193/4/5) are hidden. + /// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669). + /// + [Fact] + public void CombatIndicator_defaultNonCombat_onlyPeaceVisible() + { + var (layout, _, indicators) = FakeToolbar(); + var repo = new ClientObjectTable(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); + + // Only peace indicator (index 0 = 0x10000192) is visible. + Assert.True (indicators[0x10000192u].Visible, "peace indicator should be visible after bind"); + Assert.False(indicators[0x10000193u].Visible, "melee indicator should be hidden after bind"); + Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden after bind"); + Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden after bind"); + } + + /// + /// SetCombatMode(Melee) hides peace/missile/magic and shows only the melee indicator. + /// + [Fact] + public void CombatIndicator_setCombatModeMelee_onlyMeleeVisible() + { + var (layout, _, indicators) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); + + ctrl.SetCombatMode(CombatMode.Melee); + + Assert.False(indicators[0x10000192u].Visible, "peace indicator should be hidden in melee mode"); + Assert.True (indicators[0x10000193u].Visible, "melee indicator should be visible in melee mode"); + Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden in melee mode"); + Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden in melee mode"); + } + + /// + /// CombatModeChanged event on CombatState automatically updates the indicator. + /// + [Fact] + public void CombatIndicator_liveSignal_updatesWhenCombatStateChanges() + { + var (layout, _, indicators) = FakeToolbar(); + var repo = new ClientObjectTable(); + var combat = new CombatState(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }, + combatState: combat); + + // Initially NonCombat after bind. + Assert.True(indicators[0x10000192u].Visible, "peace should be visible initially"); + + // Server fires CombatModeChanged → Magic. + combat.SetCombatMode(CombatMode.Magic); + + Assert.False(indicators[0x10000192u].Visible, "peace should be hidden in magic mode"); + Assert.False(indicators[0x10000193u].Visible, "melee should be hidden in magic mode"); + Assert.False(indicators[0x10000194u].Visible, "missile should be hidden in magic mode"); + Assert.True (indicators[0x10000195u].Visible, "magic indicator should be visible"); + } + + // ── D1: Shortcut number (slot label) tests ─────────────────────────────── + // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); + // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). + + // Fake digit arrays: 9 peace entries (0x10..0x18), 9 war entries (0x20..0x28), + // 9 empty (background) entries (0x30..0x38). + private static readonly uint[] FakePeace = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u }; + private static readonly uint[] FakeWar = { 0x20u,0x21u,0x22u,0x23u,0x24u,0x25u,0x26u,0x27u,0x28u }; + private static readonly uint[] FakeEmpty = { 0x30u,0x31u,0x32u,0x33u,0x34u,0x35u,0x36u,0x37u,0x38u }; + + /// + /// After Bind with peace/war digit arrays, top-row cells (indices 0–8) have + /// ShortcutNum == i (the slot position) and ShortcutPeace == true (default NonCombat). + /// Bottom-row cells (indices 9–17) have ShortcutNum == -1 (no label). + /// Retail: numbers are slot LABELS — shown on ALL top-row slots including empty ones. + /// + [Fact] + public void ShortcutNumbers_afterBind_topRowHasNumbers_bottomRowEmpty() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + // Top row: ShortcutNum == slot index, peace == true. + for (int i = 0; i < Row1.Length; i++) + { + var cell = slots[Row1[i]].Cell; + Assert.Equal(i, cell.ShortcutNum); + Assert.True(cell.ShortcutPeace, $"top-row slot {i} should be peace at NonCombat"); + } + // Bottom row: no shortcut number. + foreach (var id in Row2) + Assert.Equal(-1, slots[id].Cell.ShortcutNum); + } + + /// + /// After SetCombatMode(Melee), top-row cells switch to ShortcutPeace == false (war). + /// + [Fact] + public void ShortcutNumbers_setCombatModeWar_topRowUsesWarDigits() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + ctrl.SetCombatMode(CombatMode.Melee); + + // Top row: still ShortcutNum == i, but now peace == false. + for (int i = 0; i < Row1.Length; i++) + { + var cell = slots[Row1[i]].Cell; + Assert.Equal(i, cell.ShortcutNum); + Assert.False(cell.ShortcutPeace, $"top-row slot {i} should be war after Melee"); + } + // Bottom row still has no number. + foreach (var id in Row2) + Assert.Equal(-1, slots[id].Cell.ShortcutNum); + } + + /// + /// After SetCombatMode back to NonCombat, top-row switches back to peace (ShortcutPeace == true). + /// + [Fact] + public void ShortcutNumbers_backToNonCombat_restoresPeaceDigits() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + ctrl.SetCombatMode(CombatMode.Melee); + ctrl.SetCombatMode(CombatMode.NonCombat); + + for (int i = 0; i < Row1.Length; i++) + Assert.True(slots[Row1[i]].Cell.ShortcutPeace, + $"top-row slot {i} should be peace after returning to NonCombat"); + } + + /// + /// Digit arrays are correctly injected into each cell (PeaceDigits + WarDigits references). + /// + [Fact] + public void ShortcutNumbers_digitArraysInjected() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + foreach (var id in Row1) + { + Assert.Same(FakePeace, slots[id].Cell.PeaceDigits); + Assert.Same(FakeWar, slots[id].Cell.WarDigits); + } + } + + /// + /// EmptyDigits (0x1000005e background digit) is injected into every slot cell. + /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — empty-slot branch. + /// + [Fact] + public void ShortcutNumbers_emptyDigitArrayInjected() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: FakeEmpty); + + foreach (var id in Row1) + Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits); + foreach (var id in Row2) + Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits); + } + + /// + /// When emptyDigits is null, cells have EmptyDigits == null (no digit on empty slots). + /// This is the safe fallback when the dat property 0x1000005e is absent. + /// + [Fact] + public void ShortcutNumbers_nullEmptyDigits_cellsHaveNullEmptyDigits() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: null); + + foreach (var id in Row1) + Assert.Null(slots[id].Cell.EmptyDigits); + } + + // ── E1: Guid filter + ObjectRemoved tests (D.5.4) ─────────────────────── + + /// + /// ObjectAdded for a guid NOT in the shortcut list does NOT call iconIds again + /// (no spurious Populate on creature/NPC spawns in a busy zone). + /// D.5.4: ToolbarController filters to shortcut guids only. + /// The iconIds spy lets us count how many times Populate actually ran. + /// + [Fact] + public void ObjectAdded_nonShortcutGuid_doesNotCallIconIds() + { + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + + int iconCallCount = 0; + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x77u; }, useItem: _ => { }); + + int callsAfterBind = iconCallCount; // 1 call from initial Populate + + // Fire ObjectAdded with a completely unrelated guid (a creature, NOT a shortcut). + repo.AddOrUpdate(new ClientObject { ObjectId = 0xDEADBEEFu, WeenieClassId = 42u, IconId = 0u }); + + // iconIds must NOT have been called again — the filter blocked Populate. + Assert.Equal(callsAfterBind, iconCallCount); + } + + /// + /// ObjectAdded for a guid that IS in the shortcut list calls iconIds again (deferred bind). + /// This is the filtered-path counterpart of DeferredRebind_whenItemArrivesLate. + /// + [Fact] + public void ObjectAdded_shortcutGuid_callsIconIds() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); // item NOT present yet + var shortcuts = new List + { new(Index: 1, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0) }; + + int iconCallCount = 0; + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x99u; }, useItem: _ => { }); + + Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate + Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); + + // Now the shortcut item arrives — filter must PASS and Populate re-run. + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5003u, WeenieClassId = 1u, IconId = 0x06005678u }); + + Assert.Equal(1, iconCallCount); // iconIds called exactly once for the deferred bind + Assert.Equal(0x5003u, slots[Row1[1]].Cell.ItemId); + } + + /// + /// ObjectRemoved for a guid that IS in the shortcut list clears the slot. + /// D.5.4: subscribes to ObjectRemoved so a removed item evicts its icon. + /// + [Fact] + public void ObjectRemoved_shortcutGuid_clearsSlot() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5004u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 3, ObjectGuid: 0x5004u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { }); + + Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound + + // Remove the item from the session (server despawn / trade away). + // Populate re-runs: item is gone from repo → slot clears. + repo.Remove(0x5004u); + + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); + } + + /// + /// ObjectRemoved for a guid NOT in the shortcut list does NOT call iconIds again. + /// D.5.4: the ObjectRemoved subscription also filters to shortcut guids. + /// + [Fact] + public void ObjectRemoved_nonShortcutGuid_doesNotCallIconIds() + { + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5005u, WeenieClassId = 1u, IconId = 0x06001234u }); + repo.AddOrUpdate(new ClientObject { ObjectId = 0xCAFEBABEu, WeenieClassId = 99u, IconId = 0u }); + var shortcuts = new List + { new(Index: 4, ObjectGuid: 0x5005u, SpellId: 0, Layer: 0) }; + + int iconCallCount = 0; + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => { iconCallCount++; return 0xBBu; }, useItem: _ => { }); + + int callsAfterBind = iconCallCount; // 1 call for the shortcut item + + // Remove an unrelated object — filter must block Populate. + repo.Remove(0xCAFEBABEu); + + Assert.Equal(callsAfterBind, iconCallCount); // unchanged + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs new file mode 100644 index 00000000..3f3ef20b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -0,0 +1,90 @@ +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, 1); // DirectState (DrawMode Normal=1) + info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3) + var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" }; + Assert.Equal(0x06000002u, e.ActiveMedia().File); + Assert.Equal(3, e.ActiveMedia().DrawMode); + e.ActiveState = ""; + Assert.Equal(0x06000001u, e.ActiveMedia().File); + Assert.Equal(1, e.ActiveMedia().DrawMode); + } + + [Fact] + public void ActiveMedia_NoMedia_ReturnsZero() + { + var e = new UiDatElement(new ElementInfo(), _ => (0, 0, 0)); + Assert.Equal(0u, e.ActiveMedia().File); + Assert.Equal(0, e.ActiveMedia().DrawMode); + } + + [Fact] + public void ActiveMedia_MissingNamedState_FallsBackToDirect() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06000005, 1); + var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" }; + Assert.Equal(0x06000005u, e.ActiveMedia().File); + } + + // ── G1 tests: DefaultStateName + "Normal" implicit default ─────────────── + + /// + /// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal" + /// state sprite, the ctor should default ActiveState to "Normal" so the element + /// renders its normal-state sprite without requiring explicit state assignment. + /// + [Fact] + public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent() + { + var info = new ElementInfo(); + info.StateMedia["Normal"] = (0x0000AAAAu, 1); + info.StateMedia["Hover"] = (0x0000BBBBu, 1); + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // Should have defaulted to "Normal" state. + Assert.Equal(0x0000AAAAu, e.ActiveMedia().File); + } + + /// + /// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"), + /// it takes priority over the "Normal" implicit default. + /// + [Fact] + public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet() + { + var info = new ElementInfo { DefaultStateName = "Minimized" }; + info.StateMedia["Minimized"] = (0x0000BBBBu, 1); + info.StateMedia["Maximized"] = (0x0000CCCCu, 1); + info.StateMedia["Normal"] = (0x0000DDDDu, 1); + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // DefaultStateName "Minimized" wins over "Normal" implicit default. + Assert.Equal(0x0000BBBBu, e.ActiveMedia().File); + } + + /// + /// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state + /// should still default to "" (DirectState) — no regression for chrome/grip elements. + /// + [Fact] + public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner) + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState). + Assert.Equal(0x06007777u, e.ActiveMedia().File); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs new file mode 100644 index 00000000..a0baad8e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs @@ -0,0 +1,113 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Unit tests for : verifies that the controller +/// correctly maps element ids to UiMeter instances and wires the Fill / Label providers. +/// No dats, no GL — pure data-wiring tests. +/// +public class VitalsBindingTests +{ + // ── Test 1: Health meter Fill + Label providers are bound ───────────────── + + [Fact] + public void Bind_SetsHealthMeterFillFromProvider() + { + var health = new UiMeter(); + var layout = FakeLayout((VitalsController.Health, health)); + float hp = 0.42f; + + VitalsController.Bind(layout, + healthPct: () => hp, + staminaPct: () => 1f, + manaPct: () => 1f, + healthText: () => "42/100", + staminaText: () => "", + manaText: () => ""); + + Assert.Equal(0.42f, health.Fill()!.Value); + // The meter no longer draws its own label; the cur/max is a centered UiText child. + Assert.Null(health.Label()); + Assert.Equal("42/100", NumberText(health)); + } + + // ── Test 2: All three meters wired to distinct providers ────────────────── + + [Fact] + public void Bind_AllThreeMeters_EachBoundToOwnProvider() + { + var health = new UiMeter(); + var stamina = new UiMeter(); + var mana = new UiMeter(); + var layout = FakeLayout( + (VitalsController.Health, health), + (VitalsController.Stamina, stamina), + (VitalsController.Mana, mana)); + + VitalsController.Bind(layout, + healthPct: () => 0.25f, + staminaPct: () => 0.50f, + manaPct: () => 0.75f, + healthText: () => "25/100", + staminaText: () => "50/100", + manaText: () => "75/100"); + + // Each meter should reflect its own provider, not another's. + Assert.Equal(0.25f, health.Fill()!.Value); + Assert.Equal("25/100", NumberText(health)); + + Assert.Equal(0.50f, stamina.Fill()!.Value); + Assert.Equal("50/100", NumberText(stamina)); + + Assert.Equal(0.75f, mana.Fill()!.Value); + Assert.Equal("75/100", NumberText(mana)); + } + + // ── Test 3: Missing meter ids are silently skipped (no throw) ───────────── + + [Fact] + public void Bind_MissingMeterIds_DoesNotThrow() + { + // Only Health is present; Stamina and Mana are absent from the layout. + var health = new UiMeter(); + var layout = FakeLayout((VitalsController.Health, health)); + + // Should not throw even though Stamina/Mana are missing. + VitalsController.Bind(layout, + healthPct: () => 1f, + staminaPct: () => 1f, + manaPct: () => 1f, + healthText: () => "100/100", + staminaText: () => "100/100", + manaText: () => "100/100"); + + // Health was present — it should be wired. + Assert.Equal(1f, health.Fill()!.Value); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// The cur/max text from the centered number that + /// attaches as the meter's child. + private static string NumberText(UiMeter m) + { + var num = Assert.IsType(m.Children[0]); + Assert.True(num.Centered); + var lines = num.LinesProvider(); + return lines.Count > 0 ? lines[0].Text : ""; + } + + private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items) + { + var dict = new Dictionary(); + var root = new UiPanel(); + foreach (var (id, e) in items) + { + root.AddChild(e); + dict[id] = e; + } + return new ImportedLayout(root, dict); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json new file mode 100644 index 00000000..37783bb7 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json @@ -0,0 +1,542 @@ +{ + "Id": 0, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 0, + "Height": 0, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435484, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 382, + "Height": 104, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667980, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435485, + "Type": 5, + "X": 0, + "Y": 2, + "Width": 382, + "Height": 102, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268436774, + "Type": 1, + "X": 2, + "Y": 0, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268435486, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 191, + "Height": 17, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 2, + "FontDid": 1073741825, + "StateMedia": { + "Normal": { + "Item1": 100667982, + "Item2": 1 + }, + "Ghosted": { + "Item1": 100667982, + "Item2": 1 + }, + "Talkfocus_highlight": { + "Item1": 100667981, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435470, + "Type": 268435521, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 100, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667725, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436772, + "Type": 1, + "X": 0, + "Y": 46, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 6, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436773, + "Type": 1, + "X": 0, + "Y": 64, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 7, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436591, + "Type": 1, + "X": 474, + "Y": 0, + "Width": 16, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "Maximized": { + "Item1": 100687460, + "Item2": 1 + }, + "Minimized": { + "Item1": 100687461, + "Item2": 1 + } + }, + "DefaultStateName": "Minimized", + "Children": [] + }, + { + "Id": 268435471, + "Type": 9, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 9, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667685, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435472, + "Type": 3, + "X": 0, + "Y": 9, + "Width": 490, + "Height": 74, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667669, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435473, + "Type": 12, + "X": 16, + "Y": 0, + "Width": 458, + "Height": 74, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 1073741824, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436620, + "Type": 1, + "X": 0, + "Y": 58, + "Width": 16, + "Height": 16, + "Left": 3, + "Top": 2, + "Right": 3, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal": { + "Item1": 100687630, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100687630, + "Item2": 1 + } + }, + "DefaultStateName": "Ghosted", + "Children": [] + } + ] + }, + { + "Id": 268435474, + "Type": 11, + "X": 474, + "Y": 6, + "Width": 16, + "Height": 68, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100682847, + "Item2": 3 + } + }, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435475, + "Type": 3, + "X": 0, + "Y": 83, + "Width": 490, + "Height": 17, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 8, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667706, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435476, + "Type": 6, + "X": 0, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal": { + "Item1": 100683109, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100683110, + "Item2": 3 + } + }, + "DefaultStateName": "Normal", + "Children": [ + { + "Id": 268435477, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 1073741826, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435478, + "Type": 12, + "X": 46, + "Y": 0, + "Width": 398, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 1073741824, + "StateMedia": { + "Normal_focussed": { + "Item1": 100667819, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435479, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 1, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal_focussed": { + "Item1": 100683111, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435480, + "Type": 3, + "X": 397, + "Y": 0, + "Width": 1, + "Height": 17, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "Normal_focussed": { + "Item1": 100683111, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435481, + "Type": 1, + "X": 444, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741826, + "StateMedia": { + "Normal": { + "Item1": 100669717, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100669718, + "Item2": 1 + }, + "Ghosted": { + "Item1": 100669748, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + } + ] + }, + { + "Id": 268436770, + "Type": 1, + "X": 0, + "Y": 10, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 4, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436771, + "Type": 1, + "X": 0, + "Y": 28, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 5, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json new file mode 100644 index 00000000..ff372638 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json @@ -0,0 +1,1058 @@ +{ + "Id": 268436985, + "Type": 268435533, + "X": 0, + "Y": 0, + "Width": 160, + "Height": 58, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268437048, + "Type": 3, + "X": 5, + "Y": 53, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 6, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693185, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435692, + "Type": 7, + "X": 5, + "Y": 21, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 18, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268435693, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 85, + "Height": 28, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693139, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693127, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693128, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693129, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 85, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693138, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693124, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693125, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693126, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437049, + "Type": 3, + "X": 155, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 7, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693190, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437050, + "Type": 3, + "X": 155, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 8, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693186, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435694, + "Type": 7, + "X": 5, + "Y": 37, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 19, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 25, + "Y": 0, + "Width": 100, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693141, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693133, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693134, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693135, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435695, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 25, + "Y": 0, + "Width": 100, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693140, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693130, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693131, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693132, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437051, + "Type": 9, + "X": 0, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 9, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437052, + "Type": 2, + "X": 5, + "Y": 0, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 10, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688170, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437053, + "Type": 9, + "X": 155, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 11, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437054, + "Type": 9, + "X": 0, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 12, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688171, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437055, + "Type": 9, + "X": 0, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 13, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437056, + "Type": 2, + "X": 5, + "Y": 53, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 14, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688172, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437057, + "Type": 9, + "X": 155, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 15, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437058, + "Type": 9, + "X": 155, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 16, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688173, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435686, + "Type": 7, + "X": 5, + "Y": 5, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 17, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268435691, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 66, + "Y": 0, + "Width": 18, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693137, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693121, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693122, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693123, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 66, + "Y": 0, + "Width": 18, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693136, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693118, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693119, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693120, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437043, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693187, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437044, + "Type": 3, + "X": 5, + "Y": 0, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693183, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437045, + "Type": 3, + "X": 155, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693188, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437046, + "Type": 3, + "X": 0, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693184, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437047, + "Type": 3, + "X": 0, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 5, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693189, + "Item2": 1 + } + }, + "Children": [] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs new file mode 100644 index 00000000..d45aa374 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -0,0 +1,78 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class MarkupDocumentTests +{ + private sealed class FakeBinding + { + public float HealthPercent => 0.5f; + public uint? HealthCurrent => 109; + public uint? HealthMax => 218; + public float? ManaPercent => null; + public uint? ManaCurrent => null; + public uint? ManaMax => null; + } + + [Fact] + public void Build_CreatesPanelWithMeterFillLabelAndGeometry() + { + const string xml = + "" + + " " + + ""; + + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + Assert.Equal("109/218", meter.Label()); + } + + [Fact] + public void Build_NullBindingValuesYieldNullFillAndLabel() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Null(meter.Fill()); + Assert.Null(meter.Label()); + } + + [Fact] + public void Build_ResizeAttrX_SetsHorizontalOnly() + { + const string xml = ""; + var panel = MarkupDocument.Build(xml, new object(), _ => ((uint)1, 32, 32)); + Assert.True(panel.ResizeX); + Assert.False(panel.ResizeY); + } + + [Fact] + public void Build_ParsesNineSliceBarSpriteIds() + { + const string xml = "" + + "" + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(0x06001141u, meter.BackLeft); + Assert.Equal(0x06001140u, meter.BackTile); + Assert.Equal(0x0600113Fu, meter.BackRight); + Assert.Equal(0x06001131u, meter.FrontLeft); + Assert.Equal(0x06001132u, meter.FrontTile); + Assert.Equal(0x06001133u, meter.FrontRight); + Assert.NotNull(meter.SpriteResolve); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs new file mode 100644 index 00000000..8bbadae2 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -0,0 +1,25 @@ +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); + private bool _clicked; + + [Fact] + public void Click_InvokesOnClick() + { + var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex) + { OnClick = () => _clicked = true }; + b.OnEvent(new UiEvent(0, null, UiEventType.Click)); + Assert.True(_clicked); + } + + [Fact] + public void NotClickThrough_SoItReceivesClicks() + { + var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); + Assert.False(b.ClickThrough); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs new file mode 100644 index 00000000..55a6457a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat). +/// The advance per glyph is the retail +/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter +/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the +/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98). +/// +public class UiDatFontTests +{ + private static FontCharDesc Glyph( + ushort unicode, byte width, + sbyte before = 0, sbyte after = 0, + ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0) + => new() + { + Unicode = unicode, + Width = width, + Height = height, + OffsetX = offsetX, + OffsetY = offsetY, + HorizontalOffsetBefore = before, + HorizontalOffsetAfter = after, + VerticalOffsetBefore = vBefore, + }; + + [Fact] + public void GlyphAdvance_SumsBeforeWidthAfter() + { + var g = Glyph('A', width: 8, before: 1, after: 2); + Assert.Equal(11f, UiDatFont.GlyphAdvance(g)); + } + + [Fact] + public void GlyphAdvance_HandlesNegativeBearings() + { + // Kerned glyph: a negative left-bearing pulls it leftward; the advance + // still nets out to before + width + after. + var g = Glyph('j', width: 4, before: -1, after: 0); + Assert.Equal(3f, UiDatFont.GlyphAdvance(g)); + } + + [Fact] + public void MeasureWidth_SumsEachGlyphAdvance() + { + var table = new Dictionary + { + ['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9 + ['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9 + ['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6 + ['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5 + }; + FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; + + // "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53 + Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup)); + } + + [Fact] + public void MeasureWidth_SkipsCharactersNotInFont() + { + var table = new Dictionary + { + ['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8 + }; + FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; + + // 'X' has no glyph → contributes nothing; only the two '5's count. + Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup)); + } + + [Fact] + public void MeasureWidth_EmptyOrNullIsZero() + { + FontCharDesc? Lookup(char c) => null; + Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup)); + Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup)); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs new file mode 100644 index 00000000..5e6d405f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -0,0 +1,72 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiFieldTests +{ + [Fact] + public void InsertChar_AdvancesCaret() + { + var input = new UiField(); + input.InsertChar('h'); input.InsertChar('i'); + Assert.Equal("hi", input.Text); + Assert.Equal(2, input.CaretPos); + } + + [Fact] + public void Backspace_DeletesBeforeCaret() + { + var input = new UiField(); + foreach (var c in "abc") input.InsertChar(c); + input.MoveCaret(-1); + input.Backspace(); + Assert.Equal("ac", input.Text); + Assert.Equal(1, input.CaretPos); + } + + [Fact] + public void Submit_FiresCallback_ClearsText_PushesHistory() + { + string? sent = null; + var input = new UiField { OnSubmit = t => sent = t }; + foreach (var c in "hello") input.InsertChar(c); + input.Submit(); + Assert.Equal("hello", sent); + Assert.Equal("", input.Text); + Assert.Equal(0, input.CaretPos); + } + + [Fact] + public void EmptySubmit_DoesNotFire() + { + int n = 0; + var input = new UiField { OnSubmit = _ => n++ }; + input.Submit(); + Assert.Equal(0, n); + } + + [Fact] + public void History_UpDownBrowsesPreviousSubmissions() + { + var input = new UiField { OnSubmit = _ => {} }; + foreach (var c in "first") input.InsertChar(c); input.Submit(); + foreach (var c in "second") input.InsertChar(c); input.Submit(); + input.HistoryPrev(); + Assert.Equal("second", input.Text); + input.HistoryPrev(); + Assert.Equal("first", input.Text); + input.HistoryNext(); + Assert.Equal("second", input.Text); + input.HistoryNext(); + Assert.Equal("", input.Text); + } + + [Fact] + public void History_CapsAt100() + { + var input = new UiField { OnSubmit = _ => {} }; + for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } + Assert.True(input.HistoryCount <= 100); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs new file mode 100644 index 00000000..832a8507 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiItemListTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiItemListTests +{ + [Fact] + public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren); + + [Fact] + public void StartsWithOneCell_forSingleCellSlot() + { + var list = new UiItemList(); + Assert.Equal(1, list.GetNumUIItems()); + Assert.NotNull(list.GetItem(0)); + } + + [Fact] + public void Cell_returnsTheFirstSlot() + { + var list = new UiItemList(); + Assert.Same(list.GetItem(0), list.Cell); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs new file mode 100644 index 00000000..f99e9dc5 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs @@ -0,0 +1,144 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiItemSlotTests +{ + [Fact] + public void IsLeafWidget() + => Assert.True(new UiItemSlot().ConsumesDatChildren); + + [Fact] + public void DefaultEmptySprite_isToolbarBorder() + => Assert.Equal(0x060074CFu, new UiItemSlot().EmptySprite); + + [Fact] + public void Empty_whenNoItem() + { + var s = new UiItemSlot(); + Assert.Equal(0u, s.ItemId); + Assert.Equal(0u, s.IconTexture); + } + + [Fact] + public void SetItem_setsIdAndTexture() + { + var s = new UiItemSlot(); + s.SetItem(0x5001u, 0x99u); + Assert.Equal(0x5001u, s.ItemId); + Assert.Equal(0x99u, s.IconTexture); + } + + [Fact] + public void Clear_afterSetItem_resetsToEmpty() + { + var s = new UiItemSlot(); + s.SetItem(0x5001u, 0x99u); + s.Clear(); + Assert.Equal(0u, s.ItemId); + Assert.Equal(0u, s.IconTexture); + } + + // ── Shortcut number tests ──────────────────────────────────────────────── + // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). + + [Fact] + public void ShortcutNum_defaultIsMinusOne() + { + var s = new UiItemSlot(); + Assert.Equal(-1, s.ShortcutNum); + } + + [Fact] + public void ShortcutPeace_defaultIsTrue() + { + var s = new UiItemSlot(); + Assert.True(s.ShortcutPeace); + } + + [Fact] + public void SetShortcutNum_setsIndexAndPeace() + { + var s = new UiItemSlot(); + s.SetShortcutNum(3, peace: false); + Assert.Equal(3, s.ShortcutNum); + Assert.False(s.ShortcutPeace); + } + + [Fact] + public void SetShortcutNum_peaceTrue() + { + var s = new UiItemSlot(); + s.SetShortcutNum(0, peace: true); + Assert.Equal(0, s.ShortcutNum); + Assert.True(s.ShortcutPeace); + } + + [Fact] + public void ClearShortcutNum_setsMinusOne() + { + var s = new UiItemSlot(); + s.SetShortcutNum(5, peace: true); + s.ClearShortcutNum(); + Assert.Equal(-1, s.ShortcutNum); + } + + // ── ActiveDigitArray occupancy gating (decomp UIElement_UIItem::SetShortcutNum:229481) ── + + private static readonly uint[] Peace = { 0x10u, 0x11u, 0x12u }; + private static readonly uint[] War = { 0x20u, 0x21u, 0x22u }; + private static readonly uint[] Empty = { 0x30u, 0x31u, 0x32u }; + + /// + /// When ItemId == 0 (empty slot), ActiveDigitArray returns EmptyDigits regardless + /// of ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — + /// else branch when m_elem_Icon->m_state == 0x1000001c (empty). + /// + [Fact] + public void ActiveDigitArray_emptySlot_returnsEmptyDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetShortcutNum(0, peace: true); + // ItemId == 0 → EmptyDigits + Assert.Same(Empty, s.ActiveDigitArray()); + } + + [Fact] + public void ActiveDigitArray_emptySlot_warStance_stillReturnsEmptyDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetShortcutNum(0, peace: false); + // ItemId == 0 → EmptyDigits regardless of stance + Assert.Same(Empty, s.ActiveDigitArray()); + } + + /// + /// When ItemId != 0 (occupied), ActiveDigitArray returns PeaceDigits or WarDigits + /// depending on ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481/229493). + /// + [Fact] + public void ActiveDigitArray_occupiedSlot_peaceStance_returnsPeaceDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetItem(0x5001u, 0x99u); + s.SetShortcutNum(0, peace: true); + Assert.Same(Peace, s.ActiveDigitArray()); + } + + [Fact] + public void ActiveDigitArray_occupiedSlot_warStance_returnsWarDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetItem(0x5001u, 0x99u); + s.SetShortcutNum(0, peace: false); + Assert.Same(War, s.ActiveDigitArray()); + } + + [Fact] + public void ActiveDigitArray_emptySlot_nullEmptyDigits_returnsNull() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = null }; + s.SetShortcutNum(0, peace: true); + Assert.Null(s.ActiveDigitArray()); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs new file mode 100644 index 00000000..4b1a16fe --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.UI; + +public class UiMenuTests +{ + // PopupH = RowsPerColumn(7) * RowHeight(17) = 119; popup opens upward so top = -119. + // Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17). + // Right column needs lx >= ColumnWidth(191) + Border(5) = lx >= 196 after bevel offset, + // but the original tests used lx=200 which maps ix=195 -> col=(int)(195/191)=1. OK. + + // The 14 channel items verbatim (matches ChannelItems in ChatWindowController). + private static readonly UiMenu.MenuItem[] ChannelItems = + { + new("Squelch (ignore)", (object?)null), + new("Tell to Selected", (object?)null), + new("Chat to All", (object?)ChatChannelKind.Say), + new("Tell to Fellows", (object?)ChatChannelKind.Fellowship), + new("Tell to General Chat", (object?)ChatChannelKind.General), + new("Tell to LFG Chat", (object?)ChatChannelKind.Lfg), + new("Tell to Society Chat", (object?)ChatChannelKind.Society), + new("Tell to Monarch", (object?)ChatChannelKind.Monarch), + new("Tell to Patron", (object?)ChatChannelKind.Patron), + new("Tell to Vassals", (object?)ChatChannelKind.Vassals), + new("Tell to Allegiance", (object?)ChatChannelKind.Allegiance), + new("Tell to Trade Chat", (object?)ChatChannelKind.Trade), + new("Tell to Roleplay Chat", (object?)ChatChannelKind.Roleplay), + new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi), + }; + + // Availability gate identical to ChatWindowController's EnabledProvider: the null-payload + // specials (Squelch/Tell-to-Selected) are ENABLED/white like retail; only talk-CHANNEL + // items grey when unavailable. (The widget reports any enabled pick via OnSelect; the + // controller decides whether to update Selected, so specials are inert no-ops anyway.) + private static bool ChannelAvailable(object? p) + => p is not ChatChannelKind ch + || ch is ChatChannelKind.Say or ChatChannelKind.General + or ChatChannelKind.Trade or ChatChannelKind.Lfg; + + private UiMenu MakeMenu() => new UiMenu + { + Width = 80f, Height = 18f, + Items = ChannelItems, + Selected = (object?)ChatChannelKind.Say, + EnabledProvider = ChannelAvailable, + }; + + [Fact] + public void Items_HasExpected14Entries() + { + Assert.Equal(14, ChannelItems.Length); + } + + [Fact] + public void Items_FirstEntry_IsSquelch_Special() + { + Assert.Equal("Squelch (ignore)", ChannelItems[0].Label); + Assert.Null(ChannelItems[0].Payload); + } + + [Fact] + public void Items_LastEntry_IsOlthoi() + { + var last = ChannelItems[^1]; + Assert.Equal("Tell to Olthoi Chat", last.Label); + Assert.Equal(ChatChannelKind.Olthoi, last.Payload); + } + + [Fact] + public void Items_ContainAll12ChannelKinds() + { + var kinds = new HashSet( + ChannelItems.Where(i => i.Payload is ChatChannelKind).Select(i => (ChatChannelKind)i.Payload!)); + foreach (var k in new[] + { + ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg, + ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron, + ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay, + ChatChannelKind.Society, ChatChannelKind.Olthoi, + }) + Assert.Contains(k, kinds); + } + + [Fact] + public void DefaultSelected_IsNull_OnBlankMenu() + { + // A freshly constructed UiMenu has no Selected by default (controller sets it). + Assert.Null(new UiMenu().Selected); + } + + [Fact] + public void Select_AvailableLeftColumnItem_FiresOnSelect() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + object? fired = null; + // Mirror the controller: the widget reports the pick, the controller sets Selected. + menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; }; + + // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); + Assert.Equal(ChatChannelKind.Say, fired); + Assert.Equal(ChatChannelKind.Say, menu.Selected); + } + + [Fact] + public void Select_AvailableRightColumnItem_FiresOnSelect() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + object? fired = null; + // Mirror the controller: the widget reports the pick, the controller sets Selected. + menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; }; + + // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34). + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42))); + Assert.Equal(ChatChannelKind.Trade, fired); + Assert.Equal(ChatChannelKind.Trade, menu.Selected); + } + + [Fact] + public void Select_SpecialItem_FiresNull_LeavesSelectionUnchanged() + { + var menu = MakeMenu(); // Selected = Say + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + // Mirror the controller: only channel payloads update Selected; the null-payload + // specials are deferred no-ops that leave the active channel + highlight unchanged. + bool fired = false; object? firedPayload = "sentinel"; + menu.OnSelect = p => { fired = true; firedPayload = p; if (p is ChatChannelKind) menu.Selected = p; }; + + // "Squelch (ignore)" is index 0 = left col, row 0 (null payload), white/enabled. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110))); + Assert.True(fired); // the pick IS reported... + Assert.Null(firedPayload); // ...with the special's null payload + Assert.Equal(ChatChannelKind.Say, menu.Selected); // ...but selection is unchanged (deferred no-op) + } + + [Fact] + public void Select_UnavailableChannel_DoesNotFire() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + int fired = 0; + menu.OnSelect = _ => fired++; + + // "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51). + // Fellowship is unavailable by the default static gate, so the click is inert. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); + Assert.Equal(0, fired); + } + + [Fact] + public void EnabledProvider_Overrides_DefaultGate() + { + // Override: all items enabled (even Fellowship which is normally greyed). + var menu = new UiMenu + { + Width = 80f, Height = 18f, + Items = ChannelItems, + Selected = (object?)ChatChannelKind.Say, + EnabledProvider = _ => true, + }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + object? fired = null; + menu.OnSelect = p => fired = p; + + // With every item enabled, "Tell to Fellows" (idx 3, row 3) now fires. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); + Assert.Equal(ChatChannelKind.Fellowship, fired); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiMeterTests.cs b/tests/AcDream.App.Tests/UI/UiMeterTests.cs new file mode 100644 index 00000000..9e7637e9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMeterTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiMeterTests +{ + [Fact] + public void ComputeFillRect_HalfFillIsHalfWidth() + { + var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); + Assert.Equal(0f, x); Assert.Equal(0f, y); + Assert.Equal(100f, w); Assert.Equal(12f, h); + } + + [Theory] + [InlineData(-1f, 0f)] // clamps below 0 + [InlineData(2f, 200f)] // clamps above 1 + [InlineData(0f, 0f)] + [InlineData(1f, 200f)] + public void ComputeFillRect_ClampsFraction(float pct, float expectedW) + { + var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); + Assert.Equal(expectedW, w); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs new file mode 100644 index 00000000..8a2b3d0a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs @@ -0,0 +1,27 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiNineSlicePanelTests +{ + [Fact] + public void ComputeFrameRects_PlacesCornersEdgesAndCenter() + { + var r = UiNineSlicePanel.ComputeFrameRects(100, 80, 5); + + // 5x5 corners at the four corners + Assert.Equal(new UiNineSlicePanel.Rect(0, 0, 5, 5), r.TL); + Assert.Equal(new UiNineSlicePanel.Rect(95, 0, 5, 5), r.TR); + Assert.Equal(new UiNineSlicePanel.Rect(0, 75, 5, 5), r.BL); + Assert.Equal(new UiNineSlicePanel.Rect(95, 75, 5, 5), r.BR); + + // edges span the interior (100-2*5 = 90 wide, 80-2*5 = 70 tall) + Assert.Equal(new UiNineSlicePanel.Rect(5, 0, 90, 5), r.Top); + Assert.Equal(new UiNineSlicePanel.Rect(5, 75, 90, 5), r.Bottom); + Assert.Equal(new UiNineSlicePanel.Rect(0, 5, 5, 70), r.Left); + Assert.Equal(new UiNineSlicePanel.Rect(95, 5, 5, 70), r.Right); + + // center fills the interior + Assert.Equal(new UiNineSlicePanel.Rect(5, 5, 90, 70), r.Center); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs new file mode 100644 index 00000000..c3160e66 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -0,0 +1,239 @@ +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiRootInputTests +{ + [Fact] + public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks() + { + // Regression: the per-frame anchor pass must NOT reset a window's rect, + // or move/resize get undone every frame. Windows are user-positioned. + var panel = new UiNineSlicePanel(_ => ((uint)1, 32, 32)); + Assert.Equal(AnchorEdges.None, panel.Anchors); + } + + private sealed class CoordRecorder : UiElement + { + public (int x, int y)? Down, Move; + public CoordRecorder() { CapturesPointerDrag = true; } + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; } + if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; } + return false; + } + } + + [Fact] + public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild() + { + // Regression (adversarial review): a nested child must receive target-LOCAL + // coords on MouseDown AND MouseMove for the same physical point — otherwise + // drag-select anchors ~(child offset) px off from where you click. Before the + // fix MouseDown used HitTestTopDown's window-relative coords (50,40) while + // MouseMove used target-local (42,32). + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 }; + var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 }; + panel.AddChild(child); + root.AddChild(panel); + + // child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32). + root.OnMouseDown(UiMouseButton.Left, 100, 100); + Assert.Equal((42, 32), child.Down); + + // drag to (120,110) -> local (62,42); MUST share the MouseDown frame. + root.OnMouseMove(120, 110); + Assert.Equal((62, 42), child.Move); + } + + [Fact] + public void ApplyAnchor_None_IsNoOp() + { + var e = new UiPanel { Left = 50, Top = 60, Width = 100, Height = 40, Anchors = AnchorEdges.None }; + e.ApplyAnchor(800, 600); + Assert.Equal(50f, e.Left); + Assert.Equal(60f, e.Top); + Assert.Equal(100f, e.Width); + Assert.Equal(40f, e.Height); + } + + [Fact] + public void WantsMouse_TrueOverWidget_FalseOverEmptySpace() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; + root.AddChild(panel); + + root.OnMouseMove(50, 30); // inside the panel + Assert.True(root.WantsMouse); + + root.OnMouseMove(500, 400); // empty space + Assert.False(root.WantsMouse); + } + + [Fact] + public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true }; + root.AddChild(panel); + + root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel + root.OnMouseMove(120, 90); // drag + Assert.Equal(110f, panel.Left); // 120 - 10 + Assert.Equal(80f, panel.Top); // 90 - 10 + + root.OnMouseUp(UiMouseButton.Left, 120, 90); + root.OnMouseMove(300, 300); // released — must not move + Assert.Equal(110f, panel.Left); + Assert.Equal(80f, panel.Top); + } + + [Fact] + public void NonDraggablePanel_DoesNotMoveOnDrag() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false + root.AddChild(panel); + + root.OnMouseDown(UiMouseButton.Left, 20, 20); + root.OnMouseMove(120, 90); + Assert.Equal(10f, panel.Left); + Assert.Equal(10f, panel.Top); + } + + [Fact] + public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag() + { + // A child that captures pointer drags (text selection) must NOT move its + // draggable ancestor window when the user drags inside it. + var root = new UiRoot { Width = 800, Height = 600 }; + var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true }; + var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true }; + window.AddChild(child); + root.AddChild(window); + + // Press deep inside the child, then drag. + root.OnMouseDown(UiMouseButton.Left, 60, 60); + root.OnMouseMove(160, 160); + + // Window stays put; the captured child receives the drag itself. + Assert.Equal(10f, window.Left); + Assert.Equal(10f, window.Top); + Assert.Same(child, root.Captured); + + root.OnMouseUp(UiMouseButton.Left, 160, 160); + Assert.Equal(10f, window.Left); + Assert.Equal(10f, window.Top); + } + + [Fact] + public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow() + { + // Edge resize must still win even when a CapturesPointerDrag child covers + // the frame: a resizable chat window can be resized from its border. + var root = new UiRoot { Width = 800, Height = 600 }; + var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, + Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; + // Child fills the whole window (anchored) and captures interior drags. + var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100, + CapturesPointerDrag = true, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom }; + window.AddChild(child); + root.AddChild(window); + + // Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize. + root.OnMouseDown(UiMouseButton.Left, 298, 150); + root.OnMouseMove(338, 150); + Assert.Equal(240f, window.Width); + Assert.Equal(100f, window.Left); + root.OnMouseUp(UiMouseButton.Left, 338, 150); + } + + [Fact] + public void ResizeRect_RightBottom_GrowsSizeOnly() + { + var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40); + Assert.Equal(10f, x); Assert.Equal(20f, y); + Assert.Equal(130f, w); Assert.Equal(65f, h); + } + + [Fact] + public void ResizeRect_LeftTop_MovesOriginAndClampsToMin() + { + // Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40, + // origin shifts so the RIGHT edge (110) stays put → x = 70. + var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40); + Assert.Equal(40f, w); + Assert.Equal(70f, x); + } + + [Fact] + public void HitEdges_DetectsCornerAndInteriorNone() + { + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 }; + // bottom-right corner (300,200) + Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5)); + // deep interior → no edges + Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5)); + } + + [Fact] + public void EdgeDrag_ResizesPanel_InteriorDragMoves() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, + Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; + root.AddChild(panel); + + // grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin + root.OnMouseDown(UiMouseButton.Left, 298, 150); + root.OnMouseMove(338, 150); + Assert.Equal(240f, panel.Width); + Assert.Equal(100f, panel.Left); + root.OnMouseUp(UiMouseButton.Left, 338, 150); + + // grab the interior and drag → moves + root.OnMouseDown(UiMouseButton.Left, 200, 150); + root.OnMouseMove(220, 170); + Assert.Equal(120f, panel.Left); + Assert.Equal(120f, panel.Top); + root.OnMouseUp(UiMouseButton.Left, 220, 170); + } + + [Fact] + public void HitEdges_RespectsResizeAxisLock() + { + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, ResizeY = false }; + // right edge still detected (X allowed) + Assert.True((UiRoot.HitEdges(panel, 300, 150, 5) & ResizeEdges.Right) != 0); + // bottom edge masked out (Y locked) + Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0); + } + + [Fact] + public void ComputeAnchoredRect_LeftRight_StretchesWidth() + { + // bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300. + var (x, _, w, _) = UiElement.ComputeAnchoredRect( + AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top, + mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96); + Assert.Equal(8f, x); + Assert.Equal(280f, w); // 300 - 12 - 8 + } + + [Fact] + public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin() + { + var (x, y, w, h) = UiElement.ComputeAnchoredRect( + AnchorEdges.Left | AnchorEdges.Top, + mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96); + Assert.Equal(8f, x); Assert.Equal(24f, y); + Assert.Equal(200f, w); Assert.Equal(14f, h); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs new file mode 100644 index 00000000..27804b1c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs @@ -0,0 +1,73 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); + Assert.Equal(200, s.ScrollY); + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); + Assert.Equal(0, s.ScrollY); + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs new file mode 100644 index 00000000..c2239732 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -0,0 +1,81 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure unit tests for — no GL dependency. +/// +public class UiScrollbarTests +{ + // Model: content=400, view=100, trackLen=200. + // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50. + // Travel = 200 - 50 = 150. + + [Fact] + public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset() + { + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + // PositionRatio = 0 (start). + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + Assert.Equal(0f, y, 3f); + } + + [Fact] + public void ThumbRect_AtEnd_PinsToBottomOfTrack() + { + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.ScrollToEnd(); // PositionRatio = 1. + float trackTop = 16f, trackLen = 200f; + var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen); + Assert.Equal(50f, h, 3f); + // y = trackTop + travel * 1 = 16 + 150 = 166. + Assert.Equal(166f, y, 3f); + } + + [Fact] + public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop() + { + // Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1. + // thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150. + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.ScrollToEnd(); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + Assert.Equal(166f, y, 3f); // 16 + 150 + } + + [Fact] + public void ThumbRect_MidScroll_InterpolatesPosition() + { + // content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5. + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.SetScrollY(150); + Assert.Equal(0.5f, m.PositionRatio, 3); + + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + // y = 0 + 150 * 0.5 = 75. + Assert.Equal(75f, y, 3f); + } + + [Fact] + public void ThumbRect_SmallContent_EnforcesMinThumb() + { + // content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8. + var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 }; + var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(8f, h, 3f); + } + + [Fact] + public void ThumbRect_NoOverflow_ThumbFillsTrack() + { + // content <= view → ThumbRatio = 1 → thumbH = trackLen. + var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); + Assert.Equal(100f, h, 3f); + Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop + } +} diff --git a/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs new file mode 100644 index 00000000..11e6d1eb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs @@ -0,0 +1,30 @@ +using AcDream.App.UI; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiTextDatFontTests +{ + // Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2). + private static FontCharDesc Glyph(char c) => new() + { + Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, + OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, + }; + + [Fact] + public void CharIndexAt_UsesDatGlyphAdvance() + { + float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); + Assert.Equal(0, UiText.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f)); + } + + [Fact] + public void GlyphAdvance_MatchesRetailFormula() + { + Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs new file mode 100644 index 00000000..691dc213 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiTextTests +{ + [Fact] + public void ClampScroll_PinsToZero_WhenContentFitsView() + { + // 5 lines of content in a taller view → nothing to scroll, pinned at 0. + Assert.Equal(0f, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); + } + + [Fact] + public void ClampScroll_CapsAtContentMinusView_WhenOverflowing() + { + // Content 500, view 200 → max scrollback is 300px (oldest line at top). + Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); + } + + [Fact] + public void ClampScroll_NeverNegative() + { + Assert.Equal(0f, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); + } + + // ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ── + + private static readonly Func Mono10 = static _ => 10f; + + [Fact] + public void CharIndexAt_ZeroOrNegative_IsColumnZero() + { + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f)); + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f)); + } + + [Fact] + public void CharIndexAt_SnapsToGlyphMidpoint() + { + // glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ... + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 + Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 + Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 + Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 + } + + [Fact] + public void CharIndexAt_PastEnd_IsLength() + { + Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f)); + } + + [Fact] + public void CharIndexAt_EmptyString_IsZero() + { + Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f)); + } + + // ── SelectedText assembly ──────────────────────────────────────────── + + private static IReadOnlyList Lines(params string[] texts) + { + var list = new List(texts.Length); + foreach (var t in texts) + list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1))); + return list; + } + + [Fact] + public void SelectedText_SingleLine_Substring() + { + var lines = Lines("hello world"); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(0, 11)); + Assert.Equal("world", s); + } + + [Fact] + public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised() + { + var lines = Lines("hello world"); + // caret BEFORE anchor — Order() must normalise. + var s = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6)); + Assert.Equal("world", s); + } + + [Fact] + public void SelectedText_SamePosition_IsEmpty() + { + var lines = Lines("hello"); + Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.Pos(0, 3))); + } + + [Fact] + public void SelectedText_MultiLine_JoinsWithNewline() + { + var lines = Lines("first line", "second line", "third line"); + // from col 6 of line 0 ("line") through col 5 of line 2 ("third") + var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5)); + Assert.Equal("line\nsecond line\nthird", s); + } + + [Fact] + public void SelectedText_MultiLine_TwoLines_NoMiddle() + { + var lines = Lines("alpha", "bravo"); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.Pos(1, 3)); + Assert.Equal("pha\nbra", s); + } + + [Fact] + public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised() + { + var lines = Lines("alpha", "bravo"); + // end before start → Order() swaps them. + var s = UiText.SelectedText(lines, new UiText.Pos(1, 3), new UiText.Pos(0, 2)); + Assert.Equal("pha\nbra", s); + } + + [Fact] + public void SelectedText_EmptyLineList_IsEmpty() + { + Assert.Equal("", UiText.SelectedText(Array.Empty(), + new UiText.Pos(0, 0), new UiText.Pos(0, 0))); + } + + [Fact] + public void Order_SortsByLineThenColumn() + { + var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5)); + Assert.Equal(new UiText.Pos(0, 5), s1); + Assert.Equal(new UiText.Pos(2, 1), e1); + + var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2)); + Assert.Equal(new UiText.Pos(1, 2), s2); + Assert.Equal(new UiText.Pos(1, 8), e2); + } +} diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs new file mode 100644 index 00000000..54a23f2f --- /dev/null +++ b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs @@ -0,0 +1,147 @@ +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 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + } + + [Fact] + public void BeginArrival_DuringHold_ResetsTimeoutCounter() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); + + c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); + c.Tick(); // held=1 + c.Tick(); // held=2 (one short of the timeout) + + // Re-arm mid-hold with a fresh destination: the counter must restart. + c.BeginArrival(new Vector3(2, 0, 0), 0x01250199u); + c.Tick(); // held=1 again (NOT 3 -> no placement yet) + c.Tick(); // held=2 + Assert.Empty(placed); + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + + c.Tick(); // held=3 -> timeout, forced place of the SECOND destination + var call = Assert.Single(placed); + Assert.True(call.Forced); + Assert.Equal(0x01250199u, call.Cell); + Assert.Equal(new Vector3(2, 0, 0), call.Pos); + } +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index c414ddbf..d0a92463 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -37,10 +37,10 @@ public sealed class GameEventWiringTests return body; } - private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog) MakeAll() + private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll() { var dispatcher = new GameEventDispatcher(); - var items = new ItemRepository(); + var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); @@ -101,10 +101,10 @@ public sealed class GameEventWiringTests } [Fact] - public void WireAll_WieldObject_RoutesToItemRepository() + public void WireAll_WieldObject_RoutesToClientObjectTable() { var (d, items, _, _, _) = MakeAll(); - items.AddOrUpdate(new ItemInstance { ObjectId = 0x1000, WeenieClassId = 1 }); + items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 }); byte[] payload = new byte[12]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000); @@ -114,7 +114,7 @@ public sealed class GameEventWiringTests var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); d.Dispatch(env!.Value); - var item = items.GetItem(0x1000); + var item = items.Get(0x1000); Assert.NotNull(item); Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation); Assert.Equal(0x2000u, item.ContainerId); @@ -141,7 +141,7 @@ public sealed class GameEventWiringTests // through WireAll, lands in LocalPlayerState with the right // ranks/start/current values. var dispatcher = new GameEventDispatcher(); - var items = new ItemRepository(); + var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); @@ -200,7 +200,7 @@ public sealed class GameEventWiringTests public void WireAll_PlayerDescription_FeedsSpellbook() { var dispatcher = new GameEventDispatcher(); - var items = new ItemRepository(); + var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); @@ -330,20 +330,20 @@ public sealed class GameEventWiringTests } [Fact] - public void PlayerDescription_RegistersInventoryEntries_InItemRepository() + public void PlayerDescription_RegistersInventoryEntries_InClientObjectTable() { // Issue #13 acceptance test: after a PlayerDescription with non-empty - // Inventory is dispatched through WireAll, ItemRepository.ItemCount > 0. + // Inventory is dispatched through WireAll, ClientObjectTable.ObjectCount > 0. // Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory + // equipped follow directly after spellbook_filters. var dispatcher = new GameEventDispatcher(); - var items = new ItemRepository(); + var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); - Assert.Equal(0, items.ItemCount); // pre-condition + Assert.Equal(0, items.ObjectCount); // pre-condition var sb = new MemoryStream(); using var w = new BinaryWriter(sb); @@ -370,9 +370,130 @@ public sealed class GameEventWiringTests var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); - Assert.Equal(2, items.ItemCount); - Assert.NotNull(items.GetItem(0x50000A01u)); - Assert.NotNull(items.GetItem(0x50000A02u)); + Assert.Equal(2, items.ObjectCount); + Assert.NotNull(items.Get(0x50000A01u)); + Assert.NotNull(items.Get(0x50000A02u)); + } + + [Fact] + public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse() + { + // D.5.4: PlayerDescription is a membership MANIFEST, not the data + // source. The old code set WeenieClassId = inv.ContainerType (a + // 0/1/2 discriminator), which is a misuse. After the fix, the + // registered stub has WeenieClassId == 0 and the equipped item's + // CurrentlyEquippedLocation is set to MeleeWeapon (0x1). + // Uses the SAME wire fixture as PlayerDescription_RegistersInventoryEntries_InClientObjectTable. + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + var combat = new CombatState(); + var spellbook = new Spellbook(); + var chat = new ChatLog(); + GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); + + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0u); // propertyFlags = 0 + w.Write(0x52u); // weenieType + w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT + w.Write(1u); // has_health + w.Write(0u); // attribute_flags = 0 (no attrs) + w.Write(0u); // enchantment_mask = 0 + + w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path) + w.Write(0u); // options1 + w.Write(0u); // legacy hotbar list count = 0 + w.Write(0u); // spellbook_filters + + // Inventory: 1 entry with ContainerType=1 (the OLD code would have + // set WeenieClassId=1; the new code must leave WeenieClassId==0). + w.Write(1u); + w.Write(0x700u); w.Write(1u); // guid=0x700, ContainerType=1 + + // Equipped: 1 entry with EquipLocation = MeleeWeapon (0x1). + // Wire format: guid(4) + loc(4) + priority(4) = 12 bytes per entry. + w.Write(1u); + w.Write(0x701u); w.Write((uint)EquipMask.MeleeWeapon); w.Write(0u); // guid=0x701, slot=MeleeWeapon, prio=0 + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(env!.Value); + + // (a) inventory guid is registered + Assert.NotNull(items.Get(0x700u)); + // (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone + Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId); + // (c) equipped guid has its equip slot set + Assert.NotNull(items.Get(0x701u)); + Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation); + } + + [Fact] + public void WireAll_PlayerDescription_invokesOnShortcuts() + { + // D.5.1 Task 4: WireAll must forward parsed.Shortcuts to the onShortcuts + // callback so the toolbar can read them without keeping a parser reference. + // Mirrors PlayerDescription_RegistersInventoryEntries_InClientObjectTable + // for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte + // entry, followed by the legacy-hotbar count (0) + spellbook_filters (0) + // then empty inventory and equipped. + IReadOnlyList? got = null; + + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + var combat = new CombatState(); + var spellbook = new Spellbook(); + var chat = new ChatLog(); + GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, + onShortcuts: list => got = list); + + // PlayerDescription body — minimal: no property flags, ATTRIBUTE|ENCHANTMENT + // vectorFlags (so the parser sees has_health=1, attribute_flags=0, + // enchantment_mask=0 and advances past both vector blocks), then the trailer + // with option_flags=Shortcut (0x1). + // + // Trailer layout when option_flags=0x1 (Shortcut only, no SpellLists8): + // u32 option_flags = 0x1 + // u32 options1 = 0 + // u32 count = 1 ← shortcut block (Shortcut flag set) + // u32 idx = 0 + // u32 guid = 0x5001 + // u16 spellId = 0 + // u16 layer = 0 + // u32 legacyHotbar count = 0 ← SpellLists8 NOT set → legacy fallback + // u32 spellbook_filters = 0 + // u32 inventory count = 0 + // u32 equipped count = 0 + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0u); // propertyFlags = 0 + w.Write(0x52u); // weenieType + w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT + w.Write(1u); // has_health + w.Write(0u); // attribute_flags = 0 (no attrs) + w.Write(0u); // enchantment_mask = 0 + + // Trailer + w.Write(0x00000001u); // option_flags = Shortcut + w.Write(0u); // options1 + // Shortcut block (option_flags & 0x1 set): + w.Write(1u); // count = 1 + w.Write(0u); // idx = 0 + w.Write(0x5001u); // guid = 0x5001 + w.Write((ushort)0); // spellId = 0 + w.Write((ushort)0); // layer = 0 + // SpellLists8 NOT set → legacy single-list fallback: + w.Write(0u); // legacy hotbar list count = 0 + // No DesiredComps, no CharacterOptions2, no GameplayOptions → strict path: + w.Write(0u); // spellbook_filters = 0 + w.Write(0u); // inventory count = 0 + w.Write(0u); // equipped count = 0 + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(env!.Value); + + Assert.NotNull(got); + Assert.Single(got!); + Assert.Equal(0x5001u, got![0].ObjectGuid); } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 1e9ce105..58a5a017 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -156,6 +156,288 @@ public sealed class CreateObjectTests Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3); } + // ----------------------------------------------------------------------- + // D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the + // action bar / equipment UI can read icon dat ids from spawn messages. + // ----------------------------------------------------------------------- + + [Fact] + public void TryParse_IconId_Surfaced() + { + // Icon dat id 0x06001234: the wire writer strips the 0x06000000 prefix + // before packing (WritePackedDwordOfKnownType strips it), so we write + // 0x1234 as the packed value and expect 0x06001234 back. + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000009u, + name: "SwordIcon", + itemType: (uint)ItemType.MeleeWeapon, + iconId: 0x1234u); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(0x06001234u, parsed!.Value.IconId); + } + + // ----------------------------------------------------------------------- + // D.5.1 (2026-06-17): extended WeenieHeader optional-tail walk — the parser + // now continues past UseRadius through ALL intervening fields to reach + // IconOverlay (weenieFlags bit 0x40000000) and IconUnderlay (weenieFlags2 + // bit 0x01, present when objDescFlags bit 0x04000000 is set). + // + // Two tests: + // 1. WithIconOverlay — sets only the IconOverlay bit + the minimum + // intervening fields (none in this minimal body, so weenieFlags only has + // 0x40000000). Verifies the parse walks to IconOverlay and captures it. + // 2. WithIconOverlayAndUnderlay — sets IconOverlay + the IncludesSecondHeader + // objDescFlag + weenieFlags2 bit 0x01, writes both ids, asserts both are + // captured. + // 3. NoOverlayBits_CommonCase — weenieFlags=0, verifies the extended walk + // produces no overlay (regression guard for the common spawn path). + // ----------------------------------------------------------------------- + + [Fact] + public void TryParse_IconOverlay_CapturedFromExtendedTail() + { + // Only IconOverlay (0x40000000) bit set in weenieFlags. No intervening + // optional fields, so the extended tail immediately reads the overlay id. + // ACE WritePackedDwordOfKnownType strips the 0x06000000 prefix before + // packing; the reader ORs it back in. + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x5000000Au, + name: "EnchantedSword", + itemType: (uint)ItemType.MeleeWeapon, + weenieFlags: 0x40000000u, // IconOverlay + iconOverlayId: 0x1ABCu); // will be read back as 0x06001ABC + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(0x06001ABCu, parsed!.Value.IconOverlayId); + Assert.Equal(0u, parsed.Value.IconUnderlayId); + } + + [Fact] + public void TryParse_IconOverlayAndUnderlay_BothCaptured() + { + // IncludesSecondHeader in objDescFlags (0x04000000) makes the parser read + // weenieFlags2. weenieFlags2 bit 0x01 (IconUnderlay) triggers the underlay + // read. Both overlay + underlay are captured. + // objectDescriptionFlags: 0x04000000 = IncludesSecondHeader + // weenieFlags: 0x40000000 = IconOverlay + // weenieFlags2: 0x00000001 = IconUnderlay + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x5000000Bu, + name: "MagicRing", + itemType: (uint)ItemType.Jewelry, + objectDescriptionFlags: 0x04000000u, + weenieFlags: 0x40000000u, + weenieFlags2: 0x00000001u, + iconOverlayId: 0x5678u, // → 0x06005678 + iconUnderlayId: 0x9ABCu); // → 0x06009ABC + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(0x06005678u, parsed!.Value.IconOverlayId); + Assert.Equal(0x06009ABCu, parsed.Value.IconUnderlayId); + } + + [Fact] + public void TryParse_NoOverlayBits_CommonCase_OverlaysStayZero() + { + // Regression guard: most spawned entities (creatures, scenery, players) + // have weenieFlags=0 and no second-header. The extended walk must not + // corrupt existing parsed fields and must leave overlay ids at zero. + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x5000000Cu, + name: "CommonDrudge", + itemType: (uint)ItemType.Creature, + weenieFlags: 0u); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal("CommonDrudge", parsed!.Value.Name); + Assert.Equal(0u, parsed.Value.IconOverlayId); + Assert.Equal(0u, parsed.Value.IconUnderlayId); + Assert.Null(parsed.Value.Useability); + } + + [Fact] + public void TryParse_IntermediateFieldsBeforeIconOverlay_SkippedCorrectly() + { + // Verifies the cursor arithmetic for fields between UseRadius and + // IconOverlay. This body sets several intermediate bits (Structure u16, + // MaxStructure u16, StackSize u16, Burden u16) plus IconOverlay. + // If any skip is wrong, the parser reads the wrong bytes as the + // overlay id or throws, both of which the assert would catch. + // 0x00000400 = Structure (u16) + // 0x00000800 = MaxStructure (u16) + // 0x00001000 = StackSize (u16) + // 0x00200000 = Burden (u16) + // 0x40000000 = IconOverlay + const uint flags = 0x40000000u | 0x00200000u | 0x00001000u | 0x00000800u | 0x00000400u; + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x5000000Du, + name: "FancySword", + itemType: (uint)ItemType.MeleeWeapon, + weenieFlags: flags, + structure: 50, + maxStructure: 100, + stackSize: 1, + burden: 300, + iconOverlayId: 0x2222u); // → 0x06002222 + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(0x06002222u, parsed!.Value.IconOverlayId); + } + + [Fact] + public void TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured() + { + // Verifies that the variable-length RestrictionDB skip (weenieFlags bit + // 0x04000000) lands the cursor at the correct position so that + // IconOverlay (bit 0x40000000) immediately after it is still captured. + // + // Wire layout per ACE RestrictionDB (16 bytes, zero entries): + // Version(u32) + OpenStatus(u32) + MonarchId(u32) = 12 bytes + // count(u16) + numBuckets(u16) = 4 bytes + // entries: count(0) × 8 = 0 bytes + // total = 16 bytes + // + // Also exercises the IncludesSecondHeader / IconUnderlay path so that + // all three optional-tail branches that follow HouseOwner are covered + // in a single cursor sweep. + // + // weenieFlags: 0x04000000 (HouseRestrictions) | 0x40000000 (IconOverlay) + // objectDescriptionFlags: 0x04000000 (IncludesSecondHeader → weenieFlags2 present) + // weenieFlags2: 0x00000001 (IconUnderlay) + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x5000000Eu, + name: "HousePortal", + itemType: (uint)ItemType.Portal, + objectDescriptionFlags: 0x04000000u, // IncludesSecondHeader + weenieFlags: 0x04000000u | 0x40000000u, // HouseRestrictions + IconOverlay + weenieFlags2: 0x00000001u, // IconUnderlay + iconOverlayId: 0x3333u, // → 0x06003333 + iconUnderlayId: 0x4444u); // → 0x06004444 + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(0x06003333u, parsed!.Value.IconOverlayId); + Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId); + } + + // ----------------------------------------------------------------------- + // D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured + // instead of skipped. Drives the icon's effect-overlay recolor. + // ----------------------------------------------------------------------- + + [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); + } + + [Fact] + public void TryParse_WeenieClassId_Surfaced() + { + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000020u, name: "Sword", itemType: (uint)ItemType.MeleeWeapon, + weenieClassId: 0xABCDu); + var parsed = CreateObject.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal(0xABCDu, parsed!.Value.WeenieClassId); + } + + [Fact] + public void TryParse_FullItemFields_Captured() + { + uint flags = + 0x00000008u | 0x00001000u | 0x00002000u | 0x00200000u | + 0x00000002u | 0x00000004u | 0x00004000u | 0x00008000u | + 0x00010000u | 0x00020000u | 0x00040000u | 0x00000400u | + 0x00000800u | 0x01000000u; + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000021u, name: "Pack", itemType: (uint)ItemType.Container, + weenieFlags: flags, + value: 250u, stackSize: 7, maxStackSize: 100u, burden: 42, + itemsCapacity: 24, containersCapacity: 7, + container: 0x50000099u, wielder: 0x5000009Au, + validLocations: 0x02000000u, currentWieldedLocation: 0x02000000u, + priority: 8u, structure: 5, maxStructure: 10, workmanship: 7.5f); + var parsed = CreateObject.TryParse(body); + Assert.NotNull(parsed); + var p = parsed!.Value; + Assert.Equal(250, p.Value); + Assert.Equal(7, p.StackSize); + Assert.Equal(100, p.StackSizeMax); + Assert.Equal(42, p.Burden); + Assert.Equal(24, p.ItemsCapacity); + Assert.Equal(7, p.ContainersCapacity); + Assert.Equal(0x50000099u, p.ContainerId); + Assert.Equal(0x5000009Au, p.WielderId); + Assert.Equal(0x02000000u, p.ValidLocations); + Assert.Equal(0x02000000u, p.CurrentWieldedLocation); + Assert.Equal(8u, p.Priority); + Assert.Equal(5, p.Structure); + Assert.Equal(10, p.MaxStructure); + Assert.Equal(7.5f, p.Workmanship); + } + + [Fact] + public void TryParse_MidTailFieldsSet_StillReachesIconOverlay() + { + uint flags = 0x00001000u | 0x00004000u | 0x40000000u; + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000022u, name: "Ring", itemType: (uint)ItemType.Jewelry, + weenieFlags: flags, stackSize: 1, container: 0x500000F0u, + iconOverlayId: 0x4321u); + var parsed = CreateObject.TryParse(body); + Assert.NotNull(parsed); + Assert.Equal(0x06004321u, parsed!.Value.IconOverlayId); + Assert.Equal(0x500000F0u, parsed.Value.ContainerId); + } + private static byte[] BuildMinimalCreateObjectWithWeenieHeader( uint guid, string name, @@ -163,9 +445,29 @@ public sealed class CreateObjectTests uint physicsState = 0, uint objectDescriptionFlags = 0, uint weenieFlags = 0, + uint weenieFlags2 = 0, + uint iconId = 0, + uint uiEffects = 0, uint? value = null, uint? useability = null, - float? useRadius = null) + float? useRadius = null, + uint iconOverlayId = 0, + uint iconUnderlayId = 0, + // intermediate fields for cursor-arithmetic test + ushort? structure = null, + ushort? maxStructure = null, + ushort? stackSize = null, + ushort? burden = null, + uint weenieClassId = 0x1234, + uint? maxStackSize = null, + byte? itemsCapacity = null, + byte? containersCapacity = null, + uint? container = null, + uint? wielder = null, + uint? validLocations = null, + uint? currentWieldedLocation = null, + uint? priority = null, + float? workmanship = null) { var bytes = new List(); WriteU32(bytes, CreateObject.Opcode); @@ -187,25 +489,73 @@ public sealed class CreateObjectTests // Fixed WeenieHeader prefix per ACE SerializeCreateObject. WriteU32(bytes, weenieFlags); // weenieFlags WriteString16L(bytes, name); - WritePackedDword(bytes, 0x1234); // WeenieClassId - WritePackedDword(bytes, 0); // IconId via known-type writer + WritePackedDword(bytes, weenieClassId); // WeenieClassId + WritePackedDword(bytes, iconId); // IconId via known-type writer (prefix stripped by ACE writer) WriteU32(bytes, itemType); WriteU32(bytes, objectDescriptionFlags); Align4(bytes); - // Optional WeenieHeader tail (2026-05-15) — same order as ACE - // WorldObject_Networking.cs:87-114. Each field is written only when + // IncludesSecondHeader → weenieFlags2 written immediately after the align, + // before any other optional tail field (ACE WorldObject_Networking.cs:84-85). + if ((objectDescriptionFlags & 0x04000000u) != 0) + WriteU32(bytes, weenieFlags2); + + // Optional WeenieHeader tail — same order as ACE + // WorldObject_Networking.cs:87-206. Each field is written only when // its weenieFlags bit is set, matching the parser's walker exactly. - if ((weenieFlags & 0x00000008u) != 0) // Value u32 - WriteU32(bytes, value ?? 0u); - if ((weenieFlags & 0x00000010u) != 0) // Useability u32 - WriteU32(bytes, useability ?? 0u); - if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 + // Fields not parameterized above default to 0. + if ((weenieFlags & 0x00000001u) != 0) { /* PluralName — not parameterized */ } + if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8 + if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8 + if ((weenieFlags & 0x00000100u) != 0) WriteU16(bytes, 0); // AmmoType u16 + if ((weenieFlags & 0x00000008u) != 0) WriteU32(bytes, value ?? 0u); // Value u32 + if ((weenieFlags & 0x00000010u) != 0) WriteU32(bytes, useability ?? 0u); // Usable u32 + if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 { Span tmp = stackalloc byte[4]; BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f); bytes.AddRange(tmp.ToArray()); } + if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32 + if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32 + if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte + if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16 + if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16 + if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16 + if ((weenieFlags & 0x00002000u) != 0) WriteU16(bytes, (ushort)(maxStackSize ?? 0)); // MaxStackSize u16 + if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, container ?? 0); // Container u32 + if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, wielder ?? 0); // Wielder u32 + if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32 + if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32 + if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32 + if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8 + if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8 + if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16 + if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 + { + Span tmp = stackalloc byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(tmp, workmanship ?? 0f); + bytes.AddRange(tmp.ToArray()); + } + if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16 + if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, 0); // Spell u16 + if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32 + // HouseRestrictions (0x04000000): not parameterized (zero entries). + // Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries. + // Zero entries → 16 bytes total. + if ((weenieFlags & 0x04000000u) != 0) + { + WriteU32(bytes, 0x10000002u); // Version + WriteU32(bytes, 0u); // OpenStatus + WriteU32(bytes, 0u); // MonarchId + WriteU16(bytes, 0); // count + WriteU16(bytes, 768); // numBuckets (retail constant) + } + if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, 0); // HookItemTypes u32 + if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32 + if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, 0); // HookType u16 + if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay + if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay return bytes.ToArray(); } diff --git a/tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs new file mode 100644 index 00000000..bda5555a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs @@ -0,0 +1,36 @@ +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])); +} diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs new file mode 100644 index 00000000..9c3d099a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -0,0 +1,97 @@ +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests; + +/// +/// D.5.4 Task 7 — ObjectTableWiring. +/// +/// Integration test is omitted: WorldSession.EntitySpawned has no internal +/// test seam to fire it without a real Tick + packet bytes, so subscription +/// correctness is covered by build (type-checks) + the live run. Only the +/// pure mapping (ToWeenieData) is unit-tested here. +/// +public sealed class ObjectTableWiringTests +{ + [Fact] + public void ToWeenieData_CopiesFieldsFromSpawn() + { + // Every EntitySpawn item field is set to a DISTINCT recognisable value so + // a positional transposition in ObjectTableWiring.ToWeenieData would trip + // at least one Assert. All 22 WeenieData fields are verified below. + var spawn = new WorldSession.EntitySpawn( + Guid: 0x00000600u, + Position: null, + SetupTableId: null, + AnimPartChanges: System.Array.Empty(), + TextureChanges: System.Array.Empty(), + SubPalettes: System.Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "Iron Sword", + ItemType: (uint)ItemType.MeleeWeapon, + MotionState: null, + MotionTableId: null) + with + { + WeenieClassId = 0x00001001u, + IconId = 0x06001111u, + IconOverlayId = 0x06002222u, + IconUnderlayId = 0x06003333u, + UiEffects = 0x00000004u, + Value = 7, + StackSize = 1, + StackSizeMax = 1, + Burden = 300, + ContainerId = 0x000000C9u, + WielderId = 0x000000DAu, + ValidLocations = 0x00000012u, // MeleeWeapon wield mask + CurrentWieldedLocation = 0x00000002u, // right-hand + Priority = 0x00000005u, + ItemsCapacity = 0, + ContainersCapacity = 0, + Structure = 80, + MaxStructure = 100, + Workmanship = 4.5f, + }; + + var d = ObjectTableWiring.ToWeenieData(spawn); + + // --- identity --- + Assert.Equal(0x00000600u, d.Guid); + Assert.Equal("Iron Sword", d.Name); + Assert.Equal(ItemType.MeleeWeapon, d.Type); + + // --- weenie / icon --- + Assert.Equal(0x00001001u, d.WeenieClassId); + Assert.Equal(0x06001111u, d.IconId); + Assert.Equal(0x06002222u, d.IconOverlayId); + Assert.Equal(0x06003333u, d.IconUnderlayId); + Assert.Equal(0x00000004u, d.Effects); + + // --- quantity / economy --- + Assert.Equal(7, d.Value); + Assert.Equal(1, d.StackSize); + Assert.Equal(1, d.StackSizeMax); + Assert.Equal(300, d.Burden); + + // --- container / wielder --- + Assert.Equal(0x000000C9u, d.ContainerId); + Assert.Equal(0x000000DAu, d.WielderId); + + // --- equip masks --- + Assert.Equal(0x00000012u, d.ValidLocations); + Assert.Equal(0x00000002u, d.CurrentWieldedLocation); + Assert.Equal(0x00000005u, d.Priority); + + // --- capacity --- + Assert.Equal(0, d.ItemsCapacity); + Assert.Equal(0, d.ContainersCapacity); + + // --- durability --- + Assert.Equal(80, d.Structure); + Assert.Equal(100, d.MaxStructure); + Assert.Equal(4.5f, d.Workmanship); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs index 0bdd0bec..8ebf8ddb 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -74,4 +74,18 @@ public sealed class WorldSessionCombatTests Assert.NotNull(captured); Assert.Equal(AttackTargetRequest.BuildCancel(1), captured); } + + [Fact] + public void SendQueryHealth_UsesRetailQueryHealthBuilder() + { + // Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendQueryHealth(0x50000007u); + + Assert.NotNull(captured); + Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured); + } } diff --git a/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs new file mode 100644 index 00000000..1d3a7a41 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Lighting; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; +using DatSetup = DatReaderWriter.DBObjs.Setup; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without +/// guessing or a live launch: dump the RAW dat LightInfo.Falloff for every +/// static light in the Holtburg landblocks, via the EXACT production load path +/// (). The dat is the SAME file retail reads, so +/// these falloffs ARE what retail reads (modulo any load-time transform, settled +/// separately in the decomp). Output-only — no assertions; read the log. +/// +public sealed class HoltburgTorchFalloffProbeTests +{ + private readonly ITestOutputHelper _out; + public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Dump_Holtburg_StaticLight_Falloffs() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // The meeting hall sits in the Holtburg town landblocks. Sweep a small + // neighbourhood so we catch every entrance torch the streaming window + // would load around the player at the hall. + uint[] landblocks = + { + 0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u, + }; + + // Tally every distinct raw Falloff seen (the headline number). + var falloffTally = new SortedDictionary(); + int totalLights = 0; + + foreach (uint lb in landblocks) + { + uint infoId = (lb << 16) | 0xFFFEu; + var info = dats.Get(infoId); + if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; } + + int buildings = info.Buildings?.Count ?? 0; + int objects = info.Objects?.Count ?? 0; + _out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ==="); + + // Record building-shell origins so we can rank torches by proximity. + var shells = new List<(uint model, Vector3 pos)>(); + if (info.Buildings is not null) + { + foreach (var b in info.Buildings) + { + var o = b.Frame.Origin; + shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z))); + _out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}"); + } + } + + if (info.Objects is null) continue; + foreach (var stab in info.Objects) + { + // Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary — + // identical gate to GameWindow.cs:6399. + if ((stab.Id & 0xFF000000u) != 0x02000000u) continue; + var setup = dats.Get(stab.Id); + if (setup?.Lights is null || setup.Lights.Count == 0) continue; + + var loaded = LightInfoLoader.Load( + setup, + ownerId: 0, + entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z), + entityRotation: new Quaternion( + stab.Frame.Orientation.X, stab.Frame.Orientation.Y, + stab.Frame.Orientation.Z, stab.Frame.Orientation.W)); + + foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l))) + { + float rawFalloff = kvp.Value.Falloff; + totalLights++; + falloffTally.TryGetValue(rawFalloff, out int c); + falloffTally[rawFalloff] = c + 1; + + // Nearest building shell, for "is this an entrance torch on the hall?". + float nearest = float.MaxValue; + uint nearestModel = 0; + foreach (var (model, spos) in shells) + { + float dd = Vector3.Distance(ls.WorldPosition, spos); + if (dd < nearest) { nearest = dd; nearestModel = model; } + } + + _out.WriteLine( + $" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " + + $"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " + + $"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " + + $"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " + + $"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m"); + } + } + } + + _out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ==="); + foreach (var kv in falloffTally) + _out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}"); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs new file mode 100644 index 00000000..3d873cca --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -0,0 +1,336 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class ClientObjectTableTests +{ + private static ClientObject MakeItem(uint id, string name = "Widget") => + new ClientObject + { + ObjectId = id, + WeenieClassId = 1, + Name = name, + Type = ItemType.Misc, + StackSize = 1, + Burden = 10, + Value = 5, + }; + + [Fact] + public void AddOrUpdate_FiresAddedEvent() + { + var repo = new ClientObjectTable(); + ClientObject? added = null; + repo.ObjectAdded += i => added = i; + + var item = MakeItem(100); + repo.AddOrUpdate(item); + + Assert.Same(item, added); + Assert.Equal(1, repo.ObjectCount); + Assert.Same(item, repo.Get(100)); + } + + [Fact] + public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated() + { + var repo = new ClientObjectTable(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + int propUpdateCount = 0; + repo.ObjectUpdated += _ => propUpdateCount++; + + repo.AddOrUpdate(item); // second call is an update + Assert.Equal(1, propUpdateCount); + } + + [Fact] + public void MoveItem_UpdatesContainerAndFiresEvent() + { + var repo = new ClientObjectTable(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + uint seenOld = 999, seenNew = 999; + repo.ObjectMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; }; + + repo.MoveItem(100, 42, newSlot: 3); + + Assert.Equal(0u, seenOld); // was not in any container initially + Assert.Equal(42u, seenNew); + Assert.Equal(42u, item.ContainerId); + Assert.Equal(3, item.ContainerSlot); + } + + [Fact] + public void MoveItem_Nonexistent_ReturnsFalse() + { + var repo = new ClientObjectTable(); + Assert.False(repo.MoveItem(999, 42)); + } + + [Fact] + public void Remove_FiresEventAndRemoves() + { + var repo = new ClientObjectTable(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + ClientObject? removed = null; + repo.ObjectRemoved += i => removed = i; + + Assert.True(repo.Remove(100)); + Assert.Same(item, removed); + Assert.Null(repo.Get(100)); + } + + [Fact] + public void UpdateProperties_MergesIncomingBundle() + { + var repo = new ClientObjectTable(); + var item = MakeItem(100); + item.Properties.Ints[1] = 10; + repo.AddOrUpdate(item); + + var patch = new PropertyBundle(); + patch.Ints[2] = 20; // new + patch.Ints[1] = 15; // overrides + patch.Strings[100] = "desc"; + repo.UpdateProperties(100, patch); + + Assert.Equal(15, item.Properties.Ints[1]); + Assert.Equal(20, item.Properties.Ints[2]); + Assert.Equal("desc", item.Properties.Strings[100]); + } + + [Fact] + public void Clear_RemovesAllItems() + { + var repo = new ClientObjectTable(); + repo.AddOrUpdate(MakeItem(1)); + repo.AddOrUpdate(MakeItem(2)); + repo.AddOrUpdate(MakeItem(3)); + + repo.Clear(); + Assert.Equal(0, repo.ObjectCount); + } + + [Fact] + public void UpdateIntProperty_uiEffects_setsEffectsAndFires() + { + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000ABu }); + ClientObject? fired = null; + repo.ObjectUpdated += i => fired = i; + bool ok = repo.UpdateIntProperty(0x500000ABu, ClientObjectTable.UiEffectsPropertyId, value: 0x9); + Assert.True(ok); + Assert.Equal(0x9u, repo.Get(0x500000ABu)!.Effects); + Assert.Equal(0x9, repo.Get(0x500000ABu)!.Properties.Ints[ClientObjectTable.UiEffectsPropertyId]); + Assert.NotNull(fired); + } + + [Fact] + public void UpdateIntProperty_unknownItem_returnsFalse() + { + var repo = new ClientObjectTable(); + Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1)); + } + + [Fact] + public void UpdateIntProperty_uiEffectsClearedToZero_clearsEffects() + { + // The core "item with mana vs out of mana" promise: a draining item whose + // UiEffects clears to 0 must return to its base (un-tinted) icon. Guards + // against a future `if (value != 0)` regression on the unconditional assign. + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000ACu, Effects = 0x1u }); + repo.UpdateIntProperty(0x500000ACu, ClientObjectTable.UiEffectsPropertyId, value: 0x1); + Assert.Equal(0x1u, repo.Get(0x500000ACu)!.Effects); + repo.UpdateIntProperty(0x500000ACu, ClientObjectTable.UiEffectsPropertyId, value: 0); + Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects); + } + + [Fact] + public void ClientObject_NewFields_DefaultAndSettable() + { + var o = new ClientObject + { + ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7, + Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, + }; + o.WeenieClassId = 0xABCDu; // now settable + Assert.Equal(0x42u, o.WielderId); + Assert.Equal(24, o.ItemsCapacity); + Assert.Equal(7, o.ContainersCapacity); + Assert.Equal(8u, o.Priority); + Assert.Equal(5, o.Structure); + Assert.Equal(10, o.MaxStructure); + Assert.Equal(7.5f, o.Workmanship); + Assert.Equal(0xABCDu, o.WeenieClassId); + } + + [Fact] + public void WeenieData_Construct() + { + var d = new WeenieData(Guid: 1, Name: "x", Type: ItemType.Misc, WeenieClassId: 2, + IconId: 0x06001234u, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0, + Value: 5, StackSize: 1, StackSizeMax: 1, Burden: 10, + ContainerId: 0x99u, WielderId: null, ValidLocations: null, + CurrentWieldedLocation: null, Priority: null, + ItemsCapacity: null, ContainersCapacity: null, + Structure: null, MaxStructure: null, Workmanship: null); + Assert.Equal(0x99u, d.ContainerId); + } + + private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u, + string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0, + int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) => + new WeenieData(guid, name, type, wcid, icon, 0, 0, effects, + value, stack, StackSizeMax: 1, Burden: 10, ContainerId: container, + WielderId: null, ValidLocations: null, CurrentWieldedLocation: null, + Priority: null, ItemsCapacity: null, ContainersCapacity: null, + Structure: null, MaxStructure: null, Workmanship: null); + + [Fact] + public void Ingest_NewItemWithNoPriorStub_Creates_AndFiresAdded() // the Coldeve bug + { + var table = new ClientObjectTable(); + ClientObject? added = null; + table.ObjectAdded += o => added = o; + var obj = table.Ingest(FullWeenie(0x500000B0u)); + Assert.NotNull(added); + Assert.Equal(0x06001234u, table.Get(0x500000B0u)!.IconId); + Assert.Equal(0xABCDu, obj.WeenieClassId); + } + + [Fact] + public void Ingest_Existing_PatchesInPlace_PreservesPropertyBundle() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B1u)); + table.Get(0x500000B1u)!.Properties.Ints[999u] = 7; // simulate appraise + ClientObject? updated = null; + table.ObjectUpdated += o => updated = o; + table.Ingest(FullWeenie(0x500000B1u, name: "Renamed")); + Assert.NotNull(updated); + Assert.Equal("Renamed", table.Get(0x500000B1u)!.Name); + Assert.Equal(7, table.Get(0x500000B1u)!.Properties.Ints[999u]); // NOT clobbered + } + + [Fact] + public void Ingest_AbsentNullableField_DoesNotClobber() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B2u, value: 100)); + var noValue = FullWeenie(0x500000B2u) with { Value = null }; + table.Ingest(noValue); + Assert.Equal(100, table.Get(0x500000B2u)!.Value); + } + + [Fact] + public void Ingest_Effects_AssignedUnconditionally_ClearsToZero() // D.5.2 contract + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B3u, effects: 0x1u)); + Assert.Equal(0x1u, table.Get(0x500000B3u)!.Effects); + table.Ingest(FullWeenie(0x500000B3u, effects: 0u)); + Assert.Equal(0u, table.Get(0x500000B3u)!.Effects); + } + + [Fact] + public void RecordMembership_CreatesEntry_AndSetsEquip() + { + var table = new ClientObjectTable(); + table.RecordMembership(0x500000B4u, equip: EquipMask.MeleeWeapon); + var o = table.Get(0x500000B4u); + Assert.NotNull(o); + Assert.Equal(EquipMask.MeleeWeapon, o!.CurrentlyEquippedLocation); + Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it + } + + [Fact] + public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject + { + var table = new ClientObjectTable(); + table.RecordMembership(0x500000B5u); + table.Ingest(FullWeenie(0x500000B5u)); + Assert.Equal(1, table.ObjectCount); + Assert.Equal(0x06001234u, table.Get(0x500000B5u)!.IconId); + } + + [Fact] + public void Membership_AfterIngest_NoDuplicate_PreservesData() // out-of-order: CreateObject then PD + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000B6u)); // CreateObject first (ground/vendor item) + table.RecordMembership(0x500000B6u, equip: EquipMask.MeleeWeapon); // then PD manifest + Assert.Equal(1, table.ObjectCount); + Assert.Equal(0x06001234u, table.Get(0x500000B6u)!.IconId); // data NOT clobbered by membership + Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation); + } + + [Fact] + public void ContainerIndex_IngestThenContents_OrderedBySlot() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x510u, container: 0xC0u)); + table.Ingest(FullWeenie(0x511u, container: 0xC0u)); + table.MoveItem(0x510u, 0xC0u, newSlot: 1); + table.MoveItem(0x511u, 0xC0u, newSlot: 0); + Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u)); + } + + [Fact] + public void ContainerIndex_Move_ReparentsBetweenContainers() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x520u, container: 0xC1u)); + table.MoveItem(0x520u, 0xC2u, newSlot: 0); + Assert.Empty(table.GetContents(0xC1u)); + Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u)); + } + + [Fact] + public void ContainerIndex_Remove_DropsFromContents() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x530u, container: 0xC3u)); + table.Remove(0x530u); + Assert.Empty(table.GetContents(0xC3u)); + } + + [Fact] + public void GetContents_UnknownContainer_Empty() + { + var table = new ClientObjectTable(); + Assert.Empty(table.GetContents(0xDEADu)); + } + + [Fact] + public void Ingest_CreatureTyped_ResolvesNameAndTypeViaGet() // spec §8: selection/describe creature resolution after _liveEntityInfoByGuid retirement + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x560u, name: "Drudge", type: ItemType.Creature)); + var o = table.Get(0x560u); + Assert.NotNull(o); + Assert.Equal("Drudge", o!.Name); // LiveName(guid) reads this + Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting + } + + [Fact] + public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x540u, container: 0xC4u)); + table.Ingest(FullWeenie(0x541u, container: 0xC4u)); + table.MoveItem(0x540u, 0xC4u, newSlot: 0); + table.MoveItem(0x541u, 0xC4u, newSlot: 1); + Assert.Equal(new[] { 0x540u, 0x541u }, table.GetContents(0xC4u)); + // move 0x540 to a later slot WITHIN THE SAME container — order must flip + table.MoveItem(0x540u, 0xC4u, newSlot: 5); + Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u)); + Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move + } +} diff --git a/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs deleted file mode 100644 index 79fe2ef7..00000000 --- a/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public sealed class ItemRepositoryTests -{ - private static ItemInstance MakeItem(uint id, string name = "Widget") => - new ItemInstance - { - ObjectId = id, - WeenieClassId = 1, - Name = name, - Type = ItemType.Misc, - StackSize = 1, - Burden = 10, - Value = 5, - }; - - [Fact] - public void AddOrUpdate_FiresAddedEvent() - { - var repo = new ItemRepository(); - ItemInstance? added = null; - repo.ItemAdded += i => added = i; - - var item = MakeItem(100); - repo.AddOrUpdate(item); - - Assert.Same(item, added); - Assert.Equal(1, repo.ItemCount); - Assert.Same(item, repo.GetItem(100)); - } - - [Fact] - public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated() - { - var repo = new ItemRepository(); - var item = MakeItem(100); - repo.AddOrUpdate(item); - - int propUpdateCount = 0; - repo.ItemPropertiesUpdated += _ => propUpdateCount++; - - repo.AddOrUpdate(item); // second call is an update - Assert.Equal(1, propUpdateCount); - } - - [Fact] - public void MoveItem_UpdatesContainerAndFiresEvent() - { - var repo = new ItemRepository(); - var item = MakeItem(100); - repo.AddOrUpdate(item); - - uint seenOld = 999, seenNew = 999; - repo.ItemMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; }; - - repo.MoveItem(100, 42, newSlot: 3); - - Assert.Equal(0u, seenOld); // was not in any container initially - Assert.Equal(42u, seenNew); - Assert.Equal(42u, item.ContainerId); - Assert.Equal(3, item.ContainerSlot); - } - - [Fact] - public void MoveItem_Nonexistent_ReturnsFalse() - { - var repo = new ItemRepository(); - Assert.False(repo.MoveItem(999, 42)); - } - - [Fact] - public void Remove_FiresEventAndRemoves() - { - var repo = new ItemRepository(); - var item = MakeItem(100); - repo.AddOrUpdate(item); - - ItemInstance? removed = null; - repo.ItemRemoved += i => removed = i; - - Assert.True(repo.Remove(100)); - Assert.Same(item, removed); - Assert.Null(repo.GetItem(100)); - } - - [Fact] - public void UpdateProperties_MergesIncomingBundle() - { - var repo = new ItemRepository(); - var item = MakeItem(100); - item.Properties.Ints[1] = 10; - repo.AddOrUpdate(item); - - var patch = new PropertyBundle(); - patch.Ints[2] = 20; // new - patch.Ints[1] = 15; // overrides - patch.Strings[100] = "desc"; - repo.UpdateProperties(100, patch); - - Assert.Equal(15, item.Properties.Ints[1]); - Assert.Equal(20, item.Properties.Ints[2]); - Assert.Equal("desc", item.Properties.Strings[100]); - } - - [Fact] - public void Clear_RemovesAllItems() - { - var repo = new ItemRepository(); - repo.AddOrUpdate(MakeItem(1)); - repo.AddOrUpdate(MakeItem(2)); - repo.AddOrUpdate(MakeItem(3)); - - repo.Clear(); - Assert.Equal(0, repo.ItemCount); - } -} diff --git a/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs b/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs new file mode 100644 index 00000000..174c4c41 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs @@ -0,0 +1,45 @@ +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(); + + int count = GlobalLightPacker.Pack(new[] { light }, ref buffer); + + Assert.Equal(1, count); + Assert.True(buffer.Length >= 16); + Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]); + Assert.Equal((float)(int)LightKind.Point, buffer[3]); + Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]); + Assert.Equal(5.2f, buffer[7]); + Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]); + Assert.Equal(100f, buffer[11]); + Assert.Equal(0f, buffer[12]); + } + + [Fact] + public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot() + { + float[] buffer = System.Array.Empty(); + int count = GlobalLightPacker.Pack(null, ref buffer); + Assert.Equal(0, count); + Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight); + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs new file mode 100644 index 00000000..ab3c0803 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +/// +/// 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. +/// +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) + { + var vtx = Vector3.Zero; + var normal = Vector3.UnitX; + var torch = OrangeTorch(new Vector3(dist, 0f, 0f)); + + var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch }); + + Assert.InRange(c.X, 0f, 1f); + Assert.InRange(c.Y, 0f, 1f); + Assert.InRange(c.Z, 0f, 1f); + 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)); + var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch }); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void ManyOverlappingIntenseTorches_StillClampToOne() + { + var lights = new List(); + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs new file mode 100644 index 00000000..be082b37 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +/// +/// Conformance tests for the per-vertex static-light burn-in +/// (), ported from retail calc_point_light +/// (0x0059c8b0). Golden values are hand-derived from the decompiled equation: +/// wrap = (1/1.5)·(N·D + 0.5·dist); norm = distsq>1 ? distsq·dist : dist; +/// scale = (1 − dist/Range)·intensity·(wrap/norm); contrib = min(scale·color, color). +/// +public sealed class LightBakeTests +{ + private static LightSource Torch(Vector3 pos, float intensity = 100f, float range = 10f) + => new LightSource + { + Kind = LightKind.Point, + WorldPosition = pos, + ColorLinear = Vector3.One, + Intensity = intensity, + Range = range, + IsLit = true, + }; + + [Fact] + public void NearTorch_FacingIt_SaturatesToColor() + { + // Vertex at origin facing up (+Z); torch 2 m above. + // dist=2, distsq=4, wrap=(1/1.5)(2+1)=2, norm=4·2=8, + // scale=(1-0.2)·100·(2/8)=20 → min(20·1,1)=1 per channel. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 2))); + Assert.Equal(1f, c.X, 4); + Assert.Equal(1f, c.Y, 4); + Assert.Equal(1f, c.Z, 4); + } + + [Fact] + public void FarTorch_FallsOffSmoothly() + { + // Torch 8 m above (still within Range 10). scale=(1-0.8)·100·(8/512)=0.3125. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 8))); + Assert.Equal(0.3125f, c.X, 4); + Assert.Equal(0.3125f, c.Y, 4); + Assert.Equal(0.3125f, c.Z, 4); + } + + [Fact] + public void OutOfRange_ContributesNothing() + { + // Torch 11 m above, Range 10 → dist >= falloff_eff, skipped. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 11))); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void FacingAway_BeyondWrap_ContributesNothing() + { + // Normal points away (−Z) from a torch above: N·D=−2, wrap=(1/1.5)(−2+1)<0. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, -1), Torch(new Vector3(0, 0, 2))); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void HalfLambertWrap_LightsSurfaceAngledPast90Degrees() + { + // Normal at ~100° from the light direction still gets light (Lambert would not). + // Light straight above (+Z 2 m); normal tilted to (sin100°, 0, cos100°). + double t = 100.0 * Math.PI / 180.0; + var n = new Vector3((float)Math.Sin(t), 0, (float)Math.Cos(t)); // cos100° < 0 + var c = LightBake.PointContribution(Vector3.Zero, n, Torch(new Vector3(0, 0, 2))); + Assert.True(c.X > 0f, "half-Lambert wrap should light a surface angled past 90°"); + } + + [Fact] + public void ComputeVertexColor_SumsLightsAndClampsToOne() + { + // Two saturating torches → sum clamps to 1, never overflows. + var lights = new[] + { + Torch(new Vector3(0, 0, 2)), + Torch(new Vector3(0, 0, 2)), + }; + var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); + Assert.Equal(1f, c.X, 4); + Assert.Equal(1f, c.Y, 4); + Assert.Equal(1f, c.Z, 4); + } + + [Fact] + public void ComputeVertexColor_SkipsDirectionalAndUnlit() + { + var lights = new[] + { + new LightSource { Kind = LightKind.Directional, WorldPosition = new Vector3(0,0,2), + ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = true }, + new LightSource { Kind = LightKind.Point, WorldPosition = new Vector3(0,0,2), + ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = false }, + }; + var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); + Assert.Equal(Vector3.Zero, c); + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 9df68a2b..264c498c 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -60,21 +60,29 @@ public sealed class LightManagerTests } [Fact] - public void Tick_DropsLightsOutsideRangeWithSlack() + public void Tick_SelectsByDistance_RegardlessOfViewerRange() { + // Retail D3D-style: candidacy is distance-only (the nearest 8). A torch + // lights its OWN surfaces — the shader applies the hard `d < range` cutoff + // PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing + // outside the range of is still selected; it lights the wall it sits on. + // Replaces the old viewer-range candidacy filter that suppressed it, which + // left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2 + // ambient — the "dungeon lighting off" report (#133 A7). var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range + mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range mgr.Tick(viewerWorldPos: Vector3.Zero); - Assert.Equal(0, mgr.ActiveCount); + Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface } [Fact] - public void Tick_IncludesLightsNearRangeEdge_WithSlack() + public void Tick_IncludesNearbyLight() { var mgr = new LightManager(); - // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included. + // A nearby point light is selected (distance-only candidacy; the shader + // applies the per-fragment range cutoff). mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f)); mgr.Tick(viewerWorldPos: Vector3.Zero); @@ -136,4 +144,116 @@ public sealed class LightManagerTests mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } + + // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── + + [Fact] + public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() + { + var mgr = new LightManager(); + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in + mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out + mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Single(mgr.PointSnapshot); + Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); + } + + [Fact] + public void BuildPointLightSnapshot_IndexStable_InBudget() + { + var mgr = new LightManager(); + // Registration order preserved when under MaxGlobalLights (no sort). + mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(2, mgr.PointSnapshot.Count); + Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered + Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); + } + + [Fact] + public void SelectForObject_EmptySnapshot_ReturnsZero() + { + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_InRange_Selected() + { + var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(1, n); + Assert.Equal(0, idx[0]); + } + + [Fact] + public void SelectForObject_OutOfRange_Excluded() + { + // dist 10, range 5, radius 0 → 10 >= 5 → excluded. + var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_ObjectRadiusExtendsReach() + { + // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere + // overlaps (7 < 5+3). The whole object catches the light — retail uses the + // object's bounding sphere, not its centre point. + var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + + Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); + Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); + } + + [Fact] + public void SelectForObject_MoreThan8_KeepsNearest8() + { + // 10 candidate lights all in range; expect the 8 nearest the object centre, + // ascending by distance, with the two farthest dropped. + var snapshot = new LightSource[10]; + for (int i = 0; i < 10; i++) + snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range + + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + + Assert.Equal(8, n); + // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest + // (indices 8,9 / dist 9,10) are evicted. + for (int k = 0; k < 8; k++) + Assert.Equal(k, idx[k]); + } + + [Fact] + public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() + { + // Same snapshot, same object centre → identical selection regardless of + // where any "camera" is (the method takes no camera). This is the property + // that kills the "lights up as I approach" popping. + var snapshot = new[] + { + MakePoint(new Vector3(2, 0, 0), range: 10f), + MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 + }; + Span a = stackalloc int[8]; + Span b = stackalloc int[8]; + int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); + int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); + + Assert.Equal(1, na); + Assert.Equal(na, nb); + Assert.Equal(a[0], b[0]); + } } diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs index c3884a66..676155bf 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -93,7 +93,7 @@ public sealed class LightInfoLoaderTests var light = result[0]; Assert.Equal(LightKind.Point, light.Kind); Assert.Equal(77u, light.OwnerId); - Assert.Equal(8f, light.Range); + Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24) Assert.Equal(0.8f, light.Intensity); Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f); diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs index 54dc9c28..887bd340 100644 --- a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs @@ -179,4 +179,89 @@ public class GfxObjDegradeResolverTests Assert.Equal(baseId, resolvedId); Assert.Null(resolvedGfx); } + + // ── #136: editor-only placement marker detection ────────────────────────── + + /// + /// The #136 dungeon "cone": its degrade table's slot 0 is visible ONLY at distance 0 + /// (MaxDist=0) and the table degrades to GfxObj id 0 (= nothing) at real distance. + /// Retail's distance degrade never draws it in the live client; we must skip it. + /// + [Fact] + public void IsRuntimeHiddenMarker_EditorMarkerDegradingToNothing_True() + { + const uint markerGfx = 0x010028CAu; + const uint degradeId = 0x11000118u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = markerGfx, MaxDist = 0f }, + new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [markerGfx] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.True(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), markerGfx)); + } + + /// A real LOD object — slot 0 visible out to a real distance (MaxDist>0) — + /// is NOT a marker, even though it degrades further. + [Fact] + public void IsRuntimeHiddenMarker_NormalLodObject_False() + { + const uint baseId = 0x01000055u; + const uint degradeId = 0x110006D0u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = 0x01001795u, MaxDist = 25f }, + new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); + } + + /// No degrade table at all → not a marker. + [Fact] + public void IsRuntimeHiddenMarker_NoDegradeTable_False() + { + const uint baseId = 0x01001212u; + var gfx = new GfxObj { Flags = 0, DIDDegrade = 0 }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), _ => null, baseId)); + } + + /// slot 0 is editor-only (MaxDist=0) but degrades to a REAL mesh (no id-0 + /// entry) — a genuine close-only LOD, not an invisible marker. Do NOT skip. + [Fact] + public void IsRuntimeHiddenMarker_EditorSlotButDegradesToRealMesh_False() + { + const uint baseId = 0x01002000u; + const uint degradeId = 0x11002000u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = baseId, MaxDist = 0f }, + new GfxObjInfo { Id = 0x01002001u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); + } } diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs new file mode 100644 index 00000000..e429f100 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #133 (Bug A) — the validated-claim placement branch of +/// must return the VALIDATED claim's own +/// full cell id, NOT lbPrefix | (cellId & 0xFFFF). +/// +/// +/// lbPrefix is found by scanning resident landblocks for one whose +/// [0,192) local bounds contain the candidate XY. A dungeon EnvCell's +/// local Y can be NEGATIVE relative to its own landblock (the live capture: +/// server teleport to dungeon cell 0x00070143 at local (70,-60,0.01)). +/// The dungeon landblock fails the localY >= 0 bounds test, so the loop +/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock +/// whose world bounds happen to contain the same XY) and sets +/// lbPrefix = 0xA9B30000. The old code then returned +/// 0xA9B30000 | 0x0143 = 0xA9B30143, re-stamping the validated dungeon +/// claim with the wrong landblock — the client mis-resolved the player into +/// Holtburg and spammed ACE with rejected moves +/// (movement pre-validation failed from 00070143 to A9B30143). +/// +/// +/// +/// The validated claim's prefix is authoritative; a position falling in a +/// neighbouring resident landblock must not re-stamp it. This test reproduces +/// the exact geometry of the capture (dungeon claim in landblock 0x0007, +/// candidate XY also inside resident Holtburg 0xA9B3) and asserts the +/// returned cell keeps its 0x0007 prefix. +/// +/// +public class Issue133DungeonTeleportPrefixTests +{ + private const uint DungeonLandblock = 0x00070000u; + private const uint DungeonCellId = 0x00070143u; // indoor (low 0x0143 ≥ 0x0100) + private const uint HoltburgLandblock = 0xA9B30000u; // a neighbouring resident block + + // The capture: dungeon cell 0x00070143 at dungeon-local (70, -60, 0.01). + // We place the Holtburg block at world origin so its [0,192) bounds contain + // the candidate XY, and the dungeon block at world Y-offset 130 so the SAME + // world XY lands at dungeon-local Y = 70 - 130 = -60 (the captured negative). + private static readonly Vector3 SpawnPos = new(70f, 70f, 0.01f); + + [Fact] + public void ValidatedDungeonClaim_KeepsItsLandblockPrefix_NotTheNeighbour() + { + var engine = BuildEngine(); + + // Zero delta = the snap shape (teleport arrival). cellId is the dungeon + // claim; the candidate XY also falls inside the resident Holtburg block. + var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f); + + Assert.True(result.IsOnGround); + // The validated claim's prefix is authoritative — high word stays 0x0007, + // NOT re-stamped to the neighbouring Holtburg 0xA9B3. + Assert.Equal(DungeonCellId, result.CellId); + Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u); + } + + // ── fixture ────────────────────────────────────────────────────────────── + + private static PhysicsEngine BuildEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition + // validates the claim (returns it with found=true). Its Resolved set has + // one walkable floor polygon at z=0 under the spawn XY so the #111 + // validated-claim branch grounds onto it. + cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell()); + + // Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the + // candidate XY (70,70). This is the block the lbPrefix loop wrongly matched. + engine.AddLandblock( + landblockId: HoltburgLandblock, + terrain: FlatTerrain(), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + // The dungeon's own landblock, offset so the candidate XY produces a + // NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds + // test, which is exactly why the old code fell through to the Holtburg + // prefix. Registered so the scenario is faithful (a resident dungeon block + // whose local bounds don't cover the EnvCell's negative-Y position). + engine.AddLandblock( + landblockId: DungeonLandblock, + terrain: FlatTerrain(), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 130f); + + return engine; + } + + /// Flat 81-vertex stub terrain (all zero heights). + private static TerrainSurface FlatTerrain() => new(new byte[81], new float[256]); + + private static CellPhysics MakeDungeonCell() + { + // One floor polygon: a 200×200 square at z=0 centred so it covers the + // spawn XY. Normal (0,0,1) → normal.Z = 1 ≥ FloorZ (0.6642) → walkable. + // Identity transform: cell-local == world, so the plane d = 0 (z + d = 0). + var floor = new ResolvedPolygon + { + Vertices = new[] + { + new Vector3(-100f, -100f, 0f), + new Vector3( 200f, -100f, 0f), + new Vector3( 200f, 200f, 0f), + new Vector3(-100f, 200f, 0f), + }, + Plane = new Plane(new Vector3(0f, 0f, 1f), 0f), + NumPoints = 4, + SidesType = CullMode.None, + }; + + return new CellPhysics + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [0] = floor }, + // Leaf root → point_in_cell true for any point → AdjustPosition + // validates the claim (found=true, cell unchanged). + CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(), + }; + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 2fdafc97..da508aab 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -30,6 +30,12 @@ public class PluginLoaderTests public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); + public IUiRegistry Ui { get; } = new StubUiRegistry(); + } + + private sealed class StubUiRegistry : IUiRegistry + { + public void AddMarkupPanel(string markupPath, object binding) { } } private sealed class StubLogger : IPluginLogger diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs new file mode 100644 index 00000000..522a4d07 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Streaming; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +/// +/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent +/// landblocks (ACE LandblockManager.GetAdjacentIDs returns empty for a +/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window +/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player +/// is inside a sealed dungeon cell, Tick(insideDungeon: true) collapses +/// streaming to the single dungeon landblock and unloads the neighbors. +/// +public class StreamingControllerDungeonGateTests +{ + private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + + private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock( + Encode(x, y), + Heightmap: null!, + Entities: Array.Empty()); + + private sealed record Harness( + StreamingController Ctrl, + List<(uint Id, LandblockStreamJobKind Kind)> Loads, + List Unloads, + Func ClearCalls, + GpuWorldState State); + + private static Harness Make() + { + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + int clearCalls = 0; + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 4, + farRadius: 12, + clearPendingLoads: () => clearCalls++); + return new Harness(ctrl, loads, unloads, () => clearCalls, state); + } + + [Fact] + public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter() + { + var h = Make(); + uint center = Encode(0, 7); + h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock + h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon + h.State.AddLandblock(MakeLb(1, 7)); // another neighbor + + h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); + + Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled + Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded + Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded + Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept + Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload + } + + [Fact] + public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad() + { + var h = Make(); // empty state — the dungeon landblock isn't resident yet + + h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); + + Assert.Equal(1, h.ClearCalls()); + Assert.Contains(h.Loads, l => l.Id == Encode(0, 7) + && l.Kind == LandblockStreamJobKind.LoadNear); + } + + [Fact] + public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge + h.Unloads.Clear(); + + // A Load the worker had already dequeued before ClearLoads now completes. + h.State.AddLandblock(MakeLb(0, 8)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep + + Assert.Contains(Encode(0, 8), h.Unloads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); + } + + [Fact] + public void StayingCollapsed_DoesNotReClearOrReloadCenter() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1) + h.Loads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed + + Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge + Assert.Empty(h.Loads); // no spurious center reloads + } + + [Fact] + public void Collapsed_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand() + { + // Regression: the live run broke because a dungeon cell's negative local-Y + // makes the position-derived observer landblock land one row off (0,7→0,6). + // When CurrCell flickers null mid-frame, GameWindow stops overriding to the + // cell landblock and passes that adjacent (0,6). The Chebyshev>1 guard must + // treat that as a flicker and HOLD — never expand (which would unload the + // real dungeon and re-stream the 25×25 neighbor window). + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse onto the dungeon (0,7) + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one + + Assert.Empty(h.Loads); // NO full-window reload + Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident + } + + [Fact] + public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse + h.Loads.Clear(); + h.Unloads.Clear(); + + // Exit through a portal to an outdoor location far from the dungeon block. + h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false); + + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window + } + + [Fact] + public void PreCollapse_BeforeAnyTick_LoadsOnlyDungeon_NeverBootstrapsWindow() + { + // #135: at a dungeon login/teleport we pre-collapse the instant we recenter, + // BEFORE the first Tick. The full 25×25 neighbor window must NEVER be enqueued + // — only the single dungeon landblock loads. + var h = Make(); // empty state — nothing resident, _region is null + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Single(h.Loads); // exactly one load + Assert.Equal(Encode(0, 7), h.Loads[0].Id); // the dungeon landblock + Assert.Equal(LandblockStreamJobKind.LoadNear, h.Loads[0].Kind); + Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + } + + [Fact] + public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon() + { + // The REAL runtime ordering at a dungeon login: the per-frame streaming Tick + // runs FIRST and bootstraps the full 25×25 window, THEN the spawn handler fires + // PreCollapseToDungeon. The pre-collapse must cancel the queued window loads + // (_clearPendingLoads) and unload any neighbor that already finished streaming. + var h = Make(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // frame 1: NormalTick bootstraps the window + Assert.True(h.Loads.Count > 1); // the full window was enqueued + + // Simulate neighbor landblocks that finished loading during the bootstrap, + // before the collapse edge. + h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock itself + h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon that loaded + h.State.AddLandblock(MakeLb(1, 7)); // another neighbor + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Equal(1, h.ClearCalls()); // queued window loads cancelled + Assert.Contains(Encode(0, 8), h.Unloads); // resident neighbor unloaded + Assert.Contains(Encode(1, 7), h.Unloads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); // dungeon landblock kept + } + + [Fact] + public void PreCollapse_ThenHoldTicksWithStaleObserver_StaysCollapsed() + { + // After pre-collapse the player is held (CurrCell still null → insideDungeon + // false) while the dungeon hydrates. A stale observer that is the SAME dungeon + // landblock must keep streaming collapsed — no full-window reload. + var h = Make(); + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // hold frame: not placed yet + + Assert.Empty(h.Loads); // no neighbor window + Assert.Empty(h.Unloads); + } + + [Fact] + public void PreCollapse_IsIdempotent_OnSameLandblock() + { + // A re-sent player spawn / a same-frame double call must not re-clear or + // re-enqueue. + var h = Make(); + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Equal(1, h.ClearCalls()); // clear fired only on the first collapse + Assert.Empty(h.Loads); // no second dungeon load + } + + [Fact] + public void PreCollapse_ThenPlaced_InsideDungeonTick_StaysCollapsed() + { + // When placement finally fires, the per-frame Tick(insideDungeon: true) sees + // the same collapsed landblock and holds — no re-collapse churn. + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); // dungeon landblock finished loading + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: true); // placed: gate now fires + + Assert.Equal(1, h.ClearCalls()); // no second clear + Assert.Empty(h.Loads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); + } + + [Fact] + public void NormalOutdoorTick_Unchanged_NoCollapseNoClear() + { + var h = Make(); + + h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false + + Assert.Equal(0, h.ClearCalls()); + Assert.Empty(h.Unloads); + // 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued. + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + } +} diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs new file mode 100644 index 00000000..ffb4b427 --- /dev/null +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs @@ -0,0 +1,17 @@ +using AcDream.Core.Textures; +using Xunit; + +namespace AcDream.Core.Tests.Textures; + +public class SurfaceDecoderSolidColorTests +{ + [Fact] + public void DecodeSolidColor_NullColor_ReturnsMagenta_DoesNotThrow() + { + // A malformed Base1Solid surface can carry a null ColorValue. DecodeSolidColor + // is called outside DecodeRenderSurface's try/catch (from TextureCache), so it + // must be null-safe itself — return the undecodable sentinel, never NRE. + var result = SurfaceDecoder.DecodeSolidColor(null!, 0f); + Assert.Equal(DecodedTexture.Magenta, result); + } +} diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index d07d0a64..4ceeddba 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests { // The loader stores DirColor and DirBright RAW. The SunColor property // composes them via |sunVec| per retail's UpdateLightsInternal at - // 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²) - // where the sun vector is built from heading/pitch/brightness with - // Y unscaled by brightness (decomp 261352). + // 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|. + // cdb-verified (reference-retail-ambient-values): |LScape::sunlight| == + // DirBright for every keyframe (world-space spherical vector, magnitude + // DirBright·sqrt(cos²P+sin²P) = DirBright). // // For this region: H=180°, P=70°, B=1.5 - // sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70)) - // = (0, 0.342, 1.410) - // |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509 + // sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70)) + // = (0, -0.513, 1.410) + // |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright) // DirColor.X = 200/255 = 0.7843 - // SunColor.X = 0.7843 × 1.4509 = 1.138 + // SunColor.X = 0.7843 × 1.500 = 1.1765 var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200); var loaded = SkyDescLoader.LoadFromRegion(region); Assert.NotNull(loaded); var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe; - Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); + Assert.InRange(kf.SunColor.X, 1.17f, 1.18f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index 1c677204..3d87da00 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -66,24 +66,33 @@ public sealed class SkyStateTests } [Fact] - public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne() + public void RetailSunVector_MagnitudeAlwaysEqualsDirBright() { - // Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0. - // sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0) - // |sunVec| = 1 regardless of B (because Y is unscaled by B) - var kf = new SkyKeyframe( - Begin: 0f, - SunHeadingDeg: 0f, - SunPitchDeg: 0f, - DirColor: Vector3.One, - DirBright: 2.0f, // anything - AmbColor: Vector3.One, - AmbBright: 1f, - FogColor: Vector3.One, - FogDensity: 0f); + // cdb-verified (2026-06-18, reference-retail-ambient-values): retail's + // world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP), + // whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright + // for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the + // horizon — that was the ~30% over-bright bug.) + // Horizon north (H=0°, P=0°): (0, B, 0), |.| = B. + var horizon = new SkyKeyframe( + Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f, + DirColor: Vector3.One, DirBright: 2.0f, + AmbColor: Vector3.One, AmbBright: 1f, + FogColor: Vector3.One, FogDensity: 0f); + Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f); - var v = SkyStateProvider.RetailSunVector(kf); - Assert.InRange(v.Length(), 0.99f, 1.01f); + // Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224 + // → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright. + var dawn = new SkyKeyframe( + Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f, + DirColor: Vector3.One, DirBright: 0.224f, + AmbColor: Vector3.One, AmbBright: 0.40f, + FogColor: Vector3.One, FogDensity: 0f); + var v = SkyStateProvider.RetailSunVector(dawn); + Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224 + Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1) + Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035 + Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright } [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index d5003bba..e10d56e3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -148,27 +148,30 @@ public class InputDispatcherIsActionHeldTests } [Fact] - public void IsActionHeld_does_not_check_WantCaptureMouse() + public void IsActionHeld_gated_off_while_keyboard_captured() { - // Per-frame held-state lookup is independent of UI capture: even - // with WantCaptureMouse=true a movement key already held when - // ImGui took focus continues to read as held until KeyUp. Press - // events ARE gated (the Press wouldn't fire while UI captures), - // but IsActionHeld answers the keyboard's underlying "is the - // physical key down right now" — which the legacy IsKeyPressed - // also did. The per-frame OnUpdate guard on - // ImGui.GetIO().WantCaptureKeyboard is what suppresses movement - // when chat is focused. + // Write-mode gate (2026-06-16): a focused chat input sets + // WantCaptureKeyboard, and held-key polling then reads RELEASED so typing + // "swd" doesn't move the character. This SUPERSEDES the old design (where the + // per-frame OnUpdate guard early-returned out of the whole movement block) — + // that approach also killed AUTORUN. By gating here instead, the movement block + // keeps running, so autorun (a separate latched bool ORed into Forward at the + // call site, NOT a polled key) survives write mode. WantCaptureMouse alone does + // NOT gate held-key polling — only keyboard capture does. var (dispatcher, kb, mouse, bindings) = Build(); bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); kb.EmitKeyDown(Key.W, ModifierMask.None); - mouse.WantCaptureMouse = true; - mouse.WantCaptureKeyboard = true; + // Held, no capture → reads held. + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); - // Even with both capture flags set, IsActionHeld remains true - // because W is physically held. The dispatcher only suppresses - // press transitions. + // Keyboard captured (write mode) → held-key polling reads released. + mouse.WantCaptureKeyboard = true; + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + + // Mouse capture alone must NOT gate movement polling (only keyboard does). + mouse.WantCaptureKeyboard = false; + mouse.WantCaptureMouse = true; Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs new file mode 100644 index 00000000..e0f1daad --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", chatType: 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} diff --git a/tools/cdb/a7-fixd-golden-probe.cdb b/tools/cdb/a7-fixd-golden-probe.cdb new file mode 100644 index 00000000..07627206 --- /dev/null +++ b/tools/cdb/a7-fixd-golden-probe.cdb @@ -0,0 +1,15 @@ +$$ A7 Fix D — GOLDEN: dump the nearest static lights (the meeting-hall wall torches) +$$ + the ambient/sun that acdream folds into its accumulator. Breakpoint-free, instant. +$$ Render::world_lights @ 0x008672a0; sorted_static_lights[] (RenderLight*) @ +0x3498 +$$ (verified: num_static_lights@+0x104=38, num_dynamic_lights@+0x3588=2). +$$ Stand near the meeting-hall torches so the nearest sorted lights ARE them. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden-probe.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +.echo === ambient_color / sunlight_color / sunlight (what acdream folds into the accumulator) === +dt -r1 acclient!Render::world_lights ambient_color sunlight_color sunlight num_static_lights num_dynamic_lights +.echo === nearest 10 sorted static lights (RenderLight.d3dLightIndex + info: type/intensity/falloff/color) === +.for (r $t0=0; @$t0 < 10; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RenderLight=%p ---\n", @$t0, @$t1; dt -r2 acclient!RenderLight @$t1 d3dLightIndex distancesq info } +.echo === END === +qd diff --git a/tools/cdb/a7-fixd-golden2-probe.cdb b/tools/cdb/a7-fixd-golden2-probe.cdb new file mode 100644 index 00000000..e4ed7d0e --- /dev/null +++ b/tools/cdb/a7-fixd-golden2-probe.cdb @@ -0,0 +1,17 @@ +$$ A7 Fix D — GOLDEN v2: explicit LIGHTINFO/RGBColor dump of the nearest static +$$ lights. info @ RenderLight+0x70 (LIGHTINFO); within info: color@+0x50, intensity@+0x5C, +$$ falloff@+0x60 -> absolute color@RL+0xC0, intensity@RL+0xCC, falloff@RL+0xD0. +$$ Characterizes the 38-light static set (warm town torches?) + golden for the fix. +$$ Breakpoint-free, instant, uses current scene. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden2-probe.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +.echo === ambient_color (r,g,b) === +dt acclient!RGBColor acclient!Render::world_lights+0x0 +.echo === sunlight_color (r,g,b) === +dt acclient!RGBColor acclient!Render::world_lights+0xc +.echo === nearest 8 sorted static lights: type/intensity/falloff + color(r,g,b) + distsq === +.for (r $t0=0; @$t0 < 8; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RL=%p d3dIdx=%d ---\n", @$t0, @$t1, dwo(@$t1+0x68); dt acclient!LIGHTINFO @$t1+0x70 type intensity falloff; .echo color(r,g,b):; dt acclient!RGBColor @$t1+0xc0; .echo distancesq:; dd @$t1+0xd8 L1 } +.echo === END === +qd diff --git a/tools/cdb/a7-fixd-lights-v2.cdb b/tools/cdb/a7-fixd-lights-v2.cdb new file mode 100644 index 00000000..03345800 --- /dev/null +++ b/tools/cdb/a7-fixd-lights-v2.cdb @@ -0,0 +1,36 @@ +$$ +$$ A7 Fix D (#140) v2 — fills the two gaps v1 left: +$$ (1) light COLORS (v1's dt did not expand RGBColor); expanded here as a +$$ typed RGBColor dump + raw dd hex backup (reinterpret IEEE-754 if dt fails). +$$ (2) the STATIC wall torches (the lights that actually BAKE the walls) — these +$$ only re-register on a visible-cell-set change, so the player must MOVE +$$ (walk IN and OUT of the meeting hall, circle past the torches) to trigger +$$ Render::add_static_light. +$$ +$$ v1 already proved: intensity=100/falloff=6 light is DYNAMIC (add_dynamic_light, +$$ d3dIdx=2) = the portal/effect on the hardware path, NOT a baked wall torch. +$$ viewer light = intensity 2.25 / falloff 10 (dynamic, d3dIdx=1). +$$ +$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset): +$$ LIGHTINFO* = poi(@esp+4). color@+0x50 (r/g/b floats), origin@+0x38, intensity@+0x5C, falloff@+0x60. +$$ +$$ Dynamic logging is limited to the first 8 hits (we already characterised them); +$$ ALL static hits log. qd when 12 static torches captured OR 1500 total hits (safety). + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-v2.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +r $t2 = 0 +r $t3 = 0 + +$$ STATIC wall torches (baked path) — MOVE to trigger. Color (typed + hex) + origin. +bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff cone_angle; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3; .echo origin_hex(x,y,z):; dd poi(@esp+4)+0x38 L3; .if (@$t2 >= 12) { qd } .elsif (@$t0 >= 1500) { qd } .else { gc }" + +$$ DYNAMIC lights (portal/viewer) — log first 8 with color, then silent gc. +bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .if (@$t3 <= 8) { .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3 }; .if (@$t0 >= 1500) { qd } .else { gc }" + +.printf "v2 armed: STATIC=wall torches (MOVE in/out of hall to trigger), DYNAMIC=portal/viewer; colors expanded. qd at 12 statics or 1500 total.\\n" +g diff --git a/tools/cdb/a7-fixd-lights.cdb b/tools/cdb/a7-fixd-lights.cdb new file mode 100644 index 00000000..34d2558b --- /dev/null +++ b/tools/cdb/a7-fixd-lights.cdb @@ -0,0 +1,50 @@ +$$ +$$ A7 Fix D (#140) — wall-torch vs portal light OWNERSHIP + the actual LIGHTINFO +$$ values that feed the EnvCell wall bake. 2026-06-18. +$$ +$$ Decomp already settled the render path (workflow wf_f660eb88): +$$ STATIC lights -> CPU per-vertex bake (SetStaticLightingVertexColors -> +$$ calc_point_light), DOUBLE-clamped (per-light min(scale*color,color) + +$$ per-vertex [0,1]) -> walls stay DIM even at intensity=100. +$$ DYNAMIC lights -> D3D hardware FF (minimize_envcell_lighting). +$$ Render::insert_light copies intensity VERBATIM to BOTH paths, so the only +$$ open empirical question is: which light carries intensity=100, and what do +$$ the actual wall-torch LIGHTINFOs look like (intensity/falloff/color)? +$$ +$$ CLASSIFICATION via config_hardware_light's d3dLightIndex (arg1 @ [esp+4]): +$$ add_dynamic_light base index = 1 -> dynamic idx in [1..10] (viewer light / teleport PORTAL) +$$ add_static_light base index = 11 -> static idx in [11..70] (WALL TORCHES, baked) +$$ +$$ config_hardware_light(d3dIndex, _D3DLIGHT9* out, ulong cellID, LIGHTINFO* info): +$$ d3dIndex = dwo(@esp+4) ; LIGHTINFO* = poi(@esp+0x10) (PROVEN last session) +$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset): +$$ LIGHTINFO* = poi(@esp+4) +$$ `dt acclient!LIGHTINFO type intensity falloff color` resolves the +$$ float fields symbolically (PDB types) -> readable values, no hex reinterp. +$$ +$$ USAGE: with retail in-world standing in/near the Holtburg meeting hall by a +$$ wall torch, WALK around the hall (and past the teleport portal if present) +$$ for ~15 s so static torch sets re-register. Auto-detaches (qd) after 600 +$$ total hits, leaving retail running. + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-capture.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +r $t1 = 0 +r $t2 = 0 +r $t3 = 0 + +$$ BP1: config_hardware_light — EVERY light (static+dynamic); d3dIdx classifies. +bp acclient!PrimD3DRender::config_hardware_light "r $t0=@$t0+1; r $t1=@$t1+1; .printf /D \"[CHL] hit#%d d3dIdx=%d (1-10=DYNAMIC portal/viewer, 11+=STATIC torch)\\n\", @$t1, dwo(@esp+4); dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" + +$$ BP2: add_static_light — every hit is a WALL TORCH (baked path). +bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" + +$$ BP3: add_dynamic_light — viewer light + teleport PORTAL (hardware path). +bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" + +.printf "a7-fixd-lights armed: BP1 CHL (classify via d3dIdx), BP2 STATIC=torch, BP3 DYNAMIC=portal/viewer. qd after 600 total hits.\\n" +g diff --git a/tools/cdb/a7-fixd-numstatic-probe.cdb b/tools/cdb/a7-fixd-numstatic-probe.cdb new file mode 100644 index 00000000..155bbbce --- /dev/null +++ b/tools/cdb/a7-fixd-numstatic-probe.cdb @@ -0,0 +1,18 @@ +$$ A7 Fix D — instant (breakpoint-free) read of how many STATIC lights the +$$ current scene bakes with. Confirms whether the meeting hall has static torches +$$ (-> D-1 summed-torches matters) or near-zero (-> D-2 leaked-SSBO is the cause). +$$ Stand where the meeting-hall walls are visible. No movement / no breakpoints. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-numstatic-probe.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +.echo === x acclient!*world_lights* === +x acclient!*world_lights* +.echo === x acclient!Render::world_lights === +x acclient!Render::world_lights +.echo === dt typed (num_static_lights / num_dynamic_lights / ambient_color) === +dt acclient!Render::world_lights num_static_lights num_dynamic_lights ambient_color sunlight_color +.echo === dt LightParms at symbol (fallback by explicit type) === +dt acclient!LightParms acclient!Render::world_lights num_static_lights num_dynamic_lights +.echo === END === +qd diff --git a/tools/cdb/chat-colors.cdb b/tools/cdb/chat-colors.cdb new file mode 100644 index 00000000..b9010838 --- /dev/null +++ b/tools/cdb/chat-colors.cdb @@ -0,0 +1,12 @@ +.symopt+ 0x40 +.reload /f acclient.exe +.echo ===BASE=== +lm m acclient +.echo ===DISASM_BuildChatColorLookupTable=== +uf acclient!ChatInterface::BuildChatColorLookupTable +.echo ===TABLE_REL_0x41c4a8=== +dd acclient+0x41c4a8 L40 +.echo ===TABLE_ABS_0x81c4a8=== +dd 0x81c4a8 L40 +.echo ===END=== +qd diff --git a/tools/cdb/chat-colors2.cdb b/tools/cdb/chat-colors2.cdb new file mode 100644 index 00000000..24b7a382 --- /dev/null +++ b/tools/cdb/chat-colors2.cdb @@ -0,0 +1,6 @@ +.echo ===COLOR_SYMS=== +x acclient!color* +.echo ===CHATCOLOR_SYMS=== +x acclient!*ChatColor* +.echo ===END=== +qd