Compare commits
No commits in common. "main" and "claude/thirsty-goldberg-51bb9b" have entirely different histories.
main
...
claude/thi
182 changed files with 928 additions and 34264 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -26,11 +26,8 @@ references/*
|
|||
|
||||
# Claude Code session state
|
||||
.claude/
|
||||
# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source)
|
||||
/.superpowers/
|
||||
launch.log
|
||||
launch-*.log
|
||||
proveout*.log
|
||||
launch.utf8.log
|
||||
n4-verify*.log
|
||||
|
||||
|
|
|
|||
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -108,18 +108,18 @@ movement queries.
|
|||
|
||||
## Current state
|
||||
|
||||
**Currently working toward: M1.5 — Indoor world feels right.** Dungeons RENDER +
|
||||
are navigable; **login into a dungeon** now loads + places the player and is
|
||||
**FPS-steady from the start** (#135 pre-collapse + indoor cell-floor spawn gate,
|
||||
`712f17f`+`2c92375`). The dungeon **"red cone"** was an editor-only placement marker
|
||||
acdream inherited from WB (retail hides it via distance degrade) — FIXED (#136 `6f81e2c`).
|
||||
REMAINING for M1.5: **A7 dungeon lighting** (LightBake Core landed `3b93f91`; per-vertex
|
||||
bake integration + the per-pixel torch OVER-blow still open — #79/#93); **#137 dungeon
|
||||
collision** (doors / wall openings); **#138 teleport-OUT of a dungeon** loads the outdoor
|
||||
world incompletely + position desync (the collapse→EXPAND gap — same machinery as #135).
|
||||
M2 (CombatMath) deferred. Detail in ISSUES (#135–#138) + the render/physics digests.
|
||||
Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail
|
||||
in the docs below, NOT here.
|
||||
**Currently working toward: M1.5 — Indoor world feels right.** The
|
||||
building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13
|
||||
to include **dungeon support (full Phase G.3)** — dungeons don't work at
|
||||
all: terrain-less dungeon landblocks aren't supported by the streaming/
|
||||
load/render/physics pipeline (`LandblockLoader.Load` null with no
|
||||
`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration
|
||||
→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2
|
||||
(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec.
|
||||
Recent closes (2026-06-12/13): #119/#128, #112, #113, #124,
|
||||
#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra
|
||||
threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the
|
||||
docs below, NOT here.
|
||||
|
||||
For canonical state, read in this order:
|
||||
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
|
||||
|
|
|
|||
416
docs/ISSUES.md
416
docs/ISSUES.md
|
|
@ -46,409 +46,15 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #142 — Windowed-building interiors read "like outdoors" (indoor lighting regime is per-frame, not per-stage)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (visible — windowed town buildings + look-ins are sun-lit/flat instead of torch-lit warm vs retail)
|
||||
**Filed:** 2026-06-20
|
||||
**Component:** render — indoor lighting regime (sun + ambient)
|
||||
|
||||
**Description (user, at the #140 gate):** The Agent of Arcanum house is much brighter/lit indoors in retail (both looking in from outside AND when inside); in acdream it is "not lit" — looking in and inside both "feel like outdoors." The meeting hall (a sealed interior) looked OK, so it's specifically WINDOWED buildings + look-ins.
|
||||
|
||||
**Root cause / status:** acdream's lighting REGIME (sun on/off + which ambient) is a per-FRAME global keyed on the PLAYER's cell (`GameWindow.cs:8107` `playerInsideCell`, from `:8061` `playerSeenOutside`, into `UpdateSunFromSky` `:8122`/`:10786`). Retail's is per-DRAW-STAGE: `PView::DrawCells` (0x005a4840) draws ALL EnvCells in the `useSunlightSet(0)` interior stage (0x005a49f3) — torch-lit, no sun — regardless of `SeenOutside`. So acdream's windowed interiors (`SeenOutside=true`) + look-ins stay in the outdoor regime (sun + outdoor ambient) where retail uses the indoor regime. This is the **AP-43 residual** made visible. Torches are already per-cell (AP-43); the SUN + AMBIENT are the remaining per-frame-global parts. **Fix direction:** make sun+ambient per-draw (per-object/cell) like AP-43's torches — needs a brainstorm (UBO second-ambient + per-instance indoor selector vs a third `uLightingMode`). Resolves AP-43.
|
||||
|
||||
**Files:** `GameWindow.cs:8061/8107/8122/10786` (regime), `mesh_modern.vert accumulateLights` (~:188/:193), `WbDrawDispatcher.IndoorObjectReceivesTorches` (:2076), `EnvCellRenderer` (mode-1).
|
||||
|
||||
**Research:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` (full handoff — retail decomp + acdream refs + fix fork + validation plan). Register AP-43.
|
||||
|
||||
**Acceptance:** Agent of Arcanum interior torch-lit/warm both looking-in and inside (user side-by-side vs retail); sealed interiors + dungeons unchanged.
|
||||
|
||||
---
|
||||
|
||||
## #143 — Portal swirl doesn't light the room (no dynamic-light registration)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW-MEDIUM (visible — retail's portal swirl tints the room; acdream's casts no light)
|
||||
**Filed:** 2026-06-20
|
||||
**Component:** render — dynamic point lights
|
||||
|
||||
**Description (user, at the #140 gate):** Inside the meeting hall, retail's portal swirl lights up the room; in acdream it does not.
|
||||
|
||||
**Root cause / status:** The portal swirl is a DYNAMIC light in retail (`add_dynamic_light` 0x0054d420 → `minimize_envcell_lighting` 0x0054c170 enables the cell's dynamic subset). acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~:6404) — no dynamic lights, so the portal casts nothing. Captured retail params (predecessor cdb): `intensity=100, falloff=6, color=(0.784,0,0.784)` magenta. **Fix:** register a dynamic `LightSource` for portal-swirl entities (or read the portal model's own dat lights); it then flows through the existing point-light path and the EnvCell bake. Keep it indoor (out of the AP-43 outdoor gate).
|
||||
|
||||
**Files:** portal/particle spawn path (TBD); `GameWindow.cs` `RegisterOwnedLight` (~:6404); `LightManager` (PointSnapshot / UnregisterByOwner).
|
||||
|
||||
**Research:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` (§#143).
|
||||
|
||||
**Acceptance:** portal swirl visibly tints the meeting-hall room vs retail.
|
||||
|
||||
---
|
||||
|
||||
## #141 — Toolbar interactivity — selected-object display
|
||||
|
||||
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-06-17
|
||||
**Component:** ui — D.5 toolbar / selection
|
||||
|
||||
**Description:** The action bar (D.5.1) is the retail "selected object" display. Wire the B.4 WorldPicker/selection state to the toolbar's currently-hidden elements: the two meters 0x100001A1 (selected-object Health) / 0x100001A2 (selected-object Mana) + the stack slider 0x100001A4 + the object-name line, so the bar shows what the player has selected in the world. Click-to-use + the peace/war stance indicator already shipped in D.5.1. Promote to roadmap D.5.3 (already listed there).
|
||||
|
||||
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
||||
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46.
|
||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
|
||||
|
||||
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
||||
|
||||
**Research:** `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` (meter element ids + wire catalog).
|
||||
|
||||
**Acceptance:** Selecting a world object populates the toolbar meters and name line; deselecting clears them. Matches retail side-by-side.
|
||||
|
||||
---
|
||||
|
||||
## #140 — A7 "Fix D": outdoor objects too bright near torches
|
||||
|
||||
**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
|
||||
**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C)
|
||||
**Filed:** 2026-06-18
|
||||
**Component:** render — point lighting on outdoor objects
|
||||
|
||||
**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
|
||||
|
||||
**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*.
|
||||
|
||||
**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.**
|
||||
|
||||
**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired).
|
||||
|
||||
**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches).
|
||||
|
||||
---
|
||||
|
||||
## #139 — D.2b retail UI polish: chat text colors + buttons
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client)
|
||||
**Filed:** 2026-06-16
|
||||
**Component:** ui — D.2b retail UI (chat window + buttons)
|
||||
|
||||
**Description (user):** After the widget-generalization pass landed (2026-06-16), two areas want a polish pass against retail:
|
||||
1. **Chat text colors** — the per-`ChatKind` transcript text colors need tuning to match retail more precisely. Current values come from a live cdb dump of the named `RGBAColor` constants (colorWhite / BrightPurple / LightBlue / Green / LightRed / Grey) mapped per `ChatKind` in `ChatWindowController.RetailChatColor`. The four common kinds (speech/tell/channel/system) are confirmed; the rarer kinds (emote, soul-emote, combat, popup) map to the nearest named color and may be off — verify each against a side-by-side retail client.
|
||||
2. **Buttons** — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: **pressed / hover state feedback** (`UiButton` currently draws only its default-state sprite; the dat carries `Normal`/`Pressed`/`Highlight` states it does not yet switch on), plus a check that the face 3-slice + autosize read cleanly at all widths.
|
||||
|
||||
**Root cause / status:** Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). `UiButton` intentionally mirrors `UiDatElement`'s single-state render (pressed-state was out of the generalization's scope); chat colors are best-effort from the cdb dump.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — `RetailChatColor(ChatKind)` per-kind color map.
|
||||
- `src/AcDream.App/UI/UiButton.cs` — `ActiveFile()` / `OnEvent` (no pressed-state swap yet; dat has Normal/Pressed/Highlight).
|
||||
- `src/AcDream.App/UI/UiMenu.cs` — `DrawButtonFace` (Normal vs Pressed sprite) for the channel button.
|
||||
|
||||
**Research:** `claude-memory/reference_retail_chat_colors.md` (the cdb chat-color dump + recipe).
|
||||
|
||||
**Acceptance:** Chat text colors and button (pressed/hover) states match a side-by-side retail client — user's visual sign-off.
|
||||
|
||||
---
|
||||
|
||||
## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival
|
||||
|
||||
**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads
|
||||
the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and
|
||||
**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not
|
||||
moving while my character is moving" (the avatar animates/advances but the player's
|
||||
actual position / camera doesn't track, or vice-versa).
|
||||
|
||||
**Root cause / status (hypothesis — needs investigation):** very likely a gap in the
|
||||
dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming
|
||||
is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT,
|
||||
`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new
|
||||
center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks
|
||||
(→ missing trees/scenery + no collision because shadow-object registration never ran for the
|
||||
un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) +
|
||||
`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after
|
||||
exit; (c) the position desync = the player controller / streaming observer disagree on the
|
||||
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
|
||||
Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap.
|
||||
|
||||
**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the
|
||||
collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated`
|
||||
teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch),
|
||||
`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration.
|
||||
|
||||
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery
|
||||
present), collision works, and the player position tracks correctly (no avatar-vs-camera desync).
|
||||
|
||||
---
|
||||
|
||||
## #137 — Dungeon collision incorrect at doors and wall openings
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (movement/collision correctness in dungeons)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** physics — EnvCell collision (doors, portal openings, cell geometry)
|
||||
|
||||
**Description (user):** collision is still wrong in dungeons — **doors** and **openings in
|
||||
walls** in particular. (Symptoms not fully characterized yet: likely walking through
|
||||
openings that should block / blocking at openings that should pass, and door collision not
|
||||
matching the door's open/closed state.)
|
||||
|
||||
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
|
||||
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
|
||||
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
|
||||
(wall gap) collision geometry handled differently from buildings; the per-cell
|
||||
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
|
||||
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
|
||||
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
|
||||
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
|
||||
oracle-first per the physics digest.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
|
||||
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
|
||||
`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table).
|
||||
|
||||
**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls
|
||||
block — matching retail, in the 0x0007 dungeon.
|
||||
|
||||
---
|
||||
|
||||
## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it)
|
||||
|
||||
**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone +
|
||||
green floor "petals" are gone, all real dungeon decorations still render. User-approved
|
||||
frozen-phase fix.
|
||||
**Severity:** LOW (cosmetic; one marker in one dungeon)
|
||||
**Filed/Fixed:** 2026-06-14
|
||||
**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade
|
||||
|
||||
**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a
|
||||
green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side
|
||||
retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon
|
||||
fix placed the player at the exact saved spawn next to it.
|
||||
|
||||
**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`,
|
||||
`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`,
|
||||
using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an
|
||||
**editor-only placement marker**: its `DIDDegrade` table `0x11000118` =
|
||||
`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the
|
||||
WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance.
|
||||
Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw`
|
||||
0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's
|
||||
render path is extracted from **WorldBuilder**, which — being an editor — renders every cell
|
||||
static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in
|
||||
`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT
|
||||
a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red
|
||||
herring; the marker is a separate `guid=0` dat static.)
|
||||
|
||||
**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker
|
||||
pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell
|
||||
static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare
|
||||
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`).
|
||||
Faithful equivalent of retail's runtime degrade for static geometry (always viewed at
|
||||
distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are
|
||||
untouched. 4 new `GfxObjDegradeResolver` unit tests.
|
||||
|
||||
**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers;
|
||||
apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per-
|
||||
pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap
|
||||
vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked
|
||||
under the #79/#93 A7 lighting umbrella.
|
||||
|
||||
---
|
||||
|
||||
## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
|
||||
|
||||
**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.)
|
||||
**Severity:** LOW (startup-only; self-corrects)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** streaming — first-frame bootstrap vs the dungeon collapse
|
||||
|
||||
**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED
|
||||
dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window.
|
||||
- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse`
|
||||
early (idempotent), so the expensive ocean-grid neighbour window is never enqueued
|
||||
(teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).
|
||||
- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag
|
||||
(the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn
|
||||
interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.
|
||||
- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport).
|
||||
- Observer robustness: during a teleport `PortalSpace` hold the observer follows the
|
||||
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now
|
||||
filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift
|
||||
the login-hold observer off the dungeon and trip `ExitDungeonExpand`.
|
||||
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
|
||||
`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering).
|
||||
|
||||
**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before
|
||||
settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped
|
||||
up; when logging in I get like 10 then it slowly increases."
|
||||
|
||||
**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only
|
||||
engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the
|
||||
first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their
|
||||
~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved
|
||||
the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains.
|
||||
Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full
|
||||
window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid).
|
||||
|
||||
**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse);
|
||||
login recenter `OnLiveEntitySpawnedLocked` ~2470.
|
||||
|
||||
**Acceptance:** Login into a dungeon reaches steady-state FPS within ~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). 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`).
|
||||
G.3 scope chosen). Brainstorming the spec → `docs/superpowers/specs/`.
|
||||
This is now an M1.5 exit-gate blocker, not deferred. The investigation
|
||||
below found it's not a single bug but a whole-feature gap (terrain-less
|
||||
dungeon landblocks unsupported across the pipeline).
|
||||
**Severity:** HIGH (any far/dungeon teleport is unusable)
|
||||
**Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal)
|
||||
**Component:** physics/streaming — teleport-arrival snap vs async landblock hydration
|
||||
|
|
@ -1281,19 +887,7 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783`
|
|||
|
||||
## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
|
||||
|
||||
**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A
|
||||
(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a
|
||||
headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`,
|
||||
`95d9dab`) measured `PortalVisibilityBuilder` visiting only **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"
|
||||
**Status:** OPEN — **explains user-observed "dungeons are broken"**
|
||||
**Severity:** HIGH (blocks all dungeon navigation visually)
|
||||
**Filed:** 2026-05-21
|
||||
**Component:** rendering, visibility, EnvCell portal traversal
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 1. Intentional architecture (IA) — 17 rows
|
||||
## 1. Intentional architecture (IA) — 14 rows
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -55,18 +55,15 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
||||
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
||||
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
||||
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
|
||||
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Adaptation (AD) — 28 rows
|
||||
## 2. Adaptation (AD) — 27 rows
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
|
||||
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
|
||||
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) |
|
||||
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
|
||||
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
||||
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
||||
|
|
@ -92,12 +89,10 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||
| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` |
|
||||
| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius |
|
||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 42 rows
|
||||
## 3. Documented approximation (AP) — 34 rows
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -116,7 +111,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
|
||||
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
|
||||
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
|
||||
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
|
||||
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
|
||||
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
|
||||
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
|
||||
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
||||
|
|
@ -135,21 +130,10 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
||||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
|
||||
| AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
|
||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
||||
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
|
||||
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
|
||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
||||
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
|
||||
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
|
||||
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
|
||||
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
|
||||
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Temporary stopgap (TS) — 31 rows
|
||||
## 4. Temporary stopgap (TS) — 30 rows
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -182,9 +166,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
|
||||
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
|
||||
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
||||
| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
|
||||
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
||||
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
|
||||
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -201,7 +183,6 @@ equivalence argument (promote to AD/AP) or a fix.
|
|||
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
||||
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
|
||||
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
||||
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -232,8 +213,8 @@ M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
|
|||
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
|
||||
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
|
||||
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
|
||||
Membership Stage 2 must land TS-18 (BuildingCellId).
|
||||
The audio phase lands TS-9/TS-29; the animation-hook layer lands
|
||||
Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30;
|
||||
the audio phase lands TS-9/TS-29; the animation-hook layer lands
|
||||
TS-10/TS-11/TS-12/TS-13/TS-14.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -424,19 +424,10 @@ 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.
|
||||
- **✓ 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.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`.
|
||||
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)**
|
||||
- **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish).
|
||||
- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)**
|
||||
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
|
||||
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 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.
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
# Chat-window re-drive — session handoff (2026-06-15)
|
||||
|
||||
**Status:** brainstorm STARTED (context gathered, design questions open) — not yet
|
||||
designed or implemented. Resume with `superpowers:brainstorming`.
|
||||
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` — **continue UI work HERE** (the user's
|
||||
call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch
|
||||
off `main`, it's unrelated and easy to merge). This branch is already current with
|
||||
`main` (merged `5ac9d8c`).
|
||||
|
||||
---
|
||||
|
||||
## Where we are (what shipped this session)
|
||||
|
||||
**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.**
|
||||
The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no
|
||||
per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the
|
||||
SSOT crib) FIRST — it has the full architecture + every correction. Key commits:
|
||||
|
||||
- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the
|
||||
hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired.
|
||||
- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail
|
||||
`UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch).
|
||||
- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in
|
||||
**submission (painter) order** (was per-texture batched → later bars overpainted
|
||||
their own numbers).
|
||||
- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest.
|
||||
|
||||
**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):**
|
||||
- `ElementReader` — `ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId
|
||||
inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct).
|
||||
- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget).
|
||||
- `DatWidgetFactory` — `Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else
|
||||
generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a
|
||||
dedicated widget; the widget CONSUMES the element's children (leaf — importer
|
||||
does not recurse them).** This is the pattern the chat re-drive extends.
|
||||
- `LayoutImporter` — `Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded
|
||||
`Resolve`. `ImportedLayout.FindElement(id)` for binding by id.
|
||||
- `VitalsController` — binds live data to widgets by element id (mirrors retail
|
||||
`gmVitalsUI::PostInit`). The chat controller will mirror this.
|
||||
- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc
|
||||
API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST
|
||||
reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc +
|
||||
`ToAnchors` — `RightEdge==1`=stretch, `LeftEdge==2`=track-right.
|
||||
|
||||
---
|
||||
|
||||
## Next task: re-drive the chat window through the importer (Plan 2 chat piece)
|
||||
|
||||
Today the chat window is **hand-authored**, not data-driven. The goal mirrors the
|
||||
vitals re-drive: read the chat window's dat `LayoutDesc`, build it via
|
||||
`LayoutImporter`, and bind the live chat through a `ChatController`.
|
||||
|
||||
### Current chat window (what to reproduce / replace)
|
||||
- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)`
|
||||
block (~line 1836, "Retail chat window").
|
||||
- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`,
|
||||
`MinWidth 180 / MinHeight 80`, draggable + resizable.
|
||||
- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript,
|
||||
**bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**,
|
||||
whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug
|
||||
bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame.
|
||||
- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind`
|
||||
colour via `RetailChatColor(...)` (local static in GameWindow).
|
||||
|
||||
### Chat pipeline (already shipped, Phase I — reuse, don't rebuild)
|
||||
`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input →
|
||||
ChatInputParser → LiveCommandBus → WorldSession`. See
|
||||
`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the
|
||||
pipeline stays.
|
||||
|
||||
### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI)
|
||||
`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI`
|
||||
(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has
|
||||
`gmMainChatUI::Register` etc.). Chat-layout notes:
|
||||
`docs/research/retail-ui/05-panels.md:120` (chat window layout) +
|
||||
`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`).
|
||||
|
||||
### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id
|
||||
The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it:
|
||||
- `dump-vitals-layout <datdir> [0xId]` enumerates LayoutDescs (it already lists all
|
||||
layouts containing given element ids). Use it to scan for the chat window, or grep
|
||||
the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`.
|
||||
- Then dump it and enumerate its element Types (expect a scroll/list region +
|
||||
scrollbar, maybe a text-input/edit element + channel tabs) — this drives the
|
||||
factory/widget work.
|
||||
|
||||
---
|
||||
|
||||
## Open design questions (resume the brainstorm here)
|
||||
|
||||
1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse
|
||||
`UiChatView` for the transcript, parity with today), OR expand to the FULL retail
|
||||
chat (input box for typing, channel tabs)? Recommendation to discuss: do the
|
||||
frame re-drive + transcript first (parity), defer input/tabs to a follow-up —
|
||||
but confirm with the user.
|
||||
2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the
|
||||
vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC
|
||||
`Text`/edit. Two options:
|
||||
- **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript
|
||||
region's Type → the existing `UiChatView` (which already scrolls/selects/copies);
|
||||
a `ChatController` binds the tail by element id. Minimal new code; fastest parity.
|
||||
- **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the
|
||||
decomp so the dat's scrollbar element drives scrolling. More faithful, more work;
|
||||
better as a later step.
|
||||
3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font
|
||||
to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug
|
||||
font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based,
|
||||
so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection
|
||||
hit-test rework). Likely a follow-up, not the first cut.
|
||||
|
||||
---
|
||||
|
||||
## Watchouts / lessons (don't regress these)
|
||||
- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert
|
||||
to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug).
|
||||
- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize).
|
||||
- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format
|
||||
doc §4's first reading was inverted; trust the corrected `ToAnchors`.
|
||||
- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat
|
||||
children; the importer doesn't recurse into them. Apply the same to the chat
|
||||
transcript widget.
|
||||
- **Don't fabricate dat reader internals** — `Chorizite.DatReaderWriter` is a NuGet
|
||||
package (not in `references/`); verify member names via the dump tool / reflection.
|
||||
|
||||
## Process for the next session
|
||||
1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and
|
||||
`docs/research/2026-06-15-layoutdesc-format.md`.
|
||||
2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach
|
||||
(the 3 questions above), present a design, write the spec.
|
||||
3. Then `superpowers:writing-plans` → `superpowers:subagent-driven-development`
|
||||
(same flow that shipped the vitals importer cleanly).
|
||||
4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on
|
||||
`127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 /
|
||||
testpassword2` or `notan / MittSnus81!` (character `+Je`).
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
# LayoutDesc Format Enumeration Reference
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`)
|
||||
**Sources:**
|
||||
- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F`
|
||||
- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB)
|
||||
- DatReaderWriter 2.1.7 reflection probe (deleted after use)
|
||||
|
||||
This doc is the ground-truth API table for Tasks 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<UIStateId, StateDesc>` | named states (e.g. `HideDetail`, `ShowDetail`) |
|
||||
| `Children` | **field** | `Dictionary<uint, ElementDesc>` | child elements keyed by their `ElementId` |
|
||||
|
||||
**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`.
|
||||
|
||||
The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**.
|
||||
|
||||
---
|
||||
|
||||
## 2. `StateDesc` — exact API
|
||||
|
||||
| Member | Kind | Type | Notes |
|
||||
|--------|------|------|-------|
|
||||
| `StateId` | **field** | `uint` | redundant with the dict key |
|
||||
| `PassToChildren` | **field** | `bool` | |
|
||||
| `IncorporationFlags` | **field** | `IncorporationFlags` | |
|
||||
| `Properties` | **field** | `Dictionary<uint, BaseProperty>` | keyed by property-id (uint); see §3 |
|
||||
| `Media` | **field** | `List<MediaDesc>` | polymorphic list of media items |
|
||||
|
||||
### States dictionary key type
|
||||
|
||||
`ElementDesc.States` is `Dictionary<UIStateId, StateDesc>`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum:
|
||||
|
||||
```csharp
|
||||
// Key: UIStateId.HideDetail = 268435462 (0x10000006)
|
||||
// Key: UIStateId.ShowDetail = 268435463 (0x10000007)
|
||||
```
|
||||
|
||||
See §6 for the full `UIStateId` enum.
|
||||
|
||||
**Iterating in code:**
|
||||
```csharp
|
||||
foreach (var s in d.States)
|
||||
ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored
|
||||
|
||||
`StateDesc.Properties` is `Dictionary<uint, BaseProperty>`. The `BaseProperty` base class has:
|
||||
- `BasePropertyType PropertyType` (enum)
|
||||
- `uint MasterPropertyId`
|
||||
- `bool ShouldPackMasterPropertyId`
|
||||
|
||||
Concrete subclasses (`DatReaderWriter.Types.*`):
|
||||
|
||||
| Subclass | Field | Type | Notes |
|
||||
|----------|-------|------|-------|
|
||||
| `BoolBaseProperty` | `Value` | `bool` | |
|
||||
| `IntegerBaseProperty` | `Value` | `int` | |
|
||||
| `FloatBaseProperty` | `Value` | `float` | |
|
||||
| `EnumBaseProperty` | `Value` | `uint` | |
|
||||
| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID |
|
||||
| `ArrayBaseProperty` | `Value` | `List<BaseProperty>` | array of sub-properties |
|
||||
| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` |
|
||||
| `StringInfoBaseProperty` | `Value` | `StringInfo` | |
|
||||
| `VectorBaseProperty` | `Value` | `Vector3` | |
|
||||
| `Bitfield32BaseProperty` | `Value` | `uint` | |
|
||||
| `Bitfield64BaseProperty` | `Value` | `ulong` | |
|
||||
| `InstanceIdBaseProperty` | `Value` | `uint` | |
|
||||
| `StructBaseProperty` | `Value` | `Dictionary<uint, BaseProperty>` | |
|
||||
|
||||
### Property key meanings (confirmed from decomp + dat inspection)
|
||||
|
||||
| Key | Type found in dat | Meaning | Decomp ref |
|
||||
|-----|-------------------|---------|-----------|
|
||||
| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` |
|
||||
| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` |
|
||||
| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` |
|
||||
| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` |
|
||||
| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) |
|
||||
| `0x16` | `BoolBaseProperty` | Some text flag | |
|
||||
| `0x21` | `BoolBaseProperty` | One-line mode | |
|
||||
| `0x23` | `IntegerBaseProperty` | Left margin | |
|
||||
| `0x24` | `IntegerBaseProperty` | Top margin | |
|
||||
| `0x25` | `IntegerBaseProperty` | Right margin | |
|
||||
| `0x26` | `IntegerBaseProperty` | Bottom margin | |
|
||||
| `0x27` | `BoolBaseProperty` | Some text option | |
|
||||
| `0x20` | `BoolBaseProperty` | Some text option | |
|
||||
| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` |
|
||||
| `0xCB` | `BoolBaseProperty` | Some text option | |
|
||||
|
||||
**Critical point for font DID extraction:**
|
||||
Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID:
|
||||
```csharp
|
||||
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
|
||||
if (arr.Value[0] is DataIdBaseProperty did)
|
||||
fontDid = did.Value; // e.g. 0x40000000
|
||||
```
|
||||
|
||||
**Confirmed for element `0x10000376` (the vitals text prototype):**
|
||||
- Property `0x1A` → `DataIdBaseProperty.Value = 0x40000000` (font DID)
|
||||
- Property `0x1B` → `ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white)
|
||||
|
||||
**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding.
|
||||
|
||||
---
|
||||
|
||||
## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`)
|
||||
|
||||
These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are:
|
||||
|
||||
| Value | Meaning | Where observed |
|
||||
|-------|---------|---------------|
|
||||
| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) |
|
||||
| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces |
|
||||
| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces |
|
||||
| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` |
|
||||
| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements |
|
||||
|
||||
### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`)
|
||||
|
||||
The **far-axis fields** (RightEdge, BottomEdge) drive stretch:
|
||||
- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta)
|
||||
- **RightEdge==2** ⇒ designRight is fixed (no stretch)
|
||||
- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**)
|
||||
- **LeftEdge==1** ⇒ pin left at designX (near-pin)
|
||||
- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near)
|
||||
- **value==3** ⇒ centered / floating (no anchor on that axis)
|
||||
- **value==0** ⇒ no anchor (prototype-only)
|
||||
|
||||
This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 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<MediaDesc>`. The concrete types found across the vitals layouts:
|
||||
|
||||
| Subclass | Fields | Used in vitals? | Notes |
|
||||
|----------|--------|----------------|-------|
|
||||
| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type |
|
||||
| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element |
|
||||
| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List<BaseProperty> Frames`, `MediaType Type` | not in vitals | Animated sprite |
|
||||
| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay |
|
||||
| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition |
|
||||
| `MediaDescSound` | `uint File`, ... | not in vitals | |
|
||||
| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition |
|
||||
| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | |
|
||||
| `MediaDescMessage` | `uint Id`, ... | not in vitals | |
|
||||
| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | |
|
||||
| `MediaDescMovie` | `PStringBase<char> FileName`, ... | not in vitals | |
|
||||
|
||||
Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`.
|
||||
|
||||
---
|
||||
|
||||
## 6. `DrawModeType` enum (confirmed from reflection)
|
||||
|
||||
`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`):
|
||||
|
||||
| Name | Value | Behavior | Used in vitals? |
|
||||
|------|-------|----------|----------------|
|
||||
| `Undefined` | 0 | (not used) | no |
|
||||
| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome |
|
||||
| `Overlay` | 2 | Blended overlay (not observed in vitals) | no |
|
||||
| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites |
|
||||
|
||||
**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`.
|
||||
|
||||
---
|
||||
|
||||
## 7. `UIStateId` enum (key type for `ElementDesc.States`)
|
||||
|
||||
`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window:
|
||||
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| `Undef` | 0 |
|
||||
| `Normal` | 1 |
|
||||
| `HideDetail` | 268435462 (= `0x10000006`) |
|
||||
| `ShowDetail` | 268435463 (= `0x10000007`) |
|
||||
| `IsCharacter` | 268435542 (= `0x10000056`) |
|
||||
| `IsAccount` | 268435543 (= `0x10000057`) |
|
||||
|
||||
The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name.
|
||||
|
||||
---
|
||||
|
||||
## 8. Type → meaning → render method → widget bucket
|
||||
|
||||
From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail:
|
||||
|
||||
| Type (uint) | Class registered | Render method | Widget bucket | Vitals? |
|
||||
|-------------|-----------------|---------------|---------------|---------|
|
||||
| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) |
|
||||
| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no |
|
||||
| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (drag region) | YES — top/bottom drag bars |
|
||||
| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` | YES — container/group elements, chrome corners/edges |
|
||||
| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no |
|
||||
| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no |
|
||||
| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no |
|
||||
| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral** → `UiMeter` | **YES — the three vitals bars** |
|
||||
| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no |
|
||||
| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (grip) | YES — resize grips (corners + edges) |
|
||||
| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no |
|
||||
| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base |
|
||||
| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no |
|
||||
| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no |
|
||||
| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no |
|
||||
| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no |
|
||||
| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements |
|
||||
| 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<uint, ElementDesc>` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Inheritance chain for vitals number-text elements
|
||||
|
||||
All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share:
|
||||
- `Type = 0` (text element, no render registration — renders via inherited machinery)
|
||||
- `BaseElement = 268436342 = 0x10000376`
|
||||
- `BaseLayoutId = 553648191 = 0x2100003F`
|
||||
|
||||
The base element `0x10000376` in `0x2100003F`:
|
||||
- `Type = 12` (style prototype — zero-size, never rendered directly)
|
||||
- `StateDesc.Properties`:
|
||||
- `0x1A` → `ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`**
|
||||
- `0x1B` → `ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white
|
||||
- `0x14` → `EnumBaseProperty{Value=1}` — horizontal justification = 1
|
||||
- `0x15` → `EnumBaseProperty{Value=1}` — vertical justification = 1
|
||||
- `0x23`, `0x25` → `IntegerBaseProperty{Value=0}` — margins
|
||||
|
||||
The inheritance chain for the text element in the importer is:
|
||||
```
|
||||
derived (Type=0, no StateDesc media, no font prop itself)
|
||||
inherits from base 0x10000376 in layout 0x2100003F (Type=12)
|
||||
→ font DID = 0x40000000 (from property 0x1A)
|
||||
→ font color = white ARGB(255,255,255,255) (from property 0x1B)
|
||||
```
|
||||
|
||||
The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`.
|
||||
|
||||
**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2.
|
||||
|
||||
---
|
||||
|
||||
## 11. Vitals window `0x2100006C` — confirmed element map
|
||||
|
||||
Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2)
|
||||
|
||||
### Chrome (all Type=3, `DrawMode=Normal`)
|
||||
|
||||
| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite |
|
||||
|----|---|---|---|---|----------|---------|-----------|------------|--------|
|
||||
| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) |
|
||||
| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) |
|
||||
| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) |
|
||||
| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) |
|
||||
| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) |
|
||||
| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) |
|
||||
| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) |
|
||||
| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) |
|
||||
|
||||
### Drag bars (Type=2)
|
||||
|
||||
| Id | X | Y | W | H | Notes |
|
||||
|----|---|---|---|---|-------|
|
||||
| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` |
|
||||
| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor |
|
||||
|
||||
### Resize grips (Type=9 — corners + edges)
|
||||
|
||||
| Id | X | Y | W | H | Corner/Edge |
|
||||
|----|---|---|---|---|-------------|
|
||||
| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip |
|
||||
| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip |
|
||||
| `0x1000063E` | 0 | 5 | 5 | 48 | left grip |
|
||||
| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip |
|
||||
| `0x10000641` | 155 | 53 | 5 | 5 | BR grip |
|
||||
| `0x10000642` | 155 | 5 | 5 | 48 | right grip |
|
||||
|
||||
Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list.
|
||||
|
||||
### Meter elements (Type=7 — `UiMeter`)
|
||||
|
||||
| Id | X | Y | W | H | Purpose |
|
||||
|----|---|---|---|---|---------|
|
||||
| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter |
|
||||
| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter |
|
||||
| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter |
|
||||
|
||||
Each meter has:
|
||||
- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites)
|
||||
- `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern
|
||||
- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states)
|
||||
- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000`
|
||||
|
||||
### Sprite ids confirmed from dump
|
||||
|
||||
**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer):
|
||||
- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480`
|
||||
- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483`
|
||||
- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front)
|
||||
|
||||
**Stamina bar:**
|
||||
- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486`
|
||||
- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489`
|
||||
- ShowDetail: `0x06007492` / `0x06007493`
|
||||
|
||||
**Mana bar:**
|
||||
- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C`
|
||||
- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F`
|
||||
- ShowDetail: `0x06007494` / `0x06007495`
|
||||
|
||||
---
|
||||
|
||||
## 12. Inheritance resolution rules
|
||||
|
||||
1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`.
|
||||
2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it.
|
||||
3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`.
|
||||
4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops.
|
||||
|
||||
---
|
||||
|
||||
## § Corrections to plan assumptions
|
||||
|
||||
### 1. Edge-flag semantics are INVERTED from the earlier §4 reading
|
||||
|
||||
**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch` → `right==2||right==4` for Right anchor.
|
||||
**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`:
|
||||
|
||||
| Edge value | LeftEdge meaning | RightEdge meaning |
|
||||
|-----------|-----------------|------------------|
|
||||
| 0 | no anchor | no anchor |
|
||||
| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** |
|
||||
| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) |
|
||||
| 3 | centered / floating (no anchor) | centered / floating (no anchor) |
|
||||
| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** |
|
||||
|
||||
The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4.
|
||||
|
||||
**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):**
|
||||
```csharp
|
||||
// Per UIElement::UpdateForParentSizeChange @0x00462640
|
||||
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
||||
{
|
||||
var a = AnchorEdges.None;
|
||||
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
||||
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
|
||||
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
||||
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
|
||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top;
|
||||
return a;
|
||||
}
|
||||
```
|
||||
|
||||
Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site.
|
||||
|
||||
### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int`
|
||||
|
||||
The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct.
|
||||
|
||||
### 3. `ElementDesc.Type` is `uint`, not an enum
|
||||
|
||||
The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`.
|
||||
|
||||
### 4. `DrawModeType` has no `Stretch` value
|
||||
|
||||
The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch.
|
||||
|
||||
### 5. `d.States` key is `UIStateId`, not `string`
|
||||
|
||||
The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc.
|
||||
|
||||
### 6. Font DID is in `ArrayBaseProperty`, not a direct property
|
||||
|
||||
The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is:
|
||||
```csharp
|
||||
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
|
||||
if (arr.Value[0] is DataIdBaseProperty did)
|
||||
info.FontDid = did.Value;
|
||||
```
|
||||
|
||||
### 7. Fill (`0x69`) is NOT in the dat
|
||||
|
||||
The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it.
|
||||
|
||||
### 8. Type=12 elements are style prototypes — skip them entirely
|
||||
|
||||
Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`.
|
||||
|
||||
---
|
||||
|
||||
## § Plan 1 surface vs long tail
|
||||
|
||||
**Plan 1 (vitals conformance) uses:**
|
||||
- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container)
|
||||
- DrawModes: `Normal` (1), `Alphablend` (3)
|
||||
- Media: `MediaDescImage`, `MediaDescCursor`
|
||||
- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance)
|
||||
- States: `HideDetail`, `ShowDetail`
|
||||
|
||||
**Plan 2 (long tail):**
|
||||
- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x13–0x19), all `gm*UI` custom types
|
||||
- DrawModes: `Overlay` (2), any future additions
|
||||
- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc.
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
# Handoff — next UI phase: action bar / quick slots + inventory + equipment (paperdoll)
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**From:** the session that landed the D.2b widget generalization (merged to `main` at `78c9187`).
|
||||
**Purpose:** kick off a **deep retail-faithful research phase** for the next three game panels, before any implementation. This doc + the new-session prompt at the bottom are the entry point.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where we are (what you're building on)
|
||||
|
||||
The **D.2b retail-UI toolkit is complete and on `main`.** You have:
|
||||
|
||||
- **A generic, Type-registered widget toolkit** built by `DatWidgetFactory` from the dat `LayoutDesc`: `UiButton` (Type 1), `UiMenu` (6), `UiMeter` (7), `UiScrollbar` (11), `UiText` (12), plus `UiField` (editable, controller-placed) and `UiDatElement` (generic chrome/container fallback). All in `src/AcDream.App/UI/`.
|
||||
- **The assembly pattern**: a window is a dat `LayoutDesc` → `LayoutImporter.Import(...)` walks the `ElementDesc` tree → `DatWidgetFactory` builds each element generically → a thin **`gm*UI::PostInit`-style controller** finds widgets by id (`layout.FindElement(id) as UiX`) and binds live data/behavior. See `VitalsController` and `ChatWindowController` for the two worked examples.
|
||||
- **Key toolkit rules** (read `claude-memory/project_d2b_retail_ui.md` first — it's the START-HERE digest with the full DO-NOT-RETRY list):
|
||||
- `UiElement.ConsumesDatChildren` — behavioral widgets are **leaf** (the importer doesn't build their dat sub-elements; they reproduce them procedurally).
|
||||
- The base-chain Type resolution (`ElementReader.Merge`) already surfaces each element's real registered Type.
|
||||
- Type 3 is **chrome/container** in acdream's layouts (stays `UiDatElement`), NOT a factory-registered editable Field.
|
||||
|
||||
**This phase's job is to build the next real game panels on that toolkit** — but they're complex (live item state, drag-drop, wire messages, icon rendering), so we research first per the project's mandatory **grep named → decompile → cross-ref → pseudocode → port** workflow (CLAUDE.md).
|
||||
|
||||
These panels are the roadmap's **F.5 / D.5 "core panels"** (Attributes / Skills / Paperdoll / Inventory / Spellbook).
|
||||
|
||||
---
|
||||
|
||||
## 2. The three targets + their retail entry points (concrete anchors)
|
||||
|
||||
All confirmed from the named decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
|
||||
|
||||
### A. Action bar / quick slots → `gmToolbarUI` (element class `0x10000007`)
|
||||
The retail "toolbar" is the shortcut bar. Confirmed methods (grep `gmToolbarUI::`):
|
||||
- `UseShortcut(slot)`, `AddShortcut`, `RemoveShortcut`, `RemoveShortcutInSlotNum`, `FlushShortcuts`, `CreateShortcutToItem`, `IsShortcutEligible(ACCWeenieObject*)`, `IsShortcutSlotAvailable`, `GetFirstEmptyShortcutToTheRightOf`, `RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`.
|
||||
- Slots hold **`UIElement_UIItem`** widgets (`UIElement_UIItem::SetShortcutNum` / `SetDelayedShortcutNum`); the underlying object is an **`ACCWeenieObject`** with `SetShortcutNum`.
|
||||
- Spell shortcuts: `UIElement_ItemList::ItemList_InsertSpellShortcut`, `CM_Magic::SendNotice_AddSpellShortcut`.
|
||||
|
||||
### B. Inventory → `gmInventoryUI` (element class `0x10000023`), `gmBackpackUI` (`0x10000022`)
|
||||
The inventory window + nested backpacks/side-packs. Items are server-spawned **weenies** (`ACCWeenieObject`) — see `claude-memory/feedback_weenie_vs_static.md` (selectable/interactable items are server weenies, not dat-baked).
|
||||
|
||||
### C. Equipment / paperdoll → `gmPaperDollUI` (element class `0x10000024`), `gm3DItemsUI` (`0x10000021`)
|
||||
The paperdoll window shows equipped/wielded items + the character doll. `gm3DItemsUI` is likely the 3D doll viewport (a rotating character model with equipped gear).
|
||||
|
||||
### Shared building blocks (the toolkit pieces these need that we DON'T have yet)
|
||||
- **`UIElement_UIItem`** (element class `0x10000032`) — **the item-in-a-slot widget**: an icon (from the weenie's `IconDataID`), drag-drop (retail `UIElement_Field`'s `CatchDroppedItem` / `MouseOverTop` hooks — note our `UiField` already documents these as future drag-drop hooks), a shortcut number, a quantity/burden overlay, a selection/highlight. **This is the most important new generic widget** — all three panels are grids/lists of these.
|
||||
- **`UIElement_ItemList`** (`0x10000031`) — a scrollable list/grid of items (retail's ListBox-for-items). Maps to a `UiListBox` (Type 5, not yet built) or a `UiItemGrid`.
|
||||
- **The window manager** (the *other* deferred Plan-2 piece): open/close/z-order/persist, drag via Dragbar (Type 2), resize via Resizebar (Type 9). Inventory/paperdoll/toolbar are pop-up/dockable windows that need this.
|
||||
- **Drag-drop infrastructure**: item drag between inventory ↔ equip ↔ toolbar ↔ ground, with the wire messages it triggers.
|
||||
|
||||
---
|
||||
|
||||
## 3. The research questions (the deep-research scope)
|
||||
|
||||
Produce a research doc per panel (or one combined doc) under `docs/research/` answering these, each with a **retail anchor** (named `class::method` + decomp line, or `LayoutDesc`/element-class id) and cross-referenced against the references in §4.
|
||||
|
||||
**Common / cross-cutting (do this first — it unblocks all three):**
|
||||
1. **The dat `LayoutDesc` id** for each panel (`gmToolbarUI` / `gmInventoryUI` / `gmPaperDollUI`). The element-class ids above are the *registered class*; find the actual `LayoutDesc` (`0x21xxxxxx`) that builds each window. Use the `AcDream.Cli` layout-dump tools (`dump-vitals-layout <datdir> 0xId`, the `LayoutIndexDump`) and grep the decomp for the class's `Create`/`PostInit`.
|
||||
2. **`UIElement_UIItem`** (`0x10000032`) — full port spec: what Type does it resolve to in the dat? How does it render an item icon from the weenie's `IconDataID` (→ `RenderSurface`/`Icon` overlay — cross-ref WorldBuilder/ACViewer icon decode)? How are quantity, burden, wielded/selected states drawn? What's the drag-drop state machine (`MouseOverTop`/`CatchDroppedItem`)?
|
||||
3. **The item/container data model**: items are `ACCWeenieObject`s. How does the client learn container contents — which wire messages (CreateObject, the container/inventory messages), and how is the container hierarchy (main pack → backpacks → side-packs) represented? What does acdream already parse (cross-ref the wire-message catalog, §4)?
|
||||
|
||||
**Action bar (`gmToolbarUI`):**
|
||||
4. The shortcut slot model — how many slots/bars, item vs spell shortcuts, what a slot stores (`ShortcutNum`), `IsShortcutEligible`.
|
||||
5. Persistence + wire: is the toolbar server-persisted? What messages add/remove/use a shortcut (`RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`) and what does activation send (use-item / cast-spell)?
|
||||
6. Drag-from-inventory-to-slot + drag-to-reorder (`CreateShortcutToItem`, `GetFirstEmptyShortcutToTheRightOf`).
|
||||
|
||||
**Inventory (`gmInventoryUI` / `gmBackpackUI`):**
|
||||
7. The window layout (the item grid, the side-pack tabs/list, burden/value summary).
|
||||
8. The full set of inventory wire messages (server → client item arrival + client → server actions: pick up, drop, give, move-between-containers, split-stack, merge-stack). Cross-ref ACE (what the server sends/validates) + holtburger (what the client sends).
|
||||
9. Icon rendering: weenie `IconDataID` → texture (+ any underlay/overlay/highlight for ID'd vs unidentified, wielded, selected).
|
||||
|
||||
**Equipment / paperdoll (`gmPaperDollUI` / `gm3DItemsUI`):**
|
||||
10. The equip/wield slots — the coverage/location enum (which slots exist, their screen positions on the doll).
|
||||
11. The wield/unwield wire messages (equip an item, the server's response, the resulting `ObjDesc` change on the character model).
|
||||
12. The paperdoll rendering — is it the 2D doll image + slot icons, or a 3D character viewport (`gm3DItemsUI`)? How does it assemble the equipped-character appearance (cross-ref ACViewer's ObjDesc/CreaturePalette + WorldBuilder for the model)?
|
||||
|
||||
---
|
||||
|
||||
## 4. References (the hierarchy — cross-reference at least two per question)
|
||||
|
||||
Per CLAUDE.md's reference table:
|
||||
- **Named retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + `symbols.json`) — **primary oracle for the UI logic** (the `gm*UI` / `UIElement_UIItem` classes). Grep by `class::method`.
|
||||
- **holtburger** (`references/holtburger/`) — **primary oracle for client behavior + wire**: what a client sends/receives for inventory, equip, use-item, drag-drop. Look in `client/` + `session/`.
|
||||
- **ACE** (`references/ACE/Source/ACE.Server/`) — **server expectations**: the inventory/equip/move game-action handlers, what the server validates + broadcasts.
|
||||
- **WorldBuilder** + **ACViewer** — **icon + 3D-model rendering**: `IconDataID` → texture decode (ACViewer `TextureCache.IndexToColor` / WorldBuilder `TextureHelpers`); the equipped-character ObjDesc assembly (ACViewer `StaticObjectManager` / `CreaturePalette`).
|
||||
- **Chorizite.ACProtocol** (`references/Chorizite.ACProtocol/Types/*.cs`) — protocol field order for the inventory/equip messages.
|
||||
|
||||
**Existing acdream research/memory to read first (don't re-research what's done):**
|
||||
- `claude-memory/project_d2b_retail_ui.md` — the toolkit + the find-by-id controller pattern (START HERE).
|
||||
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies.
|
||||
- `claude-memory/project_interaction_pipeline.md` — the existing WorldPicker / Select / UseSelected interaction (B.4) — the use/pickup path partly exists.
|
||||
- `claude-memory/MEMORY.md` → the **wire-message catalog** research (`research/2026-06-04-wire-message-catalog.md`): 256 opcodes, what acdream parses vs stubs vs is-missing — the inventory/equip opcodes' parse status is in there.
|
||||
- `docs/research/2026-06-15-layoutdesc-format.md` — the `LayoutDesc`/`ElementDesc` format (for reading the panel layouts).
|
||||
- The `AcDream.Cli` layout-dump tools (`dump-vitals-layout`, `LayoutIndexDump`, `LayoutFixtureDump` from this session's Task 1) — for dumping any panel's `LayoutDesc`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deliverable + approach
|
||||
|
||||
**Report-only research** — no implementation, no code changes (use the `/investigate` discipline, or just produce research docs). Output: one or more `docs/research/2026-06-NN-*-deep-dive.md` docs (mirroring the existing `2026-06-04-*-deep-dive.md` set), each with:
|
||||
- The retail anchors (class::method + decomp line; `LayoutDesc`/element-class ids).
|
||||
- The wire-message catalog for the panel (direction, trigger, field layout, ACE handler, acdream parse status).
|
||||
- The item/container model + the `UIElement_UIItem` port spec.
|
||||
- The drag-drop mechanics.
|
||||
- **A concrete "what the toolkit needs" list** — the new generic widgets (`UiItemSlot`/`UIElement_UIItem` port, `UiListBox`/item-grid, the window manager) + which `Type` they register at — so the *next* session can go straight to a brainstorm → spec → plan.
|
||||
- An end-of-doc `MEMORY.md` index line.
|
||||
|
||||
**Suggested approach:** this is broad (3 panels × decomp + 5 references + dat + wire). A **multi-agent research Workflow** fits well — e.g. one agent per panel for the class/LayoutDesc/wire scoping, plus one agent on the shared `UIElement_UIItem`/icon-rendering/drag-drop spine, then synthesize. (Ultracode authorizes this.) Or run the panels sequentially. Either way, finish with a synthesis that names the new toolkit widgets.
|
||||
|
||||
---
|
||||
|
||||
## 6. New-session prompt (paste this into a fresh session)
|
||||
|
||||
> Deep retail-faithful **research phase** (report-only, no code) for acdream's next three UI panels, building on the now-complete D.2b widget toolkit (merged to `main`). **Read the handoff first: `docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md`.
|
||||
>
|
||||
> **Targets + confirmed retail entry points** (named decomp): action bar / quick slots = `gmToolbarUI` (element class `0x10000007`); inventory = `gmInventoryUI` (`0x10000023`) + `gmBackpackUI` (`0x10000022`); equipment/paperdoll = `gmPaperDollUI` (`0x10000024`) + `gm3DItemsUI` (`0x10000021`). Shared spine: `UIElement_UIItem` (`0x10000032`, the item-in-a-slot widget) + `UIElement_ItemList` (`0x10000031`). Items are server `ACCWeenieObject` weenies.
|
||||
>
|
||||
> **Produce** a `docs/research/` deep-dive per panel (+ a synthesis) answering the §3 research questions in the handoff — each with a retail anchor (class::method + decomp line / `LayoutDesc` + element-class id), the panel's wire-message catalog (cross-ref holtburger=client, ACE=server, Chorizite.ACProtocol=field order), the item/container model, the `UIElement_UIItem` port spec + icon rendering (cross-ref WorldBuilder/ACViewer for `IconDataID`→texture), and the drag-drop mechanics. **End with a concrete "new generic widgets the toolkit needs" list** (the item-slot widget, an item list/grid, the window manager) + the `Type` each registers at, so the following session can brainstorm → spec → plan the build. Use a multi-agent research Workflow (one agent per panel + one on the shared item-slot/icon/drag-drop spine) — Ultracode is authorized. Follow the mandatory grep-named→cross-ref→pseudocode workflow; do not write implementation code this phase.
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# Action bar / quick slots (`gmToolbarUI`) — retail-faithful deep dive
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Panel:** action bar / shortcut bar — retail class `gmToolbarUI`, element class `0x10000007`, `LayoutDesc 0x21000016` (root element 300×122).
|
||||
**Scope:** handoff §3 Q1 (LayoutDesc/element map) + Q4 (shortcut slot model) + Q5 (wire + persistence) + Q6 (drag-drop / reorder). Report-only; no code written this phase.
|
||||
**Builds on:** the D.2b importer/widget toolkit (`src/AcDream.App/UI/` + `…/UI/Layout/`). The "spine" item-slot/icon doc referenced in the handoff prompt does **NOT exist** in this worktree (searched `**/*spine*`, `**/*item-slot*`, the named path — all NOT FOUND), so the `UIElement_UIItem` / `UIElement_ItemList` facts below are derived here directly from the decomp; a later synthesis should reconcile with the spine doc if it lands.
|
||||
|
||||
## 1. Summary + confidence legend
|
||||
|
||||
The retail toolbar is **one `gmToolbarUI` window** that contains **18 single-cell item slots** (two rows of 9: top `0x100001A7..AF`, bottom `0x100006B7..BF`), each slot a **`UIElement_ItemList` (element class `0x10000031`)** holding at most one **`UIElement_UIItem` (class `0x10000032`)**. A slot stores nothing but the item it currently holds; the persistent model is `ShortCutManager::shortCuts_[18]` (an array of `ShortCutData{ index_, objectID_, spellID_ }`) living on the `CPlayerModule`. Shortcuts are **server-persisted as a character option** — they arrive in the big `PlayerDescription` login message (the `CharacterOptionDataFlag::SHORTCUT` block, **already parsed by acdream**) and are mutated live by two C2S game actions: **`AddShortCut 0x019C`** and **`RemoveShortCut 0x019D`** (both already have outbound builders in acdream). Activation of a slot does **not** use a "use-shortcut" wire message — it routes through the ordinary **use-item** path (`ItemHolder::UseObject`), so it reuses acdream's existing B.4 interaction pipeline. Drag-from-inventory and drag-to-reorder are handled by `gmToolbarUI` being an `ItemListDragHandler` (multiple inheritance) whose `HandleDropRelease` resolves the target slot and calls `CreateShortcutToItem` / `AddShortcut` / `GetFirstEmptyShortcutToTheRightOf`.
|
||||
|
||||
The 2 Meters + 1 Scrollbar in the layout dump are **NOT** bar paging or extra vitals: they are the **selected-object Health & Mana meters** (`0x100001A1`/`0x100001A2`) and the **stack-size split slider** (`0x100001A4`), all inside the `m_pSelObjectField` sub-panel and **hidden by default** (`SetVisible(0)` in `PostInit`) — they appear only when you select an object / split a stack.
|
||||
|
||||
**Confidence legend** — **CONFIRMED** = quoted from named decomp or a reference file I opened; **LIKELY** = inferred from confirmed facts (source named); **UNVERIFIED** = educated guess, flagged.
|
||||
|
||||
## 2. LayoutDesc / element map (Q1) — CONFIRMED against `.layout-dumps/toolbar-0x21000016.txt`
|
||||
|
||||
`LayoutDesc 0x21000016` (Id 553648150). The dump's `Width=800 Height=600` is the LayoutDesc canvas; the **root element `0x10000191`** (ElementId 268435857, **Type `0x10000463` = the registered `gmToolbarUI` class type**) is **300×122** — that 300×122 matches the handoff's stated size and is the real window. The root's Type value `268435463 = 0x10000007`… correction: dump shows `Type = 268435463` which is `0x10000007` (the `gmToolbarUI` class id) — i.e. the root element registers as the panel class itself, exactly like `gmToolbarUI::GetUIElementType` returns `0x10000007` (decomp line 196707: `return 0x10000007;`). CONFIRMED.
|
||||
|
||||
### 2a. The 18 shortcut slots — element→slot-index map
|
||||
|
||||
`gmToolbarUI::InitShortcutArray` (decomp line 197051) wires the slots by walking `GetChildRecursive(this, <id>)` in order and `DynamicCast(0x10000031)` (= `UIElement_ItemList`), registering each with the drag handler and pushing into `m_shortcutSlots`. The push order **is** the slot index. The 18 ids extracted from the function body (decomp 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<Shortcut>` for the description.
|
||||
- **acdream already parses this**: `PlayerDescriptionParser.cs:345-356` reads `count` then `ShortcutEntry(Index, ObjectGuid, SpellId, Layer)` per entry, exposed on `Parsed.Shortcuts`.
|
||||
|
||||
Client-side restore: `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) → `FlushShortcuts()`, gets the `CPlayerModule`'s `ShortCutManager`, then `for (i=0; i<0x12; i++) { objId = shortCuts_[i]->objectID_ (+8); if (objId) AddShortcut(this, objId, i, 0); }` (decomp 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`.
|
||||
|
|
@ -1,416 +0,0 @@
|
|||
# Equipment / Paperdoll panel — retail-faithful deep-dive
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Scope:** D.2b "core panels" research phase, the equipment/paperdoll target from
|
||||
`docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md` §3 Q1 + Q10/Q11/Q12.
|
||||
**Status:** REPORT-ONLY. No code changed. The deliverable is this doc.
|
||||
**Panels:** `gmPaperDollUI` (element class `0x10000024`, LayoutDesc `0x21000024`) and
|
||||
`gm3DItemsUI` (element class `0x10000021`, LayoutDesc `0x21000021`).
|
||||
|
||||
## 1. Summary + confidence legend
|
||||
|
||||
The retail **paperdoll** (`gmPaperDollUI`) is a **3D character viewport plus ~25
|
||||
single-cell equip slots**, NOT a 2D doll image. The window's element `0x100001D5`
|
||||
(Type `13` = `UIElement_Viewport`) hosts a live `CreatureMode` mini-scene; the
|
||||
character's `CPhysicsObj` is cloned from the player and re-dressed via the SAME
|
||||
ObjDesc machinery the in-world renderer uses (`DoObjDescChangesFromDefault`). Every
|
||||
equip slot is a **single-cell `UIElement_ItemList` (class `0x10000031`)**, one per
|
||||
`EquipMask` location, mapped element-id → coverage-mask by
|
||||
`gmPaperDollUI::GetLocationInfoFromElementID`. Equipping is the
|
||||
`GetAndWieldItem` game action (opcode `0x001A`, `item_guid + EquipMask`); the
|
||||
server's visible reply is `ObjDescEvent` (`0xF625`) which triggers
|
||||
`RedressCreature`. **acdream already parses `ObjDescEvent` (0xF625) and the full
|
||||
ObjDesc/ModelData block, and already has a complete per-instance animated-character
|
||||
render path** (`EntitySpawnAdapter` → `AnimatedEntityState` with palette/part/hidden-
|
||||
part overrides). The paperdoll viewport can REUSE that path — the gap is a
|
||||
**`UiViewport` (Type 0xD) widget** that renders a single entity into a UI rect (a
|
||||
scissored mini 3D pass), an **equip-slot variant of the item-slot widget**
|
||||
(`UIElement_ItemList` 0x10000031, single cell), and the **window manager**.
|
||||
`gm3DItemsUI` (0x21000021) is a SEPARATE "Contents of Backpack" pane (an
|
||||
`UIElement_ItemList` + a text label + a scrollbar), NOT the doll — it does not host
|
||||
a viewport.
|
||||
|
||||
`gm3DItemsUI` is misnamed for our purposes: despite "3DItems", its `PostInit` wires
|
||||
a `m_itemList` (`UIElement_ItemList`) and a `m_contentsText` and sets the text to
|
||||
"Contents of Backpack". It is an inventory contents list, addressed by the inventory
|
||||
deep-dive; included here only because the handoff paired it with the paperdoll.
|
||||
|
||||
**Confidence legend:**
|
||||
- **CONFIRMED** — quoted from a source I opened (decomp line / file:line).
|
||||
- **LIKELY** — inferred from confirmed facts; the inference is named.
|
||||
- **UNVERIFIED** — educated guess; flagged loudly.
|
||||
|
||||
**Note on a missing input:** the handoff promised a "spine agent" doc at
|
||||
`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and the
|
||||
START-HERE memory `claude-memory/project_d2b_retail_ui.md`. **Both are NOT FOUND in
|
||||
this worktree** (`Glob **/project_d2b_retail_ui.md` and `**/*spine*.md` returned
|
||||
nothing). I therefore re-derived the icon/item-model claims I needed from primary
|
||||
sources (decomp + acclient.h + ACE + ACViewer + acdream source) rather than citing a
|
||||
doc I could not open. Where this overlaps the spine's scope (icon decode, the
|
||||
`UIElement_UIItem` widget, container model) I keep it terse and defer to the spine
|
||||
doc once it lands.
|
||||
|
||||
## 2. LayoutDesc / element map
|
||||
|
||||
### 2a. Paperdoll `gmPaperDollUI` 0x10000024 → LayoutDesc 0x21000024 (224×214)
|
||||
|
||||
**CONFIRMED** registration: `gmPaperDollUI::Register` (decomp line 174445):
|
||||
`UIElement::RegisterElementClass(0x10000024, gmPaperDollUI::Create);`. Pre-dump
|
||||
`.layout-dumps/paperdoll-0x21000024.txt` root `0x100001D4` is 224×214, Type
|
||||
`268435492 = 0x10000024` (the gmPaperDollUI class). **CONFIRMED.**
|
||||
|
||||
Construction chain: `gmPaperDollUI::gmPaperDollUI` (line 174228) calls
|
||||
`UIElement_Field::UIElement_Field(this, ...)` — i.e. the paperdoll IS-A Field
|
||||
subclass (matters for drag-drop: it inherits Field's drop hooks). The slot/viewport
|
||||
wiring happens in the init routine that calls `GetChildRecursive` per id
|
||||
(lines 175480-175548) — the analog of a `PostInit`. **CONFIRMED.**
|
||||
|
||||
Key elements in 0x21000024 (from the pre-dump + the init routine):
|
||||
|
||||
| Element id | dump Type | Resolves to | Role | Anchor (cite) |
|
||||
|---|---|---|---|---|
|
||||
| `0x100001D4` | 0x10000024 | gmPaperDollUI (root) | window | dump:13 |
|
||||
| `0x100001D5` | **13** | `UIElement_Viewport` (0xD) | **the 3D character doll** | dump:125; `m_pPaperDoll = GetChildRecursive(this,0x100001d5)->DynamicCast(0xd)` line 175509-175517 |
|
||||
| `0x100001D6` | 0 → base 0x100002BF/0x21000080 | `m_paperDollDragMask` | doll click/drag mask region (100×214) | dump:157; line 175538 |
|
||||
| `0x1000046D` | 0 → base | `m_paperDollDragOverlay` | drag overlay sprite (32×32) | dump:173; line 175539 |
|
||||
| `0x10000595` | 0 → ItemList | `m_sigilOneItem` (SigilOne 0x10000000) | aetheria sigil slot, hidden by default | line 175540-175542 |
|
||||
| `0x10000596` | 0 → ItemList | `m_sigilTwoItem` (SigilTwo 0x20000000) | sigil slot | line 175543-175545 |
|
||||
| `0x10000597` | 0 → ItemList | `m_sigilThreeItem` (SigilThree 0x40000000) | sigil slot | line 175546-175548 |
|
||||
| `0x100005BE` | 0 → Button base 0x21000044 | a `UIElement_Button` | the close/expand button (120×14) | dump:349; line 175549 |
|
||||
| ~25 more `0x1000xxxx` ids | **0** → base `0x100001E4` | single-cell `UIElement_ItemList` (0x10000031) | the equip slots (§3) | dump:29-476 |
|
||||
|
||||
The shared equip-slot base chain (**CONFIRMED**):
|
||||
- Each slot element has `Type = 0`, `BaseElement = 268435940 = 0x100001E4`,
|
||||
`BaseLayoutId = 553648164 = 0x21000024` (dump e.g. lines 33,49,65…).
|
||||
- Element `0x100001E4` (dump:477) has `Type = 0`, `BaseElement = 268436281 =
|
||||
0x10000339`, `BaseLayoutId = 553648189 = 0x2100003D`.
|
||||
- `0x2100003D` root element `0x10000339` (`.layout-dumps/itemlist-0x2100003D.txt:16`)
|
||||
has `Type = 268435505 = 0x10000031` = `UIElement_ItemList`, 32×32.
|
||||
⇒ **every paperdoll equip slot resolves (via `ElementReader.Merge` zero-wins-base
|
||||
Type resolution) to `UIElement_ItemList` 0x10000031, a single 32×32 cell.**
|
||||
|
||||
The init routine confirms each is cast to ItemList and registered as a drag target,
|
||||
e.g. (line 175485-175496):
|
||||
```
|
||||
eax_66 = GetChildRecursive(this, 0x100005b2); // LowerLegArmor slot
|
||||
eax_67 = eax_66->vtable->DynamicCast(0x10000031); // → UIElement_ItemList
|
||||
this->m_lowerLegSlot = eax_67;
|
||||
UIElement_ItemList::RegisterItemListDragHandler(eax_67, &this->vtable);
|
||||
this->m_lowerLegSlot->vtable->SetVisible(0); // hidden until an item lands
|
||||
```
|
||||
**CONFIRMED.** Slots default invisible and are shown only when occupied (the empty
|
||||
slot shows the doll body behind it; an occupied slot shows the item icon).
|
||||
|
||||
### 2b. gm3DItemsUI 0x10000021 → LayoutDesc 0x21000021 (234×120) — NOT the doll
|
||||
|
||||
**CONFIRMED** registration: `gm3DItemsUI::Register` (line 176723):
|
||||
`UIElement::RegisterElementClass(0x10000021, gm3DItemsUI::Create);`.
|
||||
`gm3DItemsUI::PostInit` (line 176728-176745):
|
||||
```
|
||||
this->m_contentsText = UIElement::GetChildRecursive(this, 0x100001c5);
|
||||
eax_1 = UIElement::GetChildRecursive(this, 0x100001c6);
|
||||
this->m_itemList = eax_1->vtable->DynamicCast(0x10000031); // UIElement_ItemList
|
||||
... UIElement_Text::SetText(this->m_contentsText, u"Contents of Backpack");
|
||||
```
|
||||
Pre-dump `.layout-dumps/items3d-0x21000021.txt`: root `0x100001C4` (234×120, Type
|
||||
`268435489 = 0x10000021`), child `0x100001C5` (text, base 0x10000436/0x21000077),
|
||||
child `0x100001C6` (the ItemList grid, base 0x100002B9/0x2100003D — same ItemList
|
||||
base as the slots), child `0x100001C7` (a scrollbar-shaped 16×96, base
|
||||
0x100002C7/0x2100003E). **No Viewport element.** ⇒ gm3DItemsUI is a scrollable
|
||||
**item-contents list**, not a 3D doll. **CONFIRMED.** (The "3D" in the name is
|
||||
historical; it has no `UIElement_Viewport` and no `CreatureMode`.)
|
||||
|
||||
## 3. Equip-slot model + the coverage / location enum
|
||||
|
||||
### 3a. The element-id → EquipMask mapping (`GetLocationInfoFromElementID`)
|
||||
|
||||
`gmPaperDollUI::GetLocationInfoFromElementID(elementId, out uint mask, out UI_SLOT_SIDE side)`
|
||||
(decomp line 173620) is a giant switch. It is the SSOT for which slot is which. The
|
||||
mask values are exactly ACE's `EquipMask` (`ACE/Source/ACE.Entity/Enum/EquipMask.cs`).
|
||||
**CONFIRMED** — full table below (decomp line / mask / EquipMask name / SLOT_SIDE):
|
||||
|
||||
| Element id | mask (hex) | EquipMask name | SLOT_SIDE | decomp line |
|
||||
|---|---|---|---|---|
|
||||
| `0x100005AB` | `0x1` | HeadWear | NULL | 173723 |
|
||||
| `0x100001E2` | `0x2` | ChestWear | NULL | 173688 |
|
||||
| `0x100001E3` | `0x40` | UpperLegWear | NULL | 173694 |
|
||||
| `0x100005B0` | `0x20` | HandWear | NULL | 173753 |
|
||||
| `0x100005B3` | `0x100` | FootWear | NULL | 173771 |
|
||||
| `0x100005AC` | `0x200` | ChestArmor | NULL | 173729 |
|
||||
| `0x100005AD` | `0x400` | AbdomenArmor | NULL | 173735 |
|
||||
| `0x100005AE` | `0x800` | UpperArmArmor | NULL | 173741 |
|
||||
| `0x100005AF` | `0x1000` | LowerArmArmor | NULL | 173747 |
|
||||
| `0x100005B1` | `0x2000` | UpperLegArmor | NULL | 173759 |
|
||||
| `0x100005B2` | `0x4000` | LowerLegArmor | NULL | 173765 |
|
||||
| `0x100001DA` | `0x8000` | NeckWear | NULL | 173640 |
|
||||
| `0x100001DB` | `0x10000` | WristWearLeft | LEFT | 173646 |
|
||||
| `0x100001DD` | `0x20000` | WristWearRight | RIGHT | 173658 |
|
||||
| `0x100001DC` | `0x40000` | FingerWearLeft | LEFT | 173652 |
|
||||
| `0x100001DE` | `0x80000` | FingerWearRight | RIGHT | 173664 |
|
||||
| `0x100001E1` | `0x200000` | Shield | NULL | 173682 |
|
||||
| `0x100001E0` | `0x800000` | MissileAmmo | NULL | 173676* |
|
||||
| `0x100001DF` | `0x3500000` | (weapon composite — see 3b) | NULL | 173670 |
|
||||
| `0x100005E9` | `0x8000000` | Cloak | NULL | 173777 |
|
||||
| `0x10000595` | `0x10000000` | SigilOne | NULL | 173705 |
|
||||
| `0x10000596` | `0x20000000` | SigilTwo | NULL | 173711 |
|
||||
| `0x10000597` | `0x40000000` | SigilThree | NULL | 173717 |
|
||||
| `0x1000058E` | `0x4000000` | TrinketOne | NULL | 173630 |
|
||||
|
||||
\* **`0x100001E0`** — the decomp pseudo-C shows `*arg3 = "activation type (%s)…"`
|
||||
(a string-pointer artifact where the Binary Ninja lifter lost the immediate). The
|
||||
preceding/following cases are `0x200000` (Shield) and `0x200000`/`0x40`, and the only
|
||||
remaining ready-slot mask not otherwise assigned in this switch is `MissileAmmo
|
||||
(0x00800000)`. So **`0x100001E0` = MissileAmmo `0x800000` (LIKELY** — inferred from
|
||||
the EquipMask gap + neighbors; the literal value is corrupted in the decomp).
|
||||
|
||||
`UI_SLOT_SIDE` (CONFIRMED `acclient.h:4546`): `SLOT_SIDE_NULL=0, SLOT_SIDE_LEFT=1,
|
||||
SLOT_SIDE_RIGHT=2`. SIDE distinguishes the paired jewelry slots (left/right
|
||||
wrist + finger) that share the same wear concept but different physical sides.
|
||||
|
||||
### 3b. The weapon composite slot `0x3500000`
|
||||
|
||||
`0x100001DF → 0x3500000` = `MeleeWeapon(0x100000) | MissileWeapon(0x400000) |
|
||||
TwoHanded(0x2000000) | Held(0x1000000)` (= `0x3500000`). **CONFIRMED** by bit
|
||||
decomposition against EquipMask.cs. This is the single "weapon hand" doll slot that
|
||||
accepts any wieldable weapon. `OnItemListDragOver` has a special case at line 174302:
|
||||
`if (ecx_3 == 0x200000 && (eax_3 & 0x100000) != 0) eax_3 |= ecx_3;` — i.e. a
|
||||
melee-capable item may also drop into the Shield(0x200000) slot test. **CONFIRMED.**
|
||||
|
||||
### 3c. How the client knows what is equipped — `GetUpperInvObj(mask)`
|
||||
|
||||
`gmPaperDollUI::GetUpperInvObj(uint coverageMask)` (line 174565) is how the doll
|
||||
finds the item currently in a slot:
|
||||
```
|
||||
eax = ClientObjMaintSystem::GetWeenieObject(player_id);
|
||||
eax_3 = ACCWeenieObject::GetInvPlacementList(eax); // PackableList<InventoryPlacement>
|
||||
for (i = eax_3->head; i; i = i->next) {
|
||||
if (arg2 & i->data.loc_) // coverageMask & placement.loc_
|
||||
eax_5 = InventoryPlacement::DetermineHigherPriority(...);
|
||||
}
|
||||
return iid; // the equipped item's guid
|
||||
```
|
||||
`InventoryPlacement` (**CONFIRMED** `acclient.h:33178`):
|
||||
```cpp
|
||||
struct InventoryPlacement : PackObj { uint iid_; uint loc_; uint priority_; };
|
||||
```
|
||||
So the player weenie carries a **`PackableList<InventoryPlacement>`** where each
|
||||
node is `{itemGuid, locationMask (EquipMask), priority}`. `loc_` is the EquipMask
|
||||
slot; `priority_` resolves overlap (e.g. armor over clothing on the same body part —
|
||||
this is `CoverageMask` priority, `ACE/Source/ACE.Entity/Enum/CoverageMask.cs`).
|
||||
**CONFIRMED.** The paperdoll reads this list to populate each slot's icon and to
|
||||
drive part-selection lighting (`GetSelectionMaskFromObject`, line 174762, maps an
|
||||
item guid back to which doll body parts to highlight, via the same masks).
|
||||
|
||||
**Cross-ref ACE:** `EquipMask` (loc) and `CoverageMask` (priority) are documented in
|
||||
ACE as "sent as loc / in the priority field of the equipped-items list portion of the
|
||||
player description event F7B0-0013" (`EquipMask.cs:5-6`, `CoverageMask.cs:6-7`).
|
||||
**CONFIRMED** — this is the same `InventoryPlacement {iid, loc, priority}` triple the
|
||||
client stores, populated from PlayerDescription's equipped section.
|
||||
|
||||
**acdream parse status of the placement list:** PARTIAL. `PlayerDescriptionParser`
|
||||
(0x0013) "walks all sections through enchantments; the trailing options / inventory /
|
||||
**equipped** sections are partial" (`PlayerDescriptionParser.cs:70-77`). So acdream
|
||||
does NOT yet surface the equipped `InventoryPlacement` list. The per-item equip
|
||||
*state* is, however, available from `CreateObject`/`ObjDescEvent` ModelData
|
||||
(palette/part swaps already applied to the model). **CONFIRMED** (parser comment).
|
||||
|
||||
## 4. Wield / unwield wire + the ObjDesc change
|
||||
|
||||
### 4a. Wire table
|
||||
|
||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `0x001A` (GameAction) | GetAndWieldItem | C→S | drop an item onto an equip slot / doll (auto-wield) | `GameActionGetAndWieldItem.Handle` (`Actions/GameActionGetAndWieldItem.cs:7-14`) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`C2S/Actions/Inventory_GetAndWieldItem.generated.cs:14-42`: `uint ObjectId; EquipMask Slot`) | **MISSING** (no sender in acdream; `Grep GetAndWieldItem\|0x001A src` finds only the UI font-property 0x1A, unrelated) |
|
||||
| `0x0019` (GameAction) | PutItemInContainer / move-to-pack (un-wield) | C→S | drag a wielded item back into a pack | ACE `GameActionPutItemInContainer` | `Inventory_PutItemInContainer*` | MISSING (inventory deep-dive scope) |
|
||||
| `0xF625` | ObjDescEvent | S→C | server applies/removes the wielded item → appearance change | `GameMessageObjDescEvent` ctor → `worldObject.SerializeUpdateModelData` (`Messages/GameMessageObjDescEvent.cs:10-17`) | (ModelData block) | **PARSED** — `ObjDescEvent.cs:33-73` (opcode `0xF625`, `CreateObject.ReadModelData`) |
|
||||
| `0xF745`/`0x0024` (CreateObject) | CreateObject | S→C | the wielded item object itself arrives | ACE creation message | `Item_CreateObject` | PARSED — `CreateObject.cs` |
|
||||
| `0xF7B0`/`0x0013` (GameEvent) | PlayerDescription (equipped list) | S→C | full state incl. `InventoryPlacement` equipped section | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — `PlayerDescriptionParser.cs` (equipped section not surfaced) |
|
||||
|
||||
Wire payload of `GetAndWieldItem` (**CONFIRMED** both refs agree):
|
||||
- ACE reads `uint itemGuid; (EquipMask)int32 location` (`GameActionGetAndWieldItem.cs:10-11`).
|
||||
- Chorizite writes `uint ObjectId; (uint)EquipMask Slot` (`.generated.cs:38-41`).
|
||||
- holtburger sends `GetAndWieldItem { item_guid, equip_mask }`
|
||||
(`holtburger-core/src/client/commands.rs:808-814`):
|
||||
```rust
|
||||
self.send_game_action(GameAction::GetAndWieldItem(Box::new(
|
||||
GetAndWieldItemActionData { item_guid: item, equip_mask: target_mask })))
|
||||
```
|
||||
with `target_mask` resolved by `resolve_and_clear_slots(item, slot)` (line 799) —
|
||||
i.e. the client picks the EquipMask for the target slot, exactly like the doll's
|
||||
`GetLocationInfoFromElementID`. **CONFIRMED.**
|
||||
|
||||
`GameActionType.GetAndWieldItem = 0x001A` (**CONFIRMED**
|
||||
`ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:14`).
|
||||
|
||||
### 4b. The ObjDesc change on the model (`ObjDescEvent` → `RedressCreature`)
|
||||
|
||||
Server side: equipping changes the creature's `ObjDesc` (clothing base, sub-palettes,
|
||||
texture changes, anim-part swaps) and broadcasts `ObjDescEvent (0xF625)` carrying the
|
||||
FULL new appearance (ACE comment: "It contains the entire description of what they're
|
||||
wearing", `GameMessageObjDescEvent.cs:6-9`).
|
||||
|
||||
Client side: `gmPaperDollUI::RecvNotice_PlayerObjDescChanged` (line 174324) tail-calls
|
||||
`gmPaperDollUI::RedressCreature` (line 173990). **CONFIRMED.** RedressCreature:
|
||||
```
|
||||
if (m_pInventoryObject == 0 && smartbox->player != 0) { // first time:
|
||||
eax_5 = CPhysicsObj::makeObject(GetPhysicsObject(player_id)); // clone player obj
|
||||
this->m_pInventoryObject = eax_5;
|
||||
CPhysicsObj::set_heading(eax_5, 191.367905f, 1); // face ~191° (toward viewer)
|
||||
CPhysicsObj::set_sequence_animation(m_pInventoryObject, m_didAnimation.id, 1, 1, 0);
|
||||
CreatureMode::AddObject(&m_pPaperDoll->creature_mode_objects, m_pInventoryObject);
|
||||
}
|
||||
visualDesc = SmartBox::get_player_visualdesc(smartbox);
|
||||
CPhysicsObj::DoObjDescChangesFromDefault(this->m_pInventoryObject, visualDesc); // re-dress
|
||||
```
|
||||
**CONFIRMED** (lines 173997-174012). So the doll is a CLONE of the player's
|
||||
`CPhysicsObj`, and re-dressing is `CPhysicsObj::DoObjDescChangesFromDefault` applied
|
||||
to the cloned object using the player's current `VisualDesc` — **the same ObjDesc
|
||||
apply used for in-world creatures**. The ObjDesc fields (ACViewer
|
||||
`Entity/ObjDesc.cs:18-54`): `PaletteID`, `SubPalettes`, `TextureChanges`,
|
||||
`AnimPartChanges` — **all four already parsed by acdream's `CreateObject.ReadModelData`
|
||||
/ `ObjDescEvent`** (`CreateObject.cs:652-679`: subPalette/textureChange/animPartChange
|
||||
counts + entries). **CONFIRMED.**
|
||||
|
||||
## 5. Paperdoll 3D rendering + reuse analysis
|
||||
|
||||
### 5a. It is a 3D viewport, not a 2D image
|
||||
|
||||
**CONFIRMED.** The doll is `UIElement_Viewport` (Type `0xD`), element `0x100001D5`.
|
||||
`UIElement_Viewport::Create` (line 119029-119037) allocates the element + a
|
||||
`CreatureMode` sub-object at `+0x5f0`; `PostInit` calls
|
||||
`CreatureMode::InitializeScene` (line 119084). `SetCamera` forwards to
|
||||
`CreatureMode::SetCameraPosition/Direction` (line 119089-119094). `Register` ⇒
|
||||
`RegisterElementClass(0xd, …)` (line 119126). So a Viewport is a mini 3D scene
|
||||
embedded in a UI rect, with its own camera, lights, and an object list.
|
||||
|
||||
The paperdoll init (line 175517-175535) does, once:
|
||||
```
|
||||
m_pPaperDoll = GetChildRecursive(this, 0x100001d5)->DynamicCast(0xd); // the viewport
|
||||
UIElement_Viewport::SetCamera(m_pPaperDoll, &dir, &pos); // pos/dir vec3s
|
||||
UIElement_Viewport::SetLight(m_pPaperDoll, DISTANT_LIGHT, 2.0, &dir); // one distant light
|
||||
CreatureMode::UseSharpMode(&m_pPaperDoll->creature_mode_objects); // sharper mip bias
|
||||
gmPaperDollUI::RedressCreature(this); // build + dress the doll
|
||||
```
|
||||
**CONFIRMED.** `UpdateForRace` (line 174129) re-points the camera per body-type
|
||||
(case 6/7/8/9/0xC/0xD = the playable races/genders) and swaps `m_didAnimation` (the
|
||||
idle pose DID) via `DBObj::GetDIDByEnum`. **CONFIRMED.**
|
||||
|
||||
### 5b. The viewport render loop (`CreatureMode::Render`)
|
||||
|
||||
`CreatureMode::Render` (line 91665) is the per-frame doll draw. Walk-through
|
||||
(**CONFIRMED** lines 91665-91776):
|
||||
1. Enter "creature mode" (disables world LOD degrade so the doll is full detail).
|
||||
2. For each object in `creature_mode_objects`: `CPhysicsObj::update_position` (advance
|
||||
the idle animation).
|
||||
3. Set ambient color, sunlight, FOV (`Render::SetFOVRad`), push a frame.
|
||||
4. `Render::update_viewpoint(&creature_view_frame)`, `set_default_view()`.
|
||||
5. `RenderDevice::DrawObjCellForDummies(creature_cell)` — draw the object's private
|
||||
cell, then `D3DPolyRender::FlushAlphaList`.
|
||||
|
||||
i.e. the doll lives in its own tiny `creature_cell`, lit by one distant light, drawn
|
||||
with a dedicated camera into the viewport rect. `CreatureMode::AddObject` (line 94374)
|
||||
adds the cloned `CPhysicsObj` to that cell:
|
||||
`CPhysicsObj::AddObjectToSingleCell(obj, creature_cell); SetPlacementFrame(obj,0,1);`.
|
||||
**CONFIRMED.**
|
||||
|
||||
### 5c. Can acdream REUSE its existing character render path? — YES
|
||||
|
||||
**acdream already renders animated, equipped characters in-world.** The per-instance
|
||||
path is `EntitySpawnAdapter` (`src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`):
|
||||
- `OnCreate(WorldEntity)` builds an `AnimatedEntityState(sequencer)` and applies
|
||||
`entity.HiddenPartsMask`, every `entity.PartOverrides` (`SetPartOverride(partIndex,
|
||||
gfxObjId)` — weapons/clothing/helmets that replace the Setup default), and
|
||||
pre-warms per-instance palette/texture decode via
|
||||
`GetOrUploadWithPaletteOverride(surfaceId, texOverride, paletteOverride)`.
|
||||
**CONFIRMED** `EntitySpawnAdapter.cs:100-168`.
|
||||
- `WorldEntity` carries `SourceGfxObjOrSetupId`, `MeshRefs`, `PaletteOverride`,
|
||||
`PartOverrides` (`record struct PartOverride(byte PartIndex, uint GfxObjId)`), and
|
||||
`HiddenPartsMask`. **CONFIRMED** `WorldEntity.cs:14,28,37,97,104,213`.
|
||||
|
||||
This is the EXACT data a re-dress produces: ObjDesc → base palette + sub-palettes
|
||||
(`PaletteOverride`), texture changes (`SurfaceOverrides`), anim-part swaps
|
||||
(`PartOverrides`). acdream already turns an `ObjDescEvent`/`CreateObject` ModelData
|
||||
into these fields. **So the paperdoll doll = "take the local player's WorldEntity (or
|
||||
a clone of it), feed it through the existing animated-character pipeline, and draw it
|
||||
with a fixed camera + one distant light into a UI rect."** This is the C# analog of
|
||||
`makeObject(player) + DoObjDescChangesFromDefault + CreatureMode::Render`.
|
||||
|
||||
### 5d. What a `UiViewport` (Type 0xD) widget needs to host the 3D render
|
||||
|
||||
The toolkit's `UiRenderContext` is a **2D** sprite/text submission bucket (see
|
||||
`UiElement.OnDraw(UiRenderContext)`). A 3D model render cannot go through it. A
|
||||
`UiViewport` widget therefore needs (LIKELY design — flagged):
|
||||
1. **A render-into-rect hook.** The widget's screen rect (`ScreenPosition` +
|
||||
Width/Height) defines a GL scissor + viewport. A 3D pass renders the single entity
|
||||
there, AFTER the world pass and BEFORE/INTERLEAVED with the 2D UI pass. The cleanest
|
||||
seam is a dedicated overlay callback the `UiHost`/`GameWindow` invokes for any
|
||||
`UiViewport` present, NOT a draw inside `OnDraw` (which only has a 2D context).
|
||||
**UNVERIFIED** — the exact integration point (a new `IUiViewportRenderer` Core
|
||||
interface implemented in App, per Code-Structure Rule 2) is a design call for the
|
||||
brainstorm/spec phase, not yet decided.
|
||||
2. **A private mini-scene** mirroring `CreatureMode`: one entity (`AnimatedEntityState`
|
||||
for the player clone), a fixed camera (position/direction vec3 like
|
||||
`SetCamera`, e.g. the retail values `dir.z=0.12, pos=(~-2.4, ~0.88)` floats from
|
||||
`UpdateForRace` — see the `0x3df5c28f / 0xc019999a / 0x3f6147ae` immediates at line
|
||||
175524-175526, which are little-endian floats ≈ 0.12, −2.4, 0.88; **LIKELY** —
|
||||
I read the hex but did not byte-convert each), one distant light, and an idle
|
||||
animation playing on the sequencer.
|
||||
3. **A heading toward the viewer** (`set_heading(191.37°)`, line 174001) and optional
|
||||
click-drag rotation (the doll spins under the mouse — that's
|
||||
`m_paperDollDragMask`/`CreateClickMap`, line 174636; **part-selection lighting** for
|
||||
"which armor piece is this?" highlight uses `ApplyPartSelectionLighting`, line
|
||||
174034, but that is a polish feature, not MVP).
|
||||
4. **Reuse `EntitySpawnAdapter`'s state** — feed it the player's `WorldEntity` so the
|
||||
doll automatically reflects equip changes when `ObjDescEvent` updates the player's
|
||||
ModelData. The re-dress is then "rebuild the player WorldEntity's PartOverrides/
|
||||
PaletteOverride from the new ObjDesc and refresh the viewport's entity state" — the
|
||||
C# analog of `RedressCreature`.
|
||||
|
||||
This is the single biggest new piece. The 3D machinery exists; the work is the
|
||||
**UI↔3D bridge** (a scissored single-entity pass driven by a UI rect).
|
||||
|
||||
## 6. New toolkit widgets this introduces
|
||||
|
||||
| Widget (proposed) | dat Type it registers at | leaf vs container | Purpose |
|
||||
|---|---|---|---|
|
||||
| **`UiViewport`** | **0xD** (`UIElement_Viewport`, reg line 119126) | **leaf** (`ConsumesDatChildren => true`) | Hosts a single 3D entity (the paperdoll character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`; reuses `EntitySpawnAdapter`/`AnimatedEntityState` for the model. Needs a new render-into-rect seam (a Core `IUiViewportRenderer` interface implemented in App). **The biggest new piece.** |
|
||||
| **`UiItemSlot`** (equip-slot variant of the shared item-slot) | **0x10000031** (`UIElement_ItemList`, single 32×32 cell) | **leaf** (`ConsumesDatChildren => true`) | One equip slot. Renders the equipped item's icon (from the weenie `IconDataID`), is a drag-drop target keyed to its `EquipMask` (from `GetLocationInfoFromElementID`), shows/hides per occupancy. NOTE: this is the single-cell case of the shared `UIElement_UIItem`/`UIElement_ItemList` spine widget — the equipment panel is a fixed grid of ~25 of these, one per EquipMask, NOT a scrollable list. **Defer the shared icon/drag mechanics to the spine doc** (`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`, NOT FOUND yet); this panel only adds the EquipMask binding + the fixed-position-per-slot layout. |
|
||||
| **Window manager** (shared, not paperdoll-specific) | n/a (uses Dragbar Type 2 / Resizebar Type 9 already present on chrome) | n/a | Open/close/z-order/persist for the paperdoll window. `UiElement.Draggable/Resizable` already exist; the manager wires them + persistence. Shared with inventory/toolbar — same item the handoff §2 calls "the other deferred Plan-2 piece". |
|
||||
|
||||
`gm3DItemsUI`'s pane reuses `UiItemSlot`/the spine `UiItemList` + a `UiScrollbar`
|
||||
(Type 0xB, already built) + a `UiText` (already built) — no NEW widget. It is an
|
||||
inventory-contents list (inventory deep-dive scope), not a doll.
|
||||
|
||||
## 7. Open questions / UNVERIFIED
|
||||
|
||||
- **`0x100001E0` = MissileAmmo `0x800000`** — LIKELY (the decomp immediate is
|
||||
corrupted to a string pointer at line 173676; inferred from the EquipMask gap +
|
||||
neighbors). Re-dump element `0x100001E0`'s position vs the ammo doll slot, or
|
||||
re-decompile `0x004a388a` in Ghidra to recover the real immediate, to confirm.
|
||||
- **The exact viewport camera/light immediates** (lines 175524-175526, 174144-174146)
|
||||
— I read the hex but did not byte-convert all of them to floats; the paperdoll
|
||||
brainstorm should decode `0x3df5c28f≈0.12`, `0xc019999a≈−2.4`, `0xc0400000=−3.0`,
|
||||
`0xc059999a≈−3.4`, `0x3f6147ae≈0.88`, `0x3f800000=1.0` precisely for a faithful
|
||||
framing. **UNVERIFIED.**
|
||||
- **The UI↔3D render seam** (how a UI rect drives a scissored single-entity 3D pass,
|
||||
and whether it draws after the world pass or as a UI overlay) — DESIGN-OPEN, to be
|
||||
settled in brainstorm. Code-Structure Rule 2 means the seam is a Core interface
|
||||
implemented in App. **UNVERIFIED.**
|
||||
- **acdream's PlayerDescription equipped section** is not surfaced
|
||||
(`PlayerDescriptionParser.cs:70-77`). To populate slot icons at login (vs only
|
||||
reacting to later `ObjDescEvent`s), the parser must be extended to read the
|
||||
`InventoryPlacement` equipped list. Filed as a dependency, not yet an issue.
|
||||
- **Whether the doll clones the player `WorldEntity` or builds a fresh one** — retail
|
||||
clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`, line
|
||||
173999). acdream has no player `CPhysicsObj`-as-renderable today (the local player
|
||||
isn't a `WorldEntity` in the per-instance adapter — it's the camera). LIKELY the
|
||||
paperdoll builds a dedicated `WorldEntity` from the local player's
|
||||
Setup+ObjDesc and feeds it to a private `EntitySpawnAdapter`-like host. **UNVERIFIED.**
|
||||
- **`gm3DItemsUI` true role** — its `m_itemList` + "Contents of Backpack" text is
|
||||
CONFIRMED, but whether retail ever shows 3D item models in it (the name suggests a
|
||||
historical 3D-preview) — NOT FOUND any Viewport in its layout; treated as a 2D
|
||||
contents list. If a 3D item preview surfaces elsewhere, revisit.
|
||||
|
||||
## 8. MEMORY.md index line
|
||||
|
||||
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
# Inventory panel deep-dive — `gmInventoryUI` + `gmBackpackUI`
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Phase:** D.2b core-panels research (report-only). Sibling of the action-bar
|
||||
and paperdoll deep-dives; builds on the `UIElement_UIItem` / icon / drag-drop
|
||||
**spine** research (see §1 note). Answers handoff §3 questions **Q1** (this
|
||||
panel's `LayoutDesc`), **Q7** (window layout), **Q8** (full inventory
|
||||
wire-message set), **Q9** (icon rendering states).
|
||||
|
||||
## 1. Summary + confidence legend
|
||||
|
||||
The retail inventory window is two cooperating dat windows. **`gmInventoryUI`
|
||||
(class `0x10000023`, `LayoutDesc 0x21000023`, 300×362)** is the OUTER frame: a
|
||||
title bar, a chrome border, and three slots that host CHILD windows —
|
||||
`gmPaperDollUI` (the equipped-gear doll), `gmBackpackUI` (the pack list), and
|
||||
`gm3DItemsUI` (the 3D rotating-character viewport). **`gmBackpackUI` (class
|
||||
`0x10000022`, `LayoutDesc 0x21000022`, 61×339)** is the left strip: a burden
|
||||
**Meter** (Type 7) + a `%`-burden text label, the main-pack item grid
|
||||
(`UIElement_ItemList` `0x10000031`), and the side-pack tab column (a second
|
||||
`UIElement_ItemList`). Every cell in those grids is a `UIElement_UIItem`
|
||||
(class `0x10000032`) — the shared spine widget. Items are server-spawned
|
||||
**`ACCWeenieObject`** weenies; the client learns container contents from
|
||||
`CreateObject (0xF745)` + `PlayerDescription (0x0013)` at login and from the
|
||||
`0xF7B0` GameEvent family (`ViewContents 0x0196`, `InventoryPutObjInContainer
|
||||
0x0022`, `WieldObject 0x0023`, …) thereafter; it manipulates them with
|
||||
`0xF7B1` GameActions (`PutItemInContainer 0x0019`, `DropItem 0x001B`,
|
||||
`GetAndWieldItem 0x001A`, the `Stackable*` family, `GiveObjectRequest 0x00CD`).
|
||||
|
||||
acdream already has the outbound builders for most actions
|
||||
(`InventoryActions.cs`, `InteractRequests.cs`) and parsers for most inbound
|
||||
events (`GameEvents.cs`), plus a live `ItemRepository`. The gaps are concrete
|
||||
and enumerated in §4: a missing `DropItem`/`GetAndWieldItem`/`ViewContents`/
|
||||
`NoLongerViewingContents` parser-or-builder, a 4th field on
|
||||
`InventoryPutObjInContainer`, and `CreateObject` not yet extracting
|
||||
`IconId`/`WeenieClassId`/`StackSize`/capacities.
|
||||
|
||||
> **Spine dependency.** The handoff said the SPINE agent's doc would live at
|
||||
> `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
|
||||
> At the time of writing **that file does NOT exist** (only the handoff
|
||||
> `2026-06-16-action-bar-inventory-equipment-handoff.md` is present — verified
|
||||
> by `Glob docs/research/2026-06-16-*.md`). I therefore derived the
|
||||
> inventory-relevant `UIElement_UIItem` facts FIRST-HAND from the decomp and
|
||||
> cite them here; where the spine doc later goes deeper (icon DBObj render,
|
||||
> drag state machine), this doc should be read as the inventory-specific layer
|
||||
> on top of it.
|
||||
|
||||
**Confidence legend:**
|
||||
- **CONFIRMED** — quoted from a source I opened (decomp `class::method` + line,
|
||||
or a real `file:line`).
|
||||
- **LIKELY** — inferred from a confirmed source; the inference is named.
|
||||
- **UNVERIFIED** — educated guess, flagged loudly; do not port without checking.
|
||||
|
||||
---
|
||||
|
||||
## 2. LayoutDesc / element map (Q1, Q7)
|
||||
|
||||
### 2.1 `gmInventoryUI` — outer frame, `LayoutDesc 0x21000023` (300×362)
|
||||
|
||||
**CONFIRMED Q1.** `gmInventoryUI::Register` registers element class `0x10000023`:
|
||||
> `gmInventoryUI::Register (decomp line 176285): UIElement::RegisterElementClass(0x10000023, gmInventoryUI::Create);`
|
||||
|
||||
The window is built from `LayoutDesc 0x21000023` (pre-dump
|
||||
`.layout-dumps/inventory-0x21000023.txt`). The root element `0x100001CC`
|
||||
(Type `268435491 = 0x10000023` = the gmInventoryUI class itself) is 300×362 at
|
||||
ZLevel 1000. `gmInventoryUI::PostInit` (decomp 176236) resolves its named
|
||||
children by id — these element ids match the dump 1:1, which is what confirms
|
||||
the map:
|
||||
|
||||
| Dump element | X,Y,W,H | Type (resolved) | PostInit binds to | Role |
|
||||
|---|---|---|---|---|
|
||||
| `0x100001CC` (root) | 0,0 300×362 | `0x10000023` gmInventoryUI | — | window root |
|
||||
| `0x100001CD` | 0,23 224×214 | `0x10000024` (base `0x21000024`) | `m_paperDollUI` (DynamicCast `0x10000024`) | nested **PaperDoll** window |
|
||||
| `0x100001CE` | 239,23 61×339 | `0x10000022` (base `0x21000022`) | `m_backpackUI` (DynamicCast `0x10000022`) | nested **Backpack** strip |
|
||||
| `0x100001CF` | 0,237 234×120 | `0x10000021` (base `0x21000021`) | `m_3DItemsUI` (DynamicCast `0x10000021`) | nested **3D items** viewport |
|
||||
| `0x100001D3` | 0,0 276×25 | base `0x21000191` | `m_titleText` (`GetChildRecursive`) | title bar ("Inventory of %s") |
|
||||
| `0x100001D2` | 276,0 24×23 | base `0x10000... 0x21000192` | (button: chrome) | close/X button (states Normal/pressed) |
|
||||
| `0x100001D1` | 0,361 300×1 | Type 3 (Field/chrome) | — | bottom rule line (sprite `0x06004D0B`) |
|
||||
| `0x100001D0` | 0,0 300×362 | Type 3 (Field/chrome) | — | full-window backdrop (`0x06004D0A`, Alphablend) ZLevel 100 |
|
||||
|
||||
PostInit excerpt (CONFIRMED):
|
||||
> `gmInventoryUI::PostInit (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.
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
# UI item-slot SPINE — icon-composite render + widget-level drag-drop — deep dive
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only.
|
||||
**Role:** completes the workflow's MISSING 5th doc — the shared item-slot/icon/drag-drop
|
||||
**spine** that the action-bar, inventory, and paperdoll deep-dives all depend on. The
|
||||
spine agent died on a transient API error before writing anything; this doc is the
|
||||
recovery + the gap-fill.
|
||||
**Deliverable:** this doc only. No C# changed; no game run.
|
||||
|
||||
> ## What this doc adds vs. the four existing docs
|
||||
> The three panel agents + the synthesis already recovered the **identity** facts of the
|
||||
> two spine widgets first-hand and re-verified them (synthesis §0 re-verifications,
|
||||
> §1 table, §2). I do **not** re-derive those — I cite and extend them. My NEW,
|
||||
> spine-owned contributions are the three things the panel docs explicitly deferred:
|
||||
> 1. **The icon-composite render port spec** (synthesis §4 Step 0, §5 risk #1) — the
|
||||
> full `IconData::RenderIcons` blit pipeline, and the definitive answer to the
|
||||
> direct-RenderSurface-vs-Icon-composite decode question.
|
||||
> 2. **The widget-level drag-drop state machine** (synthesis §5 risk #1, §8) — the
|
||||
> `UIElement_Field`/`UIElement_UIItem` hooks every cell inherits, below the per-panel
|
||||
> `HandleDropRelease` the panel docs covered.
|
||||
> 3. **The consolidated, authoritative `UIElement_UIItem` port spec** with the resolved
|
||||
> field names — including the **`+0x5FC` resolution** (synthesis §5 risk #2): it is
|
||||
> `UIElement_UIItem::itemID`.
|
||||
>
|
||||
> **Obsoletes in the synthesis** (the parent should patch these now that the spine
|
||||
> exists): the ⚠ banner (synthesis lines 13-31), §4 Step 0's "re-do / complete the
|
||||
> spine research (blocking)", §5 risk #1 (spine never written), §5 risk #2's "stays
|
||||
> UNVERIFIED", §6's "⚠ the SPINE doc was never written", §8's blocking note, and the
|
||||
> two panel-doc index lines' "spine still owed" caveats. Details in the closing
|
||||
> summary.
|
||||
|
||||
## 1. Summary + confidence legend
|
||||
|
||||
Every item-bearing slot in all three D.2b panels is the same pair of retail widgets:
|
||||
the **item-cell** `UIElement_UIItem` (element class `0x10000032`) sits inside a
|
||||
**slot/grid** `UIElement_ItemList` (element class `0x10000031`). The cell holds a bound
|
||||
object id (`itemID`), resolves it to an `ACCWeenieObject`, and draws a composited 32×32
|
||||
icon plus a stack of overlay sub-elements (quantity text, capacity/structure Type-7
|
||||
meters, a 10-step cooldown ring, selected/ghosted/open-container/drag-accept/sell/trade
|
||||
overlays). The icon itself is **composited at runtime from up to five `0x06xx`
|
||||
RenderSurfaces** (base + custom-underlay + custom-overlay + item-type-default-underlay +
|
||||
spell-effect-overlay) blitted into one private 32×32 surface — NOT a single texture.
|
||||
Drag-drop is a generic chain inherited from `UIElement_Field`: the cell is both a
|
||||
drag-SOURCE (`ItemList_BeginDrag` on left-press-and-move) and a drop-TARGET
|
||||
(`MouseOverTop` rollover → accept/reject state, `CatchDroppedItem` on release →
|
||||
`HandleDropRelease`), with `InqDropIconInfo` extracting the dragged object id + flags
|
||||
that tell a fresh-from-inventory drag (`flags&0xE==0`) from a within-list reorder
|
||||
(`flags&4`).
|
||||
|
||||
**acdream is well-positioned:** `ItemInstance` already models `IconId`/`IconUnderlayId`/
|
||||
`IconOverlayId`/`StackSize`/`ContainerId`/`ContainerSlot`; `TextureCache.
|
||||
GetOrUploadRenderSurface` already decodes a `0x06` id directly; `UiRoot` already has a
|
||||
real drag-drop state machine (`DragSource`/`DragPayload`/`BeginDrag`/`UpdateDragHover`/
|
||||
`FinishDrag`, even commented with the retail `0x15→0x21→0x1C→0x3E` event chain). The
|
||||
concrete gaps: `CreateObject` discards `IconId`; there is no multi-layer icon-compositor;
|
||||
`UiField` names the `CatchDroppedItem`/`MouseOverTop` hooks in a doc-comment but does not
|
||||
implement them yet.
|
||||
|
||||
**Confidence legend:**
|
||||
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
|
||||
`file:line` I opened this session.
|
||||
- **LIKELY** — inferred from a CONFIRMED source; the inference is named.
|
||||
- **UNVERIFIED** — educated guess, flagged loudly.
|
||||
|
||||
---
|
||||
|
||||
## 2. `UIElement_UIItem` port spec (consolidated + authoritative)
|
||||
|
||||
### 2.1 Identity + the resolved struct (`+0x5FC` = `itemID`)
|
||||
|
||||
`UIElement_UIItem::Register` (decomp 229339):
|
||||
`UIElement::RegisterElementClass(0x10000032, UIElement_UIItem::Create);` — class
|
||||
`0x10000032`. It is a `UIElement_Field` subclass: the destructor chains
|
||||
`UIElement_Field::~UIElement_Field(this)` (decomp 229326), and `Field::Register` is
|
||||
`RegisterElementClass(3, …)` (decomp 126190) ⇒ the underlying generic Type is **3**.
|
||||
CONFIRMED.
|
||||
|
||||
**`+0x5FC` RESOLVED — it is `UIElement_UIItem::itemID`.** The toolbar doc anchored the
|
||||
bound object id by raw offset `+0x5FC` only (toolbar §3, UNVERIFIED name). The named
|
||||
decomp resolves it: `UIItem_Update` reads `uint32_t itemID = this->itemID;` (decomp
|
||||
230230) and `this->weenObj = ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp
|
||||
230235). `HandleTargetedUseLeftClick` reads `uint32_t itemID = arg2->itemID;` (decomp
|
||||
230422). `ItemList_AddItem`'s rebuild loop tests `eax_2->itemID == arg2` (decomp 233107).
|
||||
So the field the toolbar's `RemoveShortcutInSlotNum` read at `+0x5FC` is **`itemID`** —
|
||||
the bound weenie/object guid. CONFIRMED. (The companion spell-shortcut id is
|
||||
`this->spellID`, decomp 230239/230414.)
|
||||
|
||||
**Resolved instance fields** (all CONFIRMED from `UIItem_Update` 230226-230393,
|
||||
`UIItem_SetIcon` 230143, `PostInit` 229668, `SetShortcutNum` 229465, the setters
|
||||
229190-229286, and the `acclient.h` `IconData`/`PublicWeenieDesc` structs):
|
||||
|
||||
| Field | Meaning | Anchor |
|
||||
|---|---|---|
|
||||
| `itemID` | bound object/weenie guid (the retail `+0x5FC`) | 230230 |
|
||||
| `spellID` | spell-shortcut id (0 for an item) | 230239, 230414 |
|
||||
| `weenObj` | cached `ACCWeenieObject*` from `GetWeenieObject(itemID)` | 230235 |
|
||||
| `selected` | mirror of `weenObj->selected` | 230269 |
|
||||
| `effects` | mirror of `weenObj->pwd._effects` | 230293 |
|
||||
| `waiting` | mirror of `weenObj->waiting` (the pending/ghost flag) | 230336 |
|
||||
| `isOpenable`/`isContainer`/`isContainerHolder` | container-capability flags from `_bitfield`/`_itemsCapacity`/`_containersCapacity` | 230298-230331 |
|
||||
| `m_quantity` | stack count to display | 229285 |
|
||||
| `m_selectable` | whether selection is allowed | 229266 |
|
||||
| `unghostable` | suppress the ghost overlay | 229199 |
|
||||
| `m_shortcutNum` / `m_shortcutGhosted` / `m_delayedShortcutNum` | toolbar slot index + deferred-bind sentinel `0xFFFFFFFF` | 229542-229543, 230344-230349 |
|
||||
| `m_sellState` / `m_tradeState` | vendor-sell / trade-window markers | 230362, 230377 |
|
||||
| `m_dragIcon` | translucent drag-ghost copy (created in PostInit, id `0x10000345`) | 229738 |
|
||||
|
||||
### 2.2 Sub-element id map (from `PostInit`, decomp 229672-229733) — all CONFIRMED
|
||||
|
||||
`PostInit` binds each overlay/feature sub-element by `GetChildRecursive(this, id)`. These
|
||||
ids live in the cell template `LayoutDesc 0x21000037`; the importer must reproduce them
|
||||
procedurally (the cell is a behavioral leaf). The dump `.layout-dumps/uiitem-0x21000037.txt`
|
||||
gives the per-state sprite ids (column 3 below).
|
||||
|
||||
| Member | Element id | Type | Role | Dump sprite(s) (state → 0x06id) |
|
||||
|---|---|---|---|---|
|
||||
| `m_elem_Icon` | `0x1000033B` | 3 | the composited icon, AND the empty-slot bg | `ItemSlot_Empty → 0x060074CF` (dump:45) |
|
||||
| `m_elem_Icon_Overlays` | `0x1000033C` | — | enchantment/effect overlay layer | (state-driven; see §3) |
|
||||
| `m_elem_Text` | `0x10000344` | 12 (Text) | spell name / label text | — |
|
||||
| `m_elem_Icon_CapacityBar` | `0x10000347` | 7 (Meter) | container fill (numContained/itemsCapacity) | `DirectState 0x06004D22`+`0x06004D23` (dump:693,710) |
|
||||
| `m_elem_Icon_StructureBar` | `0x10000348` | 7 (Meter) | structure/charges fill | `DirectState 0x06004D24`+`0x06004D25` (dump:727,744) |
|
||||
| `m_elem_Icon_Selected` | `0x10000342` | 3 | selection highlight | `0x06001A97 / 0x06001396 / 0x060067D2` per variant (dump:95,311,541) |
|
||||
| `m_elem_Icon_Ghosted` | `0x10000349` | 3 | greyed "pending server confirm" overlay | `DirectState 0x0600109A` (dump:761) |
|
||||
| `m_elem_Icon_ShortcutNum` | `0x1000034A` | 3 | the slot-number badge (toolbar) | media set at runtime via `SetMediaImage` (229508) |
|
||||
| `m_elem_Icon_SellState` | `0x10000437` | 3 | vendor-sell marker | — |
|
||||
| `m_elem_Icon_TradeState` | `0x10000438` | 3 | trade-window marker | — |
|
||||
| `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) |
|
||||
| `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) |
|
||||
| `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — |
|
||||
| `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | `DirectState 0x0600109D / 0x060012D9 / 0x06001DAE / 0x060067CF..D1 …` (dump:778-863) |
|
||||
| `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) |
|
||||
|
||||
**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept`
|
||||
(from the dump): `ItemSlot_Empty` (the empty-slot background sprite, default
|
||||
`0x060074CF`), `ItemSlot_DragOver_Accept` (`0x060011F9`), `ItemSlot_DragOver_Reject`
|
||||
(`0x060011F8`), `ItemSlot_DragOver_DropIn` (`0x060011F7`). The DragAccept neutral/reset
|
||||
**UIStateId** is `0x1000003f`; the inventory agent's `0x10000040`(reject)/`0x10000041`
|
||||
(accept) SetState ids (synthesis §0 re-verification, decomp 229180-229413) are the
|
||||
internal element states `SetDragAcceptState` writes — both are real; the LayoutDesc named
|
||||
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
|
||||
the C++ side. CONFIRMED.
|
||||
|
||||
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
|
||||
|
||||
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound
|
||||
weenie or its display state changes. Walk-through (CONFIRMED 230226-230392):
|
||||
1. Resolve `weenObj = GetWeenieObject(itemID)` (230235). If null & has a spellID →
|
||||
`UIItem_SetState(0x1000001d)` + `UIItem_SetIcon`; if null & no spell →
|
||||
`UIItem_SetState(0x1000001c)` (= empty) + `ClearTooltip`. (230232-230250)
|
||||
2. Set `m_elem_Icon` / `m_elem_Text` / `m_elem_Icon_Overlays` to state `0x1000001d`
|
||||
(= occupied). (230256-230265)
|
||||
3. **`UIItem_SetIcon(this)`** — (re)build the composited icon (§3). (230268)
|
||||
4. Sync `selected` ↔ `weenObj->selected`, toggling `m_elem_Icon_Selected` visibility
|
||||
(gated on `m_selectable`). (230269-230290)
|
||||
5. Recompute `isOpenable`/`isContainer`/`isContainerHolder` from
|
||||
`_bitfield`/`_itemsCapacity`/`_containersCapacity` (the player's own cell is always
|
||||
openable). (230298-230331)
|
||||
6. `UpdateCapacityDisplay` (Type-7 meter = numContained/itemsCapacity, decomp 229554-),
|
||||
`UpdateStructureDisplay`, `UpdateQuantityDisplay`, `UpdateCooldownDisplay`.
|
||||
(230332-230335)
|
||||
7. Sync `waiting` → `SetWaitingState` (toggles `m_elem_Icon_Ghosted`). (230336-230342)
|
||||
8. Apply any deferred `m_delayedShortcutNum` (re-bind once the weenie loaded). (230344-230350)
|
||||
9. Sync `m_shortcutNum`/`m_shortcutGhosted` (230352-230360), `m_sellState`/`m_tradeState`
|
||||
overlays (230362-230389), then `UpdateTooltip`. (230392)
|
||||
|
||||
Companion methods (CONFIRMED): `UIItem_SetIcon` 230143 (§3); `SetShortcutNum(slot,
|
||||
ghosted)` 229465 (writes the slot badge via `SetMediaImage`, mirrors into
|
||||
`ACCWeenieObject::SetShortcutNum`); `SetDelayedShortcutNum` 229238; `SetWaitingState`
|
||||
229190; `SetSelectedState` 229243; `SetSelectableState` 229263; `SetDragAcceptState`
|
||||
229271; `SetOpenContainerState` 229216; `SetQuantity` 229282; `UpdateCapacityDisplay`
|
||||
229554.
|
||||
|
||||
### 2.4 acdream item-cell port = `UiItemSlot`
|
||||
|
||||
A behavioral **leaf** widget (`ConsumesDatChildren => true`) keyed off resolved class
|
||||
`0x10000032`, exactly like the shipped behavioral widgets. It binds an `ItemInstance`
|
||||
(by `itemID`), draws the composited icon (§3), the quantity `UiText`, the capacity/
|
||||
structure `UiMeter`s, the cooldown ring, and the overlay states; it is a drag source +
|
||||
drop target (§5). This aligns with the synthesis §2 row (no correction). The retail
|
||||
sub-element ids in §2.2 become the named child slots the controller toggles.
|
||||
|
||||
---
|
||||
|
||||
## 3. Icon rendering pipeline — THE CRUX
|
||||
|
||||
### 3.1 The decode question, answered definitively
|
||||
|
||||
**Both halves of the synthesis's question are true, layered:** each icon LAYER is a
|
||||
`0x06xx` **RenderSurface decoded directly** (the D.2b memory's `GetOrUploadRenderSurface`
|
||||
path), but the **on-screen icon is a runtime COMPOSITE of up to five of those layers**
|
||||
blitted into one private 32×32 surface. It is NOT a single weenie texture, and it is NOT
|
||||
an "Icon DBObj type that references other surfaces" — there is no Icon DBObj; the
|
||||
composite logic lives entirely in client code (`IconData::RenderIcons`), and every input
|
||||
id is a plain RenderSurface.
|
||||
|
||||
**Proof chain (all CONFIRMED):**
|
||||
- `UIElement_UIItem::UIItem_SetIcon` (decomp 230171) sets the cell's image from
|
||||
`ACCWeenieObject::GetIcon(weenObj)`:
|
||||
`eax_15 = Graphic::Graphic(eax_13, ACCWeenieObject::GetIcon(eax_12)); … UIRegion::SetImage(this->m_elem_Icon, eax_15);`
|
||||
- `ACCWeenieObject::GetIcon` (decomp 408999): `return ACCWeenieObject::GetIconData(this)->m_pIcon;`
|
||||
- `ACCWeenieObject::GetIconData` (decomp 408224) caches a per-object `IconData` (hash by
|
||||
guid), constructing one via `IconData::IconData(eax_4, this, this->id)` (408253) on
|
||||
first use; `IconData::IconData` calls `IconData::RenderIcons(this, arg2)` (407957).
|
||||
- The `IconData` struct (`acclient.h:54112`, verbatim): `m_idIcon`, `m_idCustomOverlay`,
|
||||
`m_idCustomUnderlay`, `m_itemType`, `m_effects`, `Graphic *m_pIcon`, `Graphic *m_pDragIcon`.
|
||||
|
||||
The base id is the weenie's `_iconID`: `ACCWeenieObject::InqIconID` (decomp 406951)
|
||||
returns `this->pwd._iconID.id`. `_iconID`/`_iconOverlayID`/`_iconUnderlayID` are all
|
||||
`IDClass<_tagDataID,32,0>` in `PublicWeenieDesc` (`acclient.h:37168-37170`). CONFIRMED.
|
||||
|
||||
**Every layer is DBObj type `0xc`** — `RenderIcons` fetches each with
|
||||
`DBObj::Get(QualifiedDataID(&v, id, 0xc))` (decomp 407587/407589/407592). DBObj type
|
||||
`0xc` = `DB_TYPE_RENDERSURFACE` = `Texture` in ACE's `DatFileType` enum, id range
|
||||
`0x06000000-0x07FFFFFF` (`references/.../ACE.DatLoader/DatFileType.cs:127-128`). So all
|
||||
five ids are `0x06xx` RenderSurfaces — **decode each via
|
||||
`TextureCache.GetOrUploadRenderSurface`** per the D.2b memory gotcha, NOT `GetOrUpload`
|
||||
(feeding a `0x06` id to `GetOrUpload` walks the Surface→SurfaceTexture chain and returns
|
||||
1×1 magenta — `TextureCache.cs:112-128`, `project_d2b_retail_ui.md` "Dat sprites — the
|
||||
decode path"). CONFIRMED.
|
||||
|
||||
### 3.2 The composite — `IconData::RenderIcons` (decomp 407524), CONFIRMED
|
||||
|
||||
`RenderIcons` builds TWO graphics: `m_pDragIcon` (the drag-ghost, no underlay) and
|
||||
`m_pIcon` (the full slot icon). Field captures first (407528-407532):
|
||||
```
|
||||
m_idIcon = InqIconID() # = pwd._iconID (base)
|
||||
m_idCustomOverlay = pwd._iconOverlayID # server "enchanted" overlay
|
||||
m_idCustomUnderlay= pwd._iconUnderlayID # server "magic" underlay
|
||||
m_itemType = InqType()
|
||||
m_effects = pwd._effects
|
||||
```
|
||||
Player special-case (407546-407549): if `IsThePlayer()`, `m_idIcon =
|
||||
GetDIDByEnum(0x10000004, 7)` (the player container icon) and `m_itemType =
|
||||
TYPE_CONTAINER`.
|
||||
|
||||
Two enum-resolved layers (407552-407584):
|
||||
- **type-default underlay** `eax_11 = DBObj::GetByEnum(LowestSetBit(m_itemType)+1, …)`
|
||||
with enum `0x10000004` (the SkillTable DID-mapper namespace reused as the icon-type
|
||||
table); if `m_itemType` has no bits, index `0x21`. (407555-407564)
|
||||
- **effect overlay** `arg2 = DBObj::GetByEnum(LowestSetBit(m_effects)+1, …)` with enum
|
||||
`0x10000005`; if null, fall back to index `0x21` of the same enum. (407568-407584)
|
||||
|
||||
Then it resolves the three direct ids as DBObjs (407587-407592): `eax_19` =
|
||||
m_idCustomUnderlay, `ebp` = m_idIcon (base), `edi_1`/`var_38` = m_idCustomOverlay.
|
||||
|
||||
**Drag-icon surface** (`m_pDragIcon`, 407594-407625): a 32×32 local surface
|
||||
(`CreateLocalSurface` → `Create(0x20, 0x20, GetUISurfaceFormat, 1)`); blit base
|
||||
`ebp` `Blit_Normal`, then custom-overlay `var_38` `Blit_4Alpha`; `ReplaceColor(...,
|
||||
&pwd._iconOverlayID)` applies the overlay tint; wrapped in a `Graphic`.
|
||||
|
||||
**Full slot icon** (`m_pIcon`, 407626-407647): a second 32×32 surface; blit
|
||||
**type-default underlay `eax_11` `Blit_Normal`**, then **custom-underlay `eax_19`
|
||||
`Blit_3Alpha`**, then **the drag-icon surface `eax_26` `Blit_3Alpha`** on top (base +
|
||||
overlay already baked into it). Wrapped in a `Graphic` → `m_pIcon`.
|
||||
|
||||
**Net composite (bottom → top):**
|
||||
1. item-type default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) — Normal
|
||||
2. server custom underlay (`pwd._iconUnderlayID`) — 3Alpha
|
||||
3. base icon (`pwd._iconID`) — Normal *(baked into the drag layer first)*
|
||||
4. server custom overlay (`pwd._iconOverlayID`) + its tint — 4Alpha
|
||||
5. spell-effect overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) — *(captured `arg2`;
|
||||
note: in the 2013 BN lifting the effect-overlay capture lands but I did not see its
|
||||
explicit `Blit` in the slot-surface block; it feeds the same path. LIKELY blitted as
|
||||
part of the overlay stage — flagged, see §7.)*
|
||||
|
||||
Cache invalidation: `IconData::UpdateIcons` (407962) re-renders only when `InqIconID()`,
|
||||
`_iconOverlayID`, `_iconUnderlayID`, `InqType()`, or `_effects` changed (407968-407976);
|
||||
`ACCWeenieObject::IconDataChanged` (408201) drives it on a property update.
|
||||
|
||||
### 3.3 The decode pipeline acdream should use
|
||||
|
||||
1. On `CreateObject` (and `ObjDescEvent`/property-update), capture `IconId` (`_iconID`),
|
||||
`IconUnderlayId` (`_iconUnderlayID`), `IconOverlayId` (`_iconOverlayID`), `_effects`,
|
||||
and `ItemType` into the `ItemInstance` (the model already has the first three fields;
|
||||
`_effects` needs adding). **Gap:** `CreateObject.TryParse` discards `IconId` —
|
||||
re-verified at `CreateObject.cs:516` (`_ = ReadPackedDwordOfKnownType(body, ref pos,
|
||||
IconTypePrefix); // IconId`) and `:515` (`_ = ReadPackedDword(...) // WeenieClassId`).
|
||||
CONFIRMED.
|
||||
2. For each of the up-to-five layer ids, decode the `0x06xx` RenderSurface **directly**
|
||||
via `TextureCache.GetOrUploadRenderSurface` (per the D.2b gotcha).
|
||||
3. Composite into one 32×32 RGBA target in the order of §3.2. Two faithful options:
|
||||
(a) a CPU compositor matching retail's blit modes (Normal = src-over opaque,
|
||||
3Alpha/4Alpha = the AC alpha blits — see ACViewer `ImgTex`/`RenderSurface` decode for
|
||||
the per-format alpha handling), uploaded as one cached GL texture keyed by the
|
||||
(iconId, underlay, overlay, effects, itemType) tuple; or (b) draw the layers as
|
||||
stacked sprites at the cell rect each frame. Retail does (a) (one `m_pIcon` surface),
|
||||
and caching matches retail's `IconData` per-object cache + `UpdateIcons` dirty check —
|
||||
recommend (a).
|
||||
4. The type-default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) and effect
|
||||
overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) require resolving the retail
|
||||
icon-type / effect DID-mapper enums to concrete `0x06` ids. These map through the dat
|
||||
DidMapper/EnumMapper tables (`DatFileType` 38/36). **For MVP, the base `_iconID`
|
||||
alone is the dominant visual** (most items have no custom underlay/overlay and no
|
||||
effects); the underlay/overlay/effect layers are the "magic/enchanted/glow" polish.
|
||||
LIKELY-safe to ship base-only first, then layer in the composite. (synthesis §5
|
||||
risk #3 — verify IconId is set on a CONTAINED item's CreateObject against a live
|
||||
capture before treating it as the sole source.)
|
||||
|
||||
**Palette note (cross-ref).** Item icons are pre-rendered `0x06` RenderSurfaces; they do
|
||||
NOT take a creature/clothing subpalette overlay at icon-composite time (the composite
|
||||
only blits + tints with `_iconOverlayID`). ACViewer's `TextureCache.cs::IndexToColor`
|
||||
subpalette-overlay is for paletted INDEX16/P8 *world* textures — the canonical reference
|
||||
for THAT path, but the icon path uses the surfaces as-decoded. acdream's WB
|
||||
`TextureHelpers.cs` (in-tree) is the decode reference for the `0x06` formats themselves
|
||||
(BGRA/DXT/P8/INDEX16). CONFIRMED the composite has no subpalette step; LIKELY a paletted
|
||||
UI icon would need a palette (today `GetOrUploadRenderSurface` passes `palette: null` →
|
||||
magenta on a paletted sprite, `TextureCache.cs:135` — flagged §7).
|
||||
|
||||
### 3.4 Identified-vs-unidentified does NOT swap the icon (synthesis §5 risk #14)
|
||||
|
||||
CONFIRMED in the negative: `UIItem_Update`/`UIItem_SetIcon`/`RenderIcons` derive the icon
|
||||
purely from server-sent weenie props (`_iconID`/`_iconUnderlayID`/`_iconOverlayID`/
|
||||
`_effects`/`InqType`) — there is **no appraise/identified branch** anywhere in the icon
|
||||
path. Appraise (`IdentifyObjectResponse 0x00C9`) gates the TOOLTIP detail
|
||||
(`UpdateTooltip`, 230392), not the icon. So a slot shows the same icon before and after
|
||||
appraise. The inventory agent's risk #14 LIKELY is now CONFIRMED.
|
||||
|
||||
---
|
||||
|
||||
## 4. Item / container data model + acdream gap analysis
|
||||
|
||||
### 4.1 Items are `ACCWeenieObject` weenies
|
||||
|
||||
The cell never holds item data — it holds an `itemID` and resolves it live via
|
||||
`ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp 230235). This matches
|
||||
`claude-memory/feedback_weenie_vs_static.md` (interactable items are server-spawned
|
||||
weenies, not dat-baked). The data the cell binds to:
|
||||
|
||||
| Cell display | Source field (`PublicWeenieDesc`, `acclient.h:37163+`) |
|
||||
|---|---|
|
||||
| base icon | `_iconID` (37168) |
|
||||
| magic underlay | `_iconUnderlayID` (37170) |
|
||||
| enchanted overlay | `_iconOverlayID` (37169) |
|
||||
| effect glow | `_effects` (37183) |
|
||||
| stack count | `_stackSize` / `_maxStackSize` (37188-37189) |
|
||||
| capacity bar | `_itemsCapacity` / `_containersCapacity` (37176-37177) |
|
||||
| structure bar | `_structure` / `_maxStructure` (37186-37187) |
|
||||
| value/burden | `_value` (37179) / `_burden` (37193) |
|
||||
| container membership | `_containerID` / `_wielderID` / `_location` / `_priority` (37171-37175) |
|
||||
|
||||
### 4.2 How the client learns container contents
|
||||
|
||||
- **Login:** `PlayerDescription (0x0013)` carries the full inventory + equipped lists.
|
||||
- **Per-item spawn:** `CreateObject (0xF745)` for each weenie (incl. a pack item) with
|
||||
the WeenieHeader fields above.
|
||||
- **Open a container:** `ViewContents (0x0196)` lists `{guid, containerType}` per slot →
|
||||
`UIElement_ItemList::ItemList_OpenContainer` builds a `UIElement_UIItem` per entry.
|
||||
- **Live moves:** `ACCWeenieObject::ServerSaysMoveItem` (decomp 408086) is the client's
|
||||
per-weenie relocation: it updates `_containerID`/`_wielderID`/`_location`, re-parents
|
||||
in the local content lists (`RemoveContent`/`AddContent`), sets `current_state`
|
||||
(`IN_CONTAINER`/`IN_3D_VIEW`), and clears the `waiting` ghost. This is driven by the
|
||||
`0x0022`/`0x0023`/`0x019A` GameEvents. CONFIRMED.
|
||||
|
||||
Hierarchy is 2-deep (main pack → side-packs; a side-pack holds no side-pack) — the
|
||||
backpack hosts two `UIElement_ItemList`s, the own list (`+0x604`) and the open-other-
|
||||
container list (`+0x608`) (inventory §2.2). The outbound verbs are the `ACCWeenieObject::
|
||||
UIAttempt*` family — `UIAttemptWield` → `Event_GetAndWieldItem` (decomp 407763, with a
|
||||
stack-split-to-wield branch when `_stackSize>1`), `UIAttemptPutInContainer` →
|
||||
`Event_PutItemInContainer` (407797), `UIAttemptPutIn3D` → `Event_DropItem` (407821),
|
||||
`UIAttemptMerge`/`UIAttemptSplitToContainer`/`UIAttemptSplitTo3D`/`UIAttemptGive`
|
||||
(407840-407897, 407780). Each records a `prevRequest` for the speculative-then-confirm
|
||||
rollback. CONFIRMED.
|
||||
|
||||
### 4.3 acdream model status (focus: what the cell binds to)
|
||||
|
||||
- **`ItemInstance.cs` (verified):** already has `IconId` (cs:136), `IconUnderlayId`
|
||||
(137), `IconOverlayId` (138), `StackSize`/`StackSizeMax` (139-140), `Burden` (141),
|
||||
`Value` (142), `ContainerId` (143), `ContainerSlot` (144), `ValidLocations`/
|
||||
`CurrentlyEquippedLocation` (134-135). **Missing for the icon composite:** `_effects`
|
||||
(effect glow) and an `ItemType` already present (Type, 133). The synthesis §0 claim is
|
||||
CONFIRMED.
|
||||
- **`ItemRepository.cs` (verified):** already models the container map, the move events
|
||||
(`WieldObject`/`InventoryPutObjInContainer`/`InventoryPutObjectIn3D`/`ViewContents`/
|
||||
`CloseGroundContainer`, cs:23-27) and the `InventoryServerSaveFailed` speculative-
|
||||
revert (cs:28-31). CONFIRMED.
|
||||
- **`CreateObject.cs` (verified):** discards `IconId` (cs:516) + `WeenieClassId`
|
||||
(cs:515) + StackSize/Value/capacities — the cell's icon + quantity + capacity-bar
|
||||
source. CONFIRMED gap.
|
||||
- The full wire-gap TODO is the synthesis §3.3 — not duplicated here; the
|
||||
data-model-binding subset is: extend `CreateObject` to capture
|
||||
IconId/WeenieClassId/StackSize/Value/ItemCapacity/ContainerCapacity (+ `_effects`),
|
||||
and add `_effects` to `ItemInstance`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Drag-drop spine — the WIDGET-LEVEL state machine
|
||||
|
||||
The per-panel docs covered the panel-class `HandleDropRelease` (e.g. `gmToolbarUI :
|
||||
ItemListDragHandler`). THIS is the shared lower layer every item-cell inherits.
|
||||
|
||||
### 5.1 The retail event chain on the cell (`UIElement_UIItem::ListenToElementMessage`, decomp 229344)
|
||||
|
||||
The cell handles four element messages (CONFIRMED 229347-229418):
|
||||
- **`0x21` = begin-drag** (left-press-and-move on an occupied cell): walk to the parent
|
||||
`UIElement_ItemList` (`GetParent()->DynamicCast(0x10000031)`) and call
|
||||
`ItemList_BeginDrag(list, ptWindow.x, ptWindow.y)` (229357-229360). The list spawns the
|
||||
`m_dragIcon` ghost and arms the drag.
|
||||
- **`0x3e` = drag-over**, with two sub-cases keyed on `dwParam1`:
|
||||
- `dwParam1 == 0` (drag left this cell): reset DragAccept to neutral
|
||||
`SetState(0x1000003f)` (229381-229387).
|
||||
- else (drag hovering): if a global drag is active (`UIElementManager::s_pInstance->
|
||||
m_dragElement != 0`), forward to `ItemList_DragOver(list, target, dragElement)`
|
||||
(229390-229406); the list decides accept/reject and flips the DragAccept overlay.
|
||||
- **`0x15` = drop/release**: clear the weenie's waiting flag and hide
|
||||
`m_elem_Icon_Ghosted` (229363-229379). (The retail event-id sequence is
|
||||
`0x15→0x21→0x1C→0x3E`, which acdream's `UiRoot` already cites verbatim — `UiRoot.cs:448`.)
|
||||
|
||||
### 5.2 The drop-TARGET rollover (`UIElement_Field::MouseOverTop`, decomp 126098)
|
||||
|
||||
Every cell inherits Field's drop-target rollover. When a drag is in progress
|
||||
(`UIElementManager::s_pInstance->m_dragElement != 0`) and this field has the
|
||||
CatchDroppedItem attribute (`GetAttribute_Bool(0x36)`, plus `0x70`/`0x38`), it calls
|
||||
`m_dragDropCallback(m_dragElement, this)` to test acceptance and sets element state **9**
|
||||
(accept) or **0xa** (reject), saving the old state for restore on leave (126124-126153).
|
||||
`UIElement_Field::CatchDroppedItem` (decomp 126159) restores the rollover state then
|
||||
chains `UIElement::CatchDroppedItem` (the real drop handler). CONFIRMED.
|
||||
|
||||
The `0x36` attribute (CatchDroppedItem flag) is exactly what `UIElement_UIItem::PostInit`
|
||||
sets `true` on every cell (decomp 229744: `SetPropertyName(0x36); …(1); SetProperty`),
|
||||
with `0x3a` and `0x39` set false (229755/229766). So **every item-cell is a drop target
|
||||
by construction.** CONFIRMED.
|
||||
|
||||
### 5.3 `InqDropIconInfo` — what the drop carries (decomp 230533)
|
||||
|
||||
`UIElement_ItemList::InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads
|
||||
the dragged element's properties via `InqProperty(0x1000000f..0x10000014)` and assembles
|
||||
the flag word (230595-230617): `flags = (bit8 from 0x10000014) | (bit2 from 0x10000013)
|
||||
| (bit4 from 0x10000012) | (bit1 from var_39/0x10000011)`. The synthesis flag semantics
|
||||
hold: **`flags & 0xE == 0`** ⇒ fresh drag from inventory (place-new); **`flags & 4`** ⇒
|
||||
within-list reorder (the source slot is `m_lastShortcutNumDragged`). `objId` = the
|
||||
dragged object guid; `containerId` = its source container. CONFIRMED (the bit→source
|
||||
mapping is the toolbar/inventory docs' `HandleDropRelease`).
|
||||
|
||||
### 5.4 The drag handler interface (`ItemListDragHandler` + `RegisterItemListDragHandler`)
|
||||
|
||||
`UIElement_ItemList::RegisterItemListDragHandler(list, handler)` stores
|
||||
`this->m_dragHandler = handler` (decomp 230461-230464). Each panel registers ITSELF as
|
||||
the handler on every slot list (toolbar §5, paperdoll §2a). On a drop, the list routes
|
||||
to the handler's `HandleDropRelease`, which resolves the target slot + the
|
||||
`InqDropIconInfo` payload and issues the wire action (the per-panel docs). The shared
|
||||
contract the spine defines is: **`ItemListDragHandler { OnItemListDragOver(list,
|
||||
target, drag); HandleDropRelease(msg) }`** + `RegisterItemListDragHandler(handler)`.
|
||||
|
||||
### 5.5 Drag-ghost / cursor lifecycle
|
||||
|
||||
`m_dragIcon` (id `0x10000345`) is created in `PostInit` from a DBObj and kept hidden
|
||||
(`SetVisible(0)`, decomp 229738-229740); on begin-drag the list makes the global
|
||||
`m_dragElement` track the cursor (the translucent icon copy), and on drop it is hidden
|
||||
again. The drag-ghost graphic is the SAME `m_pDragIcon` the icon compositor built (§3.2)
|
||||
— base + overlay, no underlay. CONFIRMED.
|
||||
|
||||
### 5.6 What acdream's `UiRoot` already has vs. needs
|
||||
|
||||
**Already there (verified `UiRoot.cs`):** `DragSource`/`DragPayload` (cs:71-73),
|
||||
`BeginDrag` (cs:450), `UpdateDragHover` emitting `DragOver`/`DragEnter`/`DragLeave`
|
||||
(cs:458-482), `FinishDrag` emitting `DropReleased` with an `accepted` flag (cs:484-496),
|
||||
the 3-pixel `DragDistanceThreshold` promote-on-move (cs:84,183-189), and the retail
|
||||
`0x15→0x21→0x1C→0x3E` chain noted in the comment (cs:448). `CapturesPointerDrag` on
|
||||
`UiElement` distinguishes interior-drag from window-move.
|
||||
|
||||
**Needs to grow:** a per-cell *accept test* hook (the retail `m_dragDropCallback` /
|
||||
`CatchDroppedItem` — `UiField` only NAMES these in its doc-comment, it does NOT implement
|
||||
them: `UiField.cs:7-11` "Carries retail Field's drag-drop hooks
|
||||
(CatchDroppedItem/MouseOverTop) as stubs for future item-window use" — there is no such
|
||||
method body in the class). So the spine adds: (1) an `OnDragOver`→accept/reject result on
|
||||
`UiItemSlot` that flips its DragAccept overlay state, (2) an `OnDrop` that calls the
|
||||
panel's drag handler with the resolved `{objId, srcContainer, flags}`, and (3) the
|
||||
`m_dragIcon` translucent ghost as the drag visual. CONFIRMED gap.
|
||||
|
||||
### 5.7 Generic pick-up → drag → drop → dispatch (pseudocode)
|
||||
|
||||
```
|
||||
on left-press over an OCCUPIED UiItemSlot: # retail msg 0x21 path
|
||||
UiRoot.Captured = slot; _dragCandidate = true
|
||||
on mouse-move while captured & moved > 3px:
|
||||
UiRoot.BeginDrag(slot, payload = { objId = slot.itemID,
|
||||
srcContainer = weenie._containerID,
|
||||
srcSlotIndex = slot.shortcutNum })
|
||||
show slot.m_dragIcon tracking the cursor # retail m_dragElement
|
||||
on drag-over a target UiItemSlot/UiItemList: # retail msg 0x3e / MouseOverTop
|
||||
accepted = targetHandler.OnDragOver(target, payload) # m_dragDropCallback
|
||||
target.SetDragAccept(accepted ? Accept(0x10000041) : Reject(0x10000040))
|
||||
on drag leaving the target:
|
||||
target.SetDragAccept(Neutral 0x1000003f)
|
||||
on release over target: # retail msg 0x15 / CatchDroppedItem
|
||||
info = InqDropIconInfo(payload) # objId, srcContainer, flags
|
||||
targetHandler.HandleDropRelease(target, info) # per-panel: picks the opcode:
|
||||
# toolbar slot : flags&0xE==0 -> CreateShortcutToItem ; flags&4 -> reorder
|
||||
# pack slot : PutItemInContainer 0x0019
|
||||
# equip slot : GetAndWieldItem 0x001A (target's EquipMask)
|
||||
# ground : DropItem 0x001B
|
||||
# compatible stack: StackableMerge 0x0054 / split dialog -> Stackable*Split*
|
||||
# NPC : GiveObjectRequest 0x00CD
|
||||
slot.SetWaitingState(true) # speculative ghost until server confirm
|
||||
hide drag ghost; clear DragSource
|
||||
on server reply (move event) or rollback (InventoryServerSaveFailed 0x00A0):
|
||||
slot.SetWaitingState(false); UIItem_Update(...) # confirm or revert
|
||||
```
|
||||
The opcode-selection table is the per-panel docs' job (already covered); the spine owns
|
||||
the pick-up → ghost → accept-test → release → `InqDropIconInfo` → dispatch-to-handler
|
||||
chain above.
|
||||
|
||||
---
|
||||
|
||||
## 6. New toolkit widgets this spine introduces
|
||||
|
||||
| Widget | Registers at | Leaf vs container | Purpose |
|
||||
|---|---|---|---|
|
||||
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolved class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying Type 3); a behavioral leaf in `DatWidgetFactory` keyed off the resolved class id | **LEAF** (`ConsumesDatChildren=>true`) — reproduces the icon + §2.2 overlay sub-elements procedurally | one item-in-a-slot: composited icon (§3) + quantity `UiText` + capacity/structure `UiMeter`s + 10-step cooldown ring + selected/ghosted/open-container/drag-accept/sell/trade overlay states; binds `itemID` (retail `+0x5FC`). **The spine widget — build once.** |
|
||||
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | resolved class id `0x10000031` (dump root `0x10000339`, Type `268435505`, 32×32 — CONFIRMED `itemlist-0x2100003D.txt:13-23`) | **leaf to the importer** (`ConsumesDatChildren=>true`; manages its own `UiItemSlot` children procedurally) — logically a container of slots at runtime | a 1-cell (toolbar/equip) or N-cell (inventory) grid of `UiItemSlot`s; owns the drag handler registration. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/BeginDrag/DragOver/InqDropIconInfo/RegisterItemListDragHandler`. |
|
||||
|
||||
These exactly match the synthesis §2 / §7 rows — **no correction**. The `UiViewport`
|
||||
(Type `0xD`), window manager, and sub-window-mount are NOT spine widgets (paperdoll /
|
||||
shared-infra; out of scope here). One precision the spine adds: the `UiField` Type-3
|
||||
drag hooks are documented-but-unimplemented (§5.6) — the `UiItemSlot` is where they get a
|
||||
body, not the generic `UiField`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / UNVERIFIED — resolved + carried forward
|
||||
|
||||
**Resolved by this doc (synthesis §5 risks → now CONFIRMED):**
|
||||
- **#1 icon-composite render** — RESOLVED. Each layer is a `0x06` RenderSurface decoded
|
||||
directly; the icon is a 5-layer composite (`IconData::RenderIcons` 407524). §3.
|
||||
- **#2 `+0x5FC` field name** — RESOLVED. It is `UIElement_UIItem::itemID` (decomp
|
||||
230230). §2.1.
|
||||
- **#14 identified-vs-unidentified does NOT swap the icon** — CONFIRMED in the negative
|
||||
(no appraise branch in the icon path). §3.4.
|
||||
|
||||
**Carried forward (still need a follow-up):**
|
||||
- **Effect-overlay blit into the slot surface (§3.2 layer 5)** — the effect DBObj
|
||||
(`GetByEnum(0x10000005, lsb(effects)+1)`) is captured (`arg2`, 407575) but I did not
|
||||
see its explicit `Blit` into the `m_pIcon` surface in the 2013 BN lifting (the visible
|
||||
blits are type-default-underlay, custom-underlay, and the base+overlay drag layer).
|
||||
LIKELY it blits as part of the overlay stage; confirm with a Ghidra decompile of
|
||||
`0x0058d180` or a cdb trace before relying on the exact effect layering. UNVERIFIED.
|
||||
- **Type-default underlay + effect-overlay enum→DID resolution** — `GetByEnum(0x10000004,
|
||||
…)` / `GetByEnum(0x10000005, …)` resolve through the dat DidMapper/EnumMapper tables;
|
||||
the concrete `0x06` ids per item-type / effect were not enumerated. MVP can ship
|
||||
base-`_iconID`-only. §3.3. UNVERIFIED.
|
||||
- **Paletted UI icons** — `GetOrUploadRenderSurface` passes `palette: null`
|
||||
(`TextureCache.cs:135`), returning magenta on a paletted (INDEX16/P8) icon. Most item
|
||||
icons are pre-baked BGRA/DXT, but verify no item icon is paletted before shipping; if
|
||||
one is, wire a UI palette (the D.2b memory flags this as a known TODO). UNVERIFIED.
|
||||
- **CreateObject IconId on a CONTAINED item** (synthesis §5 risk #3) — byte-trace a live
|
||||
capture that ACE sets `IconId` on a non-3D-visible pack item's CreateObject vs.
|
||||
relying on PlayerDescription. LIKELY present; verify. (WireMCP capture of `0xF745`.)
|
||||
- **`m_dragDropCallback` shape** — retail's per-field accept callback signature
|
||||
(`callback(dragElement, this) -> bool`, decomp 126124) is confirmed; the acdream
|
||||
binding (a delegate on `UiItemSlot`/the handler) is a design call for the build spec.
|
||||
|
||||
---
|
||||
|
||||
## 8. MEMORY.md index line
|
||||
|
||||
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, the `+0x5FC` bound id RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX RESOLVED: each layer is a `0x06` RenderSurface decoded DIRECTLY via `GetOrUploadRenderSurface`, but the on-screen icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524: type-default underlay + `_iconUnderlayID` + `_iconID` base + `_iconOverlayID`+tint + effect overlay, blitted into one 32×32 surface; NOT a single texture, NOT appraise-gated). Drag-drop state machine: cell inherits `UIElement_Field::MouseOverTop`/`CatchDroppedItem` (drop-target rollover, attr 0x36) + `ListenToElementMessage` msgs 0x21 begin-drag/0x3e drag-over/0x15 drop; `InqDropIconInfo` flags 0xE==0 fresh-drag, &4 reorder; `UiRoot` already has the drag chain (0x15→0x21→0x1C→0x3E), `UiField` only STUBS the hooks. acdream gap: `CreateObject` discards IconId (cs:516). Sub-element id map + named states (`ItemSlot_Empty 0x060074CF`, DragOver Accept/Reject/DropIn 0x060011F9/F8/F7) included.
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
# D.2b core panels — SYNTHESIS (toolbar + inventory + paperdoll)
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only synthesis.
|
||||
**Role:** synthesis lead reconciling the three panel deep-dives into one authoritative
|
||||
build plan. The deliverable is this doc; no code was written.
|
||||
**Inputs (all read in full):**
|
||||
- toolbar: [`2026-06-16-action-bar-toolbar-deep-dive.md`](2026-06-16-action-bar-toolbar-deep-dive.md)
|
||||
- inventory: [`2026-06-16-inventory-deep-dive.md`](2026-06-16-inventory-deep-dive.md)
|
||||
- paperdoll: [`2026-06-16-equipment-paperdoll-deep-dive.md`](2026-06-16-equipment-paperdoll-deep-dive.md)
|
||||
- handoff: [`2026-06-16-action-bar-inventory-equipment-handoff.md`](2026-06-16-action-bar-inventory-equipment-handoff.md)
|
||||
|
||||
> ## Note: the SPINE doc was completed in a follow-up pass
|
||||
> The handoff promised a "spine agent" doc covering the shared item-slot widget, icon
|
||||
> decode, and the full drag-drop state machine. During the original workflow run the
|
||||
> spine agent died on a transient API error, so this synthesis was first written against
|
||||
> a `null` spine digest. **The spine doc has since been written:**
|
||||
> [`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
|
||||
> It resolves the two items this synthesis had left open: (1) the **icon-composite
|
||||
> render path** — each icon LAYER is a `0x06` RenderSurface decoded DIRECTLY, but the
|
||||
> on-screen icon is a **5-layer runtime composite** blitted into one private 32×32
|
||||
> surface (`IconData::RenderIcons` decomp 407524), NOT a single texture and NOT
|
||||
> appraise-gated; (2) the item-cell **bound-object field `+0x5FC` = `UIElement_UIItem::itemID`**
|
||||
> (decomp 230230). The shared `UIElement_UIItem` / `UIElement_ItemList` identity facts
|
||||
> below were first-hand-derived + re-verified by the panel agents and remain sound.
|
||||
> §4 Step 0 and the §5 risks below have been updated to reflect the completed spine doc.
|
||||
|
||||
## 0. Summary + confidence legend
|
||||
|
||||
The three D.2b core panels are all built from the **same two reusable retail widgets**:
|
||||
the **item-slot** (`UIElement_UIItem`, class `0x10000032`) and the **item-list/grid**
|
||||
(`UIElement_ItemList`, class `0x10000031`). Every slot in every panel is one of these —
|
||||
the toolbar is 18 single-cell item-lists, the inventory is N-cell item-list grids, and
|
||||
the paperdoll is ~25 single-cell item-lists keyed to `EquipMask`. Build those two
|
||||
widgets once and all three panels fall out. The paperdoll adds one genuinely new piece:
|
||||
a **`UiViewport`** (`UIElement_Viewport`, Type `0xD`) that renders a live 3D character
|
||||
clone into a UI rect — the single biggest new engineering item. All three panels are
|
||||
pop-up windows, so they all need the deferred **window manager** (open/close/z-order/
|
||||
persist + Dragbar Type 2 + Resizebar Type 9 drag-resize). On the wire, acdream is in
|
||||
good shape: most C→S builders and S→C parsers already exist; the concrete gaps are a
|
||||
handful of missing builders (`DropItem`, `GetAndWieldItem`, `NoLongerViewingContents`),
|
||||
missing parsers (`ViewContents`, `SetStackSize`, `InventoryRemoveObject`), two
|
||||
incomplete parsers (a dropped 4th field on `0x0022`; a dropped error code on `0x00A0`),
|
||||
and `CreateObject` discarding `IconId`/`StackSize`/capacities the cells need.
|
||||
|
||||
**Confidence legend** (carried from the source docs, re-checked here):
|
||||
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
|
||||
`file:line` that I or a panel agent opened.
|
||||
- **LIKELY** — inferred from a confirmed source; the inference is named.
|
||||
- **UNVERIFIED** — educated guess, flagged loudly; needs a decomp/cdb follow-up before
|
||||
porting.
|
||||
|
||||
**Synthesis-lead re-verifications (opened first-hand, this session):**
|
||||
- `CreateObject.cs:515-516` — `_ = ReadPackedDword(...) // WeenieClassId; _ = ReadPackedDwordOfKnownType(..., IconTypePrefix);` → **IconId and WeenieClassId are discarded**. CONFIRMED.
|
||||
- `acclient_2013_pseudo_c.txt:135087-135088` — `UIElement_ItemList::Register();` and `UIElement_UIItem::Register();` are real adjacent symbols (`0x0047a483`/`0x0047a488`). CONFIRMED.
|
||||
- `acclient_2013_pseudo_c.txt:135130-135132` — `gmBackpackUI::Register / gmInventoryUI::Register / gmPaperDollUI::Register` all real. CONFIRMED.
|
||||
- `acclient_2013_pseudo_c.txt:175242-175508` — the ~25 paperdoll equip slots each `DynamicCast(0x10000031)` (`m_neckSlot, m_headSlot, m_weaponReadySlot, m_ammoReadySlot, …`) + `RegisterItemListDragHandler`. CONFIRMED.
|
||||
- `acclient_2013_pseudo_c.txt:229180-229413` — the `m_elem_Icon_*` family (`_Ghosted`, `_OpenContainer`, `_Selected`, `_DragAccept`) and its `SetState` reject/accept/neutral states (`0x10000040` / `0x10000041` / `0x1000003f`) are real on `UIElement_UIItem`. CONFIRMED (corroborates the inventory agent's first-hand derivation).
|
||||
|
||||
---
|
||||
|
||||
## 1. Confirmed class ids + LayoutDesc ids + sizes
|
||||
|
||||
All confirmed via `*::Register` (`RegisterElementClass`) in the decomp + the
|
||||
pre-dumped `.layout-dumps/` trees. The element-class id and the LayoutDesc id are
|
||||
distinct namespaces (`0x10000xxx` = element class registered in C++; `0x21000xxx` =
|
||||
the dat LayoutDesc that builds the window).
|
||||
|
||||
| Panel / widget | Element class id | LayoutDesc id | Root element | Size (W×H) | Register anchor |
|
||||
|---|---|---|---|---|---|
|
||||
| `gmToolbarUI` (action bar) | `0x10000007` | `0x21000016` | `0x10000191` | 300×122 | `gmToolbarUI::Register` (decomp 196897); `GetUIElementType`→`0x10000007` (196707) |
|
||||
| `gmInventoryUI` (frame) | `0x10000023` | `0x21000023` | `0x100001CC` | 300×362 | `gmInventoryUI::Register` (decomp 176285 / `0x004a6a60`) |
|
||||
| `gmBackpackUI` (pack strip) | `0x10000022` | `0x21000022` | `0x100001C8` | 61×339 | `gmBackpackUI::Register` (decomp 176531 / `0x004a6e80`) |
|
||||
| `gmPaperDollUI` (equip doll) | `0x10000024` | `0x21000024` | `0x100001D4` | 224×214 | `gmPaperDollUI::Register` (decomp 174445 / `0x004a4560`) |
|
||||
| `gm3DItemsUI` ("Contents of Backpack") | `0x10000021` | `0x21000021` | `0x100001C4` | 234×120 | `gm3DItemsUI::Register` (decomp 176723) |
|
||||
| `UIElement_UIItem` (item-slot, shared) | `0x10000032` | `0x21000037` (32×32 cell template) | — | 32×32 | `UIElement_UIItem::Register` (decomp 229339 / `0x0047a488`) |
|
||||
| `UIElement_ItemList` (item-list/grid, shared) | `0x10000031` | `0x2100003D` (single 32×32 cell) | `0x10000339` | 32×32 cell | `UIElement_ItemList::Register` (decomp / `0x0047a483`) |
|
||||
|
||||
**Nesting (CONFIRMED `gmInventoryUI::PostInit` 176236-176259):** the inventory FRAME
|
||||
(`0x21000023`) hosts three NESTED gm\*UI windows by id — `0x100001CD`→paperdoll
|
||||
(`DynamicCast 0x10000024`), `0x100001CE`→backpack (`DynamicCast 0x10000022`),
|
||||
`0x100001CF`→3D-items (`DynamicCast 0x10000021`). This "sub-window mount" (an element
|
||||
whose Type is a high `0x10000xxx` game class with its own `BaseLayoutId`) is a
|
||||
capability the importer does **not** have yet.
|
||||
|
||||
**Note on `gm3DItemsUI`:** despite the "3D" name it is a 2D "Contents of Backpack"
|
||||
item-list (`gm3DItemsUI::PostInit` 176728 sets `m_contentsText`→"Contents of Backpack",
|
||||
`m_itemList`→`DynamicCast(0x10000031)`; its layout has NO Viewport). The 3D character
|
||||
doll is in `gmPaperDollUI`, not here. CONFIRMED.
|
||||
|
||||
---
|
||||
|
||||
## 2. CONSOLIDATED new toolkit widgets (the single authoritative list)
|
||||
|
||||
This reconciles the four docs into one list. The shipped D.2b toolkit already has
|
||||
Button(1)/Dragbar(2)/Field(3)/Menu(6)/Meter(7)/Panel(8)/Scrollbar(0xB)/Text(0xC) plus
|
||||
`UiDatElement` for generic chrome — those are **reused**, not re-listed.
|
||||
|
||||
**Type-registration model:** the shipped numeric Type registry (1=Button … 0x12=Proto)
|
||||
is the toolkit's generic-widget dispatch. The item-slot / item-list / viewport are NOT
|
||||
in that numeric table — in retail they are **`UIElement` subclasses registered by a
|
||||
full class id** via `RegisterElementClass(0x10000xxx, …)`, and in the dat their
|
||||
elements have `Type=0` and inherit the real class id through the `BaseElement` chain
|
||||
(resolved by `ElementReader.Merge`'s zero-wins-base rule). So in acdream's
|
||||
`DatWidgetFactory` they are **new behavioral leaf widgets keyed off the resolved class
|
||||
id**, exactly the same pattern as the existing behavioral widgets — they just key off
|
||||
`0x10000031`/`0x10000032`/`0xD` rather than a small numeric Type. (The numeric Type
|
||||
that `0xD`=Viewport occupies in the confirmed registry IS a generic toolkit Type, so
|
||||
`UiViewport` can register at Type `0xD` directly; the item-slot/item-list register at
|
||||
their class ids.)
|
||||
|
||||
| Widget | Registers at | Leaf vs container | Panels that use it | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3**). Behavioral leaf. | **LEAF** (`ConsumesDatChildren=>true`) — reproduces its icon + overlay sub-elements procedurally | **all three** (toolbar slots, inventory cells, paperdoll equip slots) | one item-in-a-slot: icon (underlay/base/effects-overlay) + quantity text + capacity/structure Type-7 bars + cooldown ring; holds the bound object id (retail `+0x5FC`); selection/ghost/drag-accept/open-container overlay states. **The spine widget — build once.** |
|
||||
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | class id `0x10000031`. Behavioral widget. | **leaf wrt the importer** (manages its own `UiItemSlot` children procedurally) — but logically a **container** of slots | **all three** (toolbar = 1-cell instances; inventory = N-cell grids; paperdoll = 1-cell equip slots) | a 1-cell or N-cell grid of `UiItemSlot`s. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/OpenFirstContainer`. Backpack uses **two** instances (own list `+0x604`, other-container list `+0x608`). |
|
||||
| **`UiViewport`** (port of `UIElement_Viewport`, Type `0xD`) | numeric Type **`0xD`** (confirmed registry; `UIElement_Viewport::Register`→`RegisterElementClass(0xd,…)` decomp 119126) | **LEAF** (`ConsumesDatChildren=>true`) | **paperdoll only** | hosts a single live 3D entity (the character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`. **Needs a new Core→App render-into-rect seam (`IUiViewportRenderer`, Code-Structure Rule 2). The biggest new piece.** |
|
||||
| **Window manager** (shared infra; drives Dragbar Type 2 + Resizebar Type 9) | not a registered widget — infra that drives existing Type-2/Type-9 chrome + `UiElement.Draggable/Resizable` | n/a | **all three** (plus future pop-ups) | open/close/z-order/persist for pop-up windows + faithful grip/dragbar drag-resize. Today vitals/chat use whole-window drag (accepted IA-12 approximation). This is "the other deferred Plan-2 piece." |
|
||||
| **Sub-window mount** (LayoutImporter capability, not a widget) | n/a — an element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` | container | **inventory** (frame nests paperdoll + backpack + 3D-items) | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot. The importer recurses generic children today but has never mounted another gm\*UI window. |
|
||||
| **Per-panel controllers** (`ToolbarController`, `InventoryController`, `PaperDollController`) | not widgets — controllers like `VitalsController`/`ChatWindowController` | n/a | one per panel | find-by-id binding + wire send/receive + model restore. The `gm*UI::PostInit` analogues. (Listed for completeness; each is panel-specific, not a shared widget.) |
|
||||
|
||||
### 2a. Reconciled disagreements between the agents
|
||||
|
||||
The four docs were **consistent** on the big-three widget identities; the differences
|
||||
were wording, not substance. Reconciled:
|
||||
|
||||
1. **Item-slot Type — no real conflict.** Toolbar + inventory + paperdoll all call it
|
||||
`UIElement_UIItem`, class **`0x10000032`**, a `UIElement_Field` subclass (underlying
|
||||
Type 3), built as a behavioral **leaf**. The paperdoll doc's widget table named its
|
||||
equip-slot variant "`UiItemSlot` registering at `0x10000031`" — that is the *equip
|
||||
slot* (a single-cell `UIElement_ItemList`), NOT the inner item-cell. **Reconciliation:
|
||||
`UIElement_ItemList` (`0x10000031`) is the slot/grid container; `UIElement_UIItem`
|
||||
(`0x10000032`) is the item-cell inside it.** The paperdoll equip slot is a 1-cell
|
||||
`UIElement_ItemList` that holds at most one `UIElement_UIItem` — same two-widget spine
|
||||
as everywhere else, just constrained to one cell. (CONFIRMED: every paperdoll slot is
|
||||
`DynamicCast(0x10000031)`, decomp 175242-175508; the inner cell is the
|
||||
`UIElement_UIItem` `0x10000032` per the inventory agent's `UIItem_Update`/`m_elem_Icon`
|
||||
citations, re-verified at 229180-229413.)
|
||||
|
||||
2. **Item-list "leaf vs container".** Toolbar + paperdoll said **leaf** (the importer
|
||||
doesn't build its dat children; it reproduces cells procedurally); inventory said
|
||||
**container** (it lays out an N-column grid). **Reconciliation: it is a behavioral
|
||||
LEAF to the importer** (`ConsumesDatChildren=>true`, the importer must NOT recurse its
|
||||
dat children) but it is logically a **container of `UiItemSlot`s at runtime** (it
|
||||
creates/destroys cells procedurally as items arrive). Both descriptions are correct
|
||||
at different layers; the binding rule for the factory is `ConsumesDatChildren=>true`.
|
||||
|
||||
3. **`UiViewport` Type.** Only the paperdoll doc introduced it; **Type `0xD`**,
|
||||
confirmed against the registry (`0xD`=Viewport) and `RegisterElementClass(0xd,…)`.
|
||||
No conflict.
|
||||
|
||||
4. **Window manager.** All three docs named it identically (shared, drives Dragbar
|
||||
Type 2 + Resizebar Type 9, open/close/z-order/persist). No conflict.
|
||||
|
||||
5. **`+0x5FC` (bound object id on the item-cell).** The toolbar doc anchors this by
|
||||
OFFSET only (UNVERIFIED symbolic name). The inventory/spine-territory render of the
|
||||
cell would have named it; since the spine doc is missing, **it stays UNVERIFIED** —
|
||||
carried to §5.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-panel wire-message catalog (de-duplicated)
|
||||
|
||||
All C→S ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`);
|
||||
all S→C item events ride the `0xF7B0` GameEvent envelope (`u32 0xF7B0; u32 target;
|
||||
u32 seq; u32 eventOpcode; …`). De-duplicated across the three panels; the "Panels"
|
||||
column shows which panel(s) use each. acdream parse-status is the union of what the
|
||||
three agents found (each cross-checked against `src/AcDream.Core.Net/`).
|
||||
|
||||
### 3.1 Client → server (GameActions)
|
||||
|
||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up (container=self) | `GameActionPutItemInContainer.Handle` | `Inventory_PutItemInContainer*` | **parsed** — `InteractRequests.BuildPickUp` (InteractRequests.cs:97) | inv, paperdoll (un-wield) |
|
||||
| `0x001A` | GetAndWieldItem | C→S | equip item from pack onto doll/equip slot | `GameActionGetAndWieldItem.Handle` (Actions/GameActionGetAndWieldItem.cs:7-14) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`uint ObjectId; EquipMask Slot`, generated.cs:14-42) | **MISSING** (no builder) | paperdoll, inv |
|
||||
| `0x001B` | DropItem | C→S | drop item on the ground | `GameActionDropItem.Handle` (1×u32 guid) | — | **MISSING** (no builder) | inv |
|
||||
| `0x0035` | UseWithTarget | C→S | use src item on target (toolbar target-mode / key→door) | (Interact) | — | **parsed** — `InteractRequests.BuildUseWithTarget` | toolbar, inv |
|
||||
| `0x0036` | UseItem | C→S | use/activate a single item (toolbar slot activation) | `GameActionUseItem` | — | **parsed** — `InteractRequests.BuildUse` | toolbar, inv |
|
||||
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` (from,to,amount) | — | **parsed** — `InventoryActions.BuildStackableMerge` | inv |
|
||||
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` (stack,container,place,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitToContainer` | inv |
|
||||
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` (stack,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitTo3D` | inv |
|
||||
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (arrows) | `GameActionStackableSplitToWield` (stack,equipMask,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitToWield` | inv, paperdoll |
|
||||
| `0x00CD` | GiveObjectRequest | C→S | give item/N-of-stack to NPC/player | `GameActionGiveObjectRequest.Handle` (target,item,amount) | — | **parsed** — `InventoryActions.BuildGiveObjectRequest` | inv |
|
||||
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | `GameActionType` 0x0195 (containerGuid) | — | **MISSING** (no builder) | inv |
|
||||
| `0x019C` | AddShortCut | C→S | pin item to toolbar slot (on drag-to-slot / add-selected) | `GameActionAddShortcut.Handle` → `Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** — `InventoryActions.BuildAddShortcut` *(fix field naming → Index/ObjectId/SpellId\|Layer)* | toolbar |
|
||||
| `0x019D` | RemoveShortCut | C→S | unpin / evict / overwrite a toolbar slot | `GameActionRemoveShortcut.Handle` → `Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** — `InventoryActions.BuildRemoveShortcut` | toolbar |
|
||||
|
||||
### 3.2 Server → client (GameEvents / GameMessages)
|
||||
|
||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `0x0022` | InventoryPutObjInContainer | S→C | confirm item in container at slot | `GameEventItemServerSaysContainId` (itemGuid,containerGuid,placement,**containerType**) | — | **parsed INCOMPLETE** — `GameEvents.ParsePutObjInContainer` reads 3 fields, **drops containerType**; wired (GameEventWiring.cs:239) | inv |
|
||||
| `0x0023` | WieldObject | S→C | confirm item equipped to slot | `GameEventWieldItem` (objectId, i32 equipMask) | — | **parsed + wired** — `GameEvents.ParseWieldObject`, GameEventWiring.cs:231 | paperdoll, inv |
|
||||
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` (containerGuid) | — | **parsed UNWIRED** — `GameEvents.ParseCloseGroundContainer`, not in WireAll | inv |
|
||||
| `0x00A0` | InventoryServerSaveFailed | S→C | reject speculative client move (roll back) | `GameEventInventoryServerSaveFailed` (itemGuid, weenieError) | — | **parsed UNWIRED INCOMPLETE** — reads guid only, **drops error**; not in WireAll | inv (+toolbar/paperdoll rollback) |
|
||||
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (gates tooltip, not icon) | `GameEventIdentifyObjectResponse` (guid,flags,success,property tables) | — | **parsed + wired** — `AppraiseInfoParser` via GameEventWiring.cs:245 | inv, paperdoll, toolbar (tooltip) |
|
||||
| `0x0196` | ViewContents | S→C | full contents list of an opened container | `GameEventViewContents` (container,count,{guid,containerType}×n) | — | **MISSING** (no parser) | inv |
|
||||
| `0x0197` | SetStackSize | S→C | update a stack count+value after merge/split | `GameMessageSetStackSize` (seq,guid,stackSize,value) | — | **MISSING** (no parser) | inv, toolbar |
|
||||
| `0x019A` | InventoryPutObjectIn3D | S→C | confirm item dropped to world | `GameEventItemServerSaysMoveItem` (objectGuid) | — | **parsed UNWIRED** — `GameEvents.ParsePutObjectIn3D`, not in WireAll | inv |
|
||||
| `0xF625` | ObjDescEvent | S→C | wield/unwield → full new appearance broadcast → `RedressCreature` | `GameMessageObjDescEvent` → `SerializeUpdateModelData` (GameMessageObjDescEvent.cs:10-17) | (ModelData block) | **parsed** — `ObjDescEvent.cs:33-73` (`CreateObject.ReadModelData`) | paperdoll |
|
||||
| `0xF745` | CreateObject | S→C | spawn a weenie incl. a pack item (IconId/WeenieClassId/StackSize/Value/capacities) | `GameMessageCreateObject` → `WorldObject.SerializeCreateObject` | `Item_CreateObject` | **parsed INCOMPLETE** — `CreateObject.TryParse` **discards IconId (cs:516), WeenieClassId (cs:515), StackSize, Value, ItemCapacity, ContainerCapacity** | all (icon + quantity source) |
|
||||
| (UIQueue) | InventoryRemoveObject | S→C | remove item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` (guid) | — | **MISSING** (no parser) | inv, toolbar |
|
||||
| `PlayerDescription` SHORTCUT block | persisted toolbar shortcut list | S→C | login (part of `0xF7B0`/0x0013 `PlayerDescription`) | `Player_Character.GetShortcuts()` | `ShortCutData` (Index,ObjectId,LayeredSpellId) | **parsed** — `PlayerDescriptionParser.cs:345-356` → `Parsed.Shortcuts` | toolbar |
|
||||
| `PlayerDescription` equipped `InventoryPlacement` list | persisted equipped-gear list | S→C | login | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — equipped section NOT surfaced (PlayerDescriptionParser.cs:70-77) | paperdoll |
|
||||
|
||||
**Shared-message note:** `CreateObject (0xF745)` and `IdentifyObjectResponse (0x00C9)`
|
||||
are used by all three panels and de-duplicated above. `GetAndWieldItem (0x001A)` is
|
||||
shared by inventory (equip-from-grid) and paperdoll (drop-on-doll); `UseItem (0x0036)`/
|
||||
`UseWithTarget (0x0035)` are shared by toolbar activation and inventory double-click.
|
||||
|
||||
### 3.3 acdream wire-gap TODO (the build session's concrete list)
|
||||
|
||||
- **Add C→S builders:** `GetAndWieldItem (0x001A)`, `DropItem (0x001B)`,
|
||||
`NoLongerViewingContents (0x0195)`.
|
||||
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize (0x0197)`,
|
||||
`InventoryRemoveObject`.
|
||||
- **Fix incomplete parsers:** `ParsePutObjInContainer` (read the 4th `containerType`
|
||||
u32); `ParseInventoryServerSaveFailed` (read the `weenieError` u32).
|
||||
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
|
||||
`InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`,
|
||||
`InventoryServerSaveFailed (0x00A0)`.
|
||||
- **Extend `CreateObject.TryParse`** to capture `IconId`, `WeenieClassId`, `StackSize`,
|
||||
`Value`, `ItemCapacity`, `ContainerCapacity` (cells need icon + quantity +
|
||||
capacity bar). **Re-verified discarded at `CreateObject.cs:515-516`.**
|
||||
- **Extend `PlayerDescriptionParser`** to surface the equipped `InventoryPlacement
|
||||
{iid, loc, priority}` list (paperdoll slot icons at login).
|
||||
- **Fix `InventoryActions.BuildAddShortcut` field naming** (currently
|
||||
`slotIndex/objectType/targetId`; wire layout is correct for item shortcuts but
|
||||
semantics should be `Index/ObjectId/SpellId|Layer`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended build order
|
||||
|
||||
Ordered by dependency so the next session can go straight to brainstorm → spec → plan.
|
||||
Each step states why it must come where it does.
|
||||
|
||||
**Step 0 — SPINE research (DONE — see the spine doc).**
|
||||
The spine doc is complete:
|
||||
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
|
||||
It specs the item-cell widget, the 5-layer icon composite (`IconData::RenderIcons`
|
||||
decomp 407524 — each layer a `0x06` RenderSurface decoded directly, blitted into one
|
||||
private 32×32 surface: type-default underlay / custom underlay / base `_iconID` / custom
|
||||
overlay+tint / effect overlay), `UIElement_UIItem::UIItem_Update`/`UIItem_SetIcon`
|
||||
(decomp 230226+), the overlay-state machine, and the widget-level drag-drop hooks
|
||||
(`UIElement_Field::MouseOverTop`/`CatchDroppedItem`). The `+0x5FC` field is resolved
|
||||
(`UIElement_UIItem::itemID`). Steps 2-7 can now proceed against a finished port spec;
|
||||
this is **no longer blocking**. (Note: WB `TextureHelpers.cs` / ACViewer `IndexToColor`
|
||||
are for WORLD textures — item icons take NO subpalette overlay at composite time; see
|
||||
the spine doc.)
|
||||
|
||||
**Step 1 — Window manager foundation.**
|
||||
*Why first among code:* all three panels are pop-up windows that must open/close, stack
|
||||
(z-order), persist position, and (faithfully) drag/resize via Dragbar (Type 2) +
|
||||
Resizebar (Type 9). Vitals/chat shipped with whole-window drag (accepted IA-12
|
||||
approximation); the panels need the real thing. Everything visible in Steps 5-7 mounts
|
||||
inside a managed window, so the manager is the substrate. It is independent of the wire
|
||||
work, so it can proceed in parallel with Step 0.
|
||||
|
||||
**Step 2 — `UiItemSlot` widget + icon pipeline (`UIElement_UIItem` 0x10000032).**
|
||||
*Why here:* it is the atom of all three panels. Depends on Step 0 (icon render) and on
|
||||
the §3.3 `CreateObject` extension (IconId/StackSize) for real data. Build the leaf
|
||||
widget: icon composite, quantity text, capacity/structure Type-7 bars, cooldown ring,
|
||||
and the selection/ghost/drag-accept/open-container overlay states.
|
||||
|
||||
**Step 3 — `UiItemList` / `UiItemGrid` widget (`UIElement_ItemList` 0x10000031).**
|
||||
*Why here:* it composes `UiItemSlot`s and is used by every panel (1-cell and N-cell).
|
||||
Depends on Step 2. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/
|
||||
GetItem/OpenContainer/SetChildList/SetParentContainer`. Register as a behavioral leaf
|
||||
(`ConsumesDatChildren=>true`).
|
||||
|
||||
**Step 4 — Wire wiring (builders/parsers/wireup from §3.3).**
|
||||
*Why here:* the controllers in Steps 5-7 need the full send/receive surface, and this is
|
||||
independent of the widget rendering — it can run in parallel with Steps 1-3. Add the
|
||||
missing builders/parsers, fix the two incomplete parsers, register the unwired parsers,
|
||||
extend `CreateObject` + `PlayerDescriptionParser`, fix `BuildAddShortcut` naming. Each
|
||||
new deviation gets a divergence-register row in the same commit.
|
||||
|
||||
**Step 5 — `ToolbarController` + the action bar (simplest panel).**
|
||||
*Why before the others:* the toolbar is the simplest consumer (18 single-cell lists, no
|
||||
nested sub-windows, no viewport) and exercises the full spine + window manager + wire
|
||||
path end-to-end. acdream already parses the SHORTCUT block and has both shortcut
|
||||
builders, so it's the fastest path to a working, testable panel. Bind the 18 slots,
|
||||
the hidden selected-object meters + stack slider, the panel-launcher buttons; restore
|
||||
from `Parsed.Shortcuts`; wire `UseShortcut`/`AddShortcut`/`RemoveShortcut` +
|
||||
`HandleDropRelease`.
|
||||
|
||||
**Step 6 — `InventoryController` + the inventory/backpack panels + sub-window mount.**
|
||||
*Why here:* adds the N-cell grid (Step 3 at scale), the burden Meter (reuses Type-7
|
||||
`SetLoadLevel`→fill 0x69), the dual-ItemList container model (own `+0x604` / other
|
||||
`+0x608`), and the **sub-window mount** importer capability (frame nests paperdoll +
|
||||
backpack + 3D-items). The hardest 2D panel; depends on Steps 1-4 and the new sub-window
|
||||
mount.
|
||||
|
||||
**Step 7 — `UiViewport` + `PaperDollController` + the equipment doll (biggest new piece).**
|
||||
*Why last:* it depends on everything above (window manager, equip-slot `UiItemList`
|
||||
instances, `GetAndWieldItem` wire, `PlayerDescription` equipped list) AND introduces the
|
||||
single largest new engineering item: the **UI↔3D render seam** (`IUiViewportRenderer`
|
||||
Core interface, App impl, per Code-Structure Rule 2) that renders a re-dressed player
|
||||
clone into a scissored UI rect. It reuses acdream's existing `EntitySpawnAdapter`/
|
||||
`AnimatedEntityState` character path, but the rect-scissored single-entity pass is new.
|
||||
Doing it last lets the 2D panels validate the spine first, so a 3D-render bug is
|
||||
isolated.
|
||||
|
||||
**Parallelism summary:** Step 0 (spine research) + Step 1 (window manager) + Step 4
|
||||
(wire) can all proceed independently; Steps 2→3→5→6→7 are the dependent spine→panels
|
||||
chain.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open risks / UNVERIFIED — resolve BEFORE implementation
|
||||
|
||||
Collated from all four docs; each needs a decomp or cdb follow-up before the cited step.
|
||||
|
||||
1. **SPINE doc — RESOLVED (no longer blocking).** Written:
|
||||
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
|
||||
Icon-composite render (`IconData::RenderIcons` 407524, 5 layers) + the widget-level
|
||||
drag-drop state machine (`UIElement_Field::MouseOverTop`/`CatchDroppedItem`, cell msgs
|
||||
`0x21`/`0x3e`/`0x15`, `InqDropIconInfo` flags) are now specced with anchors.
|
||||
2. **`UIElement_UIItem +0x5FC` bound-object-id field name — RESOLVED = `itemID`.**
|
||||
`UIElement_UIItem::itemID`, anchored at `UIItem_Update` decomp 230230
|
||||
(`uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`), corroborated 230422/233107
|
||||
(companion field `spellID`). See the spine doc.
|
||||
3. **`CreateObject` IconId for CONTAINED (non-3D-visible) pack items — LIKELY.**
|
||||
Confirmed on the wire + currently discarded (`CreateObject.cs:516`), but not
|
||||
byte-traced that ACE sets IconId on a *contained* item's CreateObject vs. relying on
|
||||
PlayerDescription. Verify against a live capture before treating CreateObject as the
|
||||
sole icon source.
|
||||
4. **Use-item opcode `ItemHolder::UseObject` sends (0x0035 vs 0x0036) — UNVERIFIED.**
|
||||
Throttle (0.2 s) + dispatch CONFIRMED (decomp 402923); the precise opcode branch
|
||||
(`DetermineUseResult`) not traced to the send. Both opcodes exist in acdream
|
||||
`InteractRequests.cs`; reconcile when wiring toolbar/inventory activation (Step 5/6).
|
||||
5. **`UseShortcut` target-mode path — out of scope, file follow-up.**
|
||||
`ClientUISystem::ExecuteTargetModeForItem` (use-item-on-target) depends on the cursor
|
||||
target-mode subsystem; not part of the action-bar widget itself.
|
||||
6. **`SetDelayedShortcutNum` deferral — needs a re-bind state machine.** When a slot's
|
||||
weenie isn't loaded yet (`AddShortcut` decomp 196867), the slot must re-bind once
|
||||
`CreateObject` for that guid arrives. Detail in the `ToolbarController` port (Step 5).
|
||||
7. **Paperdoll `0x100001E0` = MissileAmmo `0x800000` — LIKELY only.** The decomp
|
||||
immediate is corrupted to a string-ptr (line 173676); inferred from the EquipMask gap
|
||||
+ neighbors. Re-decompile `0x004a388a` in Ghidra to recover the real value (Step 7).
|
||||
8. **Paperdoll viewport camera/light float immediates — UNVERIFIED (not byte-decoded).**
|
||||
Lines 175524-175526 / 174144-174146; the agent read the hex but did not convert all
|
||||
floats (`0x3df5c28f≈0.12`, `0xc019999a≈−2.4`, `0xc0400000=−3.0`, `0xc059999a≈−3.4`,
|
||||
`0x3f6147ae≈0.88`, `0x3f800000=1.0`). Decode precisely for faithful framing (Step 7).
|
||||
9. **UI↔3D render seam — DESIGN-OPEN.** How a UI rect drives a scissored single-entity
|
||||
3D pass (after the world pass vs. as a UI overlay), and the exact
|
||||
`IUiViewportRenderer` Core-interface shape (Code-Structure Rule 2). Brainstorm before
|
||||
Step 7.
|
||||
10. **Does the doll clone the player `WorldEntity` or build a fresh one? — UNVERIFIED.**
|
||||
Retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`,
|
||||
line 173999); acdream has no player-as-renderable today (player = camera). LIKELY a
|
||||
dedicated `WorldEntity` from the local player's Setup+ObjDesc fed to a private
|
||||
viewport host. Settle in Step 7 brainstorm.
|
||||
11. **Inventory side-pack column `0x100001CB` (16×252, base `0x2100003E`) — UNVERIFIED.**
|
||||
Tabs (one per sub-bag) or a scrollbar gutter? Dump `0x2100003E` to settle (Step 6).
|
||||
12. **`UIElement_ItemList` grid geometry (column count, cell pitch) — LIKELY.** Cell
|
||||
template 36×36 (`0x100001C9`); `UIElement_UIItem` `0x21000037` is 32×32. Confirm the
|
||||
fixed-column wrap by reading `UIElement_ItemList::ItemList_AddItem` (Step 3).
|
||||
13. **Value/coin total NOT in the inventory window — UNVERIFIED home.** No value Meter/
|
||||
text in `0x21000022`/`0x21000023`; the window shows BURDEN only. Do NOT invent a
|
||||
value summary; find its real home before adding one.
|
||||
14. **Identified-vs-unidentified does NOT swap the icon — CONFIRMED (negative).** The
|
||||
spine doc confirms there is no appraise branch anywhere in the icon path
|
||||
(`UIItem_SetIcon` → `IconData::RenderIcons`); appraise gates `UpdateTooltip` only.
|
||||
15. **`InventoryActions.BuildAddShortcut` field-naming bug — CONFIRMED file contents,
|
||||
LIKELY latent bug.** Wire layout is correct for item shortcuts; the param names
|
||||
(`slotIndex/objectType/targetId`) are misleading. Fix to `Index/ObjectId/
|
||||
SpellId|Layer` + register a divergence row at port time (Step 4/5).
|
||||
|
||||
---
|
||||
|
||||
## 6. Proposed MEMORY.md index lines (for ALL 5 docs)
|
||||
|
||||
The parent will append these; I do NOT edit MEMORY.md.
|
||||
|
||||
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives. Big-three new widgets: `UiItemSlot`(`UIElement_UIItem` 0x10000032, shared leaf), `UiItemList/Grid`(`UIElement_ItemList` 0x10000031, shared leaf-to-importer), `UiViewport`(Type 0xD, paperdoll 3D doll), plus the shared **window manager** (Dragbar 2 + Resizebar 9) + sub-window-mount importer capability + per-panel controllers. De-duped cross-panel wire table; build order (window mgr → item-slot+icon → item-list → wire → toolbar → inventory → paperdoll; spine research DONE).
|
||||
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, `+0x5FC` RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX: each layer is a `0x06` RenderSurface decoded DIRECTLY, but the icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524; NOT one texture, NOT appraise-gated). Drag-drop = `Field::MouseOverTop`/`CatchDroppedItem` + cell msgs 0x21/0x3e/0x15 + `InqDropIconInfo` flags; `UiRoot` already has the chain, `UiField` only stubs the hooks; gap = `CreateObject` discards IconId (cs:516).
|
||||
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease`. New widgets: `UiItemSlot` + `UiItemList` + `ToolbarController`.
|
||||
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.
|
||||
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.
|
||||
- [Action-bar/inventory/equipment research handoff](research/2026-06-16-action-bar-inventory-equipment-handoff.md) — the §3 question list (Q1-Q12) + agent assignment that drove the toolbar/inventory/paperdoll/spine deep-dives. (All 5 research docs delivered; the spine doc was completed in a follow-up pass after a transient agent failure.)
|
||||
|
||||
---
|
||||
|
||||
## 7. New toolkit widgets this introduces (recap)
|
||||
|
||||
| Widget | dat Type / class it registers at | leaf vs container | Purpose |
|
||||
|---|---|---|---|
|
||||
| `UiItemSlot` | class `0x10000032` (`UIElement_UIItem`) | leaf (`ConsumesDatChildren=>true`) | shared item-cell: icon + quantity + capacity/structure bars + overlay states + bound object id |
|
||||
| `UiItemList` / `UiItemGrid` | class `0x10000031` (`UIElement_ItemList`) | leaf to importer; container of slots at runtime | shared 1-cell/N-cell grid of `UiItemSlot`s |
|
||||
| `UiViewport` | numeric Type `0xD` (`UIElement_Viewport`) | leaf (`ConsumesDatChildren=>true`) | paperdoll 3D character doll via a scissored mini 3D pass; needs `IUiViewportRenderer` Core→App seam |
|
||||
| Window manager | infra (drives Dragbar Type 2 + Resizebar Type 9) | n/a | open/close/z-order/persist + faithful grip/dragbar drag-resize for all pop-up panels |
|
||||
| Sub-window mount | LayoutImporter capability (element whose Type is a high `0x10000xxx` class with a `BaseLayoutId`) | container | nest a `LayoutDesc` window inside a parent slot (inventory frame → paperdoll/backpack/3D-items) |
|
||||
|
||||
## 8. Open questions / UNVERIFIED (recap)
|
||||
|
||||
See §5 for the full collated list with anchors. The former blocking item — the spine
|
||||
doc — is now written (icon-composite render path + widget-level drag-drop state machine
|
||||
specced with anchors; `+0x5FC` = `itemID` resolved). Remaining items are per-step
|
||||
follow-ups (decode the paperdoll camera floats, recover `0x100001E0`, dump
|
||||
`0x2100003E`, byte-trace CreateObject IconId for contained items).
|
||||
|
||||
---
|
||||
|
||||
**Single MEMORY.md index line for THIS doc:**
|
||||
|
||||
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives: shared `UiItemSlot`(0x10000032)+`UiItemList`(0x10000031) spine, `UiViewport`(Type 0xD) for the paperdoll 3D doll, window manager (Dragbar 2 + Resizebar 9) + sub-window-mount; de-duped cross-panel wire table; build order window-mgr→item-slot+icon→item-list→wire→toolbar→inventory→paperdoll (spine research DONE — see the spine deep-dive).
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
# Stateful item-icon system — RESEARCH RESOLVED (the build basis for D.5.2)
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**Supersedes the key hypotheses in** `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
|
||||
**Method:** grep-named → cross-ref (ACE/ACViewer/Chorizite) → clean Ghidra decompile
|
||||
(MCP, PDB-applied `patchmem.gpr`) → live-dat probe. Each decomp claim adversarially
|
||||
verified against source.
|
||||
|
||||
This doc records the **definitive** answers. Two handoff hypotheses were **wrong**; both
|
||||
are corrected here with evidence.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data-availability — SETTLED (handoff's "DO THIS FIRST" question)
|
||||
|
||||
**The icon ids and the effect bitfield arrive ONLY on `CreateObject`. Appraise carries
|
||||
NONE of them.** Definitive from the ACE oracle (the user's own server):
|
||||
|
||||
- `references/ACE/.../Enum/Properties/PropertyDataId.cs:5-7` (verbatim):
|
||||
*"No properties are sent to the client unless they featured an attribute. … AssessmentProperty
|
||||
gets sent in successful appraisal."*
|
||||
- `Icon = 8`, `IconOverlay = 50`, `IconUnderlay = 52` — **no `[AssessmentProperty]`** → never in
|
||||
appraise (nor `[SendOnLogin]` → never in PlayerDescription property tables).
|
||||
- `PropertyInt.UiEffects = 18` — **no `[AssessmentProperty]`** (`PropertyInt.cs:34`; the
|
||||
research-agent claim that it has the attribute was a **fabrication**, caught by the verifier).
|
||||
- `AppraiseInfo.Write` serializes only the attributed `PropertiesInt/PropertiesDID/…` tables +
|
||||
the profile blobs — **no icon / UiEffects field anywhere**.
|
||||
|
||||
Wire path for every icon input (all on the `CreateObject` weenie header, ACE
|
||||
`WorldObject_Networking.cs` + `PublicWeenieDesc::Pack` decomp `442421/442489/442628/442631`):
|
||||
|
||||
| Field | weenie-flag gate | acdream status |
|
||||
|---|---|---|
|
||||
| `_iconID` | always | captured (D.5.1) |
|
||||
| `_iconOverlayID` | weenieFlags `0x40000000` | captured (D.5.1) |
|
||||
| `_iconUnderlayID` | weenieFlags2 `0x01` | captured (D.5.1) |
|
||||
| `_effects` (UiEffects) | weenieFlags `0x80` | **read + DISCARDED** at `CreateObject.cs:669` |
|
||||
|
||||
**Consequence (corrects handoff §3.3/§3.4 + §5.4):** the pinned scroll shows no overlay because
|
||||
acdream **discards `UiEffects`** and never builds the effect treatment — NOT because the data is
|
||||
appraise-gated. **The handoff's "wire appraise → enrichment" item is a no-op**: appraise never
|
||||
carries this data, and acdream never even *sends* an `AppraiseRequest` (`AppraiseRequest.Build`
|
||||
exists but has zero call sites). The live "mana vs out-of-mana" re-trigger is a future
|
||||
`PrivateUpdateInt(UiEffects=18)` (the `0x02CD` property-update block, inventory/M2 phase), feeding
|
||||
the same re-composition contract — NOT appraise.
|
||||
|
||||
---
|
||||
|
||||
## 2. The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer — SETTLED
|
||||
|
||||
Clean Ghidra decompile of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor`
|
||||
(`0x00441530`) resolves the Binary-Ninja register/calling-convention artifacts the handoff and the
|
||||
spine doc flagged UNVERIFIED.
|
||||
|
||||
**`SurfaceWindow::ReplaceColor(this, RGBAColor src, RGBAColor dest)`** = for each pixel `==
|
||||
GetColor32(src)`, set it to `GetColor32(dest)`. A flat single-color → single-color replace.
|
||||
|
||||
**`RenderIcons` builds two surfaces (bottom→top):**
|
||||
|
||||
```
|
||||
m_pDragIcon (32x32):
|
||||
Blit base icon (m_idIcon) mode Blit_Normal (opaque)
|
||||
Blit custom overlay (m_idOverlayID) mode Blit_4Alpha
|
||||
if (effectTile != null): # effectTile = GetByEnum(0x10000005, …)
|
||||
ReplaceColor(this, src = WHITE(1,1,1,1), dest = <color from effectTile>)
|
||||
|
||||
m_pIcon (32x32):
|
||||
Blit type-default underlay (GetByEnum 0x10000004, lsb(itemType)+1, fb 0x21) Blit_Normal (opaque)
|
||||
Blit custom underlay (m_idUnderlayID) Blit_3Alpha
|
||||
Blit m_pDragIcon Blit_3Alpha
|
||||
```
|
||||
|
||||
- The **effect tile is NEVER blitted** (it's the `ReplaceColor` `dest`-color source). The dat probe
|
||||
confirms why: every `enum 0x10000005` entry is a **32×32 FULLY-OPAQUE** colored tile
|
||||
(`opaque=1024, transp=0`) — blitting one on top would erase the icon.
|
||||
- `src` color = `RGBAColor(1,1,1,1)` → `GetColor32` → `0xFFFFFFFF` (pure-white, full alpha). So
|
||||
**only pure-white-opaque pixels recolor** — the effect is the recolor of the icon/overlay's white
|
||||
highlights to the effect hue. Subtle, data-dependent.
|
||||
- **Effect index:** `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj is null,
|
||||
fallback index `0x21`. NOTE retail has **no** `lsb==-1 → 0x21` pre-check on the effect path (unlike
|
||||
the type-underlay path), so `_effects==0` → index 0 → null → fallback `0x21` (the SOLID-BLACK tile).
|
||||
- **UpdateIcons dirty-check** (`0x0058da…`, decomp `407962`): re-render on change of
|
||||
`iconID / overlayID / underlayID / itemType / _effects`. acdream's per-tuple icon cache keyed on
|
||||
exactly these IS the re-composition contract.
|
||||
|
||||
### The one residual ambiguity (decompiler-bounded)
|
||||
The exact byte `ReplaceColor`'s `dest` color is read from is `effectTile + 0xac` (= the effect tile's
|
||||
`SurfaceWindow` header) reinterpreted as `RGBAColor` — both BN and Ghidra leave this as a struct
|
||||
read neither types cleanly. It is NOT pixel data and NOT a clean field either decompiler resolves.
|
||||
**Faithful resolution:** the effect tiles are purpose-built per-effect colored tiles, so the effect
|
||||
color = the tile's own representative (mean opaque) color. This is intent-faithful, not a guess about
|
||||
an unknown constant. Flagged for cdb/visual confirmation. (Register row + visual gate.)
|
||||
|
||||
---
|
||||
|
||||
## 3. `enum 0x10000005` effect submap — golden values (live dat, MasterMap `0x25000000` → submap `0x25000009`)
|
||||
|
||||
`index = LowestSetBit(UiEffects)+1`; submap has 14 entries (idx 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.
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# Handoff — the FULL stateful item-icon system (next session)
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**From:** the D.5.1 toolbar session (the action bar shipped; its icon compositor is **partial**).
|
||||
**Purpose:** build the **complete, retail-faithful, stateful item-icon system** — the multi-layer icon composite that reflects an item's *current state* (charged/enchanted/etc.), driven by both `CreateObject` and `Appraise`. This is **shared infrastructure**: the inventory, equipment/paperdoll, vendor, and trade panels all render item icons, so it must be solved properly once, here, before those panels are built.
|
||||
|
||||
This doc is the entry point. The new-session prompt is at the bottom (§10).
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
A retail item icon is **not one sprite** — it's a runtime composite of **up to 5 layers** (`IconData::RenderIcons`, decomp `acclient_2013_pseudo_c.txt:407524` / `0058d180`), and **which layers apply depends on the item's live state** (item type, magic underlay, overlay tint, and the `_effects` bitfield). The D.5.1 toolbar built layers 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<uint,uint>`; each layer DID is a `0x06` RenderSurface decoded directly by `SurfaceDecoder.DecodeRenderSurface`.
|
||||
|
||||
---
|
||||
|
||||
## 2. What D.5.1 already built (read this code first)
|
||||
|
||||
- **`src/AcDream.App/UI/IconComposer.cs`** — the CPU compositor. `Compose(layers)` = alpha-over, sizes to layer 0. `GetIcon(ItemType, iconId, underlayId, overlayId)` resolves the **type-default underlay** (`ResolveUnderlayDid` + `EnsureUnderlaySubMap`, via `EnumIDMap` master→`0x10000004`→submap), prepends it as the opaque layer 0, then composites custom-underlay + base + custom-overlay, caches by the `(typeUnderlayDid, iconId, underlayId, overlayId)` tuple, uploads via `TextureCache.UploadRgba8`. **Layer order + the underlay are faithful** (golden test `ResolveUnderlayDid_goldenValues_matchDat` passes against the live dat).
|
||||
- **`src/AcDream.Core.Net/Messages/CreateObject.cs`** — `TryParse` now walks the **full** weenie-header optional tail (in exact ACE order, verified against `references/ACE/.../WorldObject_Networking.cs`) and captures `IconId`, `IconOverlayId` (weenieFlags `0x40000000`), `IconUnderlayId` (weenieFlags2 `0x01`). It reads `UiEffects` (weenieFlags `0x80`) but **discards it** — capturing it is part of this next phase. RestrictionDB skip is length-aware + tested.
|
||||
- **`src/AcDream.Core/Items/ItemInstance.cs`** — has `IconId`, `IconUnderlayId`, `IconOverlayId`, `Type`. **No `Effects`/`UiEffects` field yet.**
|
||||
- **`src/AcDream.Core/Items/ItemRepository.cs`** — `EnrichItem(objectId, iconId, name, type, iconOverlayId=0, iconUnderlayId=0)` writes the typed icon ids onto an existing item + fires `ItemPropertiesUpdated`. Threaded from `WorldSession.EntitySpawned` → `GameWindow.OnLiveEntitySpawned`.
|
||||
- **`src/AcDream.App/UI/Layout/ToolbarController.cs`** — calls `iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId)` per slot, re-runs `Populate()` on `ItemRepository.ItemAdded`/`ItemPropertiesUpdated` (so a late `CreateObject` re-binds the slot's icon).
|
||||
- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied peace/war `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget.
|
||||
|
||||
---
|
||||
|
||||
## 3. What's MISSING (the next session's work)
|
||||
|
||||
1. **Layer 5 — the effect overlay (`_effects`).** Capture the item's `_effects`/`UiEffects` bitfield (CreateObject reads `UiEffects` at weenieFlags `0x80` but discards it — keep it; also it may be the appraise-only `PropertyInt.UiEffects`). Add an `Effects` field to `ItemInstance`. In `IconComposer`, resolve `GetByEnum(0x10000005, LowestSetBit(effects)+1)` (the second enum submap, `master[0x10000005]`) and composite it as the top layer. Widen `GetIcon` + the cache key to include effects. **This is the user's "mana vs out-of-mana" layer** and the most likely cause of the scroll's missing overlay (if its distinctive look is the effect glow, not a static `_iconOverlayID`).
|
||||
2. **Layer 4 tint — `SurfaceWindow::ReplaceColor`.** The custom overlay is composited as a plain sprite; retail applies a per-pixel palette `ReplaceColor` tint (`407614`). Port the tint (it's a palette-index color replace — see `ACViewer TextureCache.IndexToColor` for the subpalette-overlay technique, though confirm it's the right op for icons).
|
||||
3. **Appraise-driven enrichment + RE-COMPOSITION.** The icon must update when the item's icon-relevant properties change. `IdentifyObjectResponse` (`0x00C9`, `AppraiseInfoParser` / `GameEventWiring`) currently updates the `PropertyBundle` only — it does **not** update the typed `IconId/Overlay/Underlay/Effects`. Wire appraise → update those typed fields → `ItemPropertiesUpdated` → the bound widget re-resolves the icon (the cache key already changes when an id changes, so a new composite is produced). **This is the other likely cause of the scroll's blank overlay**: the overlay/effects ids may only arrive at appraise, not on the bare `CreateObject`.
|
||||
4. **Settle the data-availability question (DO THIS FIRST — it's a 10-min capture).** Does ACE send `IconOverlay`/`UiEffects` on a *contained* (in-pack, un-appraised) item's `CreateObject`, or only at appraise? Capture the scroll's `0xF745 CreateObject` **and** its `0x00C9 IdentifyObjectResponse` with WireMCP (`mcp__wiremcp__*`, loopback `127.0.0.1:9000`) and log `CreateObject.Parsed.IconOverlayId/IconUnderlayId` at runtime. The answer decides whether the fix is "just build layer 5" (data already on CreateObject) or "build layer 5 + appraise enrichment" (data is appraise-gated). **Don't guess — capture.**
|
||||
5. **The `IsThePlayer` container icon** (paperdoll) — `GetDIDByEnum(0x10000004, 7)` + `TYPE_CONTAINER`. Needed when the paperdoll renders the player's own icon.
|
||||
6. **Identified-vs-unidentified does NOT swap the icon** (confirmed last session): appraise gates *tooltip* detail, not the base icon. So the icon layers come from the item's real props (sent on CreateObject and/or appraise), not an "identified" toggle. Don't add an appraise-gated icon variant.
|
||||
|
||||
---
|
||||
|
||||
## 4. The user's framing (their words are the spec)
|
||||
|
||||
> "the icon system in AC consists of several icons making up an icon. For example an item with mana has a different icon from the same item that is out of mana."
|
||||
|
||||
Correct, and it maps exactly onto the model above: the **`_effects` bitfield** (and the underlay/overlay ids) reflect the item's current state, and `RenderIcons` composites the corresponding layers. "With mana vs out of mana" = the effect/underlay layers present vs absent → **the icon must re-compose when that state changes** (§3.3). Build the system so the displayed icon is always a function of the item's *current* properties, updated on every relevant property change.
|
||||
|
||||
---
|
||||
|
||||
## 5. Research questions for the next session
|
||||
|
||||
1. **`_effects` source + layout.** Is the icon effect bitfield the `CreateObject` `UiEffects` (weenieFlags `0x80`), the appraise `PropertyInt.UiEffects`, or both? What are its bit values (Magical/Enchanted/…)? (grep the decomp + ACE `PropertyInt`/`UiEffects` + `IconData::RenderIcons` `_effects` use at `407575`.)
|
||||
2. **`master[0x10000005]` submap** — read it from the live dat (mirror the confirmed `0x10000004` resolve); enumerate its entries (index → effect-overlay `0x06` DID). Add a golden test like the underlay one.
|
||||
3. **The `ReplaceColor` tint** — what color/palette does layer 4 tint with, and is it a straight palette-index replace? Cross-ref `SurfaceWindow::ReplaceColor` (decomp) + ACViewer.
|
||||
4. **Appraise → icon fields** — exactly which `IdentifyObjectResponse` / `AppraiseInfo` fields carry `IconOverlay`/`IconUnderlay`/`UiEffects` (cross-ref ACE `AppraiseInfo` serialization + Chorizite). Wire them to update `ItemInstance` typed fields.
|
||||
5. **Data-availability capture** (§3.4) — the WireMCP result for the scroll.
|
||||
6. **Re-composition trigger** — confirm `ItemPropertiesUpdated` → widget re-resolve is sufficient (it is for the toolbar; verify the inventory/paperdoll widgets will subscribe the same way).
|
||||
|
||||
---
|
||||
|
||||
## 6. References (cross-reference ≥2 per question)
|
||||
|
||||
- **Named decomp** `docs/research/named-retail/acclient_2013_pseudo_c.txt`: `IconData::RenderIcons` (407524), `ACCWeenieObject::GetIconData` (408224), `DBCache::GetDIDFromEnum` (0x413940), `EnumIDMap::EnumToDID` (0x415970), `SurfaceWindow::ReplaceColor` (~407614). Headers: `acclient.h` (IconData / ACCWeenieObject struct).
|
||||
- **This session's research** (the icon facts are anchored here): `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` (the 5-layer composite, the RenderSurface-direct decode), the D.5.1 spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`.
|
||||
- **ACE** `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` (CreateObject field order), `.../Network/Structure/AppraiseInfo*.cs` (appraise fields), `ACE.Entity/Enum/PropertyInt.cs` (UiEffects).
|
||||
- **ACViewer** `references/ACViewer/ACViewer/Render/TextureCache.cs` (IndexToColor / subpalette overlay) — for the layer-4 tint + icon decode.
|
||||
- **Chorizite.ACProtocol** `.../Messages/` — PublicWeenieDesc + appraise field order.
|
||||
- **DatReaderWriter** (nuget): `EnumIDMap` (DB_TYPE_DID_MAPPER), `RenderSurface`, `DatHeader.MasterMapId`.
|
||||
- **D.2b memory crib**: `claude-memory/project_d2b_retail_ui.md` (the toolkit + the RenderSurface-vs-Surface decode gotcha; START-HERE for UI work).
|
||||
|
||||
---
|
||||
|
||||
## 7. Files involved
|
||||
|
||||
- `src/AcDream.App/UI/IconComposer.cs` — add the effect layer (`0x10000005`), the overlay tint, widen `GetIcon`/cache for effects.
|
||||
- `src/AcDream.Core/Items/ItemInstance.cs` — add `Effects` (+ any other state fields the icon needs).
|
||||
- `src/AcDream.Core.Net/Messages/CreateObject.cs` — capture `UiEffects` (already read, currently discarded) onto `Parsed`.
|
||||
- `src/AcDream.Core.Net/WorldSession.cs` (`EntitySpawn` record) + `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawned`) — thread `UiEffects` through.
|
||||
- `src/AcDream.Core/Items/ItemRepository.cs` — `EnrichItem` carry effects; **appraise enrichment** path.
|
||||
- The appraise handler — `src/AcDream.Core.Net/GameEventWiring.cs` / `AppraiseInfoParser` — update typed icon fields on `0x00C9`.
|
||||
- `src/AcDream.App/UI/UiItemSlot.cs` / `ToolbarController.cs` — already re-resolve on `ItemPropertiesUpdated`; no change expected (verify).
|
||||
|
||||
---
|
||||
|
||||
## 8. New toolkit/API shape this introduces
|
||||
|
||||
- **`IconComposer.GetIcon` becomes the single stateful icon entry point** — input is the item's full icon state `(ItemType, iconId, underlayId, overlayId, effects [, isPlayer])`; output is the composited GL texture; cache keyed by the full state tuple. Every item panel calls this.
|
||||
- **`ItemInstance` carries the full icon state** (`IconId/Underlay/Overlay/Effects/Type`), updated from BOTH `CreateObject` and `Appraise`.
|
||||
- **One re-composition contract**: any change to an item's icon state → `ItemRepository.ItemPropertiesUpdated` → bound `UiItemSlot` re-calls `GetIcon` (new state tuple → new composite). The toolbar already follows this; inventory/paperdoll reuse it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Related (separate) next toolbar work — NOT this handoff, but flagged
|
||||
|
||||
The toolbar still needs **interactivity** beyond click-to-use (tracked separately in `docs/ISSUES.md`):
|
||||
- It is the **selected-object display** — the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line show the object currently **selected in the world** (wire the B.4 `WorldPicker`/selection state → those elements).
|
||||
- Click-to-use ✅ and peace/war stance indicator + slot-number recolor ✅ are done.
|
||||
This is a distinct feature from the icon system; do the icon system first (it's the shared dependency).
|
||||
|
||||
---
|
||||
|
||||
## 10. New-session prompt (paste into a fresh session)
|
||||
|
||||
> Build the **FULL stateful item-icon system** for acdream (shared by inventory/equipment/vendor/trade — needed before those panels). **Read the handoff first: `docs/research/2026-06-17-stateful-icon-system-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md` and `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
|
||||
>
|
||||
> The D.5.1 toolbar built layers 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.
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
# Handoff — finish the action bar + start the inventory/paperdoll window
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**From:** the D.5.4 object/item-model session (SHIPPED `b506f53..6eb0fbde`, 2672 tests green, visually
|
||||
confirmed on Barris/Coldeve). The data model is now solid — every server object lives in
|
||||
`ClientObjectTable`, resolvable by guid. This handoff frames the NEXT work on the D.2b retail-UI track.
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (kept, unmerged — carries D.5.2 + D.5.4).
|
||||
**Line numbers below are as of HEAD `6eb0fbde` and WILL drift — grep the symbol, don't trust the line.**
|
||||
|
||||
---
|
||||
|
||||
## 0. Scope (settled with the user)
|
||||
|
||||
Three work streams. **The spell bar is explicitly DEFERRED** (it is a separate feature — a dedicated
|
||||
spell-casting bar — NOT the action-bar spell *shortcuts*; do not build spell-glyph rendering/casting here).
|
||||
|
||||
| Stream | What | Roadmap |
|
||||
|---|---|---|
|
||||
| **A. Selected-object meter** | The action bar's bottom strip: the player's currently-**selected** world object's Health/Mana meter + name (+ stack slider, deferred). Currently hidden. | D.5.3 (issue #140) |
|
||||
| **B. Shortcut drag / add / reorder / remove** | Drag an item from the inventory window onto a hotbar slot; reorder slots; remove. The `AddShortcut`/`RemoveShortcut` wire. Item shortcuts already RENDER + click-to-use (D.5.1/D.5.4); this is the interactive management. | D.5.3 / D.5.5 |
|
||||
| **C. Paperdoll + inventory window** | One combined window (`gmInventoryUI` nests paperdoll + backpack + 3D-items). It is the **drag SOURCE** that Stream B needs. | D.5.5 |
|
||||
|
||||
**Out of scope:** the spell bar; the stack-split UI (entry box `0x100001A3` + slider `0x100001A4`);
|
||||
the faithful Dragbar/Resizebar window resize (the IA-12 whole-window-drag approximation stays for now).
|
||||
|
||||
**Dependency reality:** Stream B's drag-*from-inventory* needs Stream C (the inventory window) as the
|
||||
drag source, and both B and C need the **drag-drop spine completed** (shared infra, §B.1). So this is
|
||||
really 2-3 sub-phases — see the build order in §4. Each gets its own brainstorm → spec → plan.
|
||||
|
||||
---
|
||||
|
||||
## 1. Read first
|
||||
|
||||
- This doc.
|
||||
- `docs/research/2026-06-16-ui-panels-synthesis.md` — **the build plan** for the core panels (build order, widget list, cross-panel wire table). Stream C follows it.
|
||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — the drag-drop spine design (§5 pseudocode is the spec for Stream B's widget hooks).
|
||||
- `docs/research/2026-06-16-inventory-deep-dive.md` + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the two panels' LayoutDesc maps + wire catalog.
|
||||
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` — `gmToolbarUI` shortcut model + the `HandleDropRelease` drag flags.
|
||||
- `claude-memory/project_object_item_model.md` (D.5.4) + `claude-memory/project_d2b_retail_ui.md` (D.2b/D.5.1/D.5.2 toolkit).
|
||||
|
||||
**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
|
||||
`class::method` → cross-ref ACE/holtburger → pseudocode → port. Conformance tests throughout.
|
||||
The named-decomp anchors for each stream are inline below.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stream A — selected-object meter (the smallest, mostly self-contained)
|
||||
|
||||
**Goal:** when the player selects a world object (LMB pick or Tab/Q combat-target), the action bar's
|
||||
bottom strip shows that object's **Health meter** + **name**; **Mana meter** for owned items.
|
||||
|
||||
**Retail lifecycle** (the oracle): `gmToolbarUI::HandleSelectionChanged`
|
||||
(`acclient_2013_pseudo_c.txt:198635`) — on selection it `SetVisible(1)`s the right meter and fires
|
||||
`CM_Combat::Event_QueryHealth(guid)` (creatures/players) or `CM_Item::Event_QueryItemMana(guid)`
|
||||
(owned items). The server replies `UpdateHealth (0x01C0)` / `UpdateItemMana`, and
|
||||
`RecvNotice_UpdateObjectHealth` (`:196213`) / `RecvNotice_UpdateItemMana` (`:196188`) call
|
||||
`SetAttribute_Float(meter, 0x69, pct)` — **property `0x69` is the fill ratio**. `UIElement_Meter`'s
|
||||
fill element is child id `2` (`UIElement_Meter::Initialize :123328`; `OnSetAttribute :123712`).
|
||||
Mana is gated on `IsOwnedByPlayer` (`:198763`).
|
||||
|
||||
**LayoutDesc elements** (toolbar `0x21000016`, `.layout-dumps/toolbar-0x21000016.txt:621-811`):
|
||||
container `0x1000019E`; name text `0x1000019F` (Type 0) + state overlay `0x100001A0`
|
||||
(states `ObjectSelected 0x06001937` / `StackedItemSelected 0x06004CF4`); **health meter `0x100001A1`**
|
||||
(Type 7); **mana meter `0x100001A2`** (Type 7); stack entry `0x100001A3`; stack slider `0x100001A4`
|
||||
(Type 11). All currently in `ToolbarController` `HiddenIds` (~`ToolbarController.cs:41`),
|
||||
`SetVisible(false)` at Bind (~`:100`).
|
||||
|
||||
**Work items:**
|
||||
1. **Fix the meter render bug** (the launch-log `meter 0x100001A1/A2: 1 Type-3 slice container
|
||||
(expected 2)` warning). `DatWidgetFactory.BuildMeter` (~`DatWidgetFactory.cs:135-154`) assumes 2
|
||||
Type-3 slice containers (back + fill). The toolbar meters have **1** container (the fill, child
|
||||
id `0x00000002`); the **back-track sprite is on the meter element's own DirectState**
|
||||
(e.g. health `0x0600193E`). Fix `BuildMeter` to detect the 1-container case and read the back
|
||||
track from the element's `StateMedia[""]`, fill from the child. (Vitals meters `0x2100006C` have 2
|
||||
containers and work — use them as the contrast.)
|
||||
2. **`SelectedObjectController`** (analogue of `VitalsController` — see the working bind pattern at
|
||||
`VitalsController.cs:61-97`): on selection-change, `SetVisible(true)` on `0x100001A1`(/`A2` for owned
|
||||
items), bind `UiMeter.Fill` to `() => combat.GetHealthPercent(selGuid)`, bind the name text
|
||||
`0x1000019F` to `ClientObjectTable.Get(selGuid)?.Name`, set the `0x100001A0` overlay state; on
|
||||
deselect `SetVisible(false)`.
|
||||
3. **Selection notification:** there is no `SelectionChanged` event today — `_selectedGuid` is a raw
|
||||
`uint?` on `GameWindow` (~`GameWindow.cs:844`), written by `PickAndStoreSelection` (LMB) and
|
||||
`SelectClosestCombatTarget` (Tab/Q), cleared on despawn. Either add an event or poll-and-diff a
|
||||
`Func<uint?>` (the `TargetIndicatorPanel` pattern). **Brainstorm: event vs poll.**
|
||||
4. **Health is ready:** `CombatState.GetHealthPercent(guid)` + `CombatState.HealthChanged`
|
||||
(`CombatState.cs:92,45`), wired from `UpdateHealth 0x01C0` (`GameEventWiring.cs:155`).
|
||||
To force a fresh value on selection, retail sends `QueryHealth` — `SocialActions.BuildQueryHealth`
|
||||
(0x01BF) already exists (`SocialActions.cs:49`). **Brainstorm: send QueryHealth on select, or rely
|
||||
on server broadcasts for now?**
|
||||
5. **Mana is NOT ready** (the harder half): no remote-target mana anywhere (`CombatState` is
|
||||
health-only; `LocalPlayerState.ManaPercent` is self-only). `QueryItemManaResponse (0x0264)` is
|
||||
*parsed* (`GameEvents.cs:416`) but **unregistered** in `GameEventWiring`, and there is **no
|
||||
outbound `QueryItemMana` builder** (its C→S opcode is unknown — `0x0264` is the reply).
|
||||
**Brainstorm/decide: defer mana entirely for D.5.3 (health-only, matching that mana is owned-item-only
|
||||
anyway), or do the full mana path?** Recommend deferring mana → ship health-meter + name first.
|
||||
6. **Stack slider/entry (`0x100001A3/A4`):** deferred (stack-split UI).
|
||||
|
||||
**Why A is mostly standalone:** it doesn't need the drag-drop spine, the window manager, or the
|
||||
inventory window. It's the quickest win and finishes the bar's *display*. Good first chunk.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stream B — shortcut drag / add / reorder / remove
|
||||
|
||||
**Item shortcuts already render + click-to-use** (D.5.1 + D.5.4). This stream is the interactive
|
||||
management: drag an item from inventory onto a slot, reorder, remove.
|
||||
|
||||
### B.1 — the drag-drop spine (SHARED infra, also needed by Stream C)
|
||||
`UiRoot` has the **complete** retail drag state machine, LIVE-wired to Silk.NET input:
|
||||
`BeginDrag`/`UpdateDragHover`/`FinishDrag` firing `DragBegin 0x15`/`DragEnter 0x21`/`DragOver 0x1C`/
|
||||
`DropReleased 0x3E` (`UiRoot.cs:450-496`), promoted on >3px move, bridged via `UiHost.WireMouse`
|
||||
(`UiHost.cs:78-88`, called at `GameWindow.cs:1769`). **But:**
|
||||
- `BeginDrag` always passes `payload: null` (`UiRoot.cs:188`); `DragPayload` has a private setter
|
||||
(`UiRoot.cs:73`) → needs a `SetDragPayload(object)` escape hatch (or a source-payload callback).
|
||||
- `UiItemSlot.OnEvent` handles only `MouseDown→Clicked` (`UiItemSlot.cs:101-105`) — **no
|
||||
DragBegin/DragEnter/DragOver/DropReleased cases**. (`UiItemSlot.ItemId` `:19` is the payload source.)
|
||||
- `UiField`'s `CatchDroppedItem`/`MouseOverTop` are **doc-comment only** (`UiField.cs:10-11`) — the
|
||||
bodies belong on `UiItemSlot`, per the spine doc §5.6.
|
||||
- No `IItemListDragHandler` interface exists; no drag ghost renderer; no `InqDropIconInfo` helper.
|
||||
|
||||
**Build (spine doc §5.7 is the spec):** (1) payload injection in `UiItemSlot` on DragBegin
|
||||
(`{objId=ItemId, srcContainer, srcSlot}`); (2) a cursor-following **drag ghost** (the icon is already
|
||||
in `UiItemSlot.IconTexture`); (3) drop-target hooks on `UiItemSlot` (DragEnter/Over→accept/reject
|
||||
overlay `0x10000041`/`0x10000040`/`0x1000003f`; DropReleased→`HandleDropRelease`); (4)
|
||||
`IItemListDragHandler { bool OnDragOver(...); void HandleDropRelease(...) }` that panels implement +
|
||||
register on their `UiItemList`.
|
||||
|
||||
### B.2 — the shortcut model + wire
|
||||
- **Mutable store missing.** Shortcuts are a **read-only** `IReadOnlyList<ShortcutEntry>`
|
||||
(`GameWindow.Shortcuts ~:600`, set once from PlayerDescription via `onShortcuts` at
|
||||
`GameEventWiring.cs:415`). Port retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`) as a
|
||||
small mutable `ShortcutStore` (18 slots; `Load`/`AddOrReplace(slot,guid)→displaced`/`Remove(slot)`).
|
||||
- **Wire builders exist with a naming bug.** `InventoryActions.BuildAddShortcut` (0x019C,
|
||||
`InventoryActions.cs:99`) — param `objectType` should be `objectGuid`; the trailing field is packed
|
||||
`spellId(u16)|layer(u16)` (0 for items). Byte layout is already correct for item-only callers; **fix
|
||||
the names before wiring.** Field order confirmed by ACE `Shortcut.cs:33`, holtburger
|
||||
`shortcuts.rs:37`, retail `ShortCutData` `acclient.h:36484`. `BuildRemoveShortcut` (0x019D) is fine.
|
||||
- **No `SendAddShortcut`/`SendRemoveShortcut` on `WorldSession`** — wrap the builders (pattern =
|
||||
`SendChangeCombatMode`: `NextGameActionSequence()` + `Build*()` + `SendGameAction()`, `:1064`).
|
||||
- **Drop flow** (retail `gmToolbarUI::HandleDropRelease :197971`): `InqDropIconInfo` flags
|
||||
`&0xE==0` = fresh-from-inventory (place), `&4` = reorder. On drop: remove target if occupied (0x019D)
|
||||
→ update store → add (0x019C) → `Populate()`. Reorder also puts the displaced item back in the source
|
||||
slot. `ToolbarController` implements `IItemListDragHandler` + gets `Action`s for the two sends.
|
||||
|
||||
**Reorder-within-bar needs no inventory; drag-from-inventory needs Stream C.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Stream C — paperdoll + inventory window (one window)
|
||||
|
||||
**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the
|
||||
**current-code readiness** + what's missing. Don't re-derive the design.
|
||||
|
||||
**READY (post-D.5.1/D.5.4):** `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`),
|
||||
`DatWidgetFactory` registers `0x10000031→UiItemList` (`:70`); the data path is
|
||||
`ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full icon fields
|
||||
(`ClientObjectTable.cs:273,188`). The toolkit + data model are in place.
|
||||
|
||||
**MISSING (the build, in synthesis order):**
|
||||
1. **Window manager** (deferred Plan-2): open/close/z-order/persist. Today every window is **always-on
|
||||
at a hardcoded position** (`ACDREAM_RETAIL_UI=1`, `GameWindow.cs:1906`); `UiHost` has no
|
||||
open/close API (`UiHost.cs:37`). Needs at minimum an **`I`-key toggle** to open/close the inventory
|
||||
window. (Faithful Dragbar/Resizebar resize stays deferred — IA-12 whole-window-drag is fine.)
|
||||
2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs:12`, only sizes
|
||||
`_cells[0]`); `Flush`/`AddItem` skeleton exists but no column-count/pitch/wrap (LIKELY 6 cols × 36px;
|
||||
confirm from `UIElement_ItemList::ItemList_AddItem`).
|
||||
3. **Sub-window mount in `LayoutImporter`** — `gmInventoryUI` (`0x21000023`) nests paperdoll
|
||||
(`0x21000024`), backpack (`0x21000022`), 3D-items (`0x21000021`) as child elements whose class id
|
||||
has its own `BaseLayoutId`. The importer only does TEMPLATE inheritance today
|
||||
(`LayoutImporter.cs:196-228`) — it has never instantiated a nested `gm*UI` window. New capability.
|
||||
4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
|
||||
`NoLongerViewingContents 0x0195` (all absent); parsers `ViewContents 0x0196`, `SetStackSize 0x0197`,
|
||||
`InventoryRemoveObject` (all absent); fix `ParsePutObjInContainer` (drops the 4th `containerType`,
|
||||
`GameEvents.cs:352`) + `ParseInventoryServerSaveFailed` (drops `weenieError`, `:377`); register
|
||||
`ViewContents`/`0x019A`/`0x0052`/`0x00A0` in `GameEventWiring`.
|
||||
5. **`UiViewport` (Type 0xD)** for the paperdoll 3D doll — **the single biggest new piece.** No widget,
|
||||
no factory registration, no renderer. Needs an `IUiViewportRenderer` **Core→App seam** (Rule 2) for a
|
||||
scissored single-entity GL pass. The doll is the local player's ObjDesc-dressed entity in a fixed
|
||||
viewport. **Heavy — brainstorm separately (see §5 open questions).**
|
||||
6. **`InventoryController` + `PaperDollController`** (the `gm*UI::PostInit` find-by-id pattern):
|
||||
backpack burden Meter (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, the
|
||||
element-id→`EquipMask` map for paperdoll slots, `ObjDescEvent 0xF625` → re-dress.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommended build order + the dependency graph
|
||||
|
||||
This spans **2-3 sub-phases**. Suggested sequence (each its own brainstorm → spec → plan):
|
||||
|
||||
1. **D.5.3a — selected-object meter** (Stream A). Standalone, quickest, finishes the bar's display.
|
||||
No spine/window-manager dependency. Recommend health-meter + name first; defer mana.
|
||||
2. **Drag-drop spine completion** (§B.1) — shared infra for B and C. Build once.
|
||||
3. **Window manager (open/close)** (§C.1) — enough to toggle the inventory window open.
|
||||
4. **D.5.5 — inventory window** (§C, grid + sub-window mount + wire gaps + `InventoryController`).
|
||||
This gives the drag **source**.
|
||||
5. **D.5.3b — shortcut drag-to-add/reorder/remove** (Stream B) — now that the spine + inventory source
|
||||
+ `ShortcutStore` + the `BuildAddShortcut` fix are in place. (Reorder-within-bar could land earlier
|
||||
with just steps 2 + the store.)
|
||||
6. **Paperdoll** (`UiViewport` + `PaperDollController`, §C.5/6) — the 3D doll, the heaviest piece.
|
||||
|
||||
**Critical-path note:** the drag-drop spine (step 2) is the lynchpin — both shortcut drag and inventory
|
||||
drag depend on it. Do it early and well (it has its own spine deep-dive as the spec).
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions for the brainstorm(s)
|
||||
|
||||
- **A:** SelectionChanged event vs poll-and-diff? Send `QueryHealth (0x01BF)` on select, or rely on
|
||||
server broadcasts? Defer mana (health-only) for D.5.3 — confirm. The meter render-bug fix:
|
||||
back-track from the element's own DirectState — verify the sprite ids (`0x0600193E` health) against the
|
||||
dump.
|
||||
- **B:** `DragPayload` shape (a `record ItemDragPayload(objId, srcContainer, srcSlot, flags)` vs the
|
||||
slot itself)? Where does the drag ghost render (UiRoot.OnDraw vs UiItemSlot overlay)? Is `UiItemList`
|
||||
or `UiItemSlot` the drop-target unit? Fire-and-forget vs optimistic-then-confirm for the shortcut wire?
|
||||
- **C:** Sub-window mount — recursive `Import()` in `LayoutImporter`, or external stitch by the
|
||||
controller? Inventory grid column count (confirm 6 from decomp)? Does the paperdoll doll clone the
|
||||
player `WorldEntity` or build a fresh ObjDesc-dressed `AnimatedEntityState` (player = camera, so there's
|
||||
no player-as-renderable today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Open the
|
||||
inventory by `I`-key only, or also the toolbar's inventory button?
|
||||
|
||||
---
|
||||
|
||||
## 7. ⚠ Corrections to the grounding research (verify against source)
|
||||
|
||||
- **`_liveEntityInfoByGuid` is GONE** (retired in D.5.4 Task 10, `a9d40ad`). A research agent's notes
|
||||
reference it as the selected-object name source at `GameWindow.cs:835/2559/12129` — **stale.**
|
||||
Post-D.5.4 the name resolves via `ClientObjectTable.Get(guid)?.Name`, or the `GameWindow.LiveName(guid)`
|
||||
/ `DescribeLiveEntity(guid)` helpers (which now read the table). Likewise "`ClientObjectTable` does not
|
||||
exist yet" is wrong — it shipped in D.5.4. Trust the table, not the dict.
|
||||
- **Line numbers throughout drift** (D.5.4 removed ~75 lines from `GameWindow`). Grep the symbol.
|
||||
|
||||
---
|
||||
|
||||
## 8. New-session prompt (paste into a fresh session)
|
||||
|
||||
> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives it references. Three work streams (spell bar DEFERRED — it is a separate feature, not the action-bar spell shortcuts): **(A)** the action bar's selected-object meter (Health + name; mana deferred — issue #140); **(B)** shortcut drag/add/reorder/remove (the `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire + the drag-drop spine completion; item shortcuts already render+click); **(C)** start the paperdoll+inventory window (one window — `gmInventoryUI` nests paperdoll/backpack/3D-items). The drag-drop spine (UiRoot has the machine; UiItemSlot lacks the hooks) is shared infra for B and C — build it early. Suggested order: A (standalone quick win) → drag-drop spine → window manager (open/close) → inventory window → shortcut drag → paperdoll (UiViewport). Use the full brainstorm → spec → plan → subagent-driven flow per stream; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Data model is solid post-D.5.4: resolve every object via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Branch `claude/hopeful-maxwell-214a12` (kept, unmerged).
|
||||
|
||||
**MEMORY.md index line:**
|
||||
- [Handoff: finish the bar + inventory/paperdoll window (2026-06-18)](research/2026-06-18-d53-bar-finish-and-inventory-handoff.md) — next D.2b-UI work after D.5.4. 3 streams (spell bar DEFERRED): (A) selected-object meter (health+name, mana deferred; fix DatWidgetFactory 1-slice-container meter bug; SelectedObjectController like VitalsController), (B) shortcut drag/add/reorder/remove (UiRoot has the drag machine, UiItemSlot lacks hooks; mutable ShortcutStore missing; BuildAddShortcut naming bug), (C) inventory+paperdoll window (needs window-manager open/close + UiItemList grid mode + sub-window mount + wire gaps + UiViewport). Build order + per-stream anchors + brainstorm questions inside. ⚠ _liveEntityInfoByGuid is GONE (D.5.4) — name via ClientObjectTable.Get.
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
# Handoff — the client object/item data model (next phase, post-D.5.2)
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**From:** the D.5.2 stateful-icon session (icon system SHIPPED + visually confirmed on a
|
||||
live Coldeve server). This handoff frames the NEXT phase: the real item/object data model.
|
||||
**Status of this work:** branch `claude/hopeful-maxwell-214a12` (kept, not merged). D.5.2 is
|
||||
complete: `52306d9..fb288ad`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this phase exists (the root cause we uncovered)
|
||||
|
||||
Visual-verifying D.5.2 on a live server (character **Barris** on Coldeve) showed **4 of 6
|
||||
hotbar items render no icon**. The diagnostic (`icon-dump.txt`, since removed) proved the
|
||||
cause: those items are **`NOT-ENRICHED`** — `ItemRepository.GetItem(guid)` returns null
|
||||
because their `CreateObject` was **dropped**.
|
||||
|
||||
The mechanism is acdream's **scaffold item model**:
|
||||
- `EnrichItem` is **enrich-existing-only**: it updates an item ONLY if it was already seeded
|
||||
as a stub (from `PlayerDescription` at login). A `CreateObject` for an item with no
|
||||
pre-existing stub is silently discarded (the toolbar handoff called this out:
|
||||
*"new-item ingestion is the inventory phase"*).
|
||||
- So only items in the login seed set get icons; everything else (most pack contents) falls
|
||||
on the floor. The 2 that showed (Energy Crystal, Revenant's Scythe) are wielded items the
|
||||
server announces up front.
|
||||
|
||||
This is **NOT a D.5.2 bug** (the icon composite is correct for every item that reaches it —
|
||||
confirmed: Energy Crystal's Magical gradient tint + the no-mana scroll's black edges both
|
||||
match retail). It is the **item/object data model** being a placeholder.
|
||||
|
||||
## 1. The retail model to port (the oracle)
|
||||
|
||||
Retail has **one master object table** — `ClientObjMaintSystem` — and **`CreateObject` is the
|
||||
canonical create/update for every object** (item, creature, player). The UI never owns item
|
||||
data: a hotbar slot, an inventory cell, a paperdoll slot, a vendor cell all hold a `guid` and
|
||||
resolve it live via `ClientObjMaintSystem::GetWeenieObject(guid)`. (Confirmed in our spine
|
||||
research: *"the cell never holds item data — it holds an itemID and resolves it live."*)
|
||||
|
||||
acdream **inverted** this: login snapshot = source of truth, `CreateObject` = second-class
|
||||
enrich. The fix is to flip it: **`CreateObject` is the authoritative ingestion**;
|
||||
`PlayerDescription` / `ViewContents (0x0196)` / shortcuts become **references + supplementary
|
||||
data**, not the primary seed. Every object the server tells us about is tracked; the UI
|
||||
resolves by guid.
|
||||
|
||||
## 2. THE crux design question (settle this FIRST in the brainstorm)
|
||||
|
||||
acdream currently has **two object tracks**:
|
||||
- the **WorldEntity** system (3D creatures / players / world items, fed by `CreateObject` →
|
||||
`GameWindow.OnLiveEntitySpawned` → `WorldEntity`), and
|
||||
- the **ItemRepository** (inventory items, `src/AcDream.Core/Items/`).
|
||||
|
||||
Retail unifies these under one `ClientObjMaintSystem` (every object is an `ACCWeenieObject`).
|
||||
**Decision to make:** unify acdream into ONE object table (retail shape), or keep the two
|
||||
tracks with a shared ingestion seam? This choice drives everything downstream (inventory,
|
||||
equipment/paperdoll, vendor, trade all resolve items from whatever table wins). Think it
|
||||
through up front — don't discover it halfway in.
|
||||
|
||||
## 3. Sources that feed the model (the ingestion surface to design around)
|
||||
|
||||
| Wire message | Role |
|
||||
|---|---|
|
||||
| `CreateObject (0xF745)` | **canonical** object create/update (full weenie: icon/name/type/stack/container/wielder/effects/…) |
|
||||
| `DeleteObject (0xF747)` | remove |
|
||||
| `PlayerDescription (0x0013)` | login snapshot: inventory + equipped + shortcuts (references; some props) |
|
||||
| `ViewContents (0x0196)` | a container's `{guid, slot}` list when opened |
|
||||
| move events `0x0019/1A/1B`, `0x0022/23`, `0x019A` | re-parent (container/wield/3D) |
|
||||
| `Public/PrivateUpdateProperty* (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.
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
|
||||
|
||||
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
|
||||
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
|
||||
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
|
||||
|
||||
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
|
||||
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
|
||||
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
|
||||
**deliberately NOT implemented** because the math contradicts the observed result —
|
||||
one more capture is needed first.
|
||||
|
||||
## SHIPPED this session (all on `main`)
|
||||
|
||||
| Fix | Commit | What | Result |
|
||||
|---|---|---|---|
|
||||
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
|
||||
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
|
||||
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
|
||||
|
||||
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
|
||||
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
|
||||
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
|
||||
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
|
||||
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
|
||||
matrices.
|
||||
|
||||
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
|
||||
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
|
||||
`LScape::set_sky_position`'s world transform. cdb read retail's actual
|
||||
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
|
||||
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
|
||||
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
|
||||
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
|
||||
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
|
||||
|
||||
## KEY LESSON: the "too purple" was NEVER a bug
|
||||
|
||||
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
|
||||
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
|
||||
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
|
||||
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
|
||||
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
|
||||
was wrong (Fix C). Don't re-investigate the purple.
|
||||
|
||||
---
|
||||
|
||||
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
|
||||
|
||||
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
|
||||
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
|
||||
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
|
||||
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
|
||||
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
|
||||
not because it'd be too bright, but because it's the wrong path entirely.
|
||||
|
||||
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
|
||||
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
|
||||
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
|
||||
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
|
||||
colours. AC town buildings are EnvCell structures, so their walls take this path.
|
||||
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light` → `insert_light` (0x0054D1B0)
|
||||
→ `config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
|
||||
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
|
||||
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
|
||||
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
|
||||
carries DYNAMIC lights for cells, not the wall torches.
|
||||
|
||||
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
|
||||
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
|
||||
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (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(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
|
||||
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
|
||||
|
||||
## Build / run
|
||||
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
|
||||
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
|
||||
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
|
||||
|
||||
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
|
||||
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
|
||||
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
|
||||
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
|
||||
|
||||
---
|
||||
|
||||
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
|
||||
|
||||
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
|
||||
"shorten torch reach" — it is "outdoor objects receive NO torches."**
|
||||
|
||||
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
|
||||
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
|
||||
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
|
||||
this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
|
||||
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
|
||||
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
|
||||
inflated. So "acdream 6 vs retail 4" was a red herring.
|
||||
|
||||
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
|
||||
`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is
|
||||
**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`.
|
||||
The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
|
||||
before `LScape::draw`), so when the building EXTERIOR shell is drawn
|
||||
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
|
||||
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
|
||||
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
|
||||
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
|
||||
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
|
||||
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
|
||||
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
|
||||
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
|
||||
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
|
||||
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
|
||||
enables it outdoors.)
|
||||
|
||||
**The three retail lighting regimes (now all mapped):**
|
||||
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
|
||||
→ acdream mode 1 (EnvCell). ✓ already correct.
|
||||
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
|
||||
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
|
||||
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
|
||||
|
||||
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object
|
||||
torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`)
|
||||
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
|
||||
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
|
||||
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
|
||||
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
|
||||
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
|
||||
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
|
||||
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
|
||||
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
|
||||
|
||||
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
|
||||
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
|
||||
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
|
||||
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
|
||||
reach is the bug.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — what the visible bug actually is (and is NOT)
|
||||
|
||||
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
|
||||
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
|
||||
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
|
||||
|
||||
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
|
||||
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
|
||||
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
|
||||
retail ("looks much better", user 2026-06-19).
|
||||
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
|
||||
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
|
||||
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
|
||||
|
||||
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
|
||||
|
||||
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
|
||||
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
|
||||
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
|
||||
**D-4 (EnvCell walls get no sun) never touched it**.
|
||||
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
|
||||
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
|
||||
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
|
||||
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
|
||||
from the dat EnvCell list — never `info.Buildings`, never characters.
|
||||
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
|
||||
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
|
||||
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
|
||||
shared uniform; each renderer re-sets it before its draws.
|
||||
|
||||
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
|
||||
|
||||
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
|
||||
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
|
||||
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
|
||||
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
|
||||
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
|
||||
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
|
||||
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
|
||||
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
|
||||
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
|
||||
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
|
||||
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
|
||||
|
||||
## The OPEN question to resolve FIRST on resume (don't guess)
|
||||
|
||||
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
|
||||
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
|
||||
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
|
||||
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
|
||||
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
|
||||
for the same one. Then:
|
||||
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
|
||||
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
|
||||
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
|
||||
the issue; tighten how building shells receive torches (the per-vertex range gate already
|
||||
localises, so this is unlikely — favour the Falloff hypothesis).
|
||||
|
||||
## Proposed fix (after the falloff is confirmed)
|
||||
|
||||
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
|
||||
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
|
||||
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
|
||||
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
|
||||
object point lights OFF == retail-match).
|
||||
|
||||
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
|
||||
|
||||
| Commit | What | Verdict |
|
||||
|---|---|---|
|
||||
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
|
||||
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
|
||||
| `cf62793` | D-1 shader clamp | keep |
|
||||
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
|
||||
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
|
||||
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
|
||||
|
||||
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
|
||||
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
|
||||
|
||||
## DO-NOT-RETRY (cost a lot this session)
|
||||
|
||||
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
|
||||
building is a mode-0 OBJECT, none of that path lights it.
|
||||
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
|
||||
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
|
||||
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
# Indoor lighting regime — HANDOFF (#142 windowed-interior regime, #143 portal dynamic light)
|
||||
|
||||
**Date:** 2026-06-20 **Base:** `main` @ `31d7ffd` (A7 #140 + all D.5 work; pushed to both remotes)
|
||||
**Milestone:** M1.5 "Indoor world feels right" **Start with: #142 (issue #1).**
|
||||
**Predecessor:** `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md`
|
||||
(RESOLVED banner — the #140 outdoor fix). Companion: `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
## Where we are
|
||||
|
||||
`#140` (outdoor building over-bright near torches) is **SHIPPED + user-confirmed + merged + pushed.**
|
||||
Real cause: retail lights outdoor objects with SUN + ambient only, never torches (the `useSunlight`
|
||||
gate); fix = gate per-object torch selection on the object being indoor (`IndoorObjectReceivesTorches`,
|
||||
`WbDrawDispatcher.cs`). Register row **AP-43**.
|
||||
|
||||
At the #140 visual gate the user spotted two INDOOR-lighting gaps (the opposite problem — interiors
|
||||
too DARK / "like outdoors"). Both are this handoff. **Neither is a regression from #140** — that fix
|
||||
only *subtracts* torch light from *outdoor* objects.
|
||||
|
||||
## The unifying insight (read this first)
|
||||
|
||||
acdream's lighting **REGIME** (sun on/off + which ambient) is a **per-FRAME global** keyed on whether
|
||||
the PLAYER is in a sealed cell. Retail's is **per-DRAW-STAGE**: the outdoor stage runs with the sun
|
||||
on, the interior-cell stage runs with the sun off + torches on. `#140` fixed the **torch** half of
|
||||
this mismatch *per-object* (AP-43). **#142 is the SUN + AMBIENT half — i.e. the AP-43 residual, now
|
||||
surfaced as a visible bug.** Finishing #142 lets us delete/narrow AP-43.
|
||||
|
||||
---
|
||||
|
||||
# #142 (issue #1) — windowed-building interiors read "like outdoors" [PRIMARY]
|
||||
|
||||
### Symptom (user, 2026-06-19, at the #140 gate)
|
||||
> "Agent of Arcanum house — in retail it is much brighter indoors; when looking into the house it is
|
||||
> lit, same light when you walk in. In acdream it is NOT lit — looking in and when inside it feels the
|
||||
> same like it is outdoors."
|
||||
|
||||
The **meeting hall** (a more sealed interior) looked OK — the user only flagged its portal (#143),
|
||||
not its walls. That contrast is the key clue (see "the three gaps").
|
||||
|
||||
### Retail mechanism (VERIFIED — read verbatim this session)
|
||||
`PView::DrawCells` (0x005a4840) draws a frame in two ordered stages:
|
||||
1. **Outside stage:** `useSunlightSet(1)` (0x005a485a) → `LScape::draw` → outdoor terrain/buildings/
|
||||
objects, **sun on, torches skipped** (the #140 mechanism).
|
||||
2. **Interior stage:** `useSunlightSet(0)` (0x005a49f3) → `restore_all_lighting` → loop over **every**
|
||||
EnvCell in `cell_draw_list` → `DrawEnvCell` (0x0059f170): walls baked
|
||||
(`SetStaticLightingVertexColors` 0x0059cfe0), objects torch-lit (`minimize_object_lighting`
|
||||
0x0054d480, enabled because `useSunlight==0` per `DrawMeshInternal` 0x0059f398), **NO sun.**
|
||||
3. `useSunlightSet(1)` (0x005a4b5d) restores outdoor mode at the very end.
|
||||
|
||||
`useSunlightSet(arg)` (0x0054d450): sets `useSunlight=arg`; `arg==1` enables the SUN as the active
|
||||
hardware light, `arg==0` enables none (sun off).
|
||||
|
||||
**KEY FACT:** `cell_draw_list` holds ALL visible EnvCells — windowed (`SeenOutside`) **and** sealed.
|
||||
Retail draws every interior in the `useSunlight==0` stage. The regime is **per-stage, never per-
|
||||
building / per-SeenOutside.** So retail torch-lights *every* building interior, including windowed
|
||||
ones and look-ins viewed from outside.
|
||||
|
||||
### acdream current state (per-FRAME global) — current line refs (@31d7ffd)
|
||||
- `GameWindow.cs:8061` `playerSeenOutside = playerRoot?.SeenOutside ?? true` — the PLAYER cell's flag.
|
||||
- `GameWindow.cs:8107` `playerInsideCell = playerRoot is not null && !playerSeenOutside`.
|
||||
- `GameWindow.cs:8122` `UpdateSunFromSky(kf, playerInsideCell)` → (`:10786`) sets the **global** sun +
|
||||
ambient: inside → sun `Intensity=0` + flat `(0.2,0.2,0.2)` ambient; outside → keyframe sun + outdoor
|
||||
ambient.
|
||||
- That ambient is uploaded ONCE per frame to the SceneLighting UBO (`CurrentAmbient.AmbientColor`,
|
||||
`:8171`) and read by BOTH mode-0 (objects) and mode-1 (EnvCell shells) in `mesh_modern.vert`.
|
||||
- **Torches are ALREADY per-cell** (AP-43: `IndoorObjectReceivesTorches` `WbDrawDispatcher.cs:2076`,
|
||||
used at `:2057`; plus `EnvCellRenderer` `SelectForObject`) — independent of `playerInsideCell`. So
|
||||
the torch half is fine; **only the SUN + AMBIENT are still per-frame-global.**
|
||||
|
||||
### The three gaps (all one root: per-frame-global vs per-stage)
|
||||
1. **Player OUTSIDE, looking INTO any building (look-in):** `playerSeenOutside=true` → outdoor regime
|
||||
→ the look-in interior gets sun + outdoor ambient. Retail draws look-in cells in the `useSunlight=0`
|
||||
stage (torch-lit). → "when looking in, not lit."
|
||||
2. **Player INSIDE a WINDOWED building** (`SeenOutside=true` cells, e.g. Agent of Arcanum):
|
||||
`playerInsideCell=false` → outdoor regime → interior gets sun + outdoor ambient. Retail:
|
||||
`useSunlight=0`, torch-lit. → "when inside, feels like outdoors."
|
||||
3. **Player INSIDE a SEALED building / dungeon** (`SeenOutside=false`): `playerInsideCell=true` →
|
||||
indoor regime → MATCHES retail. ✓ (the meeting hall + dungeons — why they looked right.)
|
||||
|
||||
### Cheap validation FIRST (before any code)
|
||||
- **Confirm the windowed-vs-sealed split is the discriminator.** Verify the Agent of Arcanum is a
|
||||
WINDOWED building (its EnvCells' `SeenOutside=true`) and the meeting hall is sealed. Dat flag:
|
||||
`EnvCellFlags.SeenOutside` (hydrated to `ObjCell.SeenOutside`; see `EnvCell.cs` / `PhysicsDataCache.cs`).
|
||||
We did NOT pin the Agent of Arcanum's landblock this session — either have the user point at it in
|
||||
game (`[B.4b] pick` line names clicked objects), or extend `HoltburgTorchFalloffProbeTests` to dump
|
||||
`SeenOutside` per EnvCell across the Holtburg landblocks and find the windowed buildings.
|
||||
- **`ACDREAM_PROBE_LIGHT=1`** ([light] line logs `insideCell` / ambient / sun) while standing inside
|
||||
the Agent of Arcanum vs the meeting hall — confirms each gets the regime predicted above.
|
||||
|
||||
### Fix direction (BRAINSTORM this — it is a design fork, not a mechanical port)
|
||||
Make the SUN + AMBIENT **per-draw-context**, mirroring AP-43's per-object torch decision. The renderer
|
||||
is batched bindless-MDI, so a per-stage global won't work across mixed batches — per-object is the
|
||||
natural fit (exact same reasoning that put AP-43 per-object; see the #140 explanation). An object/cell
|
||||
is "indoor" iff its `ParentCellId` is an EnvCell (reuse `IndoorObjectReceivesTorches`). Then:
|
||||
- **Indoor draws** (mode-1 EnvCell shells; mode-0 objects with EnvCell `ParentCellId`): SKIP the sun +
|
||||
use the **indoor** ambient (flat `(0.2,0.2,0.2)` / retail indoor). (mode-1 already skips the sun;
|
||||
it just needs the indoor ambient. mode-0 indoor objects currently ADD the sun — gate it off.)
|
||||
- **Outdoor draws:** sun + outdoor ambient (as today).
|
||||
|
||||
Open design questions for the brainstorm:
|
||||
- The shader needs BOTH ambients (indoor + outdoor) + a per-instance "indoor" selector. Options:
|
||||
(a) add an `indoorAmbient` to the SceneLighting UBO + a per-instance indoor bit (a tiny SSBO like
|
||||
the light-set, or pack into an existing per-instance field); (b) add a third `uLightingMode` (e.g.
|
||||
`2 = indoor object`: no sun, indoor ambient, torches); (c) compute both and select.
|
||||
- `UpdateSunFromSky` must stop branching on `playerInsideCell` and instead provide BOTH regimes every
|
||||
frame (outdoor sun + outdoor ambient AND the indoor flat ambient), so the shader picks per object.
|
||||
- **Verify retail's indoor ambient** (the `restore_all_lighting` path + the per-EnvCell ambient): is it
|
||||
the flat `(0.2,0.2,0.2)` we use, or the cell's own authored ambient? Cross-check before locking it.
|
||||
|
||||
**This work RESOLVES the AP-43 residual** (regime becomes per-draw → no doorway/look-in mismatch).
|
||||
Update/delete AP-43 in the same commit.
|
||||
|
||||
### Files
|
||||
- `GameWindow.cs`: `:8061`/`:8107` (`playerInsideCell`), `:8122` + `:10786` `UpdateSunFromSky` (the
|
||||
regime source), `:8171` (ambient → UBO).
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`: `accumulateLights` (sun loop under
|
||||
`if (uLightingMode==0)` ~`:193`; ambient `uCellAmbient.xyz` ~`:188`). The sun gate + ambient
|
||||
selection live here.
|
||||
- `WbDrawDispatcher.cs`: `IndoorObjectReceivesTorches` (`:2076`) — the indoor predicate to reuse;
|
||||
`ComputeEntityLightSet` (`:2057`).
|
||||
- `EnvCellRenderer.cs`: mode-1 draws (`uLightingMode=1`) — need the indoor ambient.
|
||||
- `LightManager` / the SceneLighting UBO layout (`GlobalLightPacker` is the binding-4 helper) — where a
|
||||
second ambient + the indoor selector would go.
|
||||
|
||||
---
|
||||
|
||||
# #143 (issue #2) — portal swirl doesn't light the room [SECONDARY]
|
||||
|
||||
### Symptom
|
||||
Inside the meeting hall, retail's portal swirl visibly tints/lights the room; acdream's portal lights
|
||||
nothing.
|
||||
|
||||
### Retail mechanism
|
||||
The portal swirl is a **DYNAMIC** light. `add_dynamic_light` (0x0054d420) → `insert_light`
|
||||
(0x0054d1b0) → `world_lights.dynamic_lights`. `minimize_envcell_lighting` (0x0054c170) enables the
|
||||
cell's DYNAMIC subset (class 2) as hardware lights → tints the EnvCell walls; `minimize_object_lighting`
|
||||
(0x0054d480) enables dynamics for objects in the cell too. **Captured params** (predecessor cdb,
|
||||
`tools/cdb/a7-fixd-*.cdb`): the Holtburg portal dynamic light = `intensity=100, falloff=6,
|
||||
color=(0.784, 0, 0.784)` (magenta/purple).
|
||||
|
||||
### acdream gap
|
||||
acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~`:6404` `RegisterOwnedLight`). It
|
||||
registers **no dynamic lights** — the portal entity casts no light. (`GpuWorldState.cs:101` even
|
||||
mentions "unregistering dynamic lights" but none are ever registered.)
|
||||
|
||||
### Fix approach
|
||||
Register a dynamic `LightSource` for portal-swirl entities at their world position with the retail
|
||||
params (or read the portal model's own dat `Setup.Lights` if it carries one — check the portal GfxObj/
|
||||
Setup first). It then flows through the existing point-light path (`LightManager.PointSnapshot` →
|
||||
`SelectForObject` → shader), lighting nearby EnvCell walls + indoor objects. It is a POINT light, lives
|
||||
INSIDE a cell → it must light via the indoor path (the EnvCell bake `SelectForObject` already picks any
|
||||
registered point light near a cell, so registering it may "just work" once it has a `LightSource`).
|
||||
Find where portal swirls spawn in acdream (the particle/portal emitter spawn path) and attach the light
|
||||
there; unregister on despawn (`UnregisterByOwner`). Keep it OUT of the AP-43 outdoor-object gate (it's
|
||||
indoor). Decomp anchors: `add_dynamic_light` 0x0054d420, `minimize_envcell_lighting` 0x0054c170,
|
||||
`insert_light` 0x0054d1b0.
|
||||
|
||||
---
|
||||
|
||||
## Decomp anchors (quick reference)
|
||||
`useSunlightSet` 0x0054d450 · `useSunlight` gate `DrawMeshInternal` 0x0059f398 · `PView::DrawCells`
|
||||
0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3 / `useSunlightSet(1)`
|
||||
0x005a4b5d) · `DrawEnvCell` 0x0059f170 · `SetStaticLightingVertexColors` 0x0059cfe0 · `calc_point_light`
|
||||
0x0059c8b0 (range = falloff × `static_light_factor` 1.3 @ 0x00820e24) · `minimize_object_lighting`
|
||||
0x0054d480 · `minimize_envcell_lighting` 0x0054c170 · `add_dynamic_light` 0x0054d420 · `insert_light`
|
||||
0x0054d1b0 · `config_hardware_light` 0x0059ad30 (`rangeAdjust` 1.5 @ 0x00820cc4 — the dynamic/object
|
||||
hardware path).
|
||||
|
||||
## DO-NOT-RETRY / gotchas
|
||||
- The OUTDOOR torch gate (#140 / AP-43) is correct + user-confirmed — don't touch it.
|
||||
- Don't shorten `Falloff × 1.3` — acdream reads the dat falloffs faithfully (the reach is correct).
|
||||
- The regime is a per-FRAME global; the fix is to make sun+ambient **per-DRAW** (per-object/cell),
|
||||
mirroring AP-43's torch decision — **NOT** to split into separate render passes (fights the batched
|
||||
MDI; the per-object route is why AP-43 exists).
|
||||
- Line numbers above are @`31d7ffd` and WILL drift — re-grep `playerInsideCell` / `UpdateSunFromSky` /
|
||||
`IndoorObjectReceivesTorches` before editing.
|
||||
|
||||
## Verification (the acceptance gate)
|
||||
Visual side-by-side vs retail at the **Agent of Arcanum** (looking IN from outside + walking IN) and
|
||||
the **meeting-hall portal**. Expected after #142: interiors are torch-lit/warm both looking-in and
|
||||
inside; windowed buildings no longer "feel like outdoors." After #143: the portal swirl tints the room.
|
||||
|
||||
## Pointers
|
||||
- Register: **AP-43** (`docs/architecture/retail-divergence-register.md`) — the residual this work
|
||||
resolves.
|
||||
- `claude-memory/reference_retail_ambient_values.md` — cdb values incl. the portal dynamic-light
|
||||
capture + the indoor/outdoor ambient numbers.
|
||||
- `claude-memory/project_render_pipeline_digest.md` — per-cell light + look-in (#124) + flap context.
|
||||
- #140 CHECKPOINT (above) — the full outdoor-torch story + the verified `useSunlight` decomp.
|
||||
|
|
@ -1,633 +0,0 @@
|
|||
# G.3a — Core Teleport-Into-Dungeon Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path.
|
||||
|
||||
**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` ∨ `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5).
|
||||
|
||||
**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** |
|
||||
| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** |
|
||||
|
||||
`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `TeleportArrivalController` (pure state machine, TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/World/TeleportArrivalController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public class TeleportArrivalControllerTests
|
||||
{
|
||||
// Records each Place(destPos, destCell, forced) call.
|
||||
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
|
||||
|
||||
private static TeleportArrivalController Make(
|
||||
ArrivalReadiness verdict,
|
||||
List<PlaceCall> placed,
|
||||
int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames)
|
||||
=> new(
|
||||
readiness: (_, _) => verdict,
|
||||
place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)),
|
||||
maxHoldFrames: maxHoldFrames);
|
||||
|
||||
[Fact]
|
||||
public void BeginArrival_EntersHolding()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.NotReady, placed);
|
||||
|
||||
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
|
||||
|
||||
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
|
||||
Assert.Empty(placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_WhenIdle_IsNoOp()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.Ready, placed);
|
||||
|
||||
c.Tick(); // never began
|
||||
|
||||
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
|
||||
Assert.Empty(placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_NotReady_KeepsHolding_DoesNotPlace()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.NotReady, placed);
|
||||
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
|
||||
|
||||
c.Tick();
|
||||
c.Tick();
|
||||
|
||||
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
|
||||
Assert.Empty(placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_Ready_PlacesUnforced_AndIdles()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.Ready, placed);
|
||||
c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u);
|
||||
|
||||
c.Tick();
|
||||
|
||||
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
|
||||
var call = Assert.Single(placed);
|
||||
Assert.False(call.Forced);
|
||||
Assert.Equal(0x01250126u, call.Cell);
|
||||
Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_Impossible_PlacesForced_AndIdles()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.Impossible, placed);
|
||||
c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u);
|
||||
|
||||
c.Tick();
|
||||
|
||||
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
|
||||
var call = Assert.Single(placed);
|
||||
Assert.True(call.Forced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3);
|
||||
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
|
||||
|
||||
c.Tick(); // 1
|
||||
c.Tick(); // 2
|
||||
Assert.Empty(placed);
|
||||
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
|
||||
|
||||
c.Tick(); // 3 -> timeout
|
||||
|
||||
var call = Assert.Single(placed);
|
||||
Assert.True(call.Forced);
|
||||
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginArrival_AfterPlace_ReArms()
|
||||
{
|
||||
var placed = new List<PlaceCall>();
|
||||
var c = Make(ArrivalReadiness.Ready, placed);
|
||||
|
||||
c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u);
|
||||
c.Tick(); // places #1, idle
|
||||
c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u);
|
||||
c.Tick(); // places #2, idle
|
||||
|
||||
Assert.Equal(2, placed.Count);
|
||||
Assert.Equal(0x01250127u, placed[1].Cell);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
|
||||
Expected: FAIL — `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `src/AcDream.App/World/TeleportArrivalController.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
|
||||
public enum ArrivalReadiness
|
||||
{
|
||||
/// <summary>Destination not yet hydrated; keep holding.</summary>
|
||||
NotReady,
|
||||
|
||||
/// <summary>Destination terrain + cell are ready; place now.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
|
||||
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
|
||||
/// demote rather than hold forever.</summary>
|
||||
Impossible,
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle of a single teleport arrival.</summary>
|
||||
public enum TeleportArrivalPhase { Idle, Holding }
|
||||
|
||||
/// <summary>
|
||||
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
|
||||
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
|
||||
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
|
||||
/// arrival against the resident (old) landblocks before the destination hydrated
|
||||
/// and landed the player in ocean.
|
||||
///
|
||||
/// <para>The controller is pure: readiness and placement are injected delegates,
|
||||
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
|
||||
/// player stays input-frozen while this is Holding because the GameWindow keeps
|
||||
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
|
||||
/// InWorld.</para>
|
||||
///
|
||||
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
|
||||
/// needs no external clock; it is a loud safety net for a never-hydrating
|
||||
/// destination, not a precise deadline.</para>
|
||||
/// </summary>
|
||||
public sealed class TeleportArrivalController
|
||||
{
|
||||
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
|
||||
public const int DefaultMaxHoldFrames = 600;
|
||||
|
||||
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
|
||||
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
|
||||
private readonly int _maxHoldFrames;
|
||||
|
||||
private Vector3 _destPos;
|
||||
private uint _destCell;
|
||||
private int _heldFrames;
|
||||
|
||||
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
|
||||
|
||||
public TeleportArrivalController(
|
||||
Func<Vector3, uint, ArrivalReadiness> readiness,
|
||||
Action<Vector3, uint, bool> place,
|
||||
int maxHoldFrames = DefaultMaxHoldFrames)
|
||||
{
|
||||
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
|
||||
_place = place ?? throw new ArgumentNullException(nameof(place));
|
||||
_maxHoldFrames = maxHoldFrames;
|
||||
}
|
||||
|
||||
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
|
||||
/// AFTER the streaming origin has been recentered on the destination landblock.
|
||||
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
|
||||
public void BeginArrival(Vector3 destPos, uint destCell)
|
||||
{
|
||||
_destPos = destPos;
|
||||
_destCell = destCell;
|
||||
_heldFrames = 0;
|
||||
Phase = TeleportArrivalPhase.Holding;
|
||||
}
|
||||
|
||||
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
|
||||
/// No-op when Idle.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (Phase != TeleportArrivalPhase.Holding) return;
|
||||
_heldFrames++;
|
||||
|
||||
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
|
||||
if (verdict == ArrivalReadiness.Ready)
|
||||
{
|
||||
Place(forced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
|
||||
{
|
||||
Place(forced: true);
|
||||
}
|
||||
// else NotReady -> keep holding
|
||||
}
|
||||
|
||||
private void Place(bool forced)
|
||||
{
|
||||
_place(_destPos, _destCell, forced);
|
||||
Phase = TeleportArrivalPhase.Idle;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
|
||||
Expected: PASS (7 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
|
||||
git commit -m "feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire `TeleportArrivalController` into GameWindow
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add field; lazy construct + 2 callbacks; replace the arrival snap at `:4877-4961`; per-frame `Tick` after `:6838`)
|
||||
|
||||
This task has no isolated unit test (it edits the 10k-line runtime god-object). It is verified by `dotnet build` + `dotnet test` green and the Task 4 visual gate. Make the edits exactly as shown.
|
||||
|
||||
- [ ] **Step 1: Add the field + the lazy-construct helper + the two callbacks**
|
||||
|
||||
Add near the other player/teleport fields in `GameWindow.cs` (anywhere in the field region; e.g. just above `OnTeleportStarted` at `:4971`):
|
||||
|
||||
```csharp
|
||||
// G.3a (#133): holds a teleport arrival in portal space until the destination
|
||||
// dungeon landblock/cell has hydrated, then places the player via the unchanged
|
||||
// validated-claim Resolve path. Lazily constructed on the first teleport (all
|
||||
// runtime deps are wired by then).
|
||||
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
||||
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
||||
|
||||
private void EnsureTeleportArrivalController()
|
||||
{
|
||||
if (_teleportArrival is not null) return;
|
||||
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
|
||||
readiness: TeleportArrivalReadiness,
|
||||
place: PlaceTeleportArrival);
|
||||
}
|
||||
|
||||
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
|
||||
// against the teleport's (destPos, destCell): an impossible indoor claim short-
|
||||
// circuits to immediate placement; otherwise hold until terrain is sampled and,
|
||||
// for an indoor cell, the cell struct has hydrated.
|
||||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||||
System.Numerics.Vector3 destPos, uint destCell)
|
||||
{
|
||||
if (IsSpawnClaimUnhydratable(destCell))
|
||||
return AcDream.App.World.ArrivalReadiness.Impossible;
|
||||
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
|
||||
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||
if (indoor && !_physicsEngine.IsSpawnCellReady(destCell))
|
||||
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
return AcDream.App.World.ArrivalReadiness.Ready;
|
||||
}
|
||||
|
||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||
// once the destination is ready (or force-run on impossible/timeout, logged loud).
|
||||
private void PlaceTeleportArrival(
|
||||
System.Numerics.Vector3 destPos, uint destCell, bool forced)
|
||||
{
|
||||
var resolved = _physicsEngine.Resolve(
|
||||
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
|
||||
var snappedPos = new System.Numerics.Vector3(
|
||||
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
|
||||
|
||||
if (forced)
|
||||
Console.WriteLine(
|
||||
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
|
||||
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
|
||||
|
||||
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
|
||||
{
|
||||
pe.SetPosition(snappedPos);
|
||||
pe.ParentCellId = resolved.CellId;
|
||||
pe.Rotation = _pendingTeleportRot;
|
||||
}
|
||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
||||
|
||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||
playerVelocity: System.Numerics.Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
|
||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||
|
||||
// Tell the server the client finished loading the new landblock (holtburger
|
||||
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
|
||||
_liveSession?.SendGameAction(
|
||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Construct the controller when a teleport starts**
|
||||
|
||||
In `OnTeleportStarted` (`GameWindow.cs:4971-4976`), add the ensure-call after setting PortalSpace:
|
||||
|
||||
```csharp
|
||||
private void OnTeleportStarted(uint sequence)
|
||||
{
|
||||
if (_playerController is not null)
|
||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||
EnsureTeleportArrivalController();
|
||||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the unconditional arrival snap with recenter + BeginArrival**
|
||||
|
||||
Replace the entire arrival block at `GameWindow.cs:4877-4961` (from `// Phase B.3: portal-space arrival detection.` through its closing brace) with:
|
||||
|
||||
```csharp
|
||||
// Phase B.3 / G.3a (#133): portal-space arrival detection.
|
||||
// Only runs for our own player character while in PortalSpace.
|
||||
if (_playerController is not null
|
||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
||||
&& update.Guid == _playerServerGuid)
|
||||
{
|
||||
// Compute old landblock coords from controller position (using the
|
||||
// current streaming origin as the reference center).
|
||||
var oldPos = _playerController.Position;
|
||||
int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
|
||||
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
|
||||
|
||||
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
||||
|
||||
Console.WriteLine(
|
||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||||
|
||||
System.Numerics.Vector3 newWorldPos;
|
||||
if (differentLandblock)
|
||||
{
|
||||
// Recenter the streaming controller on the new landblock NOW (kick
|
||||
// off the dungeon load). After recentering, the destination is
|
||||
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||
}
|
||||
else
|
||||
{
|
||||
newWorldPos = worldPos;
|
||||
}
|
||||
|
||||
// G.3a: do NOT snap here. The destination dungeon landblock has not
|
||||
// streamed in yet; an immediate Resolve falls back to the resident
|
||||
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
|
||||
// in portal space — TeleportArrivalController.Tick (per frame) places
|
||||
// the player via PlaceTeleportArrival once the destination cell
|
||||
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
|
||||
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
|
||||
EnsureTeleportArrivalController();
|
||||
_pendingTeleportRot = rot;
|
||||
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the per-frame Tick after the live-session drain**
|
||||
|
||||
In `OnUpdate`, immediately after `_liveSessionController?.Tick();` (`GameWindow.cs:6838`), add:
|
||||
|
||||
```csharp
|
||||
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
|
||||
// (which applies the destination landblock) and the live-session drain
|
||||
// (which may have just called BeginArrival), so a destination that
|
||||
// hydrated this frame is placed the same frame.
|
||||
_teleportArrival?.Tick();
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build + run the full suites**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: build succeeds (0 errors).
|
||||
|
||||
Run: `dotnet test`
|
||||
Expected: all suites green (App / Core / UI / Net) — no regressions. (Counts at baseline: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133)
|
||||
|
||||
Replaces the unconditional OnLivePositionUpdated snap (which resolved against
|
||||
the resident old landblocks before the destination streamed in -> ocean) with a
|
||||
recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111
|
||||
validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or
|
||||
force-snaps loudly on an impossible claim / ~10s timeout.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Decouple EnvCell physics/visibility hydration from the render-mesh guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5601-5652`
|
||||
|
||||
**Why:** `BuildLoadedCell` (the portal-visibility node) and `CacheCellStruct` (the physics BSP) currently sit *inside* `if (cellSubMeshes.Count > 0)`. A collision cell with an empty render mesh would silently get no collision and no visibility node — retail couples neither to visible geometry. This is insurance for any geometry-less dungeon cell. **It touches the shared (building) hydration path**, so its acceptance includes a no-regression check on the frozen building/cellar demo.
|
||||
|
||||
- [ ] **Step 1: Make the edit**
|
||||
|
||||
In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5601-5652`), the current shape is:
|
||||
|
||||
```csharp
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
if (cellSubMeshes.Count > 0)
|
||||
{
|
||||
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
_envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... */);
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
}
|
||||
```
|
||||
|
||||
Restructure so the transforms + physics/visibility hydration run unconditionally (they don't depend on visible geometry), and only the render registration stays behind the submesh-count guard:
|
||||
|
||||
```csharp
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
|
||||
// G.3a (#133) hydration decouple: the cell transforms and the physics +
|
||||
// visibility hydration are INDEPENDENT of whether the cell has drawable
|
||||
// geometry. Retail couples neither collision nor portal visibility to a render
|
||||
// mesh. Previously these sat behind `cellSubMeshes.Count > 0`, which silently
|
||||
// dropped collision (CellTransit.GetCellStruct -> null -> fall through floor)
|
||||
// and the visibility node for any geometry-less collision cell. CacheCellStruct
|
||||
// self-gates on a null PhysicsBSP (PhysicsDataCache.cs:172), so this is safe for
|
||||
// cells that genuinely have no physics.
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
|
||||
// Render registration only when the cell actually has drawable submeshes.
|
||||
if (cellSubMeshes.Count > 0)
|
||||
{
|
||||
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
||||
_envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... — UNCHANGED args ... */);
|
||||
}
|
||||
```
|
||||
|
||||
Keep the `_envCellRenderer?.RegisterCell(...)` call's argument list exactly as it is today (`cellTransform`, `cellOrigin`, etc.) — only its position in the block changes (now inside the `Count > 0` guard, with the transforms hoisted above).
|
||||
|
||||
- [ ] **Step 2: Build + run the full suites**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: build succeeds.
|
||||
|
||||
Run: `dotnet test`
|
||||
Expected: all suites green — in particular no regression in any existing EnvCell / streaming / membership test.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)
|
||||
|
||||
BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a
|
||||
geometry-less collision cell got no collision (fall-through) and no visibility
|
||||
node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a
|
||||
null PhysicsBSP, so this is safe. Render registration stays behind the submesh
|
||||
guard.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Visual acceptance gate (STOP — user verification)
|
||||
|
||||
This is the M1.5 dungeon-demo gate and the empirical test of #95 + the hydration decouple. It cannot be automated; hand the running client to the user.
|
||||
|
||||
- [ ] **Step 1: Build green**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 2: Launch against the live ACE server**
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_PROBE_CELL = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-g3a-gate.log"
|
||||
```
|
||||
|
||||
Run in the background; give it ~8 s to reach in-world. Use the meeting-hall portal (or `/ls` once G.3d lands) to teleport into the dungeon.
|
||||
|
||||
- [ ] **Step 2: User verifies (the acceptance criteria)**
|
||||
|
||||
The user confirms, in the running client:
|
||||
- Player **stands in the dungeon cell**, on the floor — not ocean, not falling.
|
||||
- The dungeon renders; the user can **navigate 3-5 rooms**; **walls block** movement.
|
||||
- **No ocean / no ACE `failed transition` spam** (check the ACE console + `launch-g3a-gate.log`).
|
||||
- **#95 check:** no see-through-walls, no other-dungeon geometry rendering inside the current dungeon (if it DOES blow up → proceed to the G.3b plan).
|
||||
- **Hydration-decouple no-regression:** re-walk a Holtburg building + cellar (the frozen M1.5 demo) — walls still block, no new phantom collisions, interiors render as before.
|
||||
|
||||
- [ ] **Step 3: On pass — record the milestone progress**
|
||||
|
||||
- Move #133 to **Recently closed** in `docs/ISSUES.md` with the G.3a commit SHAs.
|
||||
- If #95 did NOT reproduce, add a one-line note closing #95 as superseded (its repro was the T4-deleted WB cell-cache path); if it DID, leave #95 open and start the G.3b plan.
|
||||
- Update the roadmap G.3 row + the milestones doc (G.3a core landed).
|
||||
- Then proceed to the G.3c (faithful `TeleportAnimState`) and G.3d (recalls) plans.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (against `2026-06-13-dungeon-support-design.md` §3.1):**
|
||||
- Hold-until-hydration on the arrival path → Task 2 (BeginArrival + Tick).
|
||||
- Reuse #107 `IsSpawnCellReady` + `IsSpawnClaimUnhydratable` → Task 2 `TeleportArrivalReadiness`.
|
||||
- #111 validated-claim EnvCell placement → Task 2 `PlaceTeleportArrival` (unchanged `Resolve`).
|
||||
- Readiness predicate reuses `SampleTerrainZ` (the synced refinement) → Task 2.
|
||||
- Dest-coord validation → handled by the Impossible (indoor) + timeout (outdoor) paths; **no separate task** (YAGNI — the timeout IS the malformed-dest safety net; noted in spec §10.3).
|
||||
- Timeout safety (fail loudly, never freeze) → Task 1 `_maxHoldFrames` + Task 2 forced-place loud log.
|
||||
- Decouple physics/visibility hydration from the render-mesh guard → Task 3.
|
||||
- Visual gate (also settles #95 + hydration coupling) → Task 4.
|
||||
|
||||
**Placeholder scan:** Task 1 + its tests are complete code. Task 2/3 are exact edits with full code; the only `/* ... */` is the deliberately-unchanged `RegisterCell(...)` arg list (instruction: keep verbatim, only move it) — not a content gap. Task 4 is a manual gate (correctly not code).
|
||||
|
||||
**Type consistency:** `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` and the delegate shapes `Func<Vector3,uint,ArrivalReadiness>` + `Action<Vector3,uint,bool>` match between Task 1's class, its tests, and Task 2's `EnsureTeleportArrivalController` / `TeleportArrivalReadiness` / `PlaceTeleportArrival`. `BeginArrival(Vector3,uint)` and `Tick()` signatures match across all three.
|
||||
|
||||
**Deferred to other plans (out of G.3a scope):** #95 stab_list bounding (G.3b, conditional), `TeleportAnimState` tunnel FSM (G.3c), recall game-actions (G.3d).
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,760 +0,0 @@
|
|||
# LayoutDesc Importer — Implementation Plan (Plan 1: foundation + vitals conformance)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Read the retail vitals `LayoutDesc` (`0x2100006C`) from the dat and build a `UiElement` tree that reproduces the hand-built vitals window — proving a data-driven importer that needs no per-window graphics code.
|
||||
|
||||
**Architecture:** A `LayoutImporter` reads a layout, resolves `BaseElement`/`BaseLayoutId` inheritance, and walks the `ElementDesc` tree. A hybrid factory maps each element's `Type` to either a dedicated behavioral widget (meter → `UiMeter`, text → dat-font label) or a generic `UiDatElement` that draws any element's media by draw-mode (reusing the proven tiling primitive). A per-window `VitalsController` binds live data to elements by id, mirroring retail's `gmVitalsUI`. Everything renders through the existing `UiRoot` + primitives — nothing is deleted.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET, `Chorizite.DatReaderWriter` 2.1.7, xUnit. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`.
|
||||
|
||||
**Scope of Plan 1:** rollout steps 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;
|
||||
|
||||
/// <summary>GL-free, dat-free snapshot of a resolved layout element. Populated by the
|
||||
/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms
|
||||
/// below operate on it so they unit-test without the dats.</summary>
|
||||
public sealed class ElementInfo
|
||||
{
|
||||
public uint Id;
|
||||
public int Type;
|
||||
public float X, Y, Width, Height;
|
||||
public int Left, Top, Right, Bottom; // edge-anchor flags
|
||||
public uint FontDid; // 0 = none (inherited via Merge)
|
||||
// sprite per state: state name -> (file, drawMode). "" = DirectState.
|
||||
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||
}
|
||||
|
||||
public static class ElementReader
|
||||
{
|
||||
/// <summary>Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned
|
||||
/// to that side"; any other value = not pinned. Left+Right ⇒ width stretches.</summary>
|
||||
public static AnchorEdges ToAnchors(int left, int top, int right, int bottom)
|
||||
{
|
||||
var a = AnchorEdges.None;
|
||||
if (left == 4) a |= AnchorEdges.Left;
|
||||
if (top == 4) a |= AnchorEdges.Top;
|
||||
if (right == 4) a |= AnchorEdges.Right;
|
||||
if (bottom == 4) a |= AnchorEdges.Bottom;
|
||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>Merge a base element with a derived override: start from base, apply any
|
||||
/// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId.</summary>
|
||||
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
|
||||
{
|
||||
var m = new ElementInfo
|
||||
{
|
||||
Id = derived.Id != 0 ? derived.Id : base_.Id,
|
||||
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||
X = derived.X, Y = derived.Y, // position is the derived placement
|
||||
Width = derived.Width != 0 ? derived.Width : base_.Width,
|
||||
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||
Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia),
|
||||
};
|
||||
foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides
|
||||
return m;
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the `== 4` test if the doc says otherwise.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
|
||||
git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: UiDatElement — generic element + draw-mode render
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/UiDatElement.cs`
|
||||
|
||||
Generic widget: holds an `ElementInfo` + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are `GL_REPEAT`-wrapped).
|
||||
|
||||
- [ ] **Step 1: Write the failing test (active-state selection is pure)**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class UiDatElementTests
|
||||
{
|
||||
[Fact]
|
||||
public void ActiveMedia_PrefersNamedStateOverDirect()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
info.StateMedia[""] = (0x06000001, 0); // DirectState
|
||||
info.StateMedia["ShowDetail"] = (0x06000002, 1); // named
|
||||
var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" };
|
||||
Assert.Equal(0x06000002u, e.ActiveMedia().File);
|
||||
e.ActiveState = "";
|
||||
Assert.Equal(0x06000001u, e.ActiveMedia().File);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"`
|
||||
Expected: FAIL — `UiDatElement` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement UiDatElement**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Generic dat element: draws its active state's media by DrawMode (Normal=tile,
|
||||
/// Alphablend=blended overlay). The fallback renderer for every element type without a
|
||||
/// dedicated behavioral widget; faithful because retail's base element render is exactly
|
||||
/// "stamp the media per draw-mode".</summary>
|
||||
public sealed class UiDatElement : UiElement
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public UiDatElement(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
_info = info; _resolve = resolve;
|
||||
ClickThrough = true; // generic decoration; behavioral widgets opt back in
|
||||
}
|
||||
|
||||
public (uint File, int DrawMode) ActiveMedia()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d
|
||||
: (0u, 0);
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var (file, drawMode) = ActiveMedia();
|
||||
if (file == 0) return;
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
// DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture),
|
||||
// matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state;
|
||||
// the sprite shader already alpha-blends, so the quad is identical here.)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: confirm `DrawMode` enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
|
||||
git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: DatWidgetFactory — Type → widget (else generic)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class DatWidgetFactoryTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void Type7_Meter_MakesUiMeter()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
|
||||
Assert.IsType<UiMeter>(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownType_FallsBackToGeneric()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
|
||||
Assert.IsType<UiDatElement>(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||
Expected: FAIL — `DatWidgetFactory` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement DatWidgetFactory**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
||||
/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement.
|
||||
/// The Type→bucket assignment comes from the format enumeration (Task 1).</summary>
|
||||
public static class DatWidgetFactory
|
||||
{
|
||||
/// <param name="resolve">RenderSurface id → (GL tex, w, h).</param>
|
||||
/// <param name="datFont">Retail UI font for text elements (may be null pre-load).</param>
|
||||
public static UiElement Create(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var e = info.Type switch
|
||||
{
|
||||
7 => BuildMeter(info, resolve), // UIElement_Meter
|
||||
_ => new UiDatElement(info, resolve),
|
||||
};
|
||||
e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height;
|
||||
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static UiElement BuildMeter(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
=> new UiMeter { SpriteResolve = resolve }; // back/front slice ids + binding set by the controller
|
||||
}
|
||||
```
|
||||
> NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via `UiDatFont`. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string".
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
|
||||
git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: LayoutImporter — read layout, resolve inheritance, build tree
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
||||
|
||||
Reads a `LayoutDesc` via `DatCollection`, converts each `ElementDesc` to `ElementInfo` (resolving `BaseElement`/`BaseLayoutId` via `ElementReader.Merge`), builds the widget tree via the factory, and recurses into children. Exposes `FindElement(uint id)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test (uses the committed fixture, no dats)**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` by serializing the dumped tree (a list of `ElementInfo`-shaped records). Test that the importer's pure `BuildFromInfos` produces the right tree:
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class LayoutImporterTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
|
||||
{
|
||||
// health meter element 0x100000E6: X=5,Y=5,150x16,Type=7
|
||||
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
||||
var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
|
||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null);
|
||||
var found = tree.FindElement(0x100000E6);
|
||||
Assert.IsType<UiMeter>(found);
|
||||
Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"`
|
||||
Expected: FAIL — `LayoutImporter` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement LayoutImporter**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Reads a retail LayoutDesc into a UiElement tree. Pure tree-building
|
||||
/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell.</summary>
|
||||
public sealed class ImportedLayout
|
||||
{
|
||||
public required UiElement Root { get; init; }
|
||||
private readonly Dictionary<uint, UiElement> _byId;
|
||||
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId) { Root = root; _byId = byId; }
|
||||
public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null;
|
||||
}
|
||||
|
||||
public static class LayoutImporter
|
||||
{
|
||||
/// <summary>Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving
|
||||
/// inheritance), then BuildFromInfos. Returns null if the layout is missing.</summary>
|
||||
public static ImportedLayout? Import(DatCollection dats, uint layoutId,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) return null;
|
||||
// Convert top-level + nested ElementDescs to resolved ElementInfo.
|
||||
ElementInfo Convert(ElementDesc d) => Resolve(dats, d);
|
||||
// Build a synthetic root that holds the top-level elements as children.
|
||||
var rootInfo = new ElementInfo { Id = 0, Type = 3 };
|
||||
var children = new List<ElementInfo>();
|
||||
var nested = new Dictionary<ElementInfo, ElementDesc>();
|
||||
foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; }
|
||||
return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont);
|
||||
}
|
||||
|
||||
/// <summary>Pure builder used by tests + the shell: build a tree from a root info + its
|
||||
/// direct children infos. (The recursive dat variant handles real nested trees.)</summary>
|
||||
public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable<ElementInfo> children,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
|
||||
if (rootInfo.Id != 0) byId[rootInfo.Id] = root;
|
||||
foreach (var c in children)
|
||||
{
|
||||
var w = DatWidgetFactory.Create(c, resolve, datFont);
|
||||
root.AddChild(w);
|
||||
if (c.Id != 0) byId[c.Id] = w;
|
||||
}
|
||||
return new ImportedLayout(root, byId);
|
||||
}
|
||||
|
||||
// ---- dat-side helpers ----
|
||||
|
||||
private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld,
|
||||
DatCollection dats, Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
|
||||
foreach (var kv in ld.Elements)
|
||||
AddElement(root, kv.Value, dats, resolve, datFont, byId);
|
||||
return new ImportedLayout(root, byId);
|
||||
}
|
||||
|
||||
private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont, Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
var info = Resolve(dats, d);
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont);
|
||||
parent.AddChild(w);
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
foreach (var kv in d.Children)
|
||||
AddElement(w, kv.Value, dats, resolve, datFont, byId);
|
||||
}
|
||||
|
||||
/// <summary>ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance.</summary>
|
||||
private static ElementInfo Resolve(DatCollection dats, ElementDesc d)
|
||||
{
|
||||
var self = ToInfo(d);
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0)
|
||||
{
|
||||
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
|
||||
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
|
||||
if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
|
||||
{
|
||||
foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
|
||||
return null;
|
||||
}
|
||||
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
|
||||
{
|
||||
if (d.ElementId == id) return d;
|
||||
foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Read the verified ElementDesc fields into ElementInfo (no inheritance).</summary>
|
||||
private static ElementInfo ToInfo(ElementDesc d)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = d.ElementId, Type = (int)d.Type,
|
||||
X = d.X, Y = d.Y, Width = d.Width, Height = d.Height,
|
||||
Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge,
|
||||
};
|
||||
if (d.StateDesc is not null) ReadState(d.StateDesc, "", info);
|
||||
foreach (var s in d.States) ReadState(s.Value, s.Key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||
{
|
||||
foreach (var m in sd.Media)
|
||||
if (m is MediaDescImage img && img.File != 0)
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
// font DID (property 0x1A) read here once the format doc confirms the property API.
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: the exact `ElementDesc`/`StateDesc` member access (`d.X`, `d.Type`, `d.States`, `sd.Media`, `img.DrawMode`, the font property) must match Task 1's verified API; `dump-vitals-layout` confirms these members exist. Adjust casts/names to the real API.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
|
||||
git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: VitalsController — bind live data by id
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/VitalsController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs`
|
||||
|
||||
Mirrors `gmVitalsUI`: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class VitalsBindingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_SetsHealthMeterFillFromProvider()
|
||||
{
|
||||
var health = new UiMeter();
|
||||
var layout = FakeLayout(("0x100000E6", health));
|
||||
float hp = 0.42f;
|
||||
VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1,
|
||||
healthText: () => "42/100", staminaText: () => "", manaText: () => "");
|
||||
Assert.Equal(0.42f, health.Fill());
|
||||
}
|
||||
|
||||
private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items)
|
||||
{
|
||||
var dict = new System.Collections.Generic.Dictionary<uint, UiElement>();
|
||||
var root = new UiPanel();
|
||||
foreach (var (idHex, e) in items)
|
||||
{ uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; }
|
||||
return new ImportedLayout(root, dict);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"`
|
||||
Expected: FAIL — `VitalsController` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement VitalsController**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Per-window controller for the vitals layout (0x2100006C). Mirrors retail
|
||||
/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY
|
||||
/// per-window code — data wiring, not graphics.</summary>
|
||||
public static class VitalsController
|
||||
{
|
||||
public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE;
|
||||
|
||||
public static void Bind(ImportedLayout layout,
|
||||
Func<float> healthPct, Func<float> staminaPct, Func<float> manaPct,
|
||||
Func<string> healthText, Func<string> staminaText, Func<string> manaText)
|
||||
{
|
||||
BindMeter(layout, Health, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, manaPct, manaText);
|
||||
}
|
||||
|
||||
private static void BindMeter(ImportedLayout layout, uint id, Func<float> pct, Func<string> text)
|
||||
{
|
||||
if (layout.FindElement(id) is UiMeter m)
|
||||
{
|
||||
m.Fill = () => pct();
|
||||
m.Label = () => text();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the `UiMeter` (extend `DatWidgetFactory.BuildMeter` to read the meter's `E8/E9/EA` + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs
|
||||
git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire the importer into GameWindow behind a flag
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `_options.RetailUi` block where the vitals panel is built)
|
||||
- Modify: `src/AcDream.App/RuntimeOptions.cs` (add `RetailUiImporter` flag from `ACDREAM_RETAIL_UI_IMPORTER`)
|
||||
|
||||
Run the importer-built vitals window when `ACDREAM_RETAIL_UI_IMPORTER=1`, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over.
|
||||
|
||||
- [ ] **Step 1: Add the RuntimeOptions flag**
|
||||
|
||||
In `RuntimeOptions.cs`, add `public bool RetailUiImporter { get; init; }` and read it in `Program.cs` from `ACDREAM_RETAIL_UI_IMPORTER == "1"` (follow the existing `RetailUi` pattern).
|
||||
|
||||
- [ ] **Step 2: Wire the importer in the RetailUi block**
|
||||
|
||||
In `GameWindow.cs`, in the `if (_options.RetailUi)` block, after the existing vitals panel is built, add:
|
||||
```csharp
|
||||
if (_options.RetailUiImporter)
|
||||
{
|
||||
var imported = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats, 0x2100006Cu, ResolveChrome, _datFont);
|
||||
if (imported is not null)
|
||||
{
|
||||
AcDream.App.UI.Layout.VitalsController.Bind(imported,
|
||||
healthPct: () => _vitalsVm!.HealthPercent ?? 0f,
|
||||
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
|
||||
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
|
||||
healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}",
|
||||
staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}",
|
||||
manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}");
|
||||
imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B
|
||||
_uiHost.Root.AddChild(imported.Root);
|
||||
Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored).");
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: confirm `_dats` (the `DatCollection`) + `_datFont` (the `UiDatFont`) field names in `GameWindow`; both already exist (the chrome resolve + the dat-font load use them).
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs
|
||||
git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Vitals conformance — golden tree checks + headless render diff
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs`
|
||||
- Modify: `src/AcDream.Cli/VitalsMockup.cs` (add an importer-render mode if needed for the visual diff)
|
||||
|
||||
- [ ] **Step 1: Write the golden tree conformance test (against the fixture)**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class LayoutConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void VitalsTree_HasThreeMetersAtExpectedRects()
|
||||
{
|
||||
var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos
|
||||
(uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) };
|
||||
foreach (var (id, y) in expected)
|
||||
{
|
||||
var m = layout.FindElement(id);
|
||||
Assert.IsType<UiMeter>(m);
|
||||
Assert.Equal(5f, m!.Left);
|
||||
Assert.Equal(150f, m.Width);
|
||||
Assert.Equal(16f, m.Height);
|
||||
Assert.Equal(y, m.Top);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Add a tiny `FixtureLoader` that reads the committed JSON into `ElementInfo`s and calls `LayoutImporter.BuildFromInfos`.
|
||||
|
||||
- [ ] **Step 2: Run to verify failure, then implement FixtureLoader, then pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests"`
|
||||
Expected: FAIL → implement `FixtureLoader` → PASS.
|
||||
|
||||
- [ ] **Step 3: Headless visual diff**
|
||||
|
||||
Launch the client with both windows (`ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1`, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.)
|
||||
|
||||
- [ ] **Step 4: Full test sweep**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI"`
|
||||
Expected: PASS (all prior UI tests + the new Layout tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
|
||||
git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After Plan 1
|
||||
|
||||
**Plan 1 status: SHIPPED 2026-06-15, pixel-identical.**
|
||||
|
||||
**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): on a width change the dat edge-anchors reflow the pieces (top/bottom edges + bars stretch, corners fixed 5px, right side tracks) per retail `UIElement::UpdateForParentSizeChange @0x00462640`. (The earlier "fixed-size" note was wrong — it came from an inverted edge-flag reading, now corrected; stretch is `RightEdge==1`.) Faithful grip/dragbar-*driven* drag/resize INPUT for the whole toolkit is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bar sprites) + glyph pixel-snap (numbers stay sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels.
|
||||
|
||||
**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it.
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** enumeration (Task 1) ✓, importer + inheritance (Tasks 2,5) ✓, generic renderer (Task 3) ✓, hybrid factory (Task 4) ✓, controller/binding (Task 6) ✓, coexistence/flag (Task 7) ✓, conformance (Task 8) ✓. Window manager + chat + full long-tail = explicitly deferred to Plan 2 (spec rollout 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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,992 +0,0 @@
|
|||
# D.2b Widget Generalization Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder.
|
||||
|
||||
**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 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~<ClassName>"`.
|
||||
- **Commit style:** `feat(D.2b): <widget> — <what>` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer.
|
||||
- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8).
|
||||
- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3).
|
||||
- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5).
|
||||
- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6).
|
||||
- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2).
|
||||
- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4).
|
||||
- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1).
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1).
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget).
|
||||
- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3).
|
||||
|
||||
**Renamed (git mv + class/namespace-internal rename):**
|
||||
- `UiChatScrollbar.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2).
|
||||
- `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5).
|
||||
- `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6).
|
||||
- `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4).
|
||||
|
||||
**Modified:**
|
||||
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 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;
|
||||
|
||||
/// <summary>
|
||||
/// One-off generator for the committed chat golden fixture. Skipped by default —
|
||||
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
|
||||
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
|
||||
/// </summary>
|
||||
public class ChatLayoutFixtureGenerator
|
||||
{
|
||||
[Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
|
||||
public void GenerateChatFixture()
|
||||
{
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(FixturePath(), json);
|
||||
}
|
||||
|
||||
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
|
||||
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate the fixture (manual, dats present).**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`.
|
||||
Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions.
|
||||
|
||||
- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.**
|
||||
|
||||
In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`):
|
||||
```csharp
|
||||
public static ImportedLayout LoadChat()
|
||||
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||
=> LoadInfos("chat_21000006.json");
|
||||
|
||||
// Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json").
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
|
||||
if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
ReadOnlySpan<byte> span = bytes;
|
||||
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
|
||||
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
|
||||
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
|
||||
}
|
||||
```
|
||||
Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");`
|
||||
|
||||
- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).**
|
||||
|
||||
`ChatLayoutConformanceTests.cs`:
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class ChatLayoutConformanceTests
|
||||
{
|
||||
private static ElementInfo Find(ElementInfo n, uint id)
|
||||
{
|
||||
if (n.Id == id) return n;
|
||||
foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; }
|
||||
return null!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_ResolvesKnownElements()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
// These ids come from ChatWindowController; the resolved Type proves the base-chain merge.
|
||||
Assert.NotNull(Find(root, 0x10000011u)); // transcript
|
||||
Assert.NotNull(Find(root, 0x10000016u)); // input
|
||||
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
|
||||
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
|
||||
Assert.NotNull(Find(root, 0x10000019u)); // send button
|
||||
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu
|
||||
Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar
|
||||
Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send)
|
||||
Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min)
|
||||
// transcript + input: assert the ACTUAL resolved Type recorded in Step 2.
|
||||
// From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these.
|
||||
Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript)
|
||||
Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the conformance tests.**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"`
|
||||
Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \
|
||||
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \
|
||||
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \
|
||||
tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
|
||||
git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar
|
||||
|
||||
`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration.
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs` → `src/AcDream.App/UI/UiScrollbar.cs`
|
||||
- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/AcDream.App.Tests/UI/UiScrollbarTests.cs`
|
||||
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
|
||||
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||||
- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Rename the widget file + class.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
|
||||
```
|
||||
In `UiScrollbar.cs`: rename `class UiChatScrollbar` → `class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged.
|
||||
In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.)
|
||||
|
||||
- [ ] **Step 2: Write the failing factory test.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs` add:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type11_Scrollbar_MakesUiScrollbar()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
||||
Assert.IsType<UiScrollbar>(e);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it — verify it fails.**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"`
|
||||
Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`).
|
||||
|
||||
- [ ] **Step 4: Register Type 11 in the factory.**
|
||||
|
||||
In `DatWidgetFactory.Create`, add to the switch (before `_`):
|
||||
```csharp
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build + run factory + scrollbar tests.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).**
|
||||
|
||||
The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with:
|
||||
```csharp
|
||||
// The factory built the Type-11 track element as a UiScrollbar. Find it, bind it.
|
||||
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||
{
|
||||
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||
bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve = resolve;
|
||||
bar.TrackSprite = TrackSprite;
|
||||
bar.ThumbSprite = ThumbSprite;
|
||||
bar.ThumbTopSprite = ThumbTopSprite;
|
||||
bar.ThumbBotSprite = ThumbBotSprite;
|
||||
bar.UpSprite = UpSprite;
|
||||
bar.DownSprite = DownSprite;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
```
|
||||
- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly:
|
||||
```csharp
|
||||
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||
{
|
||||
float oldTop = bar.Top;
|
||||
bar.Top = 0f;
|
||||
bar.Height = bar.Height + oldTop;
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve = resolve;
|
||||
bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite;
|
||||
bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite;
|
||||
bar.UpSprite = UpSprite; bar.DownSprite = DownSprite;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
```
|
||||
Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;`
|
||||
|
||||
- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.**
|
||||
|
||||
`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS (whole UI suite).
|
||||
|
||||
- [ ] **Step 8: Re-check AP-41 in the divergence register.**
|
||||
|
||||
The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only").
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `UiButton` (Type 1) — Send + Max/Min
|
||||
|
||||
The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/UiButton.cs`
|
||||
- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing button-behavior test.**
|
||||
|
||||
`UiButtonTests.cs`:
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiButtonTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void Click_InvokesOnClick()
|
||||
{
|
||||
var info = new ElementInfo { Type = 1, Width = 46, Height = 18 };
|
||||
var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true };
|
||||
b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0));
|
||||
Assert.True(Clicked);
|
||||
}
|
||||
private bool Clicked;
|
||||
|
||||
[Fact]
|
||||
public void NotClickThrough_SoItReceivesClicks()
|
||||
{
|
||||
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
|
||||
Assert.False(b.ClickThrough);
|
||||
}
|
||||
}
|
||||
```
|
||||
> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed.
|
||||
|
||||
- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
|
||||
Expected: FAIL (compile error: `UiButton` not found).
|
||||
|
||||
- [ ] **Step 3: Write `UiButton`.**
|
||||
|
||||
`UiButton.cs`:
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic clickable button. Ports retail UIElement_Button
|
||||
/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828):
|
||||
/// a per-state sprite face + an optional centered caption + a click action. Built by
|
||||
/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F).
|
||||
/// The controller binds OnClick and the caption. State selection mirrors UiDatElement
|
||||
/// so existing Send/Max-Min behavior is preserved exactly.
|
||||
/// </summary>
|
||||
public sealed class UiButton : UiElement
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
public Action? OnClick { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_info = info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal";
|
||||
}
|
||||
|
||||
private uint ActiveFile()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
uint file = ActiveFile();
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the button tests — verify they pass.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Write the failing factory test + register Type 1.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type1_Button_MakesUiButton()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
|
||||
Assert.IsType<UiButton>(e);
|
||||
}
|
||||
```
|
||||
In `DatWidgetFactory.Create` switch:
|
||||
```csharp
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update the controller to bind the factory-built buttons.**
|
||||
|
||||
In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`:
|
||||
```csharp
|
||||
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||
{
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
sendEl.Label = "Send";
|
||||
sendEl.LabelFont = datFont;
|
||||
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
}
|
||||
```
|
||||
And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)` → `is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`.
|
||||
|
||||
- [ ] **Step 7: Build + run the full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `UiMenu` (Type 6) — genericize the channel menu
|
||||
|
||||
`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`.
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChannelMenu.cs` → `src/AcDream.App/UI/UiMenu.cs`
|
||||
- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Rename file + class.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the chat-specific members with the generic surface.**
|
||||
|
||||
In `UiMenu.cs`, rename `class UiChannelMenu` → `class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members:
|
||||
```csharp
|
||||
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
|
||||
public readonly record struct MenuItem(string Label, object? Payload);
|
||||
|
||||
/// <summary>The rows, populated by the controller. Laid out column-major:
|
||||
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
|
||||
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
|
||||
|
||||
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
|
||||
public object? Selected { get; set; }
|
||||
|
||||
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||
public Action<object?>? OnSelect { get; set; }
|
||||
|
||||
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
|
||||
/// Null ⇒ all rows enabled.</summary>
|
||||
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||
|
||||
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||
public Func<string>? ButtonLabelProvider { get; set; }
|
||||
```
|
||||
Make the geometry constants settable so a controller/factory can match the dat:
|
||||
```csharp
|
||||
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
|
||||
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
||||
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
|
||||
```
|
||||
Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members:
|
||||
```csharp
|
||||
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
|
||||
private float InteriorW => ColumnCount * ColumnWidth;
|
||||
private float InteriorH => RowsPerColumn * RowHeight;
|
||||
private float OuterW => InteriorW + 2 * Border;
|
||||
private float OuterH => InteriorH + 2 * Border;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).**
|
||||
|
||||
In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label:
|
||||
- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`.
|
||||
- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`.
|
||||
- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`).
|
||||
- In `OnEvent`'s pick branch, replace the channel-specific selection
|
||||
```csharp
|
||||
if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }
|
||||
```
|
||||
with
|
||||
```csharp
|
||||
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
|
||||
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||
{
|
||||
Selected = Items[idx].Payload;
|
||||
OnSelect?.Invoke(Selected);
|
||||
}
|
||||
```
|
||||
- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length` → `Items.Count`.
|
||||
Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`.
|
||||
|
||||
- [ ] **Step 4: Update the menu tests for the generic surface.**
|
||||
|
||||
In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ClickingRow_FiresOnSelect_WithPayload()
|
||||
{
|
||||
object? picked = null;
|
||||
var m = new UiMenu
|
||||
{
|
||||
Width = 46, Height = 18,
|
||||
Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") },
|
||||
OnSelect = p => picked = p,
|
||||
};
|
||||
// open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the
|
||||
// existing test's click coords, which used the same 17px rows).
|
||||
m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open
|
||||
// … click into row 0 of the open popup (reuse the prior test's local coords) …
|
||||
Assert.Equal("say", picked);
|
||||
}
|
||||
```
|
||||
> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change.
|
||||
|
||||
- [ ] **Step 5: Run the menu tests — green.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Failing factory test + register Type 6.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type6_Menu_MakesUiMenu()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
|
||||
Assert.IsType<UiMenu>(e);
|
||||
}
|
||||
```
|
||||
In `DatWidgetFactory.Create` switch:
|
||||
```csharp
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.**
|
||||
|
||||
In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`):
|
||||
```csharp
|
||||
// Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50).
|
||||
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||
{
|
||||
("Squelch (ignore)", null),
|
||||
("Tell to Selected", null),
|
||||
("Chat to All", ChatChannelKind.Say),
|
||||
("Tell to Fellows", ChatChannelKind.Fellowship),
|
||||
("Tell to General Chat", ChatChannelKind.General),
|
||||
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
||||
("Tell to Society Chat", ChatChannelKind.Society),
|
||||
("Tell to Monarch", ChatChannelKind.Monarch),
|
||||
("Tell to Patron", ChatChannelKind.Patron),
|
||||
("Tell to Vassals", ChatChannelKind.Vassals),
|
||||
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
||||
("Tell to Trade Chat", ChatChannelKind.Trade),
|
||||
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
||||
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
||||
{
|
||||
ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General",
|
||||
ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG",
|
||||
ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg",
|
||||
ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals",
|
||||
ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay",
|
||||
ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi",
|
||||
_ => "Chat",
|
||||
};
|
||||
|
||||
private static bool ChannelAvailable(ChatChannelKind k)
|
||||
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
||||
```
|
||||
Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it:
|
||||
```csharp
|
||||
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||
{
|
||||
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
||||
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
||||
menu.PopupBgSprite = MenuPopupBg;
|
||||
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
||||
menu.Items = System.Array.ConvertAll(ChannelItems,
|
||||
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
||||
menu.Selected = (object?)c._activeChannel;
|
||||
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
||||
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
||||
menu.OnSelect = p =>
|
||||
{
|
||||
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
||||
};
|
||||
c.Menu = menu;
|
||||
}
|
||||
```
|
||||
Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`:
|
||||
```csharp
|
||||
var onSelect = c.Menu.OnSelect;
|
||||
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
||||
```
|
||||
> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`.
|
||||
|
||||
- [ ] **Step 8: Build + run the full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.**
|
||||
|
||||
The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.)
|
||||
|
||||
- [ ] **Step 10: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip
|
||||
|
||||
Rename `UiChatView` → `UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen.
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChatView.cs` → `src/AcDream.App/UI/UiText.cs`
|
||||
- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
|
||||
|
||||
- [ ] **Step 1: Rename file + class + tests.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
|
||||
```
|
||||
In `UiText.cs`: rename `class UiChatView` → `class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView` → `UiText`.
|
||||
|
||||
- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).**
|
||||
|
||||
In `UiText.cs`, change:
|
||||
```csharp
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default
|
||||
```
|
||||
(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6).
|
||||
|
||||
- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).**
|
||||
|
||||
So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`:
|
||||
```csharp
|
||||
/// <summary>Optional dat state-sprite background (the element's own media), drawn
|
||||
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
```
|
||||
At the very top of `OnDraw`, before `DrawFill`:
|
||||
```csharp
|
||||
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
- Add:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type12_Text_MakesUiText()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
||||
Assert.IsType<UiText>(e);
|
||||
}
|
||||
```
|
||||
- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped).
|
||||
- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
|
||||
{
|
||||
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
|
||||
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
||||
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
||||
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run — verify the new/flipped tests fail.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||
Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement).
|
||||
|
||||
- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.**
|
||||
|
||||
In `DatWidgetFactory.cs`:
|
||||
- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`.
|
||||
- Add to the switch:
|
||||
```csharp
|
||||
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
|
||||
```
|
||||
- Add the builder:
|
||||
```csharp
|
||||
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The
|
||||
/// element's own Direct/Normal media (if any) becomes the background sprite, drawn
|
||||
/// under the text — so a Type-12 element that previously rendered via UiDatElement
|
||||
/// keeps its sprite. Lines are bound later by the controller (LinesProvider).</summary>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? m.File : 0u;
|
||||
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
|
||||
}
|
||||
```
|
||||
> Update the `Create` summary/`<returns>` doc that referenced Type-12 returning null.
|
||||
|
||||
- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"`
|
||||
Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`.
|
||||
|
||||
- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).**
|
||||
|
||||
In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind:
|
||||
```csharp
|
||||
// The factory built the Type-12 transcript as a UiText; find + bind it.
|
||||
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
c.Transcript.Font = debugFont;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
```
|
||||
Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim.
|
||||
|
||||
Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList<UiText.Line>` return type, the `Array.Empty<UiText.Line>()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`).
|
||||
|
||||
Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.)
|
||||
|
||||
- [ ] **Step 9: GameWindow follow-through.**
|
||||
|
||||
`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm.
|
||||
|
||||
- [ ] **Step 10: Build + full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8).
|
||||
|
||||
- [ ] **Step 12: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `UiField` (Type 3) — editable input
|
||||
|
||||
Rename `UiChatInput` → `UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`:
|
||||
- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it.
|
||||
- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern).
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChatInput.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
|
||||
|
||||
- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.**
|
||||
|
||||
Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6.
|
||||
|
||||
- [ ] **Step 2: Rename file + class + tests.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs
|
||||
```
|
||||
In `UiField.cs`: rename `class UiChatInput` → `class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput` → `UiField`.
|
||||
|
||||
- [ ] **Step 3: Default the background to transparent (consistency with UiText).**
|
||||
|
||||
Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6).
|
||||
|
||||
- [ ] **Step 4: Failing factory test + register Type 3.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type3_Field_MakesUiField()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
|
||||
Assert.IsType<UiField>(e);
|
||||
}
|
||||
```
|
||||
In `DatWidgetFactory.Create` switch:
|
||||
```csharp
|
||||
3 => new UiField(), // UIElement_Field (reg :126190)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run — verify pass.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Wire the input in the controller (variant per Step 1).**
|
||||
|
||||
Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`).
|
||||
|
||||
**Variant A — input resolved to Type 3 (factory-built):**
|
||||
```csharp
|
||||
c.Input = layout.FindElement(InputId) as UiField
|
||||
?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField");
|
||||
c.Input.DatFont = datFont; c.Input.Font = debugFont;
|
||||
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
||||
c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
```
|
||||
|
||||
**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):**
|
||||
```csharp
|
||||
// 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a
|
||||
// controller-placed UiField at the dat element's rect (retail authors a separate Field).
|
||||
var iInfo = FindInfo(rootInfo, InputId)
|
||||
?? throw new InvalidOperationException("chat input info 0x10000016 missing");
|
||||
if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder)
|
||||
iparent.RemoveChild(placeholder); // drop the read-only Text placeholder
|
||||
c.Input = new UiField
|
||||
{
|
||||
Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||
DatFont = datFont, Font = debugFont,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f),
|
||||
SpriteResolve = resolve, FocusFieldSprite = InputFocusField,
|
||||
};
|
||||
(inputBar).AddChild(c.Input);
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
```
|
||||
Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.)
|
||||
|
||||
- [ ] **Step 7: GameWindow follow-through.**
|
||||
|
||||
`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm.
|
||||
|
||||
- [ ] **Step 8: Build + full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Thin + verify the controller; remove dead construction
|
||||
|
||||
After Tasks 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.
|
||||
|
|
@ -1,973 +0,0 @@
|
|||
# Stateful item-icon system (D.5.2) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the item icon a live function of the item's state — capture the discarded `UiEffects` bitfield, build retail's faithful effect-recolor in the icon compositor, and wire the live `PublicUpdatePropertyInt(0x02CE)` update so the icon re-composites in real time.
|
||||
|
||||
**Architecture:** `UiEffects` flows `CreateObject → EntitySpawn → ItemInstance.Effects` and, live, `PublicUpdatePropertyInt(0x02CE) → ItemRepository.UpdateIntProperty → ItemInstance.Effects`. Any change fires `ItemPropertiesUpdated`, which the bound `UiItemSlot` already re-resolves via `IconComposer.GetIcon(…, effects)`. The compositor mirrors retail `IconData::RenderIcons`: a 2-stage composite where the effect tile (`enum 0x10000005`) supplies a `ReplaceColor(white → effectColor)` tint, never a blit layer.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit, `DatReaderWriter` (EnumIDMap/RenderSurface), Silk.NET (GL via `TextureCache`).
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md`](../specs/2026-06-17-d2b-stateful-icon-design.md). **Research:** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md).
|
||||
|
||||
**Conventions:** Every commit appends the CLAUDE.md co-author trailer:
|
||||
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Build with `dotnet build`; the tree must be green after every task.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Core data model — `ItemInstance.Effects` + `ItemRepository` hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Items/ItemInstance.cs` (add `Effects` field, ~line 138)
|
||||
- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (`EnrichItem` +param; add `UpdateIntProperty`)
|
||||
- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `ItemRepositoryTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void EnrichItem_carriesEffects()
|
||||
{
|
||||
var repo = new ItemRepository();
|
||||
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000AAu });
|
||||
bool ok = repo.EnrichItem(0x500000AAu, iconId: 0x06001234u, name: "Wand",
|
||||
type: ItemType.Caster, iconOverlayId: 0, iconUnderlayId: 0, effects: 0x1u);
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0x1u, repo.GetItem(0x500000AAu)!.Effects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateIntProperty_uiEffects_setsEffectsAndFires()
|
||||
{
|
||||
var repo = new ItemRepository();
|
||||
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000ABu });
|
||||
ItemInstance? fired = null;
|
||||
repo.ItemPropertiesUpdated += i => fired = i;
|
||||
bool ok = repo.UpdateIntProperty(0x500000ABu, 18u, value: 0x9); // 18 = UiEffects
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0x9u, repo.GetItem(0x500000ABu)!.Effects);
|
||||
Assert.Equal(0x9, repo.GetItem(0x500000ABu)!.Properties.Ints[18u]);
|
||||
Assert.NotNull(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateIntProperty_unknownItem_returnsFalse()
|
||||
{
|
||||
var repo = new ItemRepository();
|
||||
Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"`
|
||||
Expected: FAIL — `EnrichItem` has no `effects` param; `UpdateIntProperty`/`Effects` don't exist.
|
||||
|
||||
- [ ] **Step 3: Add the `Effects` field**
|
||||
|
||||
In `ItemInstance.cs`, after the `IconOverlayId` property (~line 138):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
|
||||
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
|
||||
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
|
||||
/// appraise never carries it. 0 = no effect.
|
||||
/// </summary>
|
||||
public uint Effects { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `effects` param to `EnrichItem`**
|
||||
|
||||
In `ItemRepository.cs`, change the `EnrichItem` signature + body:
|
||||
|
||||
```csharp
|
||||
public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type,
|
||||
uint iconOverlayId = 0, uint iconUnderlayId = 0, uint effects = 0)
|
||||
{
|
||||
if (!_items.TryGetValue(objectId, out var item)) return false;
|
||||
if (iconId != 0) item.IconId = iconId;
|
||||
if (!string.IsNullOrEmpty(name)) item.Name = name;
|
||||
if (type != default) item.Type = type;
|
||||
if (iconOverlayId != 0) item.IconOverlayId = iconOverlayId;
|
||||
if (iconUnderlayId != 0) item.IconUnderlayId = iconUnderlayId;
|
||||
// D.5.2: 0 is a meaningful "no effect" state (e.g. a caster out of mana),
|
||||
// so assign unconditionally — re-composition reflects the CURRENT state.
|
||||
item.Effects = effects;
|
||||
ItemPropertiesUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `UpdateIntProperty`**
|
||||
|
||||
In `ItemRepository.cs`, add after `UpdateProperties`:
|
||||
|
||||
```csharp
|
||||
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield.</summary>
|
||||
public const uint UiEffectsPropertyId = 18u;
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
|
||||
/// item: store it in the bundle and, for known typed ints, mirror to the typed
|
||||
/// field. Today: UiEffects (18) → <see cref="ItemInstance.Effects"/>. Fires
|
||||
/// ItemPropertiesUpdated so bound widgets re-composite. Extensible hook for future
|
||||
/// typed PropertyInts (StackSize, Structure, …). False if the item is unknown.
|
||||
/// </summary>
|
||||
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
|
||||
{
|
||||
if (!_items.TryGetValue(itemId, out var item)) return false;
|
||||
item.Properties.Ints[propertyId] = value;
|
||||
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
|
||||
ItemPropertiesUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs
|
||||
git commit -m "feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Capture `UiEffects` from `CreateObject`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (record field, capture site, ctor call)
|
||||
- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `CreateObjectTests.cs`, add a `uiEffects` parameter to the builder and write it for the
|
||||
UiEffects field. Change the builder signature (add `uint uiEffects = 0,` next to `iconId`)
|
||||
and the UiEffects write line (currently `WriteU32(bytes, 0); // UiEffects u32`):
|
||||
|
||||
```csharp
|
||||
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
|
||||
```
|
||||
|
||||
Then add the tests:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TryParse_UiEffects_Captured()
|
||||
{
|
||||
// weenieFlags 0x80 = UiEffects; value 0x1 = Magical.
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x50000010u, name: "MagicWand", itemType: (uint)ItemType.Caster,
|
||||
weenieFlags: 0x80u, uiEffects: 0x1u);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x1u, parsed!.Value.UiEffects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_UiEffectsThenIconOverlay_BothCaptured()
|
||||
{
|
||||
// Verifies the cursor still reaches IconOverlay after reading (not skipping) UiEffects.
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x50000011u, name: "GlowSword", itemType: (uint)ItemType.MeleeWeapon,
|
||||
weenieFlags: 0x80u | 0x40000000u, uiEffects: 0x4u, iconOverlayId: 0x1ABCu);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x4u, parsed!.Value.UiEffects);
|
||||
Assert.Equal(0x06001ABCu, parsed.Value.IconOverlayId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_NoUiEffectsBit_LeavesUiEffectsZero()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x50000012u, name: "PlainRock", itemType: (uint)ItemType.Misc, weenieFlags: 0u);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0u, parsed!.Value.UiEffects);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"`
|
||||
Expected: FAIL — `Parsed` has no `UiEffects` member.
|
||||
|
||||
- [ ] **Step 3: Add the `UiEffects` record field**
|
||||
|
||||
In `CreateObject.cs`, in the `Parsed` record, after `uint IconUnderlayId = 0`:
|
||||
|
||||
```csharp
|
||||
uint IconUnderlayId = 0,
|
||||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
|
||||
// effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect
|
||||
// state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise).
|
||||
// Previously read + discarded at the UiEffects skip. 0 = no effect.
|
||||
uint UiEffects = 0);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Capture at the UiEffects site**
|
||||
|
||||
In `CreateObject.cs`, declare the local next to `iconOverlayId`/`iconUnderlayId`:
|
||||
|
||||
```csharp
|
||||
uint iconOverlayId = 0;
|
||||
uint iconUnderlayId = 0;
|
||||
uint uiEffects = 0;
|
||||
uint weenieFlags2 = 0;
|
||||
```
|
||||
|
||||
Change the UiEffects skip to a capture:
|
||||
|
||||
```csharp
|
||||
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
|
||||
uiEffects = ReadU32(body, ref pos);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Pass it to the `Parsed` constructor**
|
||||
|
||||
In the success-path `return new Parsed(...)`, change the tail:
|
||||
|
||||
```csharp
|
||||
IconId: iconId,
|
||||
Useability: useability, UseRadius: useRadius,
|
||||
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
|
||||
UiEffects: uiEffects);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"`
|
||||
Expected: PASS (all existing CreateObject tests still pass — the builder change is additive).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
|
||||
git commit -m "feat(D.5.2): capture UiEffects from CreateObject weenie header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `PublicUpdatePropertyInt (0x02CE)` parser
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`
|
||||
- Test: `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class PublicUpdatePropertyIntTests
|
||||
{
|
||||
private static byte[] Build(uint guid, uint property, int value, byte seq = 1, uint opcode = 0x02CEu)
|
||||
{
|
||||
var b = new byte[17];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
|
||||
b[4] = seq;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(9), property);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
|
||||
return b;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_uiEffectsUpdate_returnsGuidPropValue()
|
||||
{
|
||||
var p = PublicUpdatePropertyInt.TryParse(Build(0x50000001u, property: 18u, value: 0x9));
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(0x50000001u, p!.Value.Guid);
|
||||
Assert.Equal(18u, p.Value.Property);
|
||||
Assert.Equal(0x9, p.Value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_wrongOpcode_returnsNull()
|
||||
=> Assert.Null(PublicUpdatePropertyInt.TryParse(Build(1, 18, 1, opcode: 0x02CDu)));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_truncated_returnsNull()
|
||||
=> Assert.Null(PublicUpdatePropertyInt.TryParse(new byte[16]));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"`
|
||||
Expected: FAIL — `PublicUpdatePropertyInt` does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the parser**
|
||||
|
||||
Create `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>PublicUpdatePropertyInt (0x02CE)</c> — the server updates one
|
||||
/// <c>PropertyInt</c> on a visible object (carries the object guid). Standalone
|
||||
/// GameMessage, dispatched like <see cref="PrivateUpdateVital"/> / CreateObject.
|
||||
///
|
||||
/// <para>
|
||||
/// The companion <c>PrivateUpdatePropertyInt (0x02CD)</c> targets the player's OWN
|
||||
/// object (no guid) and is not parsed here — it has no item-icon impact.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Wire layout (ACE <c>GameMessagePublicUpdatePropertyInt</c>, size hint 17):</para>
|
||||
/// <code>
|
||||
/// u32 opcode = 0x02CE
|
||||
/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital
|
||||
/// u32 guid
|
||||
/// u32 property // PropertyInt enum; UiEffects = 18
|
||||
/// i32 value
|
||||
/// </code>
|
||||
/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4).
|
||||
/// </summary>
|
||||
public static class PublicUpdatePropertyInt
|
||||
{
|
||||
public const uint Opcode = 0x02CEu;
|
||||
|
||||
public readonly record struct Parsed(uint Guid, uint Property, int Value);
|
||||
|
||||
/// <summary>Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation.</summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
|
||||
int pos = 4;
|
||||
pos += 1; // sequence byte (not honored)
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
|
||||
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
|
||||
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
|
||||
return new Parsed(guid, prop, value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs
|
||||
git commit -m "feat(D.5.2): PublicUpdatePropertyInt (0x02CE) parser"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Thread `UiEffects` through `WorldSession` + route `0x02CE`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (EntitySpawn field + ctor thread; new event; message-loop branch)
|
||||
|
||||
> No unit test: the private message loop needs a live session. The parser is covered by
|
||||
> Task 3; the event consumption by Tasks 1+8; the end-to-end path by visual verification.
|
||||
> This matches the existing `PrivateUpdateVital` routing (parser tested, loop not).
|
||||
|
||||
- [ ] **Step 1: Add `UiEffects` to the `EntitySpawn` record**
|
||||
|
||||
In `WorldSession.cs`, in the `EntitySpawn` record, after `uint IconUnderlayId = 0`:
|
||||
|
||||
```csharp
|
||||
uint IconOverlayId = 0,
|
||||
uint IconUnderlayId = 0,
|
||||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
|
||||
// effect recolor. CreateObject-only; 0 = no effect.
|
||||
uint UiEffects = 0);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Thread it at the `EntitySpawn` construction site**
|
||||
|
||||
Find the `new EntitySpawn(... parsed.Value.IconUnderlayId)` construction (the spawn fired from
|
||||
the CreateObject branch). Change its tail:
|
||||
|
||||
```csharp
|
||||
parsed.Value.IconId,
|
||||
parsed.Value.IconOverlayId,
|
||||
parsed.Value.IconUnderlayId,
|
||||
parsed.Value.UiEffects));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Declare the live-update event + payload**
|
||||
|
||||
In `WorldSession.cs`, near the other event declarations (e.g. after the `StateUpdated`
|
||||
event ~line 162), add:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Payload for <see cref="ObjectIntPropertyUpdated"/>: a single PropertyInt change on
|
||||
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
|
||||
/// property to typed state (e.g. UiEffects → the item's icon effect).
|
||||
/// </summary>
|
||||
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
|
||||
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
|
||||
/// item repository so the icon re-composites live.
|
||||
/// </summary>
|
||||
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the message-loop branch**
|
||||
|
||||
In the top-level message dispatch (where `op` is the opcode and `body` the message bytes),
|
||||
add after the `PrivateUpdateVital.CurrentOpcode` branch (~line 905):
|
||||
|
||||
```csharp
|
||||
else if (op == PublicUpdatePropertyInt.Opcode)
|
||||
{
|
||||
var p = PublicUpdatePropertyInt.TryParse(body);
|
||||
if (p is not null)
|
||||
ObjectIntPropertyUpdated?.Invoke(
|
||||
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 6: Run the Net test suite (regression)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core.Net/WorldSession.cs
|
||||
git commit -m "feat(D.5.2): thread UiEffects through EntitySpawn + route 0x02CE PublicUpdatePropertyInt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `IconComposer.ResolveEffectDid` (effect submap resolve)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/IconComposer.cs` (effect-submap fields + `ResolveEffectDid` + `EnsureEffectSubMap`)
|
||||
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing golden test**
|
||||
|
||||
In `IconComposerTests.cs`, add (dat-gated, mirroring `ResolveUnderlayDid_goldenValues_matchDat`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ResolveEffectDid_goldenValues_matchDat()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) return; // dats absent (CI) — skip cleanly
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var composer = new IconComposer(dats, null!);
|
||||
|
||||
// Golden values (live dat, MasterMap 0x25000000 → effect submap 0x25000009;
|
||||
// index = LowestSetBit(UiEffects)+1, fallback 0x21):
|
||||
// Magical (0x0001) → idx 1 → 0x060011CA
|
||||
// Poisoned (0x0002) → idx 2 → 0x060011C6
|
||||
// BoostHealth (0x0004) → idx 3 → 0x06001B05
|
||||
// BoostStamina (0x0010) → idx 5 → 0x06001B06
|
||||
// Nether (0x1000) → idx 13 (absent) → fallback 0x21 → 0x060011C5
|
||||
// none (0x0000) → idx 0 (zero) → fallback 0x21 → 0x060011C5
|
||||
Assert.Equal(0x060011CAu, composer.ResolveEffectDid(0x0001u));
|
||||
Assert.Equal(0x060011C6u, composer.ResolveEffectDid(0x0002u));
|
||||
Assert.Equal(0x06001B05u, composer.ResolveEffectDid(0x0004u));
|
||||
Assert.Equal(0x06001B06u, composer.ResolveEffectDid(0x0010u));
|
||||
Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x1000u));
|
||||
Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"`
|
||||
Expected: FAIL — `ResolveEffectDid` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add the effect-submap fields**
|
||||
|
||||
In `IconComposer.cs`, after the underlay fields (`_underlayDidByIndex`):
|
||||
|
||||
```csharp
|
||||
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
|
||||
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
|
||||
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
|
||||
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
|
||||
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
|
||||
private EnumIDMap? _effectSubMap;
|
||||
private bool _effectResolveTried;
|
||||
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `ResolveEffectDid` + `EnsureEffectSubMap`**
|
||||
|
||||
In `IconComposer.cs`, after `EnsureUnderlaySubMap`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
|
||||
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
|
||||
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
|
||||
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
|
||||
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
|
||||
/// </summary>
|
||||
internal uint ResolveEffectDid(uint effects)
|
||||
{
|
||||
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
|
||||
uint index = (uint)(lsb + 1);
|
||||
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
|
||||
EnsureEffectSubMap();
|
||||
uint did = 0;
|
||||
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
|
||||
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
|
||||
did = fb;
|
||||
_effectDidByIndex[index] = did;
|
||||
return did;
|
||||
}
|
||||
|
||||
private void EnsureEffectSubMap()
|
||||
{
|
||||
if (_effectResolveTried) return;
|
||||
_effectResolveTried = true;
|
||||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
|
||||
if (masterDid == 0) return;
|
||||
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
|
||||
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
|
||||
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"`
|
||||
Expected: PASS. (If it skips, the dats aren't at `%USERPROFILE%\Documents\Asheron's Call` — set `ACDREAM_DAT_DIR` and re-run.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
|
||||
git commit -m "feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `IconComposer` recolor helpers (`ReplaceColorWhite` + effect color)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/IconComposer.cs` (`ReplaceColorWhite`, `TryGetEffectColor`, `TryDecode`)
|
||||
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing dat-free recolor test**
|
||||
|
||||
In `IconComposerTests.cs`, add:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque()
|
||||
{
|
||||
// 2x2: [white-opaque, red-opaque, white-transparent, white-opaque]
|
||||
var px = new byte[]
|
||||
{
|
||||
255,255,255,255, // pure white opaque → replaced
|
||||
255, 0, 0,255, // red → untouched
|
||||
255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF)
|
||||
255,255,255,255, // pure white opaque → replaced
|
||||
};
|
||||
IconComposer.ReplaceColorWhite(px, 2, 2, (10, 20, 30, 255));
|
||||
Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[0..4]); // replaced
|
||||
Assert.Equal(new byte[] { 255, 0, 0, 255 }, px[4..8]); // untouched
|
||||
Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched
|
||||
Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"`
|
||||
Expected: FAIL — `ReplaceColorWhite` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add `ReplaceColorWhite`**
|
||||
|
||||
In `IconComposer.cs`, add (near `Compose`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Retail <c>SurfaceWindow::ReplaceColor</c> (0x00441530) with the icon-composite's
|
||||
/// fixed source color: replace pixels exactly equal to pure-white-opaque
|
||||
/// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with <paramref name="dest"/>. Mutates in place.
|
||||
/// </summary>
|
||||
internal static void ReplaceColorWhite(byte[] rgba, int w, int h, (byte r, byte g, byte b, byte a) dest)
|
||||
{
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
if (rgba[i * 4] == 255 && rgba[i * 4 + 1] == 255 &&
|
||||
rgba[i * 4 + 2] == 255 && rgba[i * 4 + 3] == 255)
|
||||
{
|
||||
rgba[i * 4] = dest.r; rgba[i * 4 + 1] = dest.g;
|
||||
rgba[i * 4 + 2] = dest.b; rgba[i * 4 + 3] = dest.a;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `TryGetEffectColor` + `TryDecode`**
|
||||
|
||||
In `IconComposer.cs`, add the color cache field next to `_effectDidByIndex`:
|
||||
|
||||
```csharp
|
||||
private readonly Dictionary<uint, (byte r, byte g, byte b, byte a)> _effectColorByDid = new();
|
||||
```
|
||||
|
||||
And the methods (after `ResolveEffectDid`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// The effect tint color for <paramref name="effects"/>: the effect tile's mean-opaque
|
||||
/// color (blue=Magical, green=Poisoned, …). The exact retail color byte is a
|
||||
/// decompiler-ambiguous SurfaceWindow-header read; the tile IS the per-effect color, so
|
||||
/// its representative color is the faithful equivalent (divergence DR-2). Cached per DID.
|
||||
/// </summary>
|
||||
private bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color)
|
||||
{
|
||||
color = default;
|
||||
uint did = ResolveEffectDid(effects);
|
||||
if (did == 0) return false;
|
||||
if (_effectColorByDid.TryGetValue(did, out var cached)) { color = cached; return true; }
|
||||
if (!TryDecode(did, out var d)) return false;
|
||||
long sr = 0, sg = 0, sb = 0; int n = 0;
|
||||
for (int i = 0; i < d.Width * d.Height; i++)
|
||||
{
|
||||
if (d.Rgba8[i * 4 + 3] == 0) continue;
|
||||
sr += d.Rgba8[i * 4]; sg += d.Rgba8[i * 4 + 1]; sb += d.Rgba8[i * 4 + 2]; n++;
|
||||
}
|
||||
if (n == 0) return false;
|
||||
var rep = ((byte)(sr / n), (byte)(sg / n), (byte)(sb / n), (byte)255);
|
||||
_effectColorByDid[did] = rep;
|
||||
color = rep;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded)
|
||||
{
|
||||
decoded = null!;
|
||||
if (renderSurfaceId == 0) return false;
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
|
||||
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
return false;
|
||||
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
> `DecodedTexture` is in `AcDream.Core.Textures` — already imported by `IconComposer.cs`.
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
|
||||
git commit -m "feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `IconComposer.GetIcon` 5-arg 2-stage composite + update callers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/IconComposer.cs` (`_byTuple` key + `GetIcon` rewrite + class doc)
|
||||
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` (`_iconIds` Func type + `Populate`)
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`iconIds` closure + `OnLiveEntitySpawned` effects)
|
||||
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
|
||||
|
||||
> This task changes `GetIcon`'s signature, which breaks both callers; all three files are
|
||||
> edited together so the tree compiles.
|
||||
|
||||
- [ ] **Step 1: Write the failing dat-free composite test**
|
||||
|
||||
In `IconComposerTests.cs`, add (exercises the 2-stage compose + recolor without GL/dat via
|
||||
the static `Compose`/`ReplaceColorWhite` — the GL upload in `GetIcon` needs a real cache):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TwoStageWithEffect_recolorsWhiteBeforeUnderlay()
|
||||
{
|
||||
// drag = base (white pixel) over overlay (none); recolor white→blue; then over
|
||||
// an opaque tawny underlay. The white pixel must become blue in the final.
|
||||
var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque
|
||||
var drag = IconComposer.Compose(new[] { baseIcon });
|
||||
IconComposer.ReplaceColorWhite(drag.rgba, drag.w, drag.h, (0, 0, 255, 255)); // blue
|
||||
var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque
|
||||
var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) });
|
||||
Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // blue on top
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TwoStageWithEffect"`
|
||||
Expected: FAIL — won't compile yet only if `Compose`/`ReplaceColorWhite` aren't both public/internal; they are (`Compose` public, `ReplaceColorWhite` internal from Task 6), so this test should actually PASS once Task 6 is in. If it passes immediately, that's fine — it locks the recolor-before-underlay ordering. Proceed to Step 3 regardless (the GetIcon rewrite is the real change).
|
||||
|
||||
- [ ] **Step 3: Widen the cache key**
|
||||
|
||||
In `IconComposer.cs`, change the dictionary field:
|
||||
|
||||
```csharp
|
||||
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewrite `GetIcon` to 5-arg 2-stage**
|
||||
|
||||
Replace the whole `GetIcon` method with:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Resolve (and cache) the composited GL texture for an item's icon state.
|
||||
/// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180):
|
||||
/// a DRAG composite (base + custom overlay + effect recolor) blitted over the
|
||||
/// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a
|
||||
/// ReplaceColor tint SOURCE, not a blit layer (DR-1). The 2-stage form is
|
||||
/// associative-equivalent to a single Compose when effects==0, so D.5.1 visuals are
|
||||
/// unchanged for non-effect items.
|
||||
/// </summary>
|
||||
public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
|
||||
{
|
||||
if (iconId == 0) return 0;
|
||||
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
|
||||
var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects);
|
||||
if (_byTuple.TryGetValue(key, out var tex)) return tex;
|
||||
|
||||
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
|
||||
var dragLayers = new List<(byte[] rgba, int w, int h)>();
|
||||
AddLayer(dragLayers, iconId);
|
||||
AddLayer(dragLayers, overlayId);
|
||||
(byte[] rgba, int w, int h)? drag = null;
|
||||
if (dragLayers.Count > 0)
|
||||
{
|
||||
var composed = Compose(dragLayers);
|
||||
// Effect recolor only when an effect bit is set. Retail nominally also runs the
|
||||
// effects==0 black-fallback recolor; we skip it (DR-3: white→black on every item
|
||||
// is a likely no-op but a regression risk, pending visual/cdb confirmation).
|
||||
if (effects != 0 && TryGetEffectColor(effects, out var ec))
|
||||
ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec);
|
||||
drag = composed;
|
||||
}
|
||||
|
||||
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
|
||||
var layers = new List<(byte[] rgba, int w, int h)>();
|
||||
AddLayer(layers, typeUnderlayDid);
|
||||
AddLayer(layers, underlayId);
|
||||
if (drag is { } d) layers.Add(d);
|
||||
if (layers.Count == 0) return 0;
|
||||
|
||||
var (rgba, w, h) = Compose(layers);
|
||||
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
|
||||
_byTuple[key] = handle;
|
||||
return handle;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `ToolbarController` for the new delegate arity**
|
||||
|
||||
In `ToolbarController.cs`:
|
||||
- Change the field type (~line 54):
|
||||
|
||||
```csharp
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
```
|
||||
|
||||
- Change the constructor parameter type (the `Func<ItemType, uint, uint, uint, uint> iconIds` param):
|
||||
|
||||
```csharp
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
```
|
||||
|
||||
- Change the `Bind` parameter type to match (same `Func<ItemType, uint, uint, uint, uint, uint> iconIds`).
|
||||
- In `Populate`, pass `item.Effects`:
|
||||
|
||||
```csharp
|
||||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `GameWindow` — closure + spawn enrich**
|
||||
|
||||
In `GameWindow.cs`:
|
||||
- Widen the `iconIds` closure (~line 2005):
|
||||
|
||||
```csharp
|
||||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
```
|
||||
|
||||
- Pass `spawn.UiEffects` in `OnLiveEntitySpawned`'s `EnrichItem` call (~line 2647):
|
||||
|
||||
```csharp
|
||||
Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty,
|
||||
(AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0),
|
||||
spawn.IconOverlayId, spawn.IconUnderlayId, spawn.UiEffects);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build + run the App test suite**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~IconComposer"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
|
||||
git commit -m "feat(D.5.2): IconComposer 2-stage effect composite + 5-arg GetIcon"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Wire the live `0x02CE` update into the item repository
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (subscribe `ObjectIntPropertyUpdated`, next to `VitalUpdated`)
|
||||
|
||||
> No unit test: this is a one-line session-event binding (the same shape as the existing
|
||||
> `VitalUpdated` binding). `UpdateIntProperty` is unit-tested in Task 1; the end-to-end path
|
||||
> is the visual-verification acceptance test.
|
||||
|
||||
- [ ] **Step 1: Subscribe the event**
|
||||
|
||||
In `GameWindow.cs`, next to the `VitalUpdated`/`VitalCurrentUpdated` subscriptions (~line 2630),
|
||||
add:
|
||||
|
||||
```csharp
|
||||
// D.5.2: live PublicUpdatePropertyInt(0x02CE). Route UiEffects (18) to the item
|
||||
// repository so a draining/charging item re-composites its icon in real time.
|
||||
_liveSession.ObjectIntPropertyUpdated += u =>
|
||||
{
|
||||
if (u.Property == AcDream.Core.Items.ItemRepository.UiEffectsPropertyId)
|
||||
Items.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(D.5.2): route live UiEffects updates (0x02CE) to the item icon"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Bookkeeping — divergence register, roadmap, memory
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (retire `IA-16`; add `DR-1..DR-4`)
|
||||
- Modify: `docs/plans/2026-04-11-roadmap.md` (mark D.5.2 shipped)
|
||||
- Modify: `claude-memory/project_d2b_retail_ui.md` (D.5.2 entry)
|
||||
|
||||
- [ ] **Step 1: Update the divergence register**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`:
|
||||
- **Delete the `IA-16` row** (item-icon composite PARTIAL — now complete).
|
||||
- **Add four rows** (use the table's existing column shape; anchor file:line):
|
||||
- `DR-1` — effect overlay (enum 0x10000005) is a `ReplaceColor` tint SOURCE, not a blit
|
||||
layer; this IS faithful retail behavior — do not "fix" it back to a blit. Anchor:
|
||||
`IconData::RenderIcons` 0x0058d180, `ReplaceColor` 0x00441530; code
|
||||
`src/AcDream.App/UI/IconComposer.cs` (`GetIcon`).
|
||||
- `DR-2` — effect tint color = the effect tile's mean-opaque color; the exact retail color
|
||||
byte (`effectTile + 0xac` reinterpreted as RGBAColor) is decompiler-ambiguous.
|
||||
Approximation; visual/cdb confirmation pending. Code `IconComposer.TryGetEffectColor`.
|
||||
- `DR-3` — the `effects==0` black-fallback recolor that retail nominally runs is skipped
|
||||
(white→black on every item — likely no-op, real regression risk). Code
|
||||
`IconComposer.GetIcon` (`effects != 0` gate).
|
||||
- `DR-4` — `PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins). Code
|
||||
`src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`.
|
||||
|
||||
- [ ] **Step 2: Update the roadmap shipped table**
|
||||
|
||||
In `docs/plans/2026-04-11-roadmap.md`, move D.5.2 (stateful item-icon system) into the shipped
|
||||
section with the commit range and a one-line summary (appraise dropped as no-op; effect recolor
|
||||
+ live 0x02CE wire-up).
|
||||
|
||||
- [ ] **Step 3: Update the D.2b memory digest**
|
||||
|
||||
In `claude-memory/project_d2b_retail_ui.md`, append a D.5.2 entry: UiEffects captured from
|
||||
CreateObject (was discarded) → ItemInstance.Effects → IconComposer 2-stage recolor (effect
|
||||
tile = ReplaceColor SOURCE, golden submap 0x10000005); live via PublicUpdatePropertyInt 0x02CE;
|
||||
appraise carries NO icon data (dropped). Link `[[stateful-icon-system-handoff]]` superseded by
|
||||
the RESOLVED doc.
|
||||
|
||||
- [ ] **Step 4: Full build + test sweep**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: Build succeeded (no warnings introduced).
|
||||
Run: `dotnet test`
|
||||
Expected: All green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md claude-memory/project_d2b_retail_ui.md
|
||||
git commit -m "docs(D.5.2): retire IA-16, add DR-1..4, roadmap + memory"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual verification (acceptance — after all tasks)
|
||||
|
||||
Launch against live ACE (per CLAUDE.md "Running the client" recipe), then confirm with the user:
|
||||
1. A **magical item** pinned to the toolbar shows the effect tint (white highlights take the
|
||||
effect hue).
|
||||
2. An item whose **mana drains** updates its icon live (the server's `0x02CE` UiEffects change
|
||||
re-composites without a relog).
|
||||
|
||||
If the tint is wrong/too subtle vs retail, the open lever is `DR-2` (effect color source) — a
|
||||
cdb trace of `RenderIcons`/`ReplaceColor` on a live retail client resolves the exact byte.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** §5.1→T1, §5.2→T2, §5.4→T3, §5.3→T4, §5.5→T1, §5.6→T5+T6+T7,
|
||||
§5.7→T7, §5.8→T8, §6→T9, §7 tests→T1/T2/T3/T5/T6/T7 + visual. All covered.
|
||||
- **Placeholders:** none — every code step shows full code; every command shows expected output.
|
||||
- **Type consistency:** `Func<ItemType,uint,uint,uint,uint,uint>` used identically in
|
||||
`IconComposer.GetIcon`, `ToolbarController` field/ctor/Bind, and the `GameWindow` closure;
|
||||
`UiEffectsPropertyId` (18) defined in T1 and referenced in T8; `ObjectIntPropertyUpdate`
|
||||
record defined in T4 and consumed in T8; `ReplaceColorWhite`/`ResolveEffectDid`/`Compose`
|
||||
signatures match between definition (T5/T6/T7) and tests.
|
||||
|
|
@ -1,603 +0,0 @@
|
|||
# A7 Fix D — torch over-brightness on indoor walls — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white.
|
||||
|
||||
**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md)
|
||||
|
||||
**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 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<float>();
|
||||
|
||||
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.True(buffer.Length >= 16);
|
||||
// posAndKind
|
||||
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
|
||||
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
|
||||
// dirAndRange
|
||||
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
|
||||
Assert.Equal(5.2f, buffer[7]);
|
||||
// colorAndIntensity
|
||||
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
|
||||
Assert.Equal(100f, buffer[11]);
|
||||
// coneAngleEtc
|
||||
Assert.Equal(0f, buffer[12]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
|
||||
{
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
int count = GlobalLightPacker.Pack(null, ref buffer);
|
||||
Assert.Equal(0, count);
|
||||
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
|
||||
Expected: FAIL — `GlobalLightPacker` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement `GlobalLightPacker`**
|
||||
|
||||
Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Packs a point-light snapshot into the flat float layout the bindless mesh
|
||||
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
|
||||
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
|
||||
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
|
||||
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
|
||||
/// </summary>
|
||||
public static class GlobalLightPacker
|
||||
{
|
||||
public const int FloatsPerLight = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
|
||||
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
|
||||
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
|
||||
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
|
||||
/// </summary>
|
||||
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
|
||||
{
|
||||
int n = snapshot?.Count ?? 0;
|
||||
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
|
||||
if (buffer.Length < floatsNeeded)
|
||||
buffer = new float[floatsNeeded + FloatsPerLight * 16];
|
||||
Array.Clear(buffer, 0, floatsNeeded);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var L = snapshot![i];
|
||||
int o = i * FloatsPerLight;
|
||||
buffer[o + 0] = L.WorldPosition.X;
|
||||
buffer[o + 1] = L.WorldPosition.Y;
|
||||
buffer[o + 2] = L.WorldPosition.Z;
|
||||
buffer[o + 3] = (int)L.Kind;
|
||||
buffer[o + 4] = L.WorldForward.X;
|
||||
buffer[o + 5] = L.WorldForward.Y;
|
||||
buffer[o + 6] = L.WorldForward.Z;
|
||||
buffer[o + 7] = L.Range;
|
||||
buffer[o + 8] = L.ColorLinear.X;
|
||||
buffer[o + 9] = L.ColorLinear.Y;
|
||||
buffer[o + 10] = L.ColorLinear.Z;
|
||||
buffer[o + 11] = L.Intensity;
|
||||
buffer[o + 12] = L.ConeAngle;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer**
|
||||
|
||||
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with:
|
||||
|
||||
```csharp
|
||||
private unsafe void UploadGlobalLights()
|
||||
{
|
||||
int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int count = n > 0 ? n : 1; // never zero-size
|
||||
fixed (float* gp = _globalLightData)
|
||||
UploadSsbo(_globalLightsSsbo, 4, gp,
|
||||
count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float));
|
||||
}
|
||||
```
|
||||
|
||||
Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it.
|
||||
|
||||
- [ ] **Step 6: Build and run the full Core test suite**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
Expected: build green; all tests pass (no regression — the packing is byte-identical).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||||
git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches
|
||||
|
||||
`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check).
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the conformance test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
|
||||
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
|
||||
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
|
||||
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
|
||||
/// </summary>
|
||||
public class LightBakeConformanceTests
|
||||
{
|
||||
private static LightSource OrangeTorch(Vector3 pos) => new()
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
|
||||
Intensity = 100f,
|
||||
Range = 4f * 1.3f, // falloff 4 × static_light_factor
|
||||
IsLit = true,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(1f)]
|
||||
[InlineData(2f)]
|
||||
[InlineData(3f)]
|
||||
[InlineData(4f)]
|
||||
[InlineData(5f)]
|
||||
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
|
||||
{
|
||||
// Wall vertex at the origin, normal facing the torch (+X). Torch out along +X.
|
||||
var vtx = Vector3.Zero;
|
||||
var normal = Vector3.UnitX;
|
||||
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
|
||||
|
||||
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
|
||||
|
||||
// Every channel bounded to [0,1] — intensity=100 must NOT blow to white.
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
// Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering.
|
||||
if (c.X > 0f)
|
||||
{
|
||||
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
|
||||
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeyondRange_ContributesNothing()
|
||||
{
|
||||
var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
|
||||
Assert.Equal(Vector3.Zero, c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyOverlappingIntenseTorches_StillClampToOne()
|
||||
{
|
||||
// Eight near-white intensity-100 torches all 1.5 m from the vertex: the
|
||||
// [0,1] saturate must hold (no overflow past 1.0 per channel).
|
||||
var lights = new List<LightSource>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
lights.Add(new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
|
||||
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
IsLit = true,
|
||||
});
|
||||
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests`
|
||||
Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs
|
||||
git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert`
|
||||
|
||||
Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`)
|
||||
|
||||
- [ ] **Step 1: Apply the clamp split**
|
||||
|
||||
Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`):
|
||||
|
||||
```glsl
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
|
||||
// SUN / directional — material-lit term (added with ambient, NOT into the
|
||||
// torch sum), unchanged from before.
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
|
||||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||||
// clamps the SUM to [0,1] before anything else (it is a baked emissive term),
|
||||
// so a few warm intensity-100 torches can't push the whole pixel to white the
|
||||
// way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor
|
||||
// (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap
|
||||
// inside pointContribution is unchanged.
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||||
|
||||
return lit; // frag still does the final min(lit, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.)
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.)
|
||||
|
||||
- [ ] **Step 3: Review the math against the oracle**
|
||||
|
||||
Confirm by reading both side-by-side that the shader's point path now matches `LightBake`:
|
||||
- `mesh_modern.vert` `pointContribution` ↔ `LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal.
|
||||
- new `min(pointAcc, vec3(1.0))` ↔ `LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum.
|
||||
No code change expected here — this is the verification step the commit message cites.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert
|
||||
git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140)
|
||||
|
||||
accumulateLights folded ambient+sun+torches into one accumulator clamped only
|
||||
in the frag, so a few warm intensity-100 torches blew walls/objects to white.
|
||||
Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp
|
||||
to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches
|
||||
LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5)
|
||||
|
||||
Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234)
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot)
|
||||
|
||||
- [ ] **Step 1: Add fields + the per-frame snapshot setter**
|
||||
|
||||
In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
|
||||
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
|
||||
// left bound. binding=4 = global point-light snapshot (same data/indices as the
|
||||
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
|
||||
private uint _globalLightsSsbo; // binding=4
|
||||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||||
private uint _instLightSetSsbo; // binding=5
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||||
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
|
||||
```
|
||||
|
||||
Near `SetClipRouting` (~262) add the per-frame setter:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
|
||||
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
|
||||
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
|
||||
/// reference this snapshot, which is also uploaded to binding=4 here, so the
|
||||
/// pass is self-contained. Null/empty ⇒ shells receive no point lights.
|
||||
/// </summary>
|
||||
public void SetPointSnapshot(
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
|
||||
=> _pointSnapshot = snapshot;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`**
|
||||
|
||||
In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
|
||||
_gl.GenBuffers(1, out _globalLightsSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.GenBuffers(1, out _instLightSetSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the per-cell light-set helper**
|
||||
|
||||
Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
|
||||
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
|
||||
// Cached per frame; unused slots are -1 (shader adds no point light there).
|
||||
private int[] GetCellLightSet(uint cellId)
|
||||
{
|
||||
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
|
||||
|
||||
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
|
||||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||||
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
|
||||
}
|
||||
_cellLightSetCache[cellId] = set;
|
||||
return set;
|
||||
}
|
||||
```
|
||||
|
||||
(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.)
|
||||
|
||||
- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`**
|
||||
|
||||
(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache:
|
||||
|
||||
```csharp
|
||||
_cellLightSetCache.Clear();
|
||||
```
|
||||
|
||||
(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
|
||||
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
|
||||
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
|
||||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||||
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
{
|
||||
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
||||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
```
|
||||
|
||||
(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
fixed (float* gp = _globalLightData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
|
||||
fixed (int* lp = _lightSetData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||||
```
|
||||
|
||||
(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add:
|
||||
|
||||
```csharp
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo);
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire the snapshot from GameWindow**
|
||||
|
||||
In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add:
|
||||
|
||||
```csharp
|
||||
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Dispose the new buffers**
|
||||
|
||||
In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add:
|
||||
|
||||
```csharp
|
||||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo);
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140)
|
||||
|
||||
The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left
|
||||
bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own
|
||||
binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5
|
||||
per-instance set, computed per cell by LightManager.SelectForObject over the
|
||||
cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Divergence register — correct AP-35, reconcile the Fix B row
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row)
|
||||
|
||||
- [ ] **Step 1: Correct AP-35**
|
||||
|
||||
Find the `AP-35` row. It currently describes the point-light path as per-pixel
|
||||
`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to
|
||||
reflect reality after Fix A + Fix D D-1:
|
||||
- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`.
|
||||
- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`).
|
||||
- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`.
|
||||
- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle.
|
||||
|
||||
- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row**
|
||||
|
||||
Find the row describing Fix B (per-object 8-light selection by sphere overlap vs
|
||||
retail's per-vertex sum over the full static list — `minimize_object_lighting`
|
||||
0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per
|
||||
cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the
|
||||
"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a
|
||||
new contradicting row.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md
|
||||
git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green.
|
||||
- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions).
|
||||
- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires.
|
||||
- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning).
|
||||
- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach).
|
||||
- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug.
|
||||
- **Don't touch the purple portal** — confirmed correct.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# D.5.3a — Selected-object meter — implementation plan
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md`.
|
||||
Pre-approved by user 2026-06-18; subagent-driven, sequential (build-safe in one worktree).
|
||||
|
||||
Mandatory per task: cite named-retail anchors in comments; `dotnet build` + the relevant
|
||||
`dotnet test` green; match surrounding code style. No commits by subagents — the lead commits the
|
||||
coherent set after the full build+test passes.
|
||||
|
||||
## Task order (each builds on the accumulated working tree)
|
||||
|
||||
### T1 — `WorldSession.SendQueryHealth` (+ net test) · project: `AcDream.Core.Net`
|
||||
- Add `SendQueryHealth(uint targetGuid)` mirroring `SendChangeCombatMode` (`WorldSession.cs:1134`):
|
||||
`NextGameActionSequence()` → `SocialActions.BuildQueryHealth(seq, guid)` → `SendGameAction(body)`.
|
||||
- Test in `tests/AcDream.Core.Net.Tests/`: drive it through the existing send-capture seam used by the
|
||||
other `WorldSession.Send*` tests; assert captured bytes == `BuildQueryHealth(seq, guid)`.
|
||||
- Accept: `dotnet test` for `AcDream.Core.Net.Tests` green.
|
||||
|
||||
### T2 — `DatWidgetFactory.BuildMeter` single-image shape (+ test) · project: `AcDream.App`
|
||||
- Handle `containers.Count == 1`: `BackLeft = info.StateMedia[""].File`,
|
||||
`FrontLeft = containers[0].StateMedia[""].File`, tile/right = 0. Keep `>= 2` (vitals) path unchanged.
|
||||
Warn only on `Count == 0` / `Count > 2`.
|
||||
- Extend `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`: 1-container synthetic meter
|
||||
asserts Back/Front populated + others 0; 2-container case asserts vitals path unchanged.
|
||||
- Accept: `dotnet test` for `AcDream.App.Tests` green.
|
||||
|
||||
### T3 — `SelectedObjectController` (+ test) · project: `AcDream.App`
|
||||
- New `src/AcDream.App/UI/Layout/SelectedObjectController.cs` per spec §3 (Bind signature, bind-time
|
||||
setup, `OnSelectionChanged` clear-then-populate). Cite `HandleSelectionChanged:198635`.
|
||||
- New `tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs` per spec §Testing item 2
|
||||
(mirror `ToolbarControllerTests` for building a minimal `ImportedLayout` + recording delegates).
|
||||
- Accept: `dotnet test` for `AcDream.App.Tests` green.
|
||||
|
||||
### T4 — GameWindow integration + register rows · project: `AcDream.App` (depends on T1, T3)
|
||||
- Convert `_selectedGuid` field → `SelectedGuid` property + `SelectionChanged` event (spec §1); replace
|
||||
the 3 write sites; leave read sites on the field.
|
||||
- Remove `0x100001A1` + `0x100001A2` from `ToolbarController.HiddenIds` (keep `0x100001A4`).
|
||||
- Wire `SelectedObjectController.Bind(...)` after `ToolbarController.Bind` (spec §5).
|
||||
- Add the 2 divergence rows (spec §Divergence) to
|
||||
`docs/architecture/retail-divergence-register.md`.
|
||||
- Accept: full `dotnet build` + `dotnet test` green.
|
||||
|
||||
## Then (lead)
|
||||
- Adversarial Opus review of the full diff vs spec + decomp.
|
||||
- Commit the coherent set to the branch; update roadmap/ISSUES if applicable; memory if a durable lesson.
|
||||
- Stop for the user's visual gate (the acceptance test for this stream).
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,455 +0,0 @@
|
|||
# Phase G.3 — Dungeon Support (Design Spec)
|
||||
|
||||
> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`.
|
||||
> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5
|
||||
> exit-gate. M2 (CombatMath) stays deferred until this lands.
|
||||
> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) +
|
||||
> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed
|
||||
> below).
|
||||
> **Supersedes** the §12 port-plan of
|
||||
> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md):
|
||||
> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility
|
||||
> BFS, multi-cell transit) already shipped and power the building/cellar demo.
|
||||
> r09 stays the **retail contract reference** for the wire formats, the
|
||||
> EnvCell/CellPortal layout, and the recall taxonomy.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Dungeons don't work because of **one timing+placement gap on one code path**,
|
||||
not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the
|
||||
Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock`
|
||||
present, all-zero heights) + 71 EnvCells + no buildings — it already streams,
|
||||
renders, and collides through the existing pipeline. The teleport-arrival
|
||||
handler snaps the player **before** that landblock has streamed in, so Resolve
|
||||
falls back to the resident Holtburg blocks and lands the player in ocean.
|
||||
|
||||
The fix is retail's own shape: **hold the player in portal space until the
|
||||
destination cell is hydrated, then place into the EnvCell** — reusing the
|
||||
#107/#111 login machinery — and then layer retail's portal-tunnel visual
|
||||
(`TeleportAnimState`) on top. We ship it in four installments, gated by one
|
||||
visual acceptance test.
|
||||
|
||||
---
|
||||
|
||||
## 1. Corrected root cause (verified)
|
||||
|
||||
### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified)
|
||||
|
||||
A prior research pass assumed dungeon landblocks have no `LandBlock` record, so
|
||||
`LandblockLoader.Load` returns null and the whole streaming/render/physics
|
||||
pipeline needs terrain-less support. **A direct dat probe
|
||||
(`DungeonLandblockDatProbeTests`, committed) refutes that:**
|
||||
|
||||
```
|
||||
0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat)
|
||||
LandBlockInfo: NumCells=71, Buildings=0, Objects=0
|
||||
EnvCells 0x0100.. present (the 71 dungeon rooms)
|
||||
0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114
|
||||
```
|
||||
|
||||
A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain
|
||||
height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load`
|
||||
returns a valid flat landblock; the terrain mesh builds a flat plane;
|
||||
`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing
|
||||
pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon`
|
||||
(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the
|
||||
single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon
|
||||
landblocks without a teleport — so "multi-landblock dungeon LOD" is moot).
|
||||
|
||||
### 1.2 The real blocker: teleport TIMING + PLACEMENT
|
||||
|
||||
`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`)
|
||||
detects teleport arrival as **any** player position update while in PortalSpace
|
||||
(correct, per #107), then **unconditionally**:
|
||||
|
||||
1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`).
|
||||
2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap
|
||||
the player (`:4927-4931`) — **before the destination landblock has streamed in**.
|
||||
3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends
|
||||
`LoginComplete` (`:4953-4959`).
|
||||
|
||||
Because the dungeon landblock isn't resident yet, Resolve can't find the
|
||||
destination cell, falls back to an **outdoor scan against the still-resident
|
||||
Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local
|
||||
`(30,−60)` maps into the block south of the A9B4 spawn). Streaming then shifts
|
||||
the frame out from under the player → they slide south into ocean. ACE logs the
|
||||
matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …`
|
||||
chain (captured in `launch-dungeon-diag.log`).
|
||||
|
||||
**There is no hold-until-hydration on the teleport-arrival path.** The #107
|
||||
*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate;
|
||||
the teleport path doesn't.
|
||||
|
||||
---
|
||||
|
||||
## 2. Grounded seam facts (the design rests on these)
|
||||
|
||||
All five verified against current code this session (high confidence).
|
||||
|
||||
### 2.1 Teleport-arrival + PortalSpace FSM
|
||||
- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)`
|
||||
sets `_playerController.State = PlayerState.PortalSpace`, freezing movement.
|
||||
- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns
|
||||
a zero-movement result while `State == PortalSpace` — **PortalSpace already
|
||||
doubles as the input-freeze.** It can equally serve as the hydration-wait gate.
|
||||
- Exit is **only** via the arrival detection in `OnLivePositionUpdated`
|
||||
(`:4880`). No timeout, no cell-hydration gate today.
|
||||
|
||||
### 2.2 #107/#111 login machinery (directly reusable)
|
||||
- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor
|
||||
(`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId)
|
||||
is not null` (the cell's physics BSP has hydrated).
|
||||
- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat
|
||||
`LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is
|
||||
`>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast
|
||||
(distinguishes a bogus claim from a not-yet-streamed one).
|
||||
- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for
|
||||
terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`).
|
||||
No timeout today (login can afford to wait forever; teleport cannot — see §5).
|
||||
- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when
|
||||
`snapDiag (zero-delta) && adjustedFound && indoor`, place via
|
||||
`WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own
|
||||
physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642),
|
||||
cell-local, nearest to the reference Z. Returns `null` if the cell isn't
|
||||
hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**).
|
||||
- **The teleport-arrival Resolve call is already the same shape as login entry.**
|
||||
The gate only needs to sit in front of it; no change to Resolve or
|
||||
WalkableFloorZNearest. (Both already key on the full prefixed cell id +
|
||||
indoor/outdoor.)
|
||||
|
||||
### 2.3 Streaming far recenter (works as-is)
|
||||
- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the
|
||||
near/far Chebyshev window **from scratch** around the new center — a 42 km jump
|
||||
is treated identically to a 1-step move. No incremental-movement assumption.
|
||||
- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4)
|
||||
results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU
|
||||
upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` +
|
||||
EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate
|
||||
a 5×5 near window; physics ready +1-2 frames.
|
||||
- Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2
|
||||
demote, FarRadius+2 unload), so the player isn't instantly stranded.
|
||||
- **New code needed:** reuse the #107 login-gate **terrain-ready signal**
|
||||
`_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination
|
||||
terrain landblock has applied) — no separate "landblock applied" query is
|
||||
required. Plus dest-coord validation (reject out-of-world coords — a malformed
|
||||
portal dest would otherwise leave the player in an invisible, unloadable
|
||||
landblock).
|
||||
|
||||
### 2.4 EnvCell hydration coupling (latent landmine — decouple)
|
||||
- In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both
|
||||
`BuildLoadedCell` (the portal-visibility node) **and**
|
||||
`_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render
|
||||
guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty
|
||||
(`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys)
|
||||
silently gets **no visibility node and no collision**, even if it has walkable
|
||||
physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null
|
||||
→ continue` (silently skips it) → fall-through-floor.
|
||||
- A normal dungeon *room* has textured walls → non-empty submeshes → the guard
|
||||
passes, so this is **probably not the meeting-hall blocker** — but it is a real
|
||||
correctness landmine for any geometry-less collision cell, and decoupling is
|
||||
cheap and retail-correct (physics/visibility do not depend on visible geometry).
|
||||
**Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and
|
||||
`BuildLoadedCell` on `cellStruct != null`, independent of the render submesh
|
||||
count. (`CacheCellStruct` already early-returns on null BSP internally —
|
||||
`PhysicsDataCache.cs:172` — so moving it out is safe.)
|
||||
|
||||
### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded)
|
||||
- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)**
|
||||
trace, `visibleCells` per cell exploded to 135-145 with spurious cells from
|
||||
landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB
|
||||
`EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache.
|
||||
- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4:
|
||||
"per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current
|
||||
flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera
|
||||
cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has
|
||||
**enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are
|
||||
ever processed"). Since AC dungeons are single-landblock, that confinement is
|
||||
*correct*, and the cross-landblock 135-cell blowup **structurally cannot
|
||||
reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for
|
||||
the meeting hall).
|
||||
- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted
|
||||
path; the current pipeline looked bounded. Treated #95 as likely superseded.
|
||||
- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual
|
||||
gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The
|
||||
core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean),
|
||||
but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per
|
||||
frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx`
|
||||
interior re-requests → the dungeon renders as "thin air." **#95 reproduces under
|
||||
the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the
|
||||
`0x0007` dungeon (the grounding agent's "still live" verdict was correct; this
|
||||
doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The
|
||||
retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878)
|
||||
stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plan (Approach C — phased full-G.3)
|
||||
|
||||
Each installment lands a **complete retail behavior** (the BR-2 half-port
|
||||
lesson). The visual gate sits as early as possible, right after the core.
|
||||
|
||||
### 3.1 G.3a — Core teleport-into-dungeon (the blocker)
|
||||
|
||||
**Goal:** teleporting into the meeting-hall dungeon lands the player standing in
|
||||
the dungeon cell, on the floor, with walls blocking — no ocean, no ACE
|
||||
`failed transition` spam.
|
||||
|
||||
**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`):
|
||||
- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival`
|
||||
`(destPos, destCellId, deadline)`.
|
||||
- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the
|
||||
god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and
|
||||
calls its per-frame `Tick`; `GameWindow` keeps only the wiring.
|
||||
- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve).
|
||||
|
||||
**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):**
|
||||
1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are
|
||||
in-world; recenter streaming + prioritize-load the dest landblock (existing
|
||||
path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete`
|
||||
immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for
|
||||
assets to send it).
|
||||
2. Each frame in `Holding`, evaluate the **readiness predicate**:
|
||||
- `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place
|
||||
via the safety-net demote (loud log), exit PortalSpace.
|
||||
- `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud
|
||||
log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.)
|
||||
- `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))`
|
||||
→ ready: go to 3.
|
||||
- else stay frozen, retry next frame.
|
||||
3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`.
|
||||
Because the cell is now hydrated, Resolve takes the #111 validated-claim branch
|
||||
→ `WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity
|
||||
+ controller (existing `:4935-4939` code), exit PortalSpace, resume input.
|
||||
|
||||
**Readiness predicate — reuse the #107 login triplet (no new query).** The
|
||||
hold gates on exactly the three checks the login auto-entry gate already uses
|
||||
(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos,
|
||||
destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is
|
||||
not null` (destination terrain applied) ∧ (outdoor cell OR
|
||||
`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits
|
||||
an impossible claim to immediate placement. This reuses proven, validated code
|
||||
rather than introducing a parallel "landblock applied" query.
|
||||
|
||||
**Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose
|
||||
`(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport
|
||||
hold rather than recenter to a phantom block.
|
||||
|
||||
**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of
|
||||
the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate
|
||||
each on its own non-null precondition.
|
||||
|
||||
**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles
|
||||
#95 (does the flood blow up?) and the hydration coupling (does collision work?).
|
||||
|
||||
### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13)
|
||||
|
||||
**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this
|
||||
is the next blocker, not a conditional follow-up. The dungeon will not render
|
||||
until the portal-visibility flood is bounded to the dungeon's own cell adjacency.
|
||||
|
||||
**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with
|
||||
`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent
|
||||
EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a
|
||||
radius / never the whole resident cell set. This is a render-pipeline change in
|
||||
`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own
|
||||
grounding + brainstorm before implementation (verify the dat carries the stab_list
|
||||
and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read;
|
||||
decide how it composes with the outdoor-root look-in floods). **NOT a wing-it
|
||||
inline fix.**
|
||||
|
||||
**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed
|
||||
(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y
|
||||
coordinate frame may cause the per-tick membership/landblock resolution to drift
|
||||
(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it
|
||||
persists; if so, fold the dungeon-coordinate membership handling into G.3b's
|
||||
grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95).
|
||||
|
||||
### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`)
|
||||
|
||||
**Goal:** the retail portal-space transition, ported faithfully (user decision
|
||||
2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the
|
||||
named-retail decomp where this FSM actually lives.
|
||||
|
||||
**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line
|
||||
218888) + the per-frame FSM (`219405-219774`). States:
|
||||
`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE →
|
||||
TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a
|
||||
`UIElement_Viewport` rendering the tunnel scene (creature-mode objects +
|
||||
`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the
|
||||
`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`).
|
||||
|
||||
**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold
|
||||
state's exit gates on the same readiness predicate as G.3a** — retail's loading
|
||||
visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the
|
||||
visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it
|
||||
in the tunnel viewport + the fade FSM, exit-gated identically.
|
||||
|
||||
**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM →
|
||||
pseudocode (durations, fade math, viewport scene construction) → port → test.
|
||||
Detail deferred to the G.3c implementation phase; this spec fixes the design
|
||||
(states, transitions, the readiness-gated hold) + the oracle pointers.
|
||||
|
||||
### 3.4 G.3d — Recall game-actions
|
||||
|
||||
Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone
|
||||
0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`,
|
||||
`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends
|
||||
the request; the server validates, plays the recall animation, then drives the
|
||||
**same** `PlayerTeleport → UpdatePosition` arrival flow.
|
||||
|
||||
Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a
|
||||
teleport with no portal-click choreography; (2) completes the recall UX (keybinds
|
||||
exist; the wire sends + return handling did not). Wire through the existing
|
||||
command bus.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow (the teleport happy path)
|
||||
|
||||
```
|
||||
1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input
|
||||
[G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)]
|
||||
2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb
|
||||
→ prioritize-load dest lb → re-send LoginComplete
|
||||
3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace):
|
||||
ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))
|
||||
- not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE]
|
||||
- impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log
|
||||
- timeout → force-snap + loud log + leave PortalSpace
|
||||
4. READY → Resolve(destPos, destCell) → #111 validated-claim branch
|
||||
→ WalkableFloorZNearest places on the EnvCell floor
|
||||
→ SetPosition(entity + controller) → exit PortalSpace, resume input
|
||||
[G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off]
|
||||
```
|
||||
|
||||
(ACE server send-order, for reference — `Player_Location.Teleport:686`:
|
||||
`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) →
|
||||
`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real
|
||||
`UpdatePosition` → `OnTeleportComplete` after `CreateWorldObjectsCompleted`.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling
|
||||
|
||||
| Failure | Handling | No-workaround rationale |
|
||||
|---|---|---|
|
||||
| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking |
|
||||
| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row |
|
||||
| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change |
|
||||
|
||||
The timeout is the one judgment call: holding forever on a never-hydrating
|
||||
landblock would soft-lock the client. The chosen behavior **fails loudly and
|
||||
visibly** (force-snap + log), which is the opposite of a symptom-masking grace
|
||||
period — it makes a broken teleport obvious rather than hiding it. It is recorded
|
||||
as a deliberate adaptation (retail loads synchronously; async streaming has no
|
||||
direct analog).
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing & acceptance
|
||||
|
||||
### 6.1 Headless / unit
|
||||
- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path;
|
||||
impossible-claim immediate reject; timeout force-snap; ready-predicate gating
|
||||
(fake `IsLandblockApplied` / `IsSpawnCellReady`).
|
||||
- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty
|
||||
physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`.
|
||||
- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller
|
||||
phase transitions + input-gate flips.
|
||||
- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells.
|
||||
- G.3c: `TeleportAnimState` FSM transition test (state sequence + the
|
||||
readiness-gated `TAS_TUNNEL` hold-exit).
|
||||
- G.3d: recall-builder byte tests (opcode + empty payload, per builder).
|
||||
|
||||
### 6.2 Visual gate (the acceptance test — after G.3a)
|
||||
Teleport into the meeting-hall dungeon via the portal:
|
||||
- Player stands **in the dungeon cell**, on the floor (not ocean, not falling).
|
||||
- The dungeon renders; navigate **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.
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
# D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design
|
||||
|
||||
**Date:** 2026-06-14
|
||||
**Status:** Design approved (brainstorm) + **re-grounded 2026-06-14** onto the existing `AcDream.App/UI/` retained-mode scaffold (see §0). Pending spec re-review → implementation plan.
|
||||
**Phase:** D.2b — Custom retail-look UI backend ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md))
|
||||
**Milestone:** M5 "Looks like retail" — **explicitly PARALLELIZABLE with M3/M4** ([milestones:378](../../../docs/plans/2026-05-12-milestones.md)). Opened as a parallel track while M1.5 is the active critical-path milestone; the M5 parallelizable flag is the milestone-discipline carve-out.
|
||||
**Grounding:** read-only research workflow `wf_39a90d37-e5a` (7 readers + gap-critic) + a direct read of `src/AcDream.App/UI/`. Every binding fact cites `file:line` in `src/` or a named-retail symbol.
|
||||
|
||||
---
|
||||
|
||||
## 0. Re-grounding correction (read this first)
|
||||
|
||||
The first draft of this spec proposed building a `RetailPanelHost : IPanelHost` +
|
||||
`RetailPanelRenderer : IPanelRenderer` and a retained-mode toolkit *from scratch*.
|
||||
**That was wrong.** A direct read of `src/AcDream.App/UI/` found a **complete,
|
||||
dormant retained-mode toolkit** — the 2026-04-17 scaffold the roadmap names as
|
||||
"the implementation foundation here" ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md)):
|
||||
|
||||
- **`UiRoot`** ([UiRoot.cs](../../../src/AcDream.App/UI/UiRoot.cs)) — the hard
|
||||
part is already built: mouse routing, keyboard focus, mouse capture, a full
|
||||
drag-drop state machine, tooltip timer, modal handling, click/right-click
|
||||
detection, world fall-through. Retail-faithful event codes in
|
||||
[UiEvent.cs](../../../src/AcDream.App/UI/UiEvent.cs).
|
||||
- **`UiElement`** (geometry/tree/hit-test), **`UiPanel`/`UiLabel`/`UiButton`**
|
||||
([UiPanel.cs](../../../src/AcDream.App/UI/UiPanel.cs)), **`UiHost`**
|
||||
([UiHost.cs](../../../src/AcDream.App/UI/UiHost.cs) — packages `UiRoot` +
|
||||
`TextRenderer` + font, with `Tick`/`Draw`/`WireMouse`/`WireKeyboard`),
|
||||
**`UiRenderContext`** ([UiRenderContext.cs](../../../src/AcDream.App/UI/UiRenderContext.cs)
|
||||
— transform stack + `DrawRect`/`DrawString`).
|
||||
|
||||
`UiHost` is **dormant** — never instantiated in `GameWindow` (verified: `new
|
||||
UiHost(` appears only in a doc-comment). And `UiPanel.cs` is the *exact file*
|
||||
divergence row TS-30 points at: it draws a flat translucent rect *"until our
|
||||
AcFont/UiSpriteBatch consumes [9-slice dat sprites] directly."*
|
||||
|
||||
**Consequence:** the retail UI is this existing `UiRoot` tree — a separate system
|
||||
from the ImGui `IPanelRenderer` path, **not** an `IPanelRenderer` implementation.
|
||||
Spec 1 *wires the dormant `UiHost`* and *adds the few missing pieces*, rather than
|
||||
building a backend. This is strictly less code and more faithful. §4/§5/§8/§9/§10
|
||||
below are written against the scaffold.
|
||||
|
||||
*(Process note: the grounding workflow's "UI" readers keyed on the ImGui/Abstractions
|
||||
framing in their prompts and never globbed `src/AcDream.App/UI/`. Lesson: a
|
||||
subsystem-discovery pass must glob by directory, not only by the framing the
|
||||
parent already has in mind.)*
|
||||
|
||||
## 1. Context & goal
|
||||
|
||||
acdream needs a retail-faithful game UI. The shipped path (D.2a) is an ImGui
|
||||
overlay gated on `ACDREAM_DEVTOOLS=1` — a debugger aesthetic, intentionally
|
||||
temporary. D.2b stands up the *retail-look* UI (the dormant `UiHost` tree) that
|
||||
draws retail's actual dat assets, while the ImGui devtools path stays untouched.
|
||||
|
||||
**The user's framing (2026-06-14):** AC's UI engine is Keystone — and Keystone
|
||||
was *already* markup + stylesheet (KSML, an HTML-clone XML defined by `ksml.xsd`,
|
||||
+ `controls.ini`, a CSS-like INI stylesheet). So "make it look + behave like
|
||||
retail, but author it in a CSS/HTML-style way" re-expresses AC's own design in
|
||||
its modern equivalent.
|
||||
|
||||
**Approach decision (Approach C).** Three integration families were weighed:
|
||||
(A) embed a real web engine (Ultralight/CEF), (B) a native HTML/CSS-subset lib
|
||||
(RmlUi), (C) our own KSML-style markup + stylesheet over a retained-mode toolkit
|
||||
on Silk.NET. **C chosen** for: zero external deps (keeps the native-AOT goal
|
||||
intact), lowest memory (~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<float>` reading
|
||||
`VitalsVM`.
|
||||
- The XML markup format (mirrors `ElementDesc`) + a `MarkupDocument` parser that
|
||||
**instantiates a `UiElement` subtree** + a minimal `controls.ini` stylesheet
|
||||
loader.
|
||||
- The plugin-facing contract: plugins contribute a `UiElement`/markup subtree
|
||||
added to `UiRoot` (§9) — designed now, first consumer first-party.
|
||||
|
||||
**Deferred to later sub-phases (explicitly OUT):**
|
||||
- **Wiring `UiHost`'s input** (`WireMouse`/`WireKeyboard`) into the existing
|
||||
Phase-K `InputDispatcher`. The `UiRoot` input *machinery* exists; *integrating*
|
||||
two input consumers (route unconsumed `WorldMouseFallThrough` back to the game)
|
||||
is its own concern. Spec 1 is **render-only** (`Tick` + `Draw`), so the frame +
|
||||
live bars show but the close button isn't clicked and the window isn't dragged.
|
||||
- The dat A8 glyph font loader (`AcFont`) → numeric overlays.
|
||||
- The full anchor solver (`StateDesc::UpdateSizeAndPosition` port).
|
||||
- The `LayoutDesc` binary importer (sub-project 3).
|
||||
- Reskinning Chat / Debug / Settings.
|
||||
- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).
|
||||
|
||||
## 3. Source-verified facts (do-not-trust list)
|
||||
|
||||
The grounding caught several load-bearing "facts" that were wrong/unverified.
|
||||
These are binding:
|
||||
|
||||
| Claimed (memory / first draft) | Reality (source-verified) |
|
||||
|---|---|
|
||||
| Build a retained-mode toolkit + `RetailPanelHost`/`RetailPanelRenderer` | The toolkit **exists** in `src/AcDream.App/UI/` (§0); the retail UI is the `UiRoot` tree, not an `IPanelRenderer` backend |
|
||||
| `VitalsVM` is `record VitalsVM(int HpCurrent, …)` | Sealed class: `HealthPercent` (float), `StaminaPercent`/`ManaPercent` (float?), `*Current`/`*Max` (uint?), ctor `(CombatState, LocalPlayerState?)`, `SetLocalPlayerGuid(uint)` — [VitalsVM.cs:35](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
|
||||
| Chrome sprite IDs `0x06004CC2`/`0x21000040`/`0x060074BF..C6` are known | **Unverified + contradictory.** `0x06001125` (cited elsewhere) is the char-select highlight. **No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.** |
|
||||
| `#FFDBD6A8` "parchment cream" is the panel background | It is `[editbox]`/`[treeview]` **text** color. Real frame tokens: `[title]` bg `#FFFFFFFF`, font `Verdana-10-bold`, height 19; `[body]` bg `#00000000` (transparent), `color_border=#FF4F657D` |
|
||||
| `DatCollection` is NOT thread-safe | Concurrent **reads are safe** since v2.1.7 ([2026-06-09 investigation](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). No UI-specific lock. |
|
||||
| KSML is the panel-layout language | KSML is **rich-text-in-text-regions**; the panel layout format is the binary `LayoutDesc`/`ElementDesc` tree ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)). Our markup mirrors `ElementDesc`. |
|
||||
|
||||
## 4. Architecture & placement
|
||||
|
||||
The retail UI lives in **`src/AcDream.App/UI/`** (where the scaffold already is).
|
||||
New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored
|
||||
(nothing substantial added to `GameWindow.cs` — only a few wiring lines); Rule 2
|
||||
(Core stays GL-free) and Rule 3 (panels target `AcDream.UI.Abstractions`) are
|
||||
unaffected because the retail UI is a *separate* tree, not an `IPanelRenderer`
|
||||
panel.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ retail dat (read-only fidelity source) │
|
||||
│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │
|
||||
│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
│ TextureCache.GetOrUpload(id) → Texture2D
|
||||
┌───────────────▼──────────────────────────────────────────┐
|
||||
│ src/AcDream.App/UI/ (scaffold EXISTS; + = new in Spec 1) │
|
||||
│ UiHost (exists, dormant) ─ wire into GameWindow │
|
||||
│ UiRoot/UiElement (exist) ─ input + tree + hit-test │
|
||||
│ UiRenderContext (exists) + DrawSprite(UV-rect) │
|
||||
│ UiPanel/UiLabel/UiButton (exist) │
|
||||
│ + UiNineSlicePanel : UiPanel (8-piece dat chrome) │
|
||||
│ + UiMeter : UiElement (vital bar) │
|
||||
│ + MarkupDocument (XML → UiElement subtree) │
|
||||
│ + ControlsIni (stylesheet loader) │
|
||||
│ uses Rendering/TextRenderer (+ sprite path, + DepthMask) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
│ UiMeter.Fill = () => vm.HealthPercent
|
||||
┌───────────────▼──────────────────────────────────────────┐
|
||||
│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │
|
||||
│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │
|
||||
│ ACDREAM_DEVTOOLS, fully independent of the above │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Coexistence.** Two UI systems run side by side, independently:
|
||||
`ACDREAM_DEVTOOLS=1` → the ImGui overlay (unchanged); `ACDREAM_RETAIL_UI=1` →
|
||||
the `UiHost` tree. The retail pass renders in the post-3D slot
|
||||
([GameWindow.cs:8232 region](../../../src/AcDream.App/Rendering/GameWindow.cs))
|
||||
with deterministic ordering relative to ImGui. `UiHost.Draw` already does
|
||||
`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush`
|
||||
([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)).
|
||||
|
||||
## 5. Render foundation — extend the existing 2D path
|
||||
|
||||
`UiHost` draws the `UiRoot` tree through a `UiRenderContext` backed by the shared
|
||||
`TextRenderer` ([UiHost.cs:58-67](../../../src/AcDream.App/UI/UiHost.cs)). That
|
||||
`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites
|
||||
([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag),
|
||||
[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs)). Spec 1
|
||||
adds the sprite path:
|
||||
|
||||
- **`ui_text.frag`** += a `uUseTexture==2` branch: `FragColor = texture(uTex,
|
||||
vUv) * vColor;` (the existing `0`=solid and `1`=R8-coverage branches are
|
||||
untouched).
|
||||
- **`TextRenderer`** += `DrawSprite(uint texture, float x,y,w,h, float
|
||||
u0,v0,u1,v1, Vector4 tint)` accumulating into **per-texture** sprite buffers
|
||||
(`Dictionary<uint, List<float>>`), and a `Flush` pass that, after rects+text,
|
||||
draws each texture's batch with `uUseTexture=2`. Reuses the existing
|
||||
`AppendQuad` (which already takes `u0,v0,u1,v1`) + `UploadBuffer` machinery.
|
||||
- **`TextRenderer.Flush`** += explicit **`DepthMask(false)`** (queried + restored)
|
||||
— it disables `DepthTest` today but never sets `DepthMask`
|
||||
([TextRenderer.cs:171](../../../src/AcDream.App/Rendering/TextRenderer.cs)).
|
||||
Per the project's "render self-contained GL state" rule.
|
||||
- **`UiRenderContext`** += `DrawSprite(uint texture, float x,y,w,h, float
|
||||
u0,v0,u1,v1, Vector4 tint)` that adds the current transform and forwards to
|
||||
`TextRenderer.DrawSprite` (mirrors the existing `DrawRect` forwarder at
|
||||
[UiRenderContext.cs:50](../../../src/AcDream.App/UI/UiRenderContext.cs)).
|
||||
|
||||
No new shader class, VAO, or batcher — we extend the proven path the scaffold
|
||||
already uses. (`Shader` is the simple file-based class
|
||||
[Shader.cs](../../../src/AcDream.App/Rendering/Shader.cs); `GLSLShader`'s bindless
|
||||
machinery is not needed.)
|
||||
|
||||
## 6. Dat assets & the Step-0 prove-out gate
|
||||
|
||||
`TextureCache.GetOrUpload(uint surfaceId)` returns a conventional `Texture2D`
|
||||
GL handle (1×1 magenta on failure) — exactly right for the UI batch
|
||||
([TextureCache.cs:70](../../../src/AcDream.App/Rendering/TextureCache.cs)). The
|
||||
decode chain + `PFID_*` formats already work
|
||||
([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
|
||||
`GameWindow` already holds a `TextureCache`; `UiHost`/`UiNineSlicePanel` receive
|
||||
it (or a `Func<uint,uint>` sprite-resolver) by injection.
|
||||
|
||||
**Step 0 is empirical and comes first.** Because no chrome sprite ID is verified,
|
||||
the first implementation task draws each candidate ID
|
||||
(`0x06004CC2`, `0x060074BF..C6`, `0x0600129C`, …) as a raw quad and visually
|
||||
confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The
|
||||
confirmed IDs are recorded in code comments before any chrome layout is written.
|
||||
**No ID is hardcoded on faith.**
|
||||
|
||||
The frame is **8 quads + a center fill** (4 corner + 4 edge sprites + center),
|
||||
not one stretched 9-slice texture. Slice/edge metrics are a **documented stopgap
|
||||
constant** (with a divergence row) until the `LayoutDesc` tree is parsed
|
||||
(sub-project 3).
|
||||
|
||||
## 7. Markup + stylesheet model
|
||||
|
||||
**Markup** mirrors `ElementDesc` 1:1 ([acclient.h:33693](../../../docs/research/named-retail/acclient.h));
|
||||
`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a
|
||||
`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). Authoring shape:
|
||||
|
||||
```xml
|
||||
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
|
||||
<meter id="health" x="8" y="24" w="200" h="13" fill="{HealthPercent}" color="#FF0000"/>
|
||||
<meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#D9A626"/>
|
||||
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#0000FF"/>
|
||||
</panel>
|
||||
```
|
||||
|
||||
This is the shape the future `LayoutDesc` importer will *emit*, so authoring and
|
||||
imported formats converge. It is **not** KSML (rich-text, deferred). `{Binding}`
|
||||
expressions resolve against a supplied binding object (the `VitalsVM`) via
|
||||
reflection on the property name.
|
||||
|
||||
**Anchor codes** are *defined* now (0=fixed, 1=stretch, 2=proportional-translate,
|
||||
3=center, 4=proportional-scale — from `StateDesc::UpdateSizeAndPosition`
|
||||
@`0x0069BF20`) but the **solver is deferred**: the Vitals window is fixed-size
|
||||
(placed via the existing `UiElement.Left/Top`), so Spec 1 needs no solver.
|
||||
|
||||
**Stylesheet.** A small INI loader parses `controls.ini` keyed by element-type
|
||||
section, honoring `#AARRGGBB` (alpha-first) and `font://Face-Pt[-style]`. Cascade:
|
||||
element-type defaults → per-element `class=` → inline attributes. **Optional**
|
||||
(§10): absent AC install → source-verified `[title]`/`[body]` fallback tokens.
|
||||
|
||||
## 8. VM binding (the Vitals slice)
|
||||
|
||||
The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title)
|
||||
and three `UiMeter`s. Each `UiMeter` holds a `Func<float?> Fill` bound to the
|
||||
real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)):
|
||||
`() => vm.HealthPercent`, `() => vm.StaminaPercent`, `() => vm.ManaPercent`. The
|
||||
VM already does all server plumbing, so we do **not** re-derive vitals from the
|
||||
retail `gmVitalsUI`/`CACQualities` decomp.
|
||||
|
||||
`UiMeter.OnDraw` draws the empty bar (`ctx.DrawRect`), the filled portion as a
|
||||
**partial-size rect** (`width = pct * Width`), and a centered `current/max` numeric
|
||||
overlay (`Func<string?> Label`). **Retail's vitals ARE exactly this — three stacked
|
||||
horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs.**
|
||||
Colors: Health red, **Stamina gold** (the earlier `#10F0F0` cyan research note was
|
||||
wrong), Mana blue. A `null` fill/label (pre-`PlayerDescription`) renders gracefully.
|
||||
The remaining gap to pixel-retail is the **glassy gradient bar fill sprite** + the
|
||||
**retail dat font** for the numbers (today the stub `BitmapFont` draws them) — both
|
||||
polish, deferred to §15.
|
||||
|
||||
The `VitalsVM` is constructed and given the player GUID the same way as today
|
||||
([GameWindow.cs:1330](../../../src/AcDream.App/Rendering/GameWindow.cs) ctor,
|
||||
:1984 `SetLocalPlayerGuid`); the retail-UI build path reuses that same VM
|
||||
instance.
|
||||
|
||||
## 9. Plugin contract (designed now, first consumer first-party)
|
||||
|
||||
The plugin API is a day-1 constraint; plugin authors must be able to add retail
|
||||
UI. The natural unit is a **`UiElement`/markup subtree added to `UiRoot`** (not
|
||||
`IPanel`/`IPanelRenderer`, which is the ImGui devtools path). Spec 1 adds:
|
||||
|
||||
- A small `IUiRegistry` (in `AcDream.Plugin.Abstractions`) — `void
|
||||
AddMarkupPanel(string markupPath, object binding)` (and/or `void
|
||||
AddElement(UiElement)` once a plugin-safe element surface is decided). For
|
||||
Spec 1, `AddMarkupPanel` is enough.
|
||||
- `IPluginHost` gains `IUiRegistry Ui { get; }`
|
||||
([IPluginHost.cs:8](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)
|
||||
has none today); `AppPluginHost` implements it
|
||||
([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)).
|
||||
- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens
|
||||
([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `AddMarkupPanel`
|
||||
**buffers** registrations into a list that `GameWindow` drains into `UiRoot`
|
||||
after `UiHost` is constructed. The threading/timing concern lives in the host;
|
||||
the plugin call is unconditional.
|
||||
- The first consumer is the first-party vitals panel (built directly in
|
||||
`GameWindow`, not through the registry). Wiring an actual plugin-supplied markup
|
||||
panel end-to-end is exercised by a smoke test but is otherwise the thin
|
||||
follow-up. This task group is the **last** in the plan so the visible vitals
|
||||
slice can land first if it slips.
|
||||
|
||||
## 10. Confirmed decisions (approved 2026-06-14)
|
||||
|
||||
1. **Render-only first slice.** `Tick` + `Draw` only; the `UiHost` input wiring
|
||||
(`WireMouse`/`WireKeyboard`) is **not** connected to the existing Phase-K
|
||||
`InputDispatcher` yet, so the close button isn't clickable and the window
|
||||
isn't draggable. Rationale (corrected): the `UiRoot` input *machinery* already
|
||||
exists — what's deferred is *integrating two input consumers* (routing
|
||||
unconsumed `WorldMouseFallThrough` back to the game's dispatcher), which is its
|
||||
own sub-phase.
|
||||
2. **`controls.ini` optional.** Assume `C:\Turbine\Asheron's Call\` may or may not
|
||||
exist. Add an `ACDREAM_AC_DIR` `RuntimeOptions` field; when absent, fall back
|
||||
to the source-verified `[title]`/`[body]` token values. The build never fails
|
||||
on a missing AC install.
|
||||
|
||||
## 11. Build sequence
|
||||
|
||||
| Step | Deliverable | Proves |
|
||||
|---|---|---|
|
||||
| 0 | Dat prove-out harness: draw candidate chrome IDs, confirm the real ones | Resolves the chrome-ID contradiction empirically |
|
||||
| 1 | `ui_text.frag` `uUseTexture=2` + `TextRenderer.DrawSprite` + `DepthMask` + `UiRenderContext.DrawSprite` | A dat sprite composites over the 3D scene |
|
||||
| 2 | `UiNineSlicePanel` draws an empty titled frame from confirmed dat sprites (stopgap insets) | Retail-shaped chrome renders |
|
||||
| 3 | `UiMeter` + a hand-built vitals `UiNineSlicePanel` subtree bound to `VitalsVM`, wired via `UiHost` under `ACDREAM_RETAIL_UI` (render-only) | End-to-end live data + the scaffold lights up |
|
||||
| 4 | `ControlsIni` parser (TDD) feeding the panel's title/colors | Stylesheet cascade |
|
||||
| 5 | `MarkupDocument` parser (TDD) → builds the same vitals subtree from `vitals.xml` | The Approach-C markup engine |
|
||||
| 6 *(last)* | `IUiRegistry` on `IPluginHost` + buffered drain into `UiRoot` + smoke test | Plugin-ready |
|
||||
|
||||
## 12. Error handling & edge cases
|
||||
|
||||
- **Missing/undecodable sprite** → `GetOrUpload` magenta fallback is visible;
|
||||
Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
|
||||
- **AC install absent** → `controls.ini` load skipped, baked fallback tokens used.
|
||||
- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null).
|
||||
- **Window resize** → `UiHost.Draw` already sets `Root.Width/Height` to the
|
||||
current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs));
|
||||
fixed-coord panels stay put. No DPI scaling (known out-of-scope gap).
|
||||
- **Both toggles on** → ImGui Vitals and retail Vitals may both show (fine in dev).
|
||||
|
||||
## 13. Testing
|
||||
|
||||
- **`ControlsIni` parser** (pure, no GL) — unit tests for `#AARRGGBB`, `font://`,
|
||||
cascade order. Lives in `src/AcDream.App/UI/`, tested in `tests/AcDream.App.Tests/`
|
||||
(App-layer, Rule 6).
|
||||
- **`MarkupDocument` parser** — unit tests for XML → `UiElement` tree shape
|
||||
(types, geometry) and `{Binding}` resolution against a fake binding object.
|
||||
- **`UiMeter` fill geometry** — unit test that fill fraction → partial rect width
|
||||
(pure math; `UiMeter.ComputeFillRect(pct, w, h)` as a static helper so it's
|
||||
testable without GL).
|
||||
- **`UiNineSlice` geometry** — unit test that a frame size + insets → the 9 dst
|
||||
rects (`UiNineSlicePanel.ComputeSliceRects` static helper).
|
||||
- **Plugin smoke** — a test plugin calls `host.Ui.AddMarkupPanel` and the buffered
|
||||
registration is drained (assert the panel is added to `UiRoot`).
|
||||
- **Visual acceptance** (user) — retail-shaped Vitals frame with live bars under
|
||||
`ACDREAM_RETAIL_UI=1`; ImGui path unaffected under `ACDREAM_DEVTOOLS=1`.
|
||||
- `dotnet build` + `dotnet test` green.
|
||||
|
||||
## 14. Bookkeeping
|
||||
|
||||
- **Phase D.2b**, Milestone **M5** (parallelizable; NOT on the M1.5 critical
|
||||
path). The CLAUDE.md "Current state" line stays on M1.5.
|
||||
- **Divergence register:** in the commit that ships `UiNineSlicePanel` rendering a
|
||||
real dat sprite, **delete row TS-30** ([retail-divergence-register.md:166](../../../docs/architecture/retail-divergence-register.md))
|
||||
— its cited file (`UiPanel.cs`) is upgraded by the subclass — and **add one**
|
||||
new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the
|
||||
markup/serialization layer. Assign the next sequential IA number at commit time.
|
||||
Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary;
|
||||
keystone.dll layout evaluation (no PDB)". Do **not** duplicate IA-12 (UI
|
||||
toolkit *behavioral* approximation). A second row for the stopgap slice insets
|
||||
is added when they ship.
|
||||
- **Spec file:** this document.
|
||||
|
||||
## 15. Open gaps & deferred sub-projects
|
||||
|
||||
- **Input integration** — connect `UiHost.WireMouse`/`WireKeyboard` to the Phase-K
|
||||
`InputDispatcher`, routing unconsumed `WorldMouseFallThrough`/`WorldKeyFallThrough`
|
||||
back to the game. Next sub-phase (lights up the close button + window drag that
|
||||
`UiRoot` already supports).
|
||||
- **`AcFont`** — dat A8 glyph loader (Font `0x40000xxx` → `ForegroundSurfaceDataId`
|
||||
→ RenderSurface, upload as **R8** so `ui_text.frag`'s `.r`-coverage branch works
|
||||
unchanged) → numeric overlays + retail fonts. (Today `UiLabel` uses the
|
||||
stb_truetype `BitmapFont`.)
|
||||
- **Anchor solver** — port `StateDesc::UpdateSizeAndPosition`; with the importer.
|
||||
- **`LayoutDesc` binary importer** (sub-project 3) — bulk-transpile retail layouts
|
||||
→ our markup, supplying real insets + coords. Symbols: `LayoutDesc::InqFullDesc`
|
||||
@`0x0069A520`, `ElementDesc::Incorporate` @`0x0069B5A0`
|
||||
([2026-05-08 pseudocode](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
|
||||
- **`PFID_CUSTOM_RAW_JPEG`** decode + login/char-select/chargen (sub-project 4).
|
||||
|
||||
## 16. Acceptance criteria
|
||||
|
||||
- [ ] Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code.
|
||||
- [ ] In `ACDREAM_RETAIL_UI=1`: a retail-shaped Vitals window renders via the wired
|
||||
`UiHost` — `UiNineSlicePanel` 8-piece dat-sprite border + title + drawn close
|
||||
button — with three `UiMeter` bars tracking HP/Stam/Mana live as the
|
||||
character takes damage / regens.
|
||||
- [ ] In `ACDREAM_DEVTOOLS=1`: ImGui Vitals/Chat/Debug/Settings unchanged.
|
||||
- [ ] `controls.ini` loads when present, falls back cleanly when absent.
|
||||
- [ ] `MarkupDocument` builds the vitals subtree from `vitals.xml`; pure parsers
|
||||
unit-tested; plugin smoke test drains a buffered `AddMarkupPanel`.
|
||||
- [ ] TS-30 deleted + one new IA-row added, same commit as the chrome.
|
||||
- [ ] `dotnet build` green, `dotnet test` green.
|
||||
- [ ] Visual verification by the user.
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main)
|
||||
**Status:** design — approved scope, pending spec review
|
||||
**Predecessor:** the LayoutDesc importer + the vitals re-drive
|
||||
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
|
||||
`docs/research/2026-06-15-layoutdesc-format.md`,
|
||||
`claude-memory/project_d2b_retail_ui.md`).
|
||||
**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a
|
||||
`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under
|
||||
`if (_options.RetailUi)`) with the **data-driven retail chat window** read from
|
||||
the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`,
|
||||
with **faithful behavioral widgets ported from the named retail decomp** and the
|
||||
**dat font** — the same way the vitals window became data-driven.
|
||||
|
||||
**The code is modern. The behavior is retail.** Every widget algorithm is ported
|
||||
from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited
|
||||
`class::method @address`.
|
||||
|
||||
## 2. Approved scope
|
||||
|
||||
**In scope (faithful core):**
|
||||
- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome,
|
||||
translucency).
|
||||
- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned,
|
||||
per-line chat-kind color, 10k-glyph behead cap.
|
||||
- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll,
|
||||
`thumbRatio = view/content`, wheel = **1 line per notch**.
|
||||
- Input: editable one-line field — caret, insert/delete, 100-entry command
|
||||
history (up/down arrow), focus sprite, Enter→submit.
|
||||
- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active
|
||||
outbound channel (the `ChatInputParser` default channel).
|
||||
- Send button + max/min button.
|
||||
- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel`
|
||||
so the ImGui devtools chat and the retail chat share one routing path.
|
||||
|
||||
**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI*
|
||||
plumbing acdream lacks, they are NOT UI scope cuts):**
|
||||
- **Numbered chat tabs (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<string>? OnSubmit` |
|
||||
| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta |
|
||||
| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` |
|
||||
| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection |
|
||||
| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)` → `Publish(SendChatCmd)` |
|
||||
| `UiDatFont` | no change | `Font` | already implements retail glyph advance |
|
||||
|
||||
**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one
|
||||
`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates
|
||||
D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is
|
||||
identical, only the class split differs. Accepted **ADAPTATION** divergence; both
|
||||
classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent.
|
||||
|
||||
**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`)
|
||||
render no background sprite of their own (bg comes from parent panels
|
||||
`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's
|
||||
rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the
|
||||
placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)`
|
||||
binding in `ChatInterface::PostInit`.
|
||||
|
||||
## 5. Data flow
|
||||
|
||||
- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) →
|
||||
`UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline
|
||||
unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior.
|
||||
- **Outbound:** `UiChatInput.OnSubmit(text)` →
|
||||
`ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)` → `SendChatCmd`
|
||||
→ `LiveCommandBus` → `WorldSession`. `activeChannel` comes from `UiChannelMenu`.
|
||||
- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel`
|
||||
(→ `ChatInputParser` default channel) + menu label update.
|
||||
- **Scroll:** transcript content height → `UiScrollable` → `UiChatScrollbar` thumb;
|
||||
wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset.
|
||||
|
||||
## 6. Faithfulness decisions / divergence-register rows
|
||||
|
||||
Add on landing (category in parens):
|
||||
1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`)
|
||||
not one mode-flagged `UIElement_Text`. Behavior identical.
|
||||
2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no
|
||||
in-element word-wrap at panel width. Symptom: long lines not re-wrapped on
|
||||
horizontal resize. `file:line` = `UiChatView.cs`.
|
||||
3. **(Approximation)** One color per display line, not per-glyph styled runs.
|
||||
4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds.
|
||||
5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent.
|
||||
6. **(Approximation)** Single default translucency; no focused/unfocused opacity
|
||||
transition; default dat font face+size (no `sm_nFontFace` config).
|
||||
|
||||
Retire nothing (no existing register row is fixed by this work).
|
||||
|
||||
## 7. Build sequence (tasks for the plan)
|
||||
|
||||
Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow`
|
||||
cutover (H) are the integration barrier.
|
||||
|
||||
- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a
|
||||
pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command /
|
||||
unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)*
|
||||
- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw +
|
||||
`HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1;
|
||||
keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font.
|
||||
- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb
|
||||
ratio/offset, line/page delta). Pure, fully unit-tested (no GL).
|
||||
- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place
|
||||
thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side
|
||||
up/down button ids in the dat here.
|
||||
- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` =
|
||||
`UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows,
|
||||
100-entry history with `−1`=live sentinel, focus sprite swap, `OnSubmit`. Tests
|
||||
for caret math + history.
|
||||
- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally);
|
||||
13 channels → `ChatChannelKind`; selection event + label.
|
||||
- **G. `ChatWindowController`** — `LayoutImporter.Import(0x21000006)`; bind by id;
|
||||
swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM)
|
||||
+ outbound (`ChatCommandRouter`); translucency.
|
||||
- **H. `GameWindow` cutover** — replace the hand-authored
|
||||
`UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default
|
||||
bottom-left position + resizable; remove dead code; add divergence rows;
|
||||
`dotnet build` + `dotnet test` green.
|
||||
|
||||
## 8. Testing strategy
|
||||
|
||||
- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable`
|
||||
clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔
|
||||
pixel + history navigation; `UiChatView` dat-font advance/hit-test via the
|
||||
`Func<char,FontCharDesc?>` seam.
|
||||
- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a
|
||||
`chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and
|
||||
rects.
|
||||
- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat
|
||||
resolves the root + all bound ids before wiring (guarded, like the vitals smoke).
|
||||
- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the
|
||||
retail screenshot — transcript scrolls, input types + sends, channel menu
|
||||
switches, Send works, scrollbar drags, window moves/resizes, translucency.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no
|
||||
hand-authored chat rect remains in `GameWindow.cs`.
|
||||
- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color,
|
||||
bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept.
|
||||
- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the
|
||||
transcript.
|
||||
- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the
|
||||
Send button both submit** through `ChatCommandRouter` → wire.
|
||||
- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel
|
||||
+ updates the label.
|
||||
- [ ] Max/min toggles window height; window moves + resizes; translucent frame.
|
||||
- [ ] Every ported widget cites a `class::method @address`; every deferral has a
|
||||
divergence-register row.
|
||||
- [ ] `dotnet build` + `dotnet test` green; user visual sign-off.
|
||||
|
||||
## 10. Deferred / follow-ups (filed, not built)
|
||||
|
||||
In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat
|
||||
filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font
|
||||
face/size; active/inactive opacity transition; the unidentified top-level Type-5
|
||||
ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element).
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
# LayoutDesc Importer — Design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Status:** Approved (brainstorm) — pending spec review → implementation plan
|
||||
**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation)
|
||||
**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path.
|
||||
|
||||
## Context
|
||||
|
||||
D.2b shipped a working retail vitals window and a scrollable chat window, but each was
|
||||
built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into
|
||||
`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details
|
||||
(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time.
|
||||
That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details
|
||||
that are already in the dat (the grip overlay was found only because the user spotted it).
|
||||
|
||||
The `LayoutDesc` dat is a **complete, declarative description of every window** — element
|
||||
tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips,
|
||||
meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the
|
||||
dat** with one faithful interpreter rather than transcribe it per window.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and
|
||||
produces a `UiElement` tree the existing toolkit renders — so opening any retail window is
|
||||
one call, with **no per-window graphics/layout code**. The only per-window code is live
|
||||
**data wiring** (which is inherently per-window and tiny).
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable
|
||||
dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is
|
||||
what Silk.NET + .NET already provide. (See "Decisions → Structure".)
|
||||
- Deleting or rewriting the existing toolkit/widgets/markup. They are reused.
|
||||
|
||||
## Decisions (from brainstorm 2026-06-15)
|
||||
|
||||
1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc`
|
||||
(`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean
|
||||
pass/fail. The hand-authored vitals path stays as the reference until the importer matches.
|
||||
2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format
|
||||
(every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes,
|
||||
states, properties) — not just the slice vitals uses. Matches the project's
|
||||
"behavior is retail" ethos.
|
||||
3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm**
|
||||
verbatim from the decomp, onto our modern draw primitives. A single generic renderer
|
||||
handles the trivial "stamp the sprite per draw-mode" types (the long tail, including
|
||||
types not yet catalogued); dedicated widgets handle types with real behavior (meter,
|
||||
text, scrollbar/chat, button). The decomp's render method for each type *decides* which
|
||||
bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms;
|
||||
the hybrid is only about C# packaging.
|
||||
4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom
|
||||
panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`,
|
||||
`UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice)
|
||||
become the importer's behavioral renderers.
|
||||
|
||||
## Architecture & data flow
|
||||
|
||||
```
|
||||
RETAIL WINDOWS (data-driven from the dat)
|
||||
client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen
|
||||
(LayoutDesc 0x21..) │ (UiDatElement +
|
||||
│ behavioral widgets)
|
||||
├─ resolve BaseElement / BaseLayoutId inheritance
|
||||
├─ walk ElementDesc tree → widget (hybrid factory)
|
||||
└─ apply rect / anchors / states / media / props from the dat
|
||||
|
||||
per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI)
|
||||
WindowManager ─► open/close by layout id, z-order, focus, position persistence
|
||||
|
||||
PLUGIN / CUSTOM PANELS (hand-authored, unchanged)
|
||||
*.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers)
|
||||
```
|
||||
|
||||
Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering
|
||||
toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives
|
||||
changes.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Format enumeration (Step 0 — foundational groundwork)
|
||||
|
||||
Because we chose "full faithful," the first deliverable is a **documented map** of the
|
||||
complete format, not code. Sources, cross-checked against each other:
|
||||
|
||||
- **DatReaderWriter types** — `ElementDesc`, `StateDesc`, `MediaDesc*` and their enums
|
||||
(`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout`
|
||||
already does (props **and** fields).
|
||||
- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the
|
||||
property-key meanings; the **KSML keyword registrations** (the parser registers every
|
||||
property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …).
|
||||
- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties
|
||||
actually occur and catch anything the above missed.
|
||||
|
||||
Output: a reference doc mapping each `Type` → meaning + render method, each property key →
|
||||
meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other
|
||||
component and is committed alongside the importer.
|
||||
|
||||
### 2. `LayoutImporter`
|
||||
|
||||
Reads a `LayoutDesc` by id and returns a `UiElement` subtree:
|
||||
- Walk the `ElementDesc` tree.
|
||||
- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its
|
||||
rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and
|
||||
properties from the (resolved) element.
|
||||
- Recurse into children.
|
||||
- Expose `FindElement(uint id)` on the result so controllers wire by id.
|
||||
|
||||
Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver,
|
||||
`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it
|
||||
builds `UiElement`s; rendering stays in the toolkit.
|
||||
|
||||
### 3. Inheritance resolution
|
||||
|
||||
An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states
|
||||
/ media; the derived element overrides. Resolve by loading the base layout, finding the base
|
||||
element, and merging (base first, then derived overrides) **before** instantiating.
|
||||
Required even for vitals: the number-text element inherits its font/style from base layout
|
||||
`0x2100003F`. Cycle-guard the resolution.
|
||||
|
||||
### 4. Hybrid widget factory (`Type` → renderer)
|
||||
|
||||
- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`,
|
||||
text → dat-font label, scrollable/list region → `UiChatView`/list widget, button →
|
||||
`UiButton`, resizable window root → `UiNineSlicePanel`.
|
||||
- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic).
|
||||
- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media).
|
||||
|
||||
The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method
|
||||
(trivial blit → generic; real algorithm → widget).
|
||||
|
||||
### 5. `UiDatElement` (generic renderer)
|
||||
|
||||
A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its
|
||||
`OnDraw` ports retail's base blit branch:
|
||||
- `Normal` → **tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) —
|
||||
the mechanism already proven for the bars + chrome.
|
||||
- `Alphablend` → blended overlay.
|
||||
- `Stretch` (if present) → scale.
|
||||
- image → sprite; cursor → hover cursor.
|
||||
Reuses the tiling, dat-font, nine-slice draw primitives.
|
||||
|
||||
### 6. Per-window controllers (live-data binding)
|
||||
|
||||
Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the
|
||||
imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill,
|
||||
`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is
|
||||
the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g.
|
||||
`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill).
|
||||
|
||||
### 7. `WindowManager`
|
||||
|
||||
`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default
|
||||
position (then persist user move/resize), manage z-order / focus / close. Orchestrates the
|
||||
focus/drag/resize mechanics `UiRoot` already provides.
|
||||
|
||||
### 8. States / expand / hover
|
||||
|
||||
Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from
|
||||
the dat; the active state selects which media draws. A click or hover flips the active state.
|
||||
Click-to-expand and hover highlight fall out generically — no per-window code.
|
||||
|
||||
## Rollout order (milestones)
|
||||
|
||||
1. **Enumerate the format** (§1) → reference doc.
|
||||
2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk).
|
||||
3. **`UiDatElement`** generic renderer (port the draw-mode blit branch).
|
||||
4. **Hybrid factory** (Type → widget/generic).
|
||||
5. **`VitalsController`** (bind by id).
|
||||
6. **Re-drive vitals → diff against the current window.** ✅ conformance gate.
|
||||
7. **`WindowManager`** (open/close/persist).
|
||||
8. **Extend** to chat (`ChatController`), then new windows for free.
|
||||
|
||||
## Testing / conformance
|
||||
|
||||
- **Golden tree checks** — the importer-built vitals tree has the expected element rects,
|
||||
resolved sprites, and active states (assert against the known `0x2100006C` values).
|
||||
- **Inheritance unit tests** — base+override merge, cycle-guard.
|
||||
- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile.
|
||||
- **Bind-by-id unit tests** — controller wires the right element.
|
||||
- **Headless visual diff** — `render-vitals-mockup` / a tree-render comparison vs the
|
||||
hand-built reference (no live server needed).
|
||||
- **Final** — in-client visual verification (the user) once the gate passes.
|
||||
|
||||
## Coexistence / don't-delete (restated)
|
||||
|
||||
- `MarkupDocument` + `*.xml` stay for plugin/custom panels.
|
||||
- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/
|
||||
nine-slice primitives stay — reused as the importer's behavioral renderers.
|
||||
- The hand-authored vitals path stays as the conformance reference until the importer
|
||||
matches it; only then is vitals flipped to the importer.
|
||||
|
||||
## Risks & open questions
|
||||
|
||||
- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is
|
||||
mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real
|
||||
layouts; the vitals conformance gate catches regressions.
|
||||
- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are
|
||||
generic, written once — not per-window. The generic fallback means an un-widgeted type
|
||||
still renders its sprites in the meantime.
|
||||
- **Position persistence** scope (per-window saved rects) — minimal at first (dat default +
|
||||
in-session move/resize); durable persistence can follow.
|
||||
- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing.
|
||||
|
||||
## Reference anchors
|
||||
|
||||
- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row
|
||||
`0x21000075`; vitals number-text base layout `0x2100003F`.
|
||||
- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren`
|
||||
@`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font),
|
||||
`ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order),
|
||||
the KSML keyword registrations (~`0x71b540`+).
|
||||
- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump),
|
||||
`dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render).
|
||||
- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model,
|
||||
dat-font, tools).
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
# D.5.1 — Toolbar (action bar) — Phase 1 design
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Status:** design approved (brainstorm), spec under review → writing-plans next
|
||||
**Phase:** D.5.1 — first sub-phase of D.5 "Core panels" (D.2b retail-look track). NEW
|
||||
sub-phase; roadmap registration is plan step 0 (roadmap discipline rule 4).
|
||||
**Builds on:** the shipped D.2b widget toolkit (`b7f7e2b`→`89626cd`) — generic
|
||||
Type-registered widgets built by `DatWidgetFactory`, assembled by `LayoutImporter`,
|
||||
bound by thin `gm*UI::PostInit`-style controllers. See
|
||||
[`claude-memory/project_d2b_retail_ui.md`](../../../claude-memory/project_d2b_retail_ui.md).
|
||||
|
||||
**Research evidence base (the anchors live here — this spec cites, does not re-derive):**
|
||||
- [`docs/research/2026-06-16-ui-panels-synthesis.md`](../../research/2026-06-16-ui-panels-synthesis.md) — the build plan + consolidated widget list + cross-panel wire table
|
||||
- [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — `UIElement_UIItem`/`UIElement_ItemList` port spec, the icon composite, drag-drop spine
|
||||
- [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md) — `gmToolbarUI` shortcut model + wire + element map
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Ship the **action bar (`gmToolbarUI`)** as the first data-driven *game* panel (vitals
|
||||
and chat were HUD). 18 shortcut slots built from `LayoutDesc 0x21000016` via the existing
|
||||
`LayoutImporter`, populated from the persisted `PlayerDescription` shortcut block, each
|
||||
pinned item rendering its **real composited icon**, with **click-to-use**. Gated
|
||||
`ACDREAM_RETAIL_UI=1`, whole-window-drag.
|
||||
|
||||
The point of doing the toolbar first is that it is the **thinnest end-to-end slice that
|
||||
exercises the entire shared item spine** — the `UiItemSlot` widget, the icon composite
|
||||
pipeline, the `UiItemList` widget, a find-by-id controller, and the `CreateObject` icon
|
||||
extension — on the simplest of the three panels (no nested sub-windows, no 3D viewport,
|
||||
no multi-column grid). Everything built here is reused verbatim by the inventory and
|
||||
paperdoll phases.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In scope (Phase 1):**
|
||||
- `UiItemSlot` widget (port of `UIElement_UIItem`, class `0x10000032`) — empty-slot + icon render.
|
||||
- `UiItemList` widget (port of `UIElement_ItemList`, class `0x10000031`) — single-cell instances.
|
||||
- Icon composite pipeline (faithful CPU pre-composite — Approach A, §4.3).
|
||||
- `CreateObject.TryParse` extension to capture `IconId` onto `ItemInstance`.
|
||||
- `ToolbarController` — find-by-id bind, populate-from-shortcuts, deferred re-bind, click-to-use.
|
||||
- Toolbar window mounted under `ACDREAM_RETAIL_UI=1`, whole-window-drag.
|
||||
|
||||
**Out of scope (later D.5 sub-phases):**
|
||||
- Drag/reorder within the bar; drag-to-add from inventory (needs inventory as a drag source).
|
||||
- The `AddShortCut`/`RemoveShortCut` mutate wire (`0x019C`/`0x019D`) — builders already exist; wiring them is deferred to the drag phase.
|
||||
- The hidden selected-object Health/Mana meters (`0x100001A1`/`A2`) + the stack-split slider (`0x100001A4`) — stay `SetVisible(0)`, matching `gmToolbarUI::PostInit`.
|
||||
- Spell shortcuts (`ItemList_InsertSpellShortcut`, `CM_Magic` path).
|
||||
- Faithful window manager (Dragbar/Resizebar drag-resize) — uses the accepted IA-12 whole-window-drag approximation.
|
||||
- Inventory and paperdoll panels.
|
||||
|
||||
## 3. Retail anchors (the load-bearing facts, verified)
|
||||
|
||||
All confirmed against the named decomp during the research phase and re-verified for this
|
||||
spec. Lines are `acclient_2013_pseudo_c.txt`.
|
||||
|
||||
- **Window:** `gmToolbarUI` element class `0x10000007` → `LayoutDesc 0x21000016` (300×122).
|
||||
`gmToolbarUI::Register` (decomp 196897), `GetUIElementType`→`0x10000007` (196707).
|
||||
- **18 slots, two rows of 9:** element ids `0x100001A7-AF` (top) + `0x100006B7-BF` (bottom),
|
||||
wired in `gmToolbarUI::InitShortcutArray` (decomp 197051); each is a `DynamicCast(0x10000031)`
|
||||
= `UIElement_ItemList`, pushed into `m_shortcutSlots` in slot-index order.
|
||||
- **Slot content:** each slot list holds one `UIElement_UIItem` (item-cell, class
|
||||
`0x10000032`). The cell's bound weenie guid is `UIElement_UIItem::itemID` (offset `+0x5FC`),
|
||||
read in `UIItem_Update` (decomp 230230: `uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`).
|
||||
- **Persisted model:** `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`); the struct is
|
||||
`ShortCutData { int index_; uint objectID_; uint spellID_; }` (`acclient.h:36484`). Delivered
|
||||
at login in the `PlayerDescription` `SHORTCUT` block (`CharacterOptionDataFlag.SHORTCUT 0x1`).
|
||||
acdream already parses it → `PlayerDescriptionParser.cs:345-356` → `Parsed.Shortcuts`
|
||||
(`ShortcutEntry{Index, ObjectGuid, SpellId, Layer}`).
|
||||
- **Populate at login:** `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) — `FlushShortcuts`
|
||||
then for i in 0..0x12 read `shortCuts_[i]->objectID_` and `AddShortcut(this, objId, i, send=0)`.
|
||||
- **Deferred bind:** `UIElement_UIItem::SetDelayedShortcutNum` / `AddShortcut` (decomp 196867)
|
||||
re-binds a slot whose weenie hasn't loaded yet once `CreateObject` for that guid arrives.
|
||||
- **Activation (click-to-use):** `gmToolbarUI::UseShortcut` (decomp 196395) → `ItemHolder::UseObject`
|
||||
(decomp 402923, 0.2s throttle `m_timeLastUsed + 0.2`) → ordinary use-item dispatch (NOT a
|
||||
shortcut-specific wire message). acdream's use-item path = `InteractRequests.BuildUse` (`0x0036`).
|
||||
- **Icon composite:** `UIElement_UIItem::UIItem_SetIcon` (230143) → `ACCWeenieObject::GetIconData`
|
||||
(408224) → `IconData::RenderIcons` (407524). Five layers, bottom→top: item-type default
|
||||
underlay `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)`; custom underlay `_iconUnderlayID`;
|
||||
base `_iconID`; custom overlay `_iconOverlayID` + `SurfaceWindow::ReplaceColor` tint; effect
|
||||
overlay `DBObj::GetByEnum(0x10000005, lsb(effects)+1)`. **Every layer is DBObj type `0xc`
|
||||
= RenderSurface, id range `0x06000000-0x07FFFFFF`** — decoded DIRECTLY via
|
||||
`TextureCache.GetOrUploadRenderSurface` (the D.2b RenderSurface-vs-Surface gotcha: feeding
|
||||
a `0x06` id to `GetOrUpload` returns 1×1 magenta). Icon is NOT appraise-gated (no appraise
|
||||
branch in the icon path; appraise gates `UpdateTooltip` only).
|
||||
- **acdream gap:** `CreateObject.TryParse` currently DISCARDS `IconId` (`CreateObject.cs:516`:
|
||||
`_ = ReadPackedDwordOfKnownType(..., IconTypePrefix)`). `ItemInstance` already has the
|
||||
`IconId`/`IconUnderlayId`/`IconOverlayId`/`StackSize`/`ContainerId` fields.
|
||||
|
||||
## 4. Architecture & components
|
||||
|
||||
Five new/extended units, each with one purpose and a defined interface. The pattern
|
||||
mirrors the shipped vitals/chat re-drive exactly: dat `LayoutDesc` → `LayoutImporter` →
|
||||
`DatWidgetFactory` builds widgets generically → a thin controller binds by id.
|
||||
|
||||
### 4.1 `UiItemSlot` (new behavioral widget) — port of `UIElement_UIItem` (`0x10000032`)
|
||||
|
||||
- **Location:** `src/AcDream.App/UI/UiItemSlot.cs`.
|
||||
- **Registration:** `DatWidgetFactory` dispatches it on the resolved element **class id**
|
||||
`0x10000032`. NOTE: the shipped factory keys off the small *numeric* Types (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<RenderSurface>` → `SurfaceDecoder.DecodeRenderSurface(palette:null)`), the same path
|
||||
`TextureCache.GetOrUploadRenderSurface` already uses — but composited on the CPU rather than
|
||||
drawn as separate sprites.
|
||||
- **Enum-mapper layers:** the type-default underlay (`GetByEnum(0x10000004, …)`) and effect
|
||||
overlay (`GetByEnum(0x10000005, …)`) require reading the two DBObj enum-mapper tables. These
|
||||
are bounded lookups (index → RenderSurface id); port them as part of this unit. If a mapper
|
||||
proves more involved than the research suggests, the base + custom underlay/overlay layers
|
||||
still composite correctly and the enum layers can land as a tight follow-up within the phase
|
||||
(documented, not silently dropped).
|
||||
- **Why pre-composite, not stacked draws:** the custom-overlay `ReplaceColor` tint is a
|
||||
per-pixel palette operation, not a simple alpha-blend — it cannot be reproduced by a tinted
|
||||
`DrawSprite`. CPU compositing is therefore the faithful path, and it's the shared spine for
|
||||
all three panels, so it's built correctly once.
|
||||
- **Depends on:** `DatCollection` (RenderSurface decode), GL texture upload.
|
||||
|
||||
### 4.4 `CreateObject` icon extension + `ItemInstance`
|
||||
|
||||
- **Location:** `src/AcDream.Core.Net/Messages/CreateObject.cs`, `src/AcDream.Core/Items/ItemInstance.cs`.
|
||||
- **Change:** in `CreateObject.TryParse`, capture the `IconId` (currently discarded at
|
||||
`CreateObject.cs:516`) — and the underlay/overlay/effect ids if present in the same block —
|
||||
onto the parsed object so `ItemRepository` stores them on `ItemInstance` (fields already exist).
|
||||
- **Planning delta (see the plan):** fact-gathering found this is wider than "just capture IconId."
|
||||
acdream has NO `CreateObject`→`ItemRepository` wiring at all (the repo is populated only from
|
||||
`PlayerDescription` with stub `ItemInstance`s), and `Parsed.Shortcuts` is parsed then discarded
|
||||
in `GameEventWiring`. So the plan adds three small wiring pieces: capture IconId (Task 1), enrich
|
||||
the repo from the `WorldSession.EntitySpawned` event (Tasks 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.
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track)
|
||||
**Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review
|
||||
**Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive
|
||||
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
|
||||
`docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`,
|
||||
`docs/research/2026-06-15-layoutdesc-format.md`,
|
||||
`claude-memory/project_d2b_retail_ui.md`).
|
||||
**Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in
|
||||
`claude-memory/project_d2b_retail_ui.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` /
|
||||
`UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement`
|
||||
click-wiring into **generic, Type-registered widgets** built by
|
||||
`DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step,
|
||||
`VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior**
|
||||
controller — the acdream analogue of retail `gm*UI::PostInit`.
|
||||
|
||||
**The code is modern. The behavior is retail.** This pass changes the
|
||||
*construction path* of widgets, not their on-screen behavior. The chat window
|
||||
must stay visually and behaviorally identical through every step except the final
|
||||
(gated) vitals rewire.
|
||||
|
||||
### 1.1 Why this is mostly already done
|
||||
|
||||
The trace that opened this work (re-confirmed in this design session) established
|
||||
two facts that make the generalization a *registration* task, not a new mechanism:
|
||||
|
||||
1. **The importer's base-chain Type resolution is already retail-faithful.**
|
||||
`ElementReader.Merge` resolves a Type-0 placement element up its
|
||||
`BaseElement`/`BaseLayoutId` chain to the base's real registered Type
|
||||
(`ElementReader.cs:137-140`). Every chat/vitals element therefore already
|
||||
resolves to the retail class it would instantiate.
|
||||
|
||||
2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style
|
||||
prototype to skip."** Verified directly in the decomp:
|
||||
`UIElement::RegisterElementClass(0xc, UIElement_Text::Create)`
|
||||
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The
|
||||
`Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1
|
||||
expedient* (AP-37: skip the vitals number elements so they render via
|
||||
`UiMeter.Label`), **not** a structural truth.
|
||||
|
||||
So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their
|
||||
real widget type) **dissolves**: the resolved Type is already correct. The factory
|
||||
just needs to *register* generic widgets for those Types instead of skipping them
|
||||
or dropping to `UiDatElement`.
|
||||
|
||||
### 1.2 Why this matters beyond chat (the strategic purpose)
|
||||
|
||||
Chat is the **proving ground**, not the destination. The payoff is that every
|
||||
future panel — **inventory, spell bar, vendor, character sheet, trade, skills** —
|
||||
becomes *assembled from dat data + a thin controller* instead of being hand-built
|
||||
from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a
|
||||
shared `UIElement` toolkit), and it is the reason to do this pass carefully now.
|
||||
|
||||
**What this pass gives all future windows (the foundation):**
|
||||
- The **generic widget toolkit** — `UiButton`, `UiField`, `UiScrollbar`, `UiText`,
|
||||
`UiMenu` — built automatically by `DatWidgetFactory` from the dat layout.
|
||||
- The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and
|
||||
cemented on chat. Inventory's controller, vendor's controller, etc. all take the
|
||||
same shape.
|
||||
|
||||
**What those specific windows additionally need (out of scope here; cheap once the
|
||||
pattern exists):**
|
||||
- **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5)
|
||||
and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into
|
||||
`UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` /
|
||||
`MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass
|
||||
already builds). Each gets *registered when that window needs it* — which is
|
||||
exactly why §3 bounds "full registry" to the Types chat+vitals use today rather
|
||||
than speculatively building all 14 retail classes.
|
||||
- **The window manager** — open/close/z-order/persist, drag-bars (Type 2),
|
||||
resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to
|
||||
this one — and lands alongside, because pop-up/stackable windows (inventory,
|
||||
vendor) need it.
|
||||
- **Per-domain data plumbing** — item icons, live container contents, vendor stock
|
||||
lists. Game-state work, separate from the UI toolkit.
|
||||
|
||||
This pass is therefore the **reusable toolkit + assembly pattern** that makes those
|
||||
later windows mostly-free to build. It is the load-bearing first half of the road
|
||||
to inventory/vendor/spell-bar, not the whole road.
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail reference (the registry + the PostInit pattern)
|
||||
|
||||
### 2.1 The Type → class registry (`UIElement::RegisterElementClass`)
|
||||
|
||||
Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited):
|
||||
|
||||
| Type | Retail class | Reg. line | | Type | Retail class | Reg. line |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 |
|
||||
| 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 |
|
||||
| 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 |
|
||||
| 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 |
|
||||
| 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 |
|
||||
| 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 |
|
||||
| 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — |
|
||||
|
||||
Type 0 has no class of its own — a Type-0 element is a placement/override that
|
||||
inherits its class from its base. That is exactly what `ElementReader.Merge`
|
||||
already does.
|
||||
|
||||
> **Implementation correction (2026-06-16, settled during execution).** Two of
|
||||
> this design's registration assumptions changed once the empirical resolved
|
||||
> Types were in hand (Task 1):
|
||||
> 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.**
|
||||
> So the input is **Variant B** — the factory builds it as a `UiText`
|
||||
> placeholder and `ChatWindowController` removes that and controller-places a
|
||||
> `UiField` at its rect. (Confirmed by the chat golden fixture.)
|
||||
> 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals
|
||||
> (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are
|
||||
> sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals
|
||||
> `0x10000633` → sprite `0x060074C3`) and the transcript/input **container**
|
||||
> panels — NOT editable fields. Retail draws those as inert media-bearing
|
||||
> Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and
|
||||
> without a spurious focus/edit affordance. Registering Type 3 → `UiField`
|
||||
> (which draws no dat sprite) would blank the vitals bevel. So the factory
|
||||
> switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text
|
||||
> (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships
|
||||
> (the renamed editable widget) — it is just controller-placed, not
|
||||
> factory-wired. Register Type 3 → `UiField` only when a window carries a
|
||||
> factory-built editable Type-3 field (and `UiField` then grows a
|
||||
> background-media draw + an opt-in editable flag). Guarded by
|
||||
> `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a
|
||||
> `UiDatElement` drawing its sprite).
|
||||
|
||||
### 2.2 The `gm*UI::PostInit` binding pattern (the controller target)
|
||||
|
||||
`gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and
|
||||
`gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget:
|
||||
|
||||
```
|
||||
UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id
|
||||
UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type
|
||||
this->m_pHealthMeter = m; // store
|
||||
if (!m) { /* skip */ } // null-check
|
||||
```
|
||||
|
||||
acdream analogue (already half-present in `ChatWindowController`):
|
||||
|
||||
```csharp
|
||||
var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast
|
||||
if (send is not null) send.OnClick = () => input.Submit(); // bind behavior
|
||||
```
|
||||
|
||||
The faithful end-state is: **the factory builds every widget from the dat; the
|
||||
controller only finds-by-id and binds data/callbacks** — it never constructs a
|
||||
widget.
|
||||
|
||||
### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`)
|
||||
|
||||
Traced against the live dat (HIGH confidence; base ids in parentheses):
|
||||
|
||||
| Element | Resolves to | Retail class | Today |
|
||||
|---|---|---|---|
|
||||
| `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` |
|
||||
| `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` |
|
||||
| `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` |
|
||||
| `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` |
|
||||
| `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` |
|
||||
| `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` |
|
||||
|
||||
> **Plan-phase verification #1 (load-bearing):** the editable **input**
|
||||
> `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only
|
||||
> transcript — surprising for an editable field (retail's editable text is
|
||||
> Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface`
|
||||
> Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and
|
||||
> the `0x10000372` base prototype's Type before relying on it. The design is
|
||||
> robust either way — see §4.3(a).
|
||||
|
||||
---
|
||||
|
||||
## 3. Approved scope
|
||||
|
||||
**Decision (this session):** *Full registry, chat-first, vitals rewire as the
|
||||
final, separately-committed, separately-gated step.*
|
||||
|
||||
**In scope:**
|
||||
- Register generic widgets for the Types the chat + vitals windows actually use:
|
||||
**Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7)
|
||||
already done.
|
||||
- Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`.
|
||||
- Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget
|
||||
construction).
|
||||
- **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the
|
||||
vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`),
|
||||
retiring `UiMeter.Label` for vitals.
|
||||
|
||||
**Explicitly NOT in scope ("full registry" is bounded to what these windows use):**
|
||||
- The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar`
|
||||
(9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16),
|
||||
`GroupBox` (17). Those elements **continue to render correctly as
|
||||
`UiDatElement`** (the universal fallback is non-negotiable). No
|
||||
`UIElement_ColorPicker` port for a window that has no color picker. When a future
|
||||
window needs one of these, it gets registered then.
|
||||
- No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat
|
||||
re-drive deferred them — see that spec's §2).
|
||||
- `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals
|
||||
simply stops using it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 `DatWidgetFactory` — the faithful Type switch
|
||||
|
||||
`DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to:
|
||||
|
||||
```csharp
|
||||
UiElement e = info.Type switch
|
||||
{
|
||||
1 => BuildButton(info, resolve, datFont), // UIElement_Button
|
||||
3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a)
|
||||
6 => BuildMenu(info, resolve, datFont), // UIElement_Menu
|
||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged)
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar
|
||||
12 => BuildText(info, resolve, datFont), // UIElement_Text
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (unchanged)
|
||||
};
|
||||
```
|
||||
|
||||
The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The
|
||||
`Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base
|
||||
prototype* (Type 12 with no own geometry that is only referenced via
|
||||
`BaseLayoutId`, never placed) must still not draw. In practice such prototypes are
|
||||
never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only
|
||||
builds placed elements. **Plan-phase verification #2:** confirm no Type-12
|
||||
prototype is double-built after the skip is removed (the chat/vitals golden
|
||||
fixtures catch this).
|
||||
|
||||
Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label
|
||||
font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The
|
||||
controller binds providers/callbacks afterward.
|
||||
|
||||
### 4.2 The generic widgets
|
||||
|
||||
Each generic widget extends `UiElement`, is constructed by the factory from
|
||||
`ElementInfo`, and exposes **data providers + callbacks** for the controller to
|
||||
bind. The chat-specific knowledge moves *out* of the widgets and *into* the
|
||||
controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the
|
||||
talk-focus channel list).
|
||||
|
||||
| Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds |
|
||||
|---|---|---|---|---|
|
||||
| `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` |
|
||||
| `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption |
|
||||
| `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action<object>`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` |
|
||||
| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func<IReadOnlyList<(string,Vector4)>>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors |
|
||||
| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit` → `ChatCommandRouter` |
|
||||
|
||||
**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside
|
||||
`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/`
|
||||
references them. This matches the current split (`UiMeter` in `UI/`,
|
||||
`UiDatElement` in `UI/Layout/`).
|
||||
|
||||
**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are
|
||||
removed (or kept as thin obsolete aliases only if needed mid-migration).
|
||||
|
||||
### 4.3 The two wrinkles
|
||||
|
||||
**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution:
|
||||
- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the
|
||||
controller only binds `OnSubmit`.
|
||||
- If it resolves to **Type 12** → the dat element is a display Text in this
|
||||
layout; the controller *replaces* it with a controller-placed `UiField` at its
|
||||
rect (today's pattern for the track/menu). `UiField` exists as a registered
|
||||
generic widget regardless; only *who places it* differs.
|
||||
|
||||
Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the
|
||||
input is always provider-bound — the open question only affects whether the
|
||||
factory or the controller *instantiates* it.
|
||||
|
||||
**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the
|
||||
vitals number elements (Type-0 → base Type-12 Text) *could* build as real
|
||||
`UiText`. Today they are **meter children, consumed** (the importer does not
|
||||
recurse a meter's children — `LayoutImporter.cs:113`), rendered via
|
||||
`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText`
|
||||
for each number (matching retail `UIElement_Text` vitals numbers) and drops
|
||||
`UiMeter.Label` for vitals.
|
||||
|
||||
This is **step 7 — the last commit, separately gated**, with its own fixture
|
||||
update and the user's visual sign-off, because vitals shipped pixel-identical and
|
||||
is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical
|
||||
result, we **stop and keep the meter-label path** for vitals — a smaller,
|
||||
documented divergence (AP-37 narrowed, not retired). The decision to land step 7
|
||||
is the user's, made on the running client.
|
||||
|
||||
### 4.4 The thin controller (after step 6)
|
||||
|
||||
`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id)
|
||||
as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic
|
||||
(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget
|
||||
construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat
|
||||
data shaping). What *leaves* the controller: the construction of `UiChatView`,
|
||||
`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the
|
||||
controller binds them instead.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration sequence (one widget per commit; build + test green each step)
|
||||
|
||||
Ordered least-risk → most-risk; the chat window is fully generalized before vitals
|
||||
is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests)
|
||||
green, its own commit naming the widget; the live chat window stays visually
|
||||
identical through steps 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).
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
# D.2b — Stateful item-icon system (D.5.2) — design
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**Phase:** D.2b retail-UI engine → D.5.2 (the shared icon infrastructure before the
|
||||
inventory / equipment / vendor / trade panels).
|
||||
**Research basis (READ FIRST):** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md)
|
||||
— the definitive, source-verified answers (clean Ghidra decompile + live-dat probe + ACE
|
||||
oracle). It **supersedes** the hypotheses in `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
The displayed item icon must **always be a function of the item's current state** — the
|
||||
shared compositor every item panel reuses. Two concrete gaps remain after D.5.1:
|
||||
|
||||
1. The **effect treatment** (retail's `UiEffects`-driven recolor) is unbuilt, and acdream
|
||||
**discards** the `UiEffects` bitfield at `CreateObject.cs` (the UiEffects skip).
|
||||
2. There is no **live** re-trigger: when an item's state changes (the user's "item with
|
||||
mana vs out of mana"), the icon must re-composite.
|
||||
|
||||
User decisions (2026-06-17): **(a)** port the effect treatment **faithfully** (retail's
|
||||
subtle white-pixel recolor, not a bold overlay); **(b)** D.5.2 **includes** the live
|
||||
`PublicUpdatePropertyInt(0x02CE)` wire-up so the icon updates in real time.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In scope**
|
||||
- Capture `UiEffects` (weenieFlags `0x80`) from `CreateObject` onto the item.
|
||||
- The faithful 2-stage effect composite in `IconComposer`.
|
||||
- The live `PublicUpdatePropertyInt(0x02CE)` parser → `UiEffects` → re-composition.
|
||||
- Conformance tests + divergence-register bookkeeping.
|
||||
|
||||
**Out of scope (with reasons)**
|
||||
- **Appraise-driven icon enrichment** — DROPPED. ACE proves appraise carries no icon /
|
||||
UiEffects data (`Icon`/`IconOverlay`/`IconUnderlay` and `PropertyInt.UiEffects` all lack
|
||||
`[AssessmentProperty]`). It is a no-op, and acdream never sends an appraise anyway.
|
||||
- `IsThePlayer` paperdoll container icon (`GetDIDByEnum(0x10000004, 7)`) — paperdoll phase.
|
||||
- `PrivateUpdatePropertyInt(0x02CD)` (player's own object, no guid) — not an item path.
|
||||
|
||||
## 3. Background — the corrected retail facts (from the RESOLVED doc)
|
||||
|
||||
- **All icon inputs are CreateObject-only.** `_iconID` (always), `_iconOverlayID`
|
||||
(weenieFlags `0x40000000`), `_iconUnderlayID` (weenieFlags2 `0x01`), `_effects`/UiEffects
|
||||
(weenieFlags `0x80`). D.5.1 already captures the first three; `_effects` is discarded.
|
||||
- **The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer.** Clean decompile
|
||||
of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor` (`0x00441530`):
|
||||
|
||||
```
|
||||
drag surface = Blit base (Blit_Normal) + Blit custom overlay (Blit_4Alpha)
|
||||
+ if effect: ReplaceColor(this=drag, src=WHITE(1,1,1,1), dest=<effect color>)
|
||||
slot icon = Blit type-default underlay (Blit_Normal, opaque)
|
||||
+ Blit custom underlay (Blit_3Alpha)
|
||||
+ Blit drag surface (Blit_3Alpha)
|
||||
```
|
||||
`ReplaceColor` replaces pixels exactly equal to `0xFFFFFFFF` with the dest color. The
|
||||
effect tiles (`enum 0x10000005`) are 32×32 **fully-opaque** colored squares — they cannot
|
||||
be blitted on top (would erase the icon); they source the recolor.
|
||||
- **Effect index** = `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj
|
||||
is null → fallback index `0x21`. (No `lsb==-1 → 0x21` pre-check on the effect path, unlike
|
||||
the type-underlay path.)
|
||||
- **Dirty-check** (`UpdateIcons`): re-render on change of `iconID / overlayID / underlayID /
|
||||
itemType / _effects`. acdream's per-tuple icon cache keyed on exactly these IS the
|
||||
re-composition contract.
|
||||
|
||||
### Golden effect-submap values (live dat — MasterMap `0x25000000` → submap `0x25000009`)
|
||||
|
||||
| UiEffects | bit | index | effect DID | tile mean RGB |
|
||||
|---|---|---|---|---|
|
||||
| Magical | 0x0001 | 1 | `0x060011CA` | blue |
|
||||
| Poisoned | 0x0002 | 2 | `0x060011C6` | green |
|
||||
| BoostHealth | 0x0004 | 3 | `0x06001B05` | red |
|
||||
| BoostStamina | 0x0010 | 5 | `0x06001B06` | yellow |
|
||||
| Nether | 0x1000 | 13 (absent) | → fallback `0x060011C5` | black |
|
||||
| (none, `_effects==0`) | — | 0 (zero) | → fallback `0x060011C5` | black |
|
||||
|
||||
Full table + the type-underlay (`0x10000004`) cross-check are in the RESOLVED doc.
|
||||
|
||||
## 4. Architecture & data flow
|
||||
|
||||
```
|
||||
CreateObject (0xF745) ──UiEffects(0x80)──┐
|
||||
├──► ItemInstance.Effects ──► ItemRepository.ItemPropertiesUpdated
|
||||
PublicUpdatePropertyInt(0x02CE) ──────────┤ │
|
||||
prop==UiEffects(18), guid==item │ ▼
|
||||
└──────────► UiItemSlot re-calls IconComposer.GetIcon(…, effects)
|
||||
(new cache key ⇒ fresh composite)
|
||||
```
|
||||
|
||||
The re-composition contract (`ItemPropertiesUpdated` → widget re-resolve via the
|
||||
toolbar's `Populate`) already exists; D.5.2 feeds it the effect state from two sources.
|
||||
The live `0x02CE` event is bound in `GameWindow`'s session-event binding (next to the
|
||||
existing `VitalUpdated` subscription) — NOT `GameEventWiring`, which only handles the
|
||||
`0xF7B0` GameEvent sub-opcode dispatcher.
|
||||
|
||||
## 5. Components
|
||||
|
||||
Each component below states **what it does / how it's used / what it depends on.**
|
||||
|
||||
### 5.1 `ItemInstance.Effects` (`AcDream.Core/Items/ItemInstance.cs`)
|
||||
- **What:** a `uint Effects` field — the live UiEffects bitfield (0 = no effect).
|
||||
- **Use:** read by the icon-id resolver; written by `EnrichItem` (CreateObject) and
|
||||
`UpdateIntProperty` (live update).
|
||||
- **Depends on:** nothing (pure data).
|
||||
|
||||
### 5.2 `CreateObject.Parsed.UiEffects` (`AcDream.Core.Net/Messages/CreateObject.cs`)
|
||||
- **What:** capture the `UiEffects` u32 (weenieFlags `0x80`) currently read-and-discarded;
|
||||
add `uint UiEffects = 0` to the `Parsed` record.
|
||||
- **Use:** threaded into `EntitySpawn`.
|
||||
- **Depends on:** the existing weenie-tail walk (no order change — UiEffects already sits at
|
||||
its correct position in the walk).
|
||||
|
||||
### 5.3 `WorldSession.EntitySpawn.UiEffects` + the `0x02CE` route (`AcDream.Core.Net/WorldSession.cs`)
|
||||
- **What:** add `uint UiEffects = 0` to `EntitySpawn`, thread `parsed.Value.UiEffects`; add a
|
||||
message-loop branch for `PublicUpdatePropertyInt.Opcode (0x02CE)` that parses the body and
|
||||
fires a new `ObjectIntPropertyUpdated(guid, property, value)` event.
|
||||
- **Use:** `GameWindow` consumes `EntitySpawn`; `GameEventWiring` consumes the new event.
|
||||
- **Depends on:** `CreateObject.Parsed.UiEffects`, `PublicUpdatePropertyInt` parser.
|
||||
|
||||
### 5.4 `PublicUpdatePropertyInt` parser (`AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`, NEW)
|
||||
- **What:** a static parser mirroring `PrivateUpdateVital.cs`. Wire layout (ACE
|
||||
`GameMessagePublicUpdatePropertyInt`, size hint 17):
|
||||
```
|
||||
u32 opcode = 0x02CE
|
||||
u8 sequence (single byte, per the PrivateUpdateVital note)
|
||||
u32 guid
|
||||
u32 property (PropertyInt enum; UiEffects = 18)
|
||||
i32 value
|
||||
```
|
||||
`TryParse(body) -> (uint Guid, uint Property, int Value)?` — null on opcode mismatch /
|
||||
truncation. (Sequence parsed-past, not honored — latest-wins; see divergence DR-4.)
|
||||
- **Use:** called from the `WorldSession` `0x02CE` branch.
|
||||
- **Depends on:** nothing.
|
||||
|
||||
### 5.5 `ItemRepository` (`AcDream.Core/Items/ItemRepository.cs`)
|
||||
- **What:**
|
||||
- `EnrichItem(..., uint effects = 0)` — assign `item.Effects = effects` (unconditional; 0
|
||||
is a meaningful "no effect" state).
|
||||
- `UpdateIntProperty(uint itemId, uint propertyId, int value)` — NEW extensible hook:
|
||||
stores into `Properties.Ints[propertyId]`, and for known typed ints maps to the typed
|
||||
field (`propertyId == 18 (UiEffects) → item.Effects = (uint)value`), then fires
|
||||
`ItemPropertiesUpdated`. Returns false if the item is unknown.
|
||||
- **Use:** `EnrichItem` from `GameWindow.OnLiveEntitySpawned`; `UpdateIntProperty` from
|
||||
`GameEventWiring` on `ObjectIntPropertyUpdated`.
|
||||
- **Depends on:** `ItemInstance.Effects`.
|
||||
|
||||
### 5.6 `IconComposer` (`AcDream.App/UI/IconComposer.cs`) — the compositor
|
||||
- **What:** `GetIcon(ItemType, iconId, underlayId, overlayId, effects)` — 5-arg, cache key
|
||||
widened to include `effects`. Implements the faithful 2-stage composite (§3):
|
||||
- **Stage 1 (drag):** `Compose([base, customOverlay])`; if `effects != 0` and the effect
|
||||
color resolves, `ReplaceColor(white → effectColor)` on the drag buffer.
|
||||
- **Stage 2 (slot):** `Compose([typeUnderlay, customUnderlay, drag])`.
|
||||
- `ResolveEffectDid(effects)` mirrors `ResolveUnderlayDid` but via `enum 0x10000005`
|
||||
(`EnsureEffectSubMap`), index `LowestSetBit(effects)+1`, fallback `0x21`.
|
||||
- `TryGetEffectColor(effects)` decodes the effect tile and returns its **mean-opaque**
|
||||
color (the faithful representative; the exact retail byte is a decompiler-ambiguous
|
||||
`SurfaceWindow`-header read — see DR-2).
|
||||
- `ReplaceColorWhite(rgba, w, h, dest)` — retail `ReplaceColor` (`0x00441530`): replace
|
||||
pixels `== (255,255,255,255)` with `dest`.
|
||||
- **Effect recolor applies only when `effects != 0`** (DR-3: retail nominally runs the
|
||||
`effects==0` black-fallback recolor; we skip it — likely a no-op but a regression risk).
|
||||
- **Use:** called by the toolbar's `iconIds` delegate (and future item panels).
|
||||
- **Depends on:** `DatCollection`, `TextureCache`, `SurfaceDecoder`, `EnumIDMap`.
|
||||
- **Note:** the 2-stage form is associative-equivalent to D.5.1's single Compose for the
|
||||
non-effect case (Porter-Duff "over" is associative), so shipped D.5.1 visuals are
|
||||
unchanged when `effects == 0`.
|
||||
|
||||
### 5.7 Delegate widening (`ToolbarController.cs` + `GameWindow.cs`)
|
||||
- **What:** the `iconIds` delegate becomes `Func<ItemType, uint, uint, uint, uint, uint>`
|
||||
(+effects); `ToolbarController.Populate` passes `item.Effects`; `GameWindow`'s closure +
|
||||
`OnLiveEntitySpawned` pass `spawn.UiEffects`.
|
||||
- **Depends on:** §5.1, §5.6.
|
||||
|
||||
### 5.8 `GameWindow` session-event binding (`AcDream.App/Rendering/GameWindow.cs`)
|
||||
- **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated` (alongside the existing
|
||||
`VitalUpdated` subscription, ~line 2630); route `Property == 18 (UiEffects)` to
|
||||
`Items.UpdateIntProperty(guid, 18, value)`. (Top-level session events bind here, not in
|
||||
`GameEventWiring` — that class only handles the `0xF7B0` GameEvent dispatcher.)
|
||||
- **Depends on:** §5.3, §5.5.
|
||||
|
||||
## 6. Divergence-register changes
|
||||
|
||||
- **Retire `IA-16`** (item-icon composite PARTIAL) — the composite is now complete.
|
||||
- **Add DR-1** — effect overlay is a `ReplaceColor` recolor SOURCE, not a blit layer (this
|
||||
IS the faithful retail behavior; row documents the model so future readers don't "fix" it
|
||||
back to a blit). Anchor: `RenderIcons` `0x0058d180`, `ReplaceColor` `0x00441530`.
|
||||
- **Add DR-2** — the effect tint color uses the effect tile's mean-opaque color; the exact
|
||||
retail color byte (`effectTile + 0xac` reinterpreted as `RGBAColor`) is decompiler-
|
||||
ambiguous. Approximation; visual/cdb confirmation pending.
|
||||
- **Add DR-3** — we skip the `_effects==0` black-fallback recolor that retail nominally runs.
|
||||
- **Add DR-4** — `PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins).
|
||||
|
||||
## 7. Tests (conformance + acceptance)
|
||||
|
||||
- **Resolve (dat-gated golden):** `ResolveEffectDid` → Magical `0x060011CA`, Poisoned
|
||||
`0x060011C6`, BoostHealth `0x06001B05`, None & Nether → fallback `0x060011C5`.
|
||||
- **Recolor (dat-free):** `ReplaceColorWhite` turns `0xFFFFFFFF` pixels into the dest color
|
||||
and leaves non-white pixels untouched; a 2-layer compose + recolor yields the expected
|
||||
pixels.
|
||||
- **Parse:** `CreateObject.TryParse` captures `UiEffects` from a synthetic body with the
|
||||
`0x80` flag; `PublicUpdatePropertyInt.TryParse` returns `(guid, prop, value)` from golden
|
||||
bytes and rejects a wrong opcode / truncation.
|
||||
- **Repository:** `EnrichItem(effects:…)` sets `Effects`; `UpdateIntProperty(guid, 18, v)`
|
||||
sets `Effects` and fires `ItemPropertiesUpdated`; returns false for an unknown guid.
|
||||
- **Acceptance (visual):** build + `dotnet test` green, then the user confirms in the live
|
||||
client — a magical item shows the effect tint, and an item draining mana updates live.
|
||||
|
||||
## 8. Acceptance criteria checklist
|
||||
|
||||
- [ ] `UiEffects` captured on `CreateObject`, threaded to `ItemInstance.Effects`.
|
||||
- [ ] `IconComposer.GetIcon` 5-arg with the faithful 2-stage composite + effect recolor.
|
||||
- [ ] `ResolveEffectDid` golden test passes against the live dat.
|
||||
- [ ] `PublicUpdatePropertyInt(0x02CE)` parsed; `UiEffects` updates re-composite live.
|
||||
- [ ] Appraise path left as-is (no speculative icon enrichment added).
|
||||
- [ ] Register: `IA-16` retired; `DR-1..DR-4` added (same commits as the code they describe).
|
||||
- [ ] `dotnet build` + `dotnet test` green; roadmap + memory digest updated.
|
||||
- [ ] Visual verification by the user.
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
# A7 Fix D — warm torch over-brightness on indoor walls (#140)
|
||||
|
||||
**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting
|
||||
**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan.
|
||||
**Investigation source of truth:**
|
||||
[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md)
|
||||
(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
## Problem
|
||||
|
||||
The Holtburg meeting-hall walls (and outdoor objects near torches) blow out
|
||||
**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this.
|
||||
|
||||
The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE,
|
||||
yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle**
|
||||
for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous):
|
||||
|
||||
- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) →
|
||||
`SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its
|
||||
SOLE caller). Wall torches are STATIC objects → baked into vertex colours.
|
||||
- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light` → `config_hardware_light`
|
||||
(0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic
|
||||
subset for a cell. The previously-captured `intensity=100` light is on THIS path.
|
||||
|
||||
`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the
|
||||
decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own
|
||||
sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to
|
||||
`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights;
|
||||
a hall has a handful, each warm-capped.
|
||||
|
||||
### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0)
|
||||
|
||||
Holtburg: **38 static + 2 dynamic** lights.
|
||||
|
||||
| Light | path | type | intensity | falloff | colour (r,g,b) |
|
||||
|---|---|---|---|---|---|
|
||||
| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white |
|
||||
| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch |
|
||||
| 38× wall torch | static / **bake** | point | 100 | 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.
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
# D.5.3a — Selected-object meter (Stream A) — design
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**Phase:** D.5.3a (the action bar's bottom strip). Roadmap: D.2b retail-UI track, issue #140.
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Handoff parent:** `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §2.
|
||||
|
||||
## Goal
|
||||
|
||||
When the player selects a world object (LMB pick → `PickAndStoreSelection`, or Tab/Q combat-target
|
||||
→ `SelectClosestCombatTarget`), the action bar's bottom strip shows:
|
||||
|
||||
- the selected object's **name** (always, for any selection), and
|
||||
- a live **Health** meter — only for targets that are a player, a pet, or attackable
|
||||
(retail's `IsPlayer() || pet_owner || ObjectIsAttackable()` gate).
|
||||
|
||||
On deselect (or despawn of the selected object) the strip clears.
|
||||
|
||||
**Out of scope (deferred):** the **Mana** meter (`0x100001A2`, issue #140 — owned-item-only),
|
||||
the stack-size entry box + slider (`0x100001A3`/`0x100001A4`), and the formatted stack-count name
|
||||
suffix. Mana is a tracked feature gap, not a runtime deviation.
|
||||
|
||||
## Retail oracle
|
||||
|
||||
`gmToolbarUI::HandleSelectionChanged` — `docs/research/named-retail/acclient_2013_pseudo_c.txt:198635`.
|
||||
Verbatim behavior (the spec follows this exactly):
|
||||
|
||||
1. **Clear-then-populate.** On any selection change (`m_iidSelectedObject != selectedID`):
|
||||
- `UIElement_Text::SetText(m_pSelObjectName, "")` — clear the name.
|
||||
- `m_pSelObjectField->SetState(0)` — reset the overlay field (`0x100001A0`) to its blank
|
||||
DirectState.
|
||||
- If the health meter was visible: `Event_QueryHealth(0)` (cancel) + `SetVisible(0)`.
|
||||
- If the mana meter was visible: `Event_QueryItemMana(0)` + `SetVisible(0)`.
|
||||
- Hide stack entry box + slider.
|
||||
2. **Selection == 0** → set the use-object button to disabled state and return (strip stays cleared).
|
||||
3. **Selection != 0** (weenie object resolved):
|
||||
- Name = `ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)`. `_stackSize <= 1` → plain name;
|
||||
`_stackSize > 1` → formatted with count (**deferred**).
|
||||
- For a non-stack (`_stackSize == 0 || _stackSize <= 1`):
|
||||
- `eax_29 = IsPlayer()`; if not player and no `pet_owner`, `eax_32 = ObjectIsAttackable(selectedID)`.
|
||||
- **If `IsPlayer || pet_owner != 0 || attackable`:** `m_pSelObjectField->SetState(0x1000000b)`
|
||||
(the "ObjectSelected" state) **and** `CM_Combat::Event_QueryHealth(m_iidSelectedObject)`.
|
||||
(Health meter becomes visible via the subsequent `RecvNotice_UpdateObjectHealth` path,
|
||||
which `SetVisible(1)`s it and sets the fill — see handoff §2.)
|
||||
- **Else:** `m_pSelObjectField->SetState(0x1000000b)`; if `IsOwnedByPlayer`,
|
||||
`CM_Item::Event_QueryItemMana(selectedID)` (**mana deferred**).
|
||||
|
||||
Supporting anchors: `RecvNotice_UpdateObjectHealth` (`:196213`) → `SetAttribute_Float(meter, 0x69, pct)`
|
||||
(property `0x69` = fill ratio); `UIElement_Meter::Initialize` (`:123328`), `OnSetAttribute` (`:123712`).
|
||||
|
||||
State/sprite ids (from `.layout-dumps/toolbar-0x21000016.txt`): the overlay field `0x100001A0`
|
||||
carries states **ObjectSelected** (id `0x1000000b`, sprite `0x06001937`) and **StackedItemSelected**
|
||||
(sprite `0x06004CF4`); health meter `0x100001A1` back-track DirectState `0x0600193E`, fill child
|
||||
`0x00000002` DirectState `0x0600193F`; mana meter `0x100001A2` back `0x060022D5` / fill `0x060022D6`.
|
||||
|
||||
## Current-code facts (verified at HEAD)
|
||||
|
||||
- **Selection state** is a private field `_selectedGuid` (`GameWindow.cs` ~`:848`), assigned at 3 sites:
|
||||
`PickAndStoreSelection` (~`:11571`), `SelectClosestCombatTarget` (~`:11961`), and the despawn-clear
|
||||
(`if (_selectedGuid == serverGuid) _selectedGuid = null;` ~`:3710`). No change event exists.
|
||||
`TargetIndicatorPanel` polls it via `selectedGuidProvider: () => _selectedGuid`.
|
||||
- **`CombatState`** (`AcDream.Core.Combat`) has `GetHealthPercent(guid)` (returns `1f` if unseen) and
|
||||
`HealthChanged`. `UpdateHealth (0x01C0)` → `OnUpdateHealth` is already wired (`GameEventWiring`).
|
||||
- **`SocialActions.BuildQueryHealth(uint seq, uint targetGuid)`** exists (opcode `0x01BF`, replies
|
||||
`UpdateHealth 0x01C0`). No `WorldSession.SendQueryHealth` wrapper yet.
|
||||
- **`IsLiveCreatureTarget(uint guid)`** (`GameWindow.cs` ~`:11979`): not-self + in-world +
|
||||
`ItemType.Creature` flag. Used to gate Tab/Q targeting and `UseItemByGuid`.
|
||||
- **`VitalsController.Bind`** is the proven bind pattern: find meter by id, set `m.Fill = () => pct()`
|
||||
(polled each draw), attach a centered `UiText` child (dat font, `ClickThrough`) for text.
|
||||
- **`UiMeter.DrawHBar`** already renders a *single full-width sprite* correctly: with `tile`/`right`
|
||||
ids = 0, the left-cap spans the whole bar and the fill UV-crops to the fraction. **No `UiMeter`
|
||||
change is needed** for the single-image toolbar meters.
|
||||
- **`DatWidgetFactory.BuildMeter`** assumes **2** Type-3 slice containers (vitals 3-slice). The toolbar
|
||||
selected-object meters have **1** Type-3 child (the fill, on its own DirectState) with the back-track
|
||||
on the *meter element's own* DirectState → the `containers.Count != 2` branch mishandles them.
|
||||
- **`UiDatElement.ActiveState`** (string) drives `ActiveMedia()`; `""` = blank DirectState. This is the
|
||||
overlay-state switch for `0x100001A0`.
|
||||
- **`ClientObject`** exposes `Name` and `StackSize`. `ClientObjectTable.Get(guid)` returns the object
|
||||
(or null). `ToolbarController` already binds with `Objects` (the `ClientObjectTable`).
|
||||
- **`ToolbarController.HiddenIds`** currently hides `0x100001A1` (health), `0x100001A2` (mana),
|
||||
`0x100001A4` (stack slider) at bind.
|
||||
|
||||
## Decisions (settled in brainstorm)
|
||||
|
||||
- **Selection signal: event via property setter.** Convert `_selectedGuid` → a `SelectedGuid` property
|
||||
whose setter fires `event Action<uint?>? SelectionChanged` only when the value actually changes.
|
||||
Replace the 3 assignment sites with the property; reads unchanged. (Retail-faithful — selection is
|
||||
event-driven; the setter centralizes the fire and auto-dedups.)
|
||||
- **Send `QueryHealth (0x01BF)` on select** for health-bearing targets (retail-faithful; builder
|
||||
exists). Continuous updates still come from server `UpdateHealth` broadcasts.
|
||||
- **Mana deferred** (issue #140).
|
||||
|
||||
## Architecture
|
||||
|
||||
Three new units + one refactor + one wiring change. Each unit is independently testable.
|
||||
|
||||
### 1. `GameWindow.SelectedGuid` property + `SelectionChanged` event (refactor)
|
||||
|
||||
```csharp
|
||||
public event Action<uint?>? SelectionChanged;
|
||||
private uint? _selectedGuid;
|
||||
private uint? SelectedGuid
|
||||
{
|
||||
get => _selectedGuid;
|
||||
set
|
||||
{
|
||||
if (_selectedGuid == value) return; // dedup: fire only on real change
|
||||
_selectedGuid = value;
|
||||
SelectionChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace the 3 *write* sites (`_selectedGuid = …`) with `SelectedGuid = …`. Leave all *read* sites
|
||||
(`_selectedGuid is uint`, `() => _selectedGuid`, the despawn comparison's read half) on the field —
|
||||
they observe the same backing store. The despawn-clear becomes
|
||||
`if (_selectedGuid == serverGuid) SelectedGuid = null;`.
|
||||
|
||||
### 2. `DatWidgetFactory.BuildMeter` — handle the single-image meter shape
|
||||
|
||||
After ordering the Type-3 child containers by `ReadOrder`:
|
||||
|
||||
- **`containers.Count >= 2`** (vitals): unchanged — `SliceIds(containers[0])` → Back\*,
|
||||
`SliceIds(containers[1])` → Front\*.
|
||||
- **`containers.Count == 1`** (toolbar selected-object meter): single-image back+fill.
|
||||
- `m.BackLeft = info.StateMedia[""].File` (the meter element's own DirectState back-track),
|
||||
`BackTile = BackRight = 0`.
|
||||
- `m.FrontLeft = containers[0].StateMedia[""].File` (the fill child's own DirectState),
|
||||
`FrontTile = FrontRight = 0`.
|
||||
- The fill child has **no** image grandchildren, so `SliceIds` must **not** be used for it; read the
|
||||
container's own `StateMedia[""]` directly.
|
||||
- **`containers.Count == 0`**: leave the warning (genuinely malformed).
|
||||
|
||||
Keep a `Console.WriteLine` only for the genuinely-unexpected `Count == 0` (or `> 2`) case; the
|
||||
`Count == 1` case is now a handled shape, not a warning.
|
||||
|
||||
`UiMeter` is unchanged — `DrawHBar(BackLeft=fullSprite,0,0,clipW=Width)` draws the back once,
|
||||
`DrawHBar(FrontLeft=fullSprite,0,0,clipW=Width*p)` UV-crops the fill to the health fraction.
|
||||
|
||||
### 3. `SelectedObjectController` (new — `src/AcDream.App/UI/Layout/SelectedObjectController.cs`)
|
||||
|
||||
The `HandleSelectionChanged` analogue. A sealed class (like `ToolbarController`) bound once.
|
||||
|
||||
**Element ids** (constants): name `0x1000019F`, overlay field `0x100001A0`, health meter `0x100001A1`.
|
||||
(`0x100001A2` mana / `0x100001A3`/`0x100001A4` stack are not touched here — deferred.)
|
||||
|
||||
**`Bind` signature:**
|
||||
|
||||
```csharp
|
||||
public static SelectedObjectController Bind(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged, // hands the controller its handler to register
|
||||
Func<uint, bool> isHealthTarget, // IsLiveCreatureTarget proxy
|
||||
Func<uint, string?> name, // ClientObjectTable.Get(g)?.Name
|
||||
Func<uint, float> healthPercent, // CombatState.GetHealthPercent
|
||||
Func<uint, uint> stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state)
|
||||
Action<uint> sendQueryHealth, // WorldSession.SendQueryHealth (no-op if offline)
|
||||
UiDatFont? datFont)
|
||||
```
|
||||
|
||||
`subscribeSelectionChanged` is invoked once with the controller's `OnSelectionChanged` handler so the
|
||||
host can do `c => SelectionChanged += c` without the controller referencing `GameWindow`. (Keeps the
|
||||
Core-clean delegate-seam style of `TargetIndicatorPanel`.)
|
||||
|
||||
**Bind-time setup:**
|
||||
- Find the three elements (silently skip any that are absent — partial/test layouts).
|
||||
- `_healthMeter.Visible = false` (this controller now **owns** the meter's initial-hidden state).
|
||||
- Attach a centered `UiText` child to the name element (mirror `VitalsController.BindMeter`'s number
|
||||
attach): `Centered`, `DatFont = datFont`, `ClickThrough`, `AcceptsFocus=false`, `IsEditControl=false`,
|
||||
`CapturesPointerDrag=false`, anchored to fill the parent, `LinesProvider = () =>` the current name as
|
||||
a single white line (empty → no lines). Color: white for D.5.3a (`new Vector4(1,1,1,1)`).
|
||||
- `_healthMeter.Fill = () => _current is uint g ? healthPercent(g) : 0f` (polled each draw).
|
||||
- Register the handler via `subscribeSelectionChanged(OnSelectionChanged)`.
|
||||
|
||||
**`OnSelectionChanged(uint? guid)`** (mirrors the decomp's clear-then-populate):
|
||||
- **Clear first:** `_healthMeter.Visible = false`; overlay `ActiveState = ""`; `_currentName = null`.
|
||||
- Set `_current = guid`.
|
||||
- If `guid is null` → done (strip cleared).
|
||||
- Else:
|
||||
- `_currentName = name(guid)` (the name `UiText` reads this).
|
||||
- overlay `ActiveState = stackSize(guid) > 1 ? "StackedItemSelected" : "ObjectSelected"`.
|
||||
- If `isHealthTarget(guid)`: `_healthMeter.Visible = true`; `sendQueryHealth(guid)`.
|
||||
- (else: name + overlay only — friendly NPC / non-owned item / scenery.)
|
||||
|
||||
State held: `_current` (uint?), `_currentName` (string?). The meter `Fill` + name `LinesProvider`
|
||||
read these closures, so the per-frame draw reflects live data without a tick.
|
||||
|
||||
> **Note on the meter-visible timing.** Retail makes the health meter visible from
|
||||
> `RecvNotice_UpdateObjectHealth` (when the queried value arrives), not from
|
||||
> `HandleSelectionChanged` itself. acdream shows it immediately on select for a health target (the
|
||||
> fill polls `GetHealthPercent`, which is `1.0` until the `QueryHealth` reply lands a beat later).
|
||||
> This avoids a one-round-trip blank-then-pop and is visually indistinguishable for a full-HP target;
|
||||
> for a damaged target the bar corrects within one server round-trip. Recorded as a divergence row.
|
||||
|
||||
### 4. `WorldSession.SendQueryHealth(uint targetGuid)` (new)
|
||||
|
||||
```csharp
|
||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||
public void SendQueryHealth(uint targetGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
|
||||
SendGameAction(body);
|
||||
}
|
||||
```
|
||||
|
||||
(Pattern = `SendChangeCombatMode`, `WorldSession.cs:1134`.)
|
||||
|
||||
### 5. GameWindow wiring (minimal)
|
||||
|
||||
After `ToolbarController.Bind` (the toolbar layout is in scope as `toolbarLayout`, dat font as
|
||||
`vitalsDatFont`):
|
||||
|
||||
```csharp
|
||||
AcDream.App.UI.Layout.SelectedObjectController.Bind(
|
||||
toolbarLayout,
|
||||
subscribeSelectionChanged: h => SelectionChanged += h,
|
||||
isHealthTarget: IsLiveCreatureTarget,
|
||||
name: g => Objects.Get(g)?.Name,
|
||||
healthPercent: g => Combat.GetHealthPercent(g),
|
||||
stackSize: g => Objects.Get(g)?.StackSize ?? 0u,
|
||||
sendQueryHealth: g => _liveSession?.SendQueryHealth(g),
|
||||
datFont: vitalsDatFont);
|
||||
```
|
||||
|
||||
Also: remove **only** `0x100001A1` from `ToolbarController.HiddenIds` — the health meter is now owned
|
||||
by `SelectedObjectController` (it hides A1 at bind, shows on a health-target select). `0x100001A2`
|
||||
(mana, deferred #140) and `0x100001A4` (stack slider, deferred) **stay** in `HiddenIds`: they have no
|
||||
controller yet, so they must stay hidden or their dat back-track sprites render as stray empty bars.
|
||||
(`HiddenIds = { 0x100001A2, 0x100001A4 }`.) Convert the `_selectedGuid` field → the `SelectedGuid`
|
||||
property (unit 1).
|
||||
|
||||
## Data flow
|
||||
|
||||
select → `SelectedGuid` setter → `SelectionChanged(guid)` → `SelectedObjectController.OnSelectionChanged`
|
||||
→ name + overlay set, meter shown (health target), `SendQueryHealth(guid)` → server `UpdateHealth 0x01C0`
|
||||
→ `GameEventWiring` → `CombatState.OnUpdateHealth` → cache → meter `Fill` poll reads
|
||||
`GetHealthPercent` → bar fills. Deselect / despawn → `SelectionChanged(null)` → strip cleared.
|
||||
|
||||
## Error handling / edge cases
|
||||
|
||||
- **Unknown guid** → `GetHealthPercent` returns `1.0` (full) until the `QueryHealth` reply arrives.
|
||||
- **Selected entity despawns** → existing despawn-clear sets `SelectedGuid = null` → `SelectionChanged(null)`.
|
||||
- **Partial / test layout** (missing elements) → controller silently skips absent elements
|
||||
(`VitalsController` pattern).
|
||||
- **No live session** → `_liveSession?.SendQueryHealth` no-ops.
|
||||
- **Re-select the same guid** → property setter dedups; no redundant query / re-show.
|
||||
|
||||
## Testing (conformance)
|
||||
|
||||
All App-layer tests in `tests/AcDream.App.Tests/`; net test in `tests/AcDream.Core.Net.Tests/`.
|
||||
|
||||
1. **`DatWidgetFactoryTests`** (extend): feed a synthetic 1-container meter `ElementInfo` (back on the
|
||||
element's `StateMedia[""]`, fill on the single Type-3 child's `StateMedia[""]`) → assert
|
||||
`BackLeft == backFile`, `FrontLeft == fillFile`, `BackTile/BackRight/FrontTile/FrontRight == 0`,
|
||||
and no warning path taken. Add/keep a 2-container case asserting the vitals 3-slice path is
|
||||
unchanged.
|
||||
2. **`SelectedObjectControllerTests`** (new — mirror `ToolbarControllerTests`): build a minimal
|
||||
`ImportedLayout` containing `0x1000019F`/`0x100001A0` (as `UiDatElement`)/`0x100001A1` (as
|
||||
`UiMeter`). Use recording delegates. Assert:
|
||||
- bind → health meter `Visible == false`, a name `UiText` child attached.
|
||||
- select health target → meter `Visible == true`, overlay `ActiveState == "ObjectSelected"`, name
|
||||
provider returns the object name, `sendQueryHealth` invoked exactly once with the guid.
|
||||
- select stack (`stackSize > 1`) → overlay `ActiveState == "StackedItemSelected"`.
|
||||
- select non-health target → meter stays hidden, name set, `sendQueryHealth` **not** invoked.
|
||||
- deselect (`null`) → meter hidden, overlay `ActiveState == ""`, name provider returns empty.
|
||||
- re-fire same guid path is driven by the event, so the dedup is the property's job (covered in 3).
|
||||
3. **`SendQueryHealth`** (net test): drive `WorldSession.SendQueryHealth(guid)` through the existing
|
||||
send-capture seam (the same harness `SendChangeCombatMode` / chat sends use) and assert the captured
|
||||
GameAction bytes equal `SocialActions.BuildQueryHealth(seq, guid)`.
|
||||
4. **`SelectedGuid` dedup**: the property is on `GameWindow` (not unit-testable in isolation). Its
|
||||
contract — "fires once on change, never on same value, fires `null` on clear" — is asserted
|
||||
indirectly by test 2's reliance on single-fire and confirmed at the visual gate. No standalone test.
|
||||
|
||||
## Divergence register rows (add in the implementation commit)
|
||||
|
||||
- **Health-meter gate approximation.** Retail shows the health meter for
|
||||
`IsPlayer() || pet_owner || ObjectIsAttackable()`; acdream uses `IsLiveCreatureTarget`
|
||||
(the `ItemType.Creature` flag). Risk: a friendly (non-attackable) NPC shows a health meter where
|
||||
retail would show name+overlay only. Cite `SelectedObjectController` + `HandleSelectionChanged:198754`.
|
||||
- **Meter-visible timing.** acdream shows the health meter on select; retail shows it from
|
||||
`RecvNotice_UpdateObjectHealth` when the queried value arrives. Risk: a freshly-selected
|
||||
off-screen-damaged target reads full for one server round-trip. Cite
|
||||
`SelectedObjectController.OnSelectionChanged` + `HandleSelectionChanged:198757`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dotnet build` green; `dotnet test` green (new + existing).
|
||||
- Every AC-specific behavior cites its named-retail anchor in comments.
|
||||
- Divergence rows added.
|
||||
- Visual gate (user): selecting a creature shows its name + a correct HP bar; deselecting clears the
|
||||
strip; selecting a non-creature object shows the name only.
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
# D.5.4 — Client object/item data model (foundation) — design
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**Status:** design approved (brainstorm) → spec under review → writing-plans next
|
||||
**Phase:** D.5.4 — the data-model foundation under D.5 "Core panels" (D.2b retail-look track).
|
||||
Registered in the roadmap D.5 sub-phase ledger; blocks D.5.5+ (inventory / paperdoll /
|
||||
vendor / trade panels resolve items from this table).
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (D.5.1 + D.5.2 already landed here; this continues it).
|
||||
**User constraint:** *"architecturally solid, no quick fixes"* — do NOT band-aid `EnrichItem`
|
||||
to add new items; design the model properly.
|
||||
|
||||
**Research evidence base (this spec cites; it does not re-derive):**
|
||||
- [`docs/research/2026-06-18-item-object-model-handoff.md`](../../research/2026-06-18-item-object-model-handoff.md) — the phase framing + the crux
|
||||
- [`docs/research/deepdives/r06-items-inventory.md`](../../research/deepdives/r06-items-inventory.md) — item/property/container model + `PublicWeenieDesc` wire layout (§4) + burden (§6) + 2-deep containers (§7)
|
||||
- [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — `ClientObjMaintSystem` / resolve-by-guid model
|
||||
- [`docs/research/2026-06-16-inventory-deep-dive.md`](../../research/2026-06-16-inventory-deep-dive.md) — inventory wire catalog + container learning
|
||||
- The named-retail decomp `acclient_2013_pseudo_c.txt` / `acclient.h` (the oracle for the two-table model)
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace acdream's **enrich-existing-only** item scaffold with retail's **canonical-create**
|
||||
object model. Today `ItemRepository.EnrichItem` (`ItemRepository.cs:162`) returns `false`
|
||||
and silently drops a `CreateObject` for any item that wasn't pre-seeded as a stub from
|
||||
`PlayerDescription` — so items acquired mid-session, ground items, vendor items, and pack
|
||||
items the login snapshot didn't enumerate never enter the model and render no icon (confirmed
|
||||
live on Coldeve, character Barris: 4 of 6 hotbar slots blank).
|
||||
|
||||
After this phase: **`CreateObject (0xF745)` is the canonical create-or-update for every
|
||||
server object**, the data table holds the data side of *every* object (items and creatures
|
||||
alike), `PlayerDescription`/shortcuts are references, the container membership index is live,
|
||||
and all UI resolves objects by guid. The Coldeve blank-icon bug is fixed at the root, and the
|
||||
foundation D.5.5's panels sit on is in place.
|
||||
|
||||
This is a **data-model + ingestion** phase. No new panels ship; the toolbar (D.5.1) is the
|
||||
only live consumer and must keep working (visually unchanged).
|
||||
|
||||
## 2. The crux — settled (the three brainstorm decisions)
|
||||
|
||||
The handoff's §2 framing ("retail unifies everything under one `ClientObjMaintSystem`") was a
|
||||
misread. The named decomp shows retail is a **two-table** design, and the brainstorm settled
|
||||
the architecture against that ground truth:
|
||||
|
||||
1. **Two tables, fix ingestion** (not unify). Retail's `CObjectMaint` holds *two* hash tables
|
||||
keyed by the same guid — `object_table` (`CPhysicsObj`, render/physics) and
|
||||
`weenie_object_table` (`ACCWeenieObject`, data/UI) — cross-linked by pointer, created and
|
||||
destroyed together. The UI *only* calls `GetWeenieObject(guid)`; physics *only* calls
|
||||
`GetPhysicsObject(guid)`. acdream's existing `WorldEntity` + item-table split already
|
||||
mirrors this. We keep them separate (joined by guid) and fix the *ingestion*, not the
|
||||
structure. A merge would also violate Code Structure Rule 2 (`WorldEntity` carries
|
||||
`MeshRef`/GfxObj dat handles and rendering-coupled AABB math; merging drags GL into
|
||||
`AcDream.Core`).
|
||||
|
||||
2. **Complete model + container index.** Capture the full item weenie-field set (currently
|
||||
parsed-then-discarded) into the data object, maintain a live container-membership index
|
||||
(`containerGuid → ordered items`), evict on `DeleteObject`, fix the `WeenieClassId` misuse,
|
||||
and expose a formal resolve-by-guid surface. Defer only the panel UIs and panel-driven
|
||||
flows to D.5.5.
|
||||
|
||||
3. **All objects (true weenie table).** `CreateObject` upserts *every* object (creatures,
|
||||
players, NPCs, items) into the data table, making it acdream's `ACCWeenieObject`-equivalent
|
||||
and retiring the redundant `GameWindow._liveEntityInfoByGuid` (Name+ItemType duplicate) so
|
||||
selection/target also resolves from the one table. End state = exactly retail's two tables.
|
||||
|
||||
## 3. Retail anchors (the load-bearing facts)
|
||||
|
||||
All from the named decomp (`acclient_2013_pseudo_c.txt` / `acclient.h`), verified during the
|
||||
D.5.4 code-map research:
|
||||
|
||||
- **Two parallel tables, one manager.** `CObjectMaint` (`acclient.h:33078`) holds
|
||||
`object_table : LongHash<CPhysicsObj>`, `weenie_object_table : LongHash<CWeenieObject>`,
|
||||
matching `null_object_table` / `null_weenie_object_table` placeholders (for out-of-order
|
||||
create), `visible_object_table`, and `object_inventory_table : LongHash<CObjectInventory>`
|
||||
(the per-container contents lists). Both object tables keyed by the same `uint32` guid.
|
||||
- **`CreateObject` is create-OR-update (timestamp-driven upsert), not create-only.**
|
||||
`SmartBox::HandleCreateObject` (decomp ~93740) first calls
|
||||
`CObjectMaint::GetObjectA(guid, &phys, &weenie)` (the 3-arg overload, ~269768) to detect
|
||||
whether *either* table already has the guid. Fresh → `ACCObjectMaint::CreateObject`
|
||||
(~356155) which allocates the `ACCWeenieObject` (`ACCFactory::MakeCWeenieObject_Internal`,
|
||||
0x150 bytes, ~354698), inserts it, fills `pwd` via `SetWeenieDesc`, cross-links, and inserts
|
||||
the `CPhysicsObj`. Existing → the **update branch** patches in place via `SetWeenieDesc`
|
||||
(data) + per-timestamp physics updates. **There is no full-object replace** — updates merge.
|
||||
- **The weenie object holds all item game-data** in `pwd` (`PublicWeenieDesc`, `acclient.h:37163+`):
|
||||
`_iconID/_iconOverlayID/_iconUnderlayID`, `_effects`, `_type`, `_stackSize/_maxStackSize`,
|
||||
`_value`, `_burden`, `_containerID/_wielderID/_location/_priority`,
|
||||
`_itemsCapacity/_containersCapacity`, `_structure/_maxStructure`, `_workmanship`, …
|
||||
- **Every object is an `ACCWeenieObject`** — creatures/players included; the UI resolves a
|
||||
selected creature's name/health from the same table via `GetWeenieObject`.
|
||||
- **`DeleteObject` frees both objects atomically** (`ACCObjectMaint::DeleteObject` ~355020 →
|
||||
`CObjectMaint::DeleteObject(guid)` ~270149) — physics and weenie removed in one call.
|
||||
- **The wire layout** of the `PublicWeenieDesc` flag-gated tail is r06 §4; acdream's
|
||||
`CreateObject.cs:558-806` already walks every field in exact ACE order (it skips the ones it
|
||||
doesn't keep — capturing them is changing `pos += N` to read the value).
|
||||
|
||||
## 4. Scope
|
||||
|
||||
**In scope (D.5.4):**
|
||||
- Rename + broaden: `ItemRepository` → `ClientObjectTable`, `ItemInstance` → `ClientObject`,
|
||||
events `Item*` → `Object*`. The data table holds the data side of **all** server objects.
|
||||
- `CreateObject.TryParse` captures the full item field set (see §6.1) — currently discarded.
|
||||
- **Upsert is a field-level merge** (create-if-absent, else patch wire-carried fields in
|
||||
place, preserving the `PropertyBundle` and move-state). `EnrichItem` is deleted.
|
||||
- Ingestion wiring moves **off `GameWindow`** into `AcDream.Core.Net` (`ObjectTableWiring`):
|
||||
`CreateObject`→upsert, `DeleteObject`→remove, the `0x02CE` UiEffects path→`UpdateIntProperty`.
|
||||
- Container membership index (`containerGuid → ordered item guids`), live on upsert + move +
|
||||
remove, exposed via `GetContents(guid)`.
|
||||
- `WeenieClassId` captured from `CreateObject` (stop misusing `PlayerDescription`'s
|
||||
`ContainerType` as the class id).
|
||||
- `PlayerDescription` becomes a membership manifest (records "this guid is mine / in
|
||||
container / equipped at slot"); out-of-order with `CreateObject` is safe (whichever arrives
|
||||
first creates the entry, the other merges).
|
||||
- Retire `GameWindow._liveEntityInfoByGuid`; migrate its consumers
|
||||
(`IsLiveCreatureTarget`/`DescribeLiveEntity`/target-indicator) to `ClientObjectTable.Get`.
|
||||
- `ToolbarController` resolves via `ClientObjectTable.Get` and **filters its event handler by
|
||||
guid** (only re-binds when a changed guid is one of its 18 shortcuts).
|
||||
- `DeleteObject` (0xF747) evicts from the table.
|
||||
- Conformance tests throughout (§8). Preserve the D.5.2 effects-contract tests.
|
||||
|
||||
**Out of scope (D.5.5+, explicit non-goals):**
|
||||
- The panel UIs themselves (inventory / paperdoll / vendor / trade / spellbook).
|
||||
- `ViewContents (0x0196)` open/close flow + the still-unwired inbound move events
|
||||
(`InventoryPutObjectIn3D 0x019A`, `CloseGroundContainer 0x0052`,
|
||||
`InventoryServerSaveFailed 0x00A0`) and their builders (`DropItem`/`GetAndWieldItem`/
|
||||
`NoLongerViewingContents`).
|
||||
- Drag-drop mutate wire (`AddShortcut`/`RemoveShortcut`, `PutItemInContainer` from UI, etc.).
|
||||
- `ShortCutManager` durable persistence (shortcuts stay in the current closure path).
|
||||
- The broader `PublicUpdateProperty*` family beyond the existing `UiEffects (0x02CE)` path
|
||||
(live StackSize/Value/Structure updates) — captured at create time, but the per-property
|
||||
live-update parsers are D.5.5/M2.
|
||||
- `null_object_table`-style pre-queuing of a child `CreateObject` that arrives before its
|
||||
parent. (Our upsert already makes plain out-of-order PD↔CreateObject safe; the parent/child
|
||||
parenting edge case is deferred — see §10 risks.)
|
||||
|
||||
## 5. Architecture & components
|
||||
|
||||
Two guid-keyed tables, joined by guid, both mutated on the render thread:
|
||||
|
||||
| Table | acdream type | retail analogue | holds | layer |
|
||||
|---|---|---|---|---|
|
||||
| Render/physics | `WorldEntity` (+ `GpuWorldState`) | `object_table` / `CPhysicsObj` | mesh, position, AABB, cell | `AcDream.Core/World` + `AcDream.App` |
|
||||
| **Data/UI** | **`ClientObjectTable`** of **`ClientObject`** | `weenie_object_table` / `ACCWeenieObject` | icon, name, type, stack, value, container/equip, properties | `AcDream.Core/Items` (pure data) |
|
||||
|
||||
**Components (file → responsibility → change):**
|
||||
|
||||
1. **`ClientObject`** (`AcDream.Core/Items/ItemInstance.cs` → renamed file/type from
|
||||
`ItemInstance`). Per-object data record. *Change:* add the §6.1 fields; make `WeenieClassId`
|
||||
settable; keep `PropertyBundle`. Item-specific fields are simply unset for creatures
|
||||
(faithful to retail's `ACCWeenieObject` for non-items).
|
||||
|
||||
2. **`ClientObjectTable`** (`AcDream.Core/Items/ItemRepository.cs` → renamed). The guid-keyed
|
||||
store + container index + event surface. *Change:*
|
||||
- `AddOrUpdate` becomes a **field-level merge upsert** (§7.2), not a whole-object replace.
|
||||
- Add the container index: `Dictionary<uint, List<uint>>` keyed by containerGuid, kept
|
||||
ordered by slot; updated on upsert / `MoveItem` / `Remove`; exposed via
|
||||
`IReadOnlyList<uint> GetContents(uint containerGuid)`.
|
||||
- Events renamed `ObjectAdded/ObjectUpdated/ObjectRemoved/ObjectMoved`.
|
||||
- `EnrichItem` deleted.
|
||||
- Keep `ConcurrentDictionary` (plugin reads) + `GetItem`→`Get` resolve surface.
|
||||
|
||||
3. **`ObjectTableWiring`** (new, `AcDream.Core.Net/ObjectTableWiring.cs`). Static
|
||||
`Wire(WorldSession session, ClientObjectTable table)` subscribing the WorldSession
|
||||
GameMessage-level events: `EntitySpawned`→`AddOrUpdate(merge)`, `EntityDeleted`→`Remove`,
|
||||
`ObjectIntPropertyUpdated`→`UpdateIntProperty`. This is the seam that moves item ingestion
|
||||
off `GameWindow` (Rule 1) while keeping `AcDream.Core` GL-free (Rule 2).
|
||||
|
||||
4. **`CreateObject.cs`** (`AcDream.Core.Net/Messages`). *Change:* capture the §6.1 fields into
|
||||
`Parsed` (extend the record); the wire-cursor walk already exists — replace the `pos += N`
|
||||
skips with value reads. **Risk:** the `Parsed` positional ctor + `WorldSession.EntitySpawn`
|
||||
mirror must both grow; cursor arithmetic must stay byte-identical (locked by tests).
|
||||
|
||||
5. **`WorldSession.EntitySpawn`** (`AcDream.Core.Net/WorldSession.cs:47`). *Change:* add the
|
||||
new fields so they reach the ingestion wiring.
|
||||
|
||||
6. **`GameEventWiring.cs`** (`AcDream.Core.Net`). *Change:* `PlayerDescription` handler stops
|
||||
creating "source of truth" stubs with `WeenieClassId = ContainerType`; instead it records
|
||||
membership (a merge upsert that sets container/equip placement + marks the guid as the
|
||||
player's). `WieldObject`/`InventoryPutObjInContainer` → `MoveItem` stays (already wired).
|
||||
|
||||
7. **`GameWindow.cs`** (`AcDream.App`). *Change:* delete the `EnrichItem` call; construct
|
||||
`ClientObjectTable` + call `ObjectTableWiring.Wire`; retire `_liveEntityInfoByGuid` and
|
||||
point its consumers at `ClientObjectTable.Get`. Render-entity build is unchanged.
|
||||
|
||||
8. **`ToolbarController.cs`** (`AcDream.App/UI/Layout`). *Change:* resolve via
|
||||
`ClientObjectTable.Get`; event handler filters by guid (only re-bind affected shortcut
|
||||
slots); subscribe to `ObjectRemoved` too (today it doesn't, leaving stale slots).
|
||||
|
||||
9. **`IconComposer.cs`** — unchanged (takes fields, not the table).
|
||||
|
||||
## 6. Data model
|
||||
|
||||
### 6.1 `ClientObject` fields to add (capture from `CreateObject`)
|
||||
|
||||
The `ClientObject` type **already declares** most of these fields (they exist on today's
|
||||
`ItemInstance`), but `CreateObject` **does not populate them** — it walks past them on the
|
||||
wire. This table is the wire-capture work: rows marked **new** also need a field added to the
|
||||
type; the rest just need the parser to read the value into the existing field instead of
|
||||
skipping it. The cursor walk already exists in `CreateObject.cs:558-806` (each field has a
|
||||
`pos += N` skip today). Wire bits per r06 §4 / `PublicWeenieDesc`:
|
||||
|
||||
| Field | Wire bit | field state | Notes |
|
||||
|---|---|---|---|
|
||||
| `WeenieClassId` | fixed prefix PackedDword (`CreateObject.cs:538`) | **make settable** | discarded today; init-only on the type |
|
||||
| `Value` | `0x00000008` | exists | `pos += 4` today |
|
||||
| `StackSize` / `StackSizeMax` | `0x00001000` / `0x00002000` | exists | skipped today |
|
||||
| `Burden` | `0x00200000` | exists | skipped today |
|
||||
| `ContainerId` | `0x00004000` | exists | item's parent container guid (drives the index) |
|
||||
| `ValidLocations` | `0x00010000` | exists | EquipMask (paperdoll needs it) |
|
||||
| `CurrentWieldedLocation` | `0x00020000` | exists → `CurrentlyEquippedLocation` | EquipMask |
|
||||
| `ItemsCapacity` / `ContainersCapacity` | `0x00000002` / `0x00000004` | **new** | feed `Container` (u8 each) |
|
||||
| `WielderId` | `0x00008000` | **new** | equip placement |
|
||||
| `Priority` (ClothingPriority) | `0x00040000` | **new** | layer order |
|
||||
| `Structure` / `MaxStructure` | `0x00000400` / `0x00000800` | **new** | charges/uses |
|
||||
| `Workmanship` | `0x01000000` (f32) | **new** | salvage/tinker display |
|
||||
|
||||
`ContainerType` (PD inventory entry, 0/1/2) moves to its own field on the entry/`Container`,
|
||||
no longer aliased onto `WeenieClassId`.
|
||||
|
||||
### 6.2 Container index
|
||||
|
||||
`ClientObjectTable` maintains the equivalent of retail's `object_inventory_table`:
|
||||
`containerGuid → ordered list of item guids` (ordered by `ContainerSlot`). It is derived data,
|
||||
rebuilt from each object's `ContainerId`/`ContainerSlot`:
|
||||
- **on upsert:** if the object has a non-zero `ContainerId`, (re)index it under that parent.
|
||||
- **on `MoveItem`:** remove from old container list, add to new (or to equip if `WielderId`).
|
||||
- **on `Remove`:** drop from its container list.
|
||||
- **expose** `GetContents(containerGuid)` → ordered item guids (inventory panel reads this).
|
||||
|
||||
Equip placement (`WielderId` + `CurrentWieldedLocation`) is tracked the same way so paperdoll
|
||||
can ask "what's equipped in slot X" without scanning.
|
||||
|
||||
## 7. Ingestion lifecycle
|
||||
|
||||
### 7.1 The flow
|
||||
- **`CreateObject (0xF745)`** → `WorldSession` parses (full field set) → fires `EntitySpawned`
|
||||
→ **`ObjectTableWiring`** calls `ClientObjectTable.AddOrUpdate(merge)` for **every** object,
|
||||
independent of whether it also becomes a `WorldEntity` (inventory items have no position).
|
||||
`GameWindow` keeps its own `EntitySpawned` subscription for the render-entity build.
|
||||
- **`DeleteObject (0xF747)` / Pickup** → `EntityDeleted` → `ClientObjectTable.Remove(guid)`
|
||||
(today this leaks until `Clear()`). Render teardown unchanged.
|
||||
- **`PlayerDescription (0x0013)`** → membership manifest: a merge upsert that marks each
|
||||
inventory/equipped guid as the player's and records placement (container/equip slot). The
|
||||
*data* (icon/name/type/…) arrives from `CreateObject`. Shortcuts stay on the existing path.
|
||||
- **`WieldObject 0x0023` / `InventoryPutObjInContainer 0x0022`** → `MoveItem` (already wired) →
|
||||
re-parents in the container index.
|
||||
- **`PublicUpdatePropertyInt 0x02CE` (UiEffects)** → `UpdateIntProperty` (already wired,
|
||||
preserved).
|
||||
|
||||
### 7.2 Upsert = field-level merge (the key correctness rule)
|
||||
`AddOrUpdate` must NOT replace the whole object (today's `_items[id] = item` clobbers appraise
|
||||
`PropertyBundle` + move-state on a `CreateObject` re-send; retail's update branch patches via
|
||||
`SetWeenieDesc`). The merge rule:
|
||||
- **Absent** → insert the new object; fire `ObjectAdded`.
|
||||
- **Present** → patch only the wire-carried fields onto the existing object (Name, Type,
|
||||
Icon*, Effects, Stack, Value, Burden, capacities, `WeenieClassId`, and placement
|
||||
`ContainerId`/`CurrentWieldedLocation`/`WielderId` when the wire carries them); **preserve**
|
||||
the `PropertyBundle` (appraise detail) and any state the wire didn't carry; fire
|
||||
`ObjectUpdated`.
|
||||
- **Effects** keeps the D.5.2 contract: assign unconditionally from the parsed value (0 = "no
|
||||
effect", a meaningful state) so re-composition reflects the current server state.
|
||||
|
||||
### 7.3 Out-of-order safety
|
||||
Because upsert is create-or-merge, the PD↔CreateObject arrival order is irrelevant: whichever
|
||||
arrives first creates the entry; the other merges its fields in. No drops (the root fix for
|
||||
the Coldeve bug), no silent races.
|
||||
|
||||
### 7.4 Threading
|
||||
Unchanged: the net channel drains on the render-thread `OnUpdate`; both tables mutate on the
|
||||
render thread; `ConcurrentDictionary` is retained only for safe plugin reads. Events fire
|
||||
synchronously on the render thread (matching today).
|
||||
|
||||
## 8. Testing (conformance throughout)
|
||||
|
||||
xUnit, hand-built byte fixtures (matching `CreateObjectTests` / `ItemRepositoryTests` style;
|
||||
no pcap, no Moq). New + changed tests:
|
||||
- **Full-field-capture parse:** each new weenie-header field reads correctly; cursor
|
||||
arithmetic stays byte-identical (a packet with a mid-tail field set still reaches
|
||||
IconOverlay/IconUnderlay). Extend `CreateObjectTests`.
|
||||
- **Upsert creates a brand-new object** (no PD stub) — the Coldeve bug; this test would have
|
||||
failed before the fix and locks it.
|
||||
- **Upsert merge** preserves `PropertyBundle` (appraise) + move-state across a `CreateObject`
|
||||
re-send; does not clobber.
|
||||
- **Out-of-order:** CreateObject-before-PD and PD-before-CreateObject converge to identical
|
||||
state.
|
||||
- **Container index:** add/move/remove keeps `GetContents` correct and slot-ordered; 2-deep
|
||||
container depth (r06 §7); equip placement queryable.
|
||||
- **`DeleteObject` eviction** removes from the table + the container index.
|
||||
- **`WeenieClassId`** is the real class id from CreateObject, not the PD ContainerType.
|
||||
- **`_liveEntityInfoByGuid` retirement regression:** selection/describe still resolve
|
||||
name+type for a creature via `ClientObjectTable.Get`.
|
||||
- **Toolbar guid-filter:** an unrelated object's `ObjectAdded` does not re-bind a shortcut
|
||||
slot; a shortcut's `ObjectUpdated` does.
|
||||
- **Preserve** the D.5.2 effects tests (`effects==0` clears; per-pixel tint) under the new
|
||||
merge path.
|
||||
|
||||
## 9. Divergence register
|
||||
|
||||
- **Retire** the enrich-only stopgap rows (the `EnrichItem` drops-unseeded-items behavior is
|
||||
gone). Delete those rows in the same commit that lands the fix.
|
||||
- **Add** a row for the global-event-with-guid-filter consumer model vs. retail's per-object
|
||||
`NoticeRegistrar` observer dispatch (a deliberate simplification — consumers filter by guid
|
||||
rather than registering per-object observers). Note it; don't hide it.
|
||||
- **Add** a row (or note under it) for the deferred `null_object_table`-style parent/child
|
||||
pre-queue (out-of-order *parented* create) — see §10.
|
||||
|
||||
## 10. Risks & open questions
|
||||
|
||||
- **Cursor arithmetic regression** in `CreateObject.cs` is the highest-risk change: turning
|
||||
skips into reads must not shift any offset. Mitigation: the field walk already exists and is
|
||||
test-covered; add per-field value assertions and a "mixed flags reach IconOverlay" test.
|
||||
- **`AddOrUpdate` merge vs. replace** touches existing `AddOrUpdate` callers (PD seeding,
|
||||
appraise `UpdateProperties`). Audit every caller; the merge must be a strict superset of
|
||||
prior behavior for the toolbar path.
|
||||
- **Event volume:** upserting all objects fires `ObjectAdded` per creature spawn. The toolbar
|
||||
guid-filter handles it; future panels must filter too (documented in the table's event
|
||||
XML-doc).
|
||||
- **`_liveEntityInfoByGuid` retirement timing:** the ingestion wiring and `GameWindow`'s render
|
||||
handler both subscribe to `EntitySpawned`; ensure the table is populated before any consumer
|
||||
queries (consumers run on later user interaction, so this is safe, but assert it).
|
||||
- **Parented item ordering** (a child `CreateObject` arriving before its parent) — retail uses
|
||||
`null_object_table` pre-queuing. Deferred; PD↔CreateObject ordering is handled, but document
|
||||
the parent/child gap so D.5.5 picks it up if a panel needs it.
|
||||
- **Naming churn:** the rename touches `GameEventWiring`, `ToolbarController`, tests, and the
|
||||
`IconComposer` call site. Mechanical but wide; do it as a focused rename commit so the diff
|
||||
reads cleanly.
|
||||
|
||||
## 11. Acceptance criteria
|
||||
|
||||
- `dotnet build` + `dotnet test` green (the full suite, including the new conformance tests).
|
||||
- A `CreateObject` for an item with **no** prior PD stub registers it in the table and the
|
||||
toolbar renders its icon (the Coldeve repro, exercised by a unit test; visual confirmation
|
||||
on a live server is the user's gate).
|
||||
- The toolbar still renders correctly for pre-seeded items (no regression).
|
||||
- Selection/target still resolves creature name+type after `_liveEntityInfoByGuid` retirement.
|
||||
- Roadmap D.5 ledger updated (D.5.4 → shipped); divergence register rows added/retired;
|
||||
memory digest updated if there's a durable lesson.
|
||||
|
|
@ -50,11 +50,6 @@
|
|||
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
|
||||
next to the binary so MarkupDocument.Build can load them at runtime. -->
|
||||
<None Include="UI\assets\**\*.xml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ namespace AcDream.App.Plugins;
|
|||
|
||||
public sealed class AppPluginHost : IPluginHost
|
||||
{
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
Events = events;
|
||||
Ui = ui;
|
||||
}
|
||||
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
public IUiRegistry Ui { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
|
||||
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
||||
/// UiHost tree after construction.
|
||||
/// </summary>
|
||||
public sealed class BufferedUiRegistry : IUiRegistry
|
||||
{
|
||||
public readonly record struct Pending(string MarkupPath, object Binding);
|
||||
|
||||
private readonly List<Pending> _pending = new();
|
||||
|
||||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
=> _pending.Add(new Pending(markupPath, binding));
|
||||
|
||||
/// <summary>Return + clear all buffered registrations.</summary>
|
||||
public IReadOnlyList<Pending> Drain()
|
||||
{
|
||||
var copy = _pending.ToArray();
|
||||
_pending.Clear();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,7 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
|||
|
||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
|
||||
|
||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
|
|
@ -57,7 +56,7 @@ try
|
|||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
|
||||
window.Run();
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -46,12 +46,10 @@ layout(std140, binding = 1) uniform SceneLighting {
|
|||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
|
||||
// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
|
||||
// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
|
||||
// Range / crisp boundaries" note was a misread; it is the literal cause of
|
||||
// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
|
||||
// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
|
||||
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
|
||||
// attenuation inside Range; hard edge at Range; spotlights use a
|
||||
// binary cos-cone test. This is deliberate — the retail "bubble of
|
||||
// light" look relies on crisp boundaries.
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
|
|
@ -75,19 +73,14 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
|||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
// calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
|
||||
// carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
|
||||
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
|
||||
float atten = 1.0; // retail: no attenuation inside Range
|
||||
if (kind == 2) {
|
||||
// Spotlight: hard-edged cos-cone test.
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
// Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single
|
||||
// point/spot light can't push a channel past its own colour, regardless of
|
||||
// intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag.
|
||||
lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz);
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
in vec3 vNormal;
|
||||
in vec2 vTexCoord;
|
||||
in vec3 vWorldPos;
|
||||
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||||
in flat uvec2 vTextureHandle;
|
||||
in flat uint vTextureLayer;
|
||||
|
||||
|
|
@ -32,11 +31,36 @@ layout(std140, binding = 1) uniform SceneLighting {
|
|||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
|
||||
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
|
||||
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
|
||||
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
|
||||
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
int kind = int(uLights[i].posAndKind.w);
|
||||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||||
if (kind == 0) {
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += Lcol * ndl;
|
||||
} else {
|
||||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||||
float d = length(toL);
|
||||
float range = uLights[i].dirAndRange.w;
|
||||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0;
|
||||
if (kind == 2) {
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
|
|
@ -82,8 +106,8 @@ void main() {
|
|||
if (color.a < 0.05) discard;
|
||||
}
|
||||
|
||||
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
|
||||
vec3 lit = vLit;
|
||||
vec3 N = normalize(vNormal);
|
||||
vec3 lit = accumulateLights(N, vWorldPos);
|
||||
|
||||
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
|
||||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||||
|
|
|
|||
|
|
@ -69,33 +69,6 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
|
|||
uint instanceClipSlot[];
|
||||
};
|
||||
|
||||
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
|
||||
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
|
||||
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
|
||||
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
|
||||
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
|
||||
// (its torches swapping into the global top-8). Two SSBOs replace that for
|
||||
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
|
||||
//
|
||||
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
|
||||
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
|
||||
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
|
||||
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
|
||||
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
|
||||
// it once per entity (the set is constant across the entity's parts/tuples).
|
||||
struct GlobalLight {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
|
||||
GlobalLight gLights[];
|
||||
};
|
||||
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
|
||||
int instanceLightIdx[]; // 8 per instance; -1 = unused
|
||||
};
|
||||
|
||||
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
|
||||
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
|
||||
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
|
||||
|
|
@ -122,107 +95,10 @@ uniform mat4 uViewProjection;
|
|||
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
|
||||
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
|
||||
uniform int uDrawIDOffset;
|
||||
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
|
||||
|
||||
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
|
||||
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
|
||||
// above). IDENTICAL std140 layout to mesh_modern.frag.
|
||||
//
|
||||
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
|
||||
// torch/point lights Gouraud-interpolate across each triangle the way retail's
|
||||
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
|
||||
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
|
||||
// per-vertex spreads it into a soft, broad gradient with no hard edge.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
|
||||
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
|
||||
// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal.
|
||||
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
|
||||
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
|
||||
int kind = int(L.posAndKind.w);
|
||||
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
|
||||
float distsq = dot(toL, toL);
|
||||
float d = sqrt(distsq);
|
||||
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3
|
||||
if (d >= range || range <= 1e-4) return vec3(0.0);
|
||||
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
|
||||
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
|
||||
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
|
||||
// nothing (retail's hardware path). toL is un-normalised (length d).
|
||||
float angular = (uLightingMode == 1)
|
||||
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
|
||||
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
|
||||
if (angular <= 0.0) return vec3(0.0);
|
||||
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
|
||||
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
|
||||
float norm = (distsq > 1.0) ? (distsq * d) : d;
|
||||
float intensity = L.colorAndIntensity.w;
|
||||
float scale = (1.0 - d / range) * intensity * (angular / norm);
|
||||
if (kind == 2) {
|
||||
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
|
||||
if (cos_l <= cos_edge) scale = 0.0;
|
||||
}
|
||||
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
|
||||
// a single light can't push a channel past its colour. Summed lit clamped in frag.
|
||||
vec3 baseCol = L.colorAndIntensity.xyz;
|
||||
return min(scale * baseCol, baseCol);
|
||||
}
|
||||
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
|
||||
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
|
||||
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
|
||||
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
|
||||
if (uLightingMode == 0) {
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
}
|
||||
|
||||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||||
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
|
||||
// few warm intensity-100 torches can't push the whole pixel to white the way
|
||||
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
|
||||
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||||
|
||||
return lit; // frag still does the final min(lit, 1.0)
|
||||
}
|
||||
|
||||
out vec3 vNormal;
|
||||
out vec2 vTexCoord;
|
||||
out vec3 vWorldPos;
|
||||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||||
out flat uvec2 vTextureHandle;
|
||||
out flat uint vTextureLayer;
|
||||
|
||||
|
|
@ -247,7 +123,6 @@ void main() {
|
|||
|
||||
vWorldPos = worldPos.xyz;
|
||||
vNormal = normalize(mat3(model) * aNormal);
|
||||
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
|
||||
vTexCoord = aTexCoord;
|
||||
|
||||
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
||||
|
|
|
|||
|
|
@ -7,13 +7,10 @@ uniform sampler2D uTex;
|
|||
uniform int uUseTexture;
|
||||
|
||||
void main() {
|
||||
if (uUseTexture == 1) {
|
||||
if (uUseTexture != 0) {
|
||||
// Font atlas is a single-channel R8 texture; red = coverage alpha.
|
||||
float coverage = texture(uTex, vUv).r;
|
||||
FragColor = vec4(vColor.rgb, vColor.a * coverage);
|
||||
} else if (uUseTexture == 2) {
|
||||
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
|
||||
FragColor = texture(uTex, vUv) * vColor;
|
||||
} else {
|
||||
FragColor = vColor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,39 +25,14 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
private readonly Shader _shader;
|
||||
private readonly uint _vao;
|
||||
private readonly uint _vbo;
|
||||
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
|
||||
private int _vboCapacityBytes;
|
||||
|
||||
private readonly List<float> _textBuf = new(8192);
|
||||
private readonly List<float> _rectBuf = new(1024);
|
||||
// Submission-ordered sprite segments: consecutive DrawSprite calls with the
|
||||
// SAME texture batch into one segment; a texture change starts a new segment.
|
||||
// Drawing segments in submission order preserves painter z-order for
|
||||
// sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture
|
||||
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
|
||||
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
|
||||
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
|
||||
private readonly List<SpriteSeg> _spriteSegs = new();
|
||||
private int _segUsed;
|
||||
private int _textVerts;
|
||||
private int _rectVerts;
|
||||
private Vector2 _screenSize;
|
||||
|
||||
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
|
||||
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
|
||||
// rect panel backgrounds (which otherwise always win because rects flush after
|
||||
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
|
||||
private readonly List<float> _overlayTextBuf = new(1024);
|
||||
private readonly List<float> _overlayRectBuf = new(256);
|
||||
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
|
||||
private int _overlaySegUsed;
|
||||
private int _overlayTextVerts;
|
||||
private int _overlayRectVerts;
|
||||
|
||||
/// <summary>When true, Draw* calls route to the overlay layer (flushed last, on top
|
||||
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
|
||||
public bool OverlayMode { get; set; }
|
||||
|
||||
public TextRenderer(GL gl, string shaderDir)
|
||||
{
|
||||
_gl = gl;
|
||||
|
|
@ -81,20 +56,6 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
|
||||
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
|
||||
// background draw UNDER its text in painter order, which DrawRect's separate
|
||||
// bucket cannot (it always composites after all sprites).
|
||||
_whiteTex = _gl.GenTexture();
|
||||
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
|
||||
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
|
||||
fixed (byte* wp = whitePixel)
|
||||
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
|
||||
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||||
}
|
||||
|
||||
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
||||
|
|
@ -103,32 +64,17 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
_screenSize = screenSize;
|
||||
_textBuf.Clear();
|
||||
_rectBuf.Clear();
|
||||
_segUsed = 0; // pool the SpriteSeg objects across frames
|
||||
_textVerts = 0;
|
||||
_rectVerts = 0;
|
||||
_overlayTextBuf.Clear();
|
||||
_overlayRectBuf.Clear();
|
||||
_overlaySegUsed = 0;
|
||||
_overlayTextVerts = 0;
|
||||
_overlayRectVerts = 0;
|
||||
OverlayMode = false;
|
||||
}
|
||||
|
||||
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
|
||||
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
||||
{
|
||||
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
|
||||
else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; }
|
||||
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
|
||||
_rectVerts += 6;
|
||||
}
|
||||
|
||||
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
|
||||
/// when active), so it composites in painter order with sprites + dat-font text. Use
|
||||
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
|
||||
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
|
||||
/// the text instead.</summary>
|
||||
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
||||
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
|
||||
|
||||
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
|
||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||
{
|
||||
|
|
@ -173,47 +119,16 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
if (gw > 0 && gh > 0)
|
||||
{
|
||||
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
|
||||
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
|
||||
AppendQuad(_textBuf,
|
||||
gx, gy, gw, gh,
|
||||
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
|
||||
color);
|
||||
_textVerts += 6;
|
||||
}
|
||||
cursorX += g.Advance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a textured sprite quad in screen pixel space with an explicit
|
||||
/// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per
|
||||
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate).
|
||||
/// </summary>
|
||||
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
||||
{
|
||||
SpriteSeg seg = OverlayMode
|
||||
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
|
||||
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
|
||||
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
|
||||
}
|
||||
|
||||
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
|
||||
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
|
||||
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
|
||||
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
|
||||
{
|
||||
if (used > 0 && segs[used - 1].Texture == texture)
|
||||
return segs[used - 1];
|
||||
if (used < segs.Count)
|
||||
{
|
||||
var s = segs[used++];
|
||||
s.Texture = texture;
|
||||
s.Verts.Clear();
|
||||
return s;
|
||||
}
|
||||
var ns = new SpriteSeg { Texture = texture };
|
||||
segs.Add(ns);
|
||||
used++;
|
||||
return ns;
|
||||
}
|
||||
|
||||
private static void AppendQuad(List<float> buf,
|
||||
float x, float y, float w, float h,
|
||||
float u0, float v0, float u1, float v1, Vector4 color)
|
||||
|
|
@ -244,9 +159,7 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
|
||||
public void Flush(BitmapFont? font)
|
||||
{
|
||||
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
|
||||
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
|
||||
if (!anyNormal && !anyOverlay) return;
|
||||
if (_textVerts == 0 && _rectVerts == 0) return;
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetVec2("uScreenSize", _screenSize);
|
||||
|
|
@ -258,85 +171,36 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
|
||||
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
|
||||
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
|
||||
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
|
||||
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
|
||||
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
|
||||
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
|
||||
// blending and must own this state (feedback_render_self_contained_gl_state).
|
||||
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
|
||||
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
|
||||
_gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||||
_gl.Disable(EnableCap.Multisample);
|
||||
_gl.Disable(EnableCap.DepthTest);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.DepthMask(false);
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
|
||||
// LAYERED compositing for the UI (background → fill → text):
|
||||
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
|
||||
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
|
||||
// 3. Text glyphs — on top
|
||||
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
|
||||
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
|
||||
// composite on top, in that order. The OVERLAY layer repeats all three
|
||||
// AFTER the normal layer, so open popups beat even the rect backgrounds.
|
||||
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
|
||||
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
|
||||
|
||||
// Restore GL state.
|
||||
_gl.DepthMask(true);
|
||||
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
|
||||
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
/// <summary>Draw one compositing layer: sprites (submission order, one call per
|
||||
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
|
||||
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
|
||||
private void DrawLayer(
|
||||
List<SpriteSeg> spriteSegs, int segUsed,
|
||||
List<float> rectBuf, int rectVerts,
|
||||
List<float> textBuf, int textVerts, BitmapFont? font)
|
||||
{
|
||||
// 1. RGBA dat sprites — one draw call per distinct GL texture.
|
||||
if (segUsed > 0)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 2);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_shader.SetInt("uTex", 0);
|
||||
for (int i = 0; i < segUsed; i++)
|
||||
{
|
||||
var seg = spriteSegs[i];
|
||||
if (seg.Verts.Count == 0) continue;
|
||||
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
|
||||
UploadBuffer(seg.Verts);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Untextured rects — widget fills on top of the chrome.
|
||||
if (rectVerts > 0)
|
||||
// Untextured rects first — they form panel backgrounds.
|
||||
if (_rectVerts > 0)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 0);
|
||||
UploadBuffer(rectBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
|
||||
UploadBuffer(_rectBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
|
||||
}
|
||||
|
||||
// 3. Textured debug-font text glyphs on top.
|
||||
if (textVerts > 0 && font is not null)
|
||||
// Textured text glyphs.
|
||||
if (_textVerts > 0 && font is not null)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 1);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
|
||||
_shader.SetInt("uTex", 0);
|
||||
UploadBuffer(textBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
|
||||
UploadBuffer(_textBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
|
||||
}
|
||||
|
||||
// Restore GL state.
|
||||
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
private void UploadBuffer(List<float> buf)
|
||||
|
|
@ -359,7 +223,6 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_gl.DeleteTexture(_whiteTex);
|
||||
_gl.DeleteBuffer(_vbo);
|
||||
_gl.DeleteVertexArray(_vao);
|
||||
_shader.Dispose();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
private readonly GL _gl;
|
||||
private readonly DatCollection _dats;
|
||||
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
|
||||
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
|
||||
/// <summary>
|
||||
/// Composite cache for surface-with-override-origtex entries (Phase 5
|
||||
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
|
||||
|
|
@ -31,18 +30,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
|
||||
private uint _magentaHandle;
|
||||
|
||||
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
|
||||
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
|
||||
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
|
||||
private readonly Dictionary<uint, uint> _handlesByRenderSurfaceId = new();
|
||||
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
|
||||
|
||||
// Ad-hoc handles produced by the public UploadRgba8(byte[],int,int,bool) wrapper
|
||||
// (used by IconComposer for composited item icons). These are NOT stored in any
|
||||
// of the keyed caches above, so Dispose must sweep this list to avoid leaking
|
||||
// GL texture objects until process exit.
|
||||
private readonly List<uint> _adhocHandles = new();
|
||||
|
||||
private readonly Wb.BindlessSupport? _bindless;
|
||||
|
||||
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
|
||||
|
|
@ -93,74 +80,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
|
||||
/// pixel dimensions. UI 9-slice geometry needs the source size to
|
||||
/// compute slice UVs. Cached alongside the handle.
|
||||
/// </summary>
|
||||
public uint GetOrUpload(uint surfaceId, out int width, out int height)
|
||||
{
|
||||
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing)
|
||||
&& _sizeBySurfaceId.TryGetValue(surfaceId, out var sz))
|
||||
{
|
||||
width = sz.w; height = sz.h;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
|
||||
uint h = UploadRgba8(decoded);
|
||||
_handlesBySurfaceId[surfaceId] = h;
|
||||
_sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height);
|
||||
width = decoded.Width; height = decoded.Height;
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
|
||||
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
|
||||
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
|
||||
/// for world-geometry materials. This is the correct path for retail UI
|
||||
/// chrome + font glyph sheets, which reference RenderSurface directly.
|
||||
/// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object
|
||||
/// health-bar track 0x0600193E — are decoded against the RenderSurface's own
|
||||
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
|
||||
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
|
||||
/// a 1x1 magenta handle on miss.
|
||||
/// </summary>
|
||||
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
|
||||
{
|
||||
if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing)
|
||||
&& _rsSizeById.TryGetValue(renderSurfaceId, out var sz))
|
||||
{
|
||||
width = sz.w; height = sz.h;
|
||||
return existing;
|
||||
}
|
||||
|
||||
DecodedTexture decoded;
|
||||
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
||||
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
{
|
||||
// Resolve the surface's own default palette so paletted UI sprites decode
|
||||
// correctly instead of the magenta fallback (the back-track 0x0600193E behind
|
||||
// the selected-object health bar is PFID_P8/INDEX16). Non-paletted formats
|
||||
// (DefaultPaletteId==0) keep the previous null-palette behaviour unchanged.
|
||||
Palette? palette = rs.DefaultPaletteId != 0
|
||||
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
||||
: null;
|
||||
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||||
}
|
||||
else
|
||||
{
|
||||
decoded = DecodedTexture.Magenta;
|
||||
}
|
||||
|
||||
uint h = UploadRgba8(decoded, nearest);
|
||||
_handlesByRenderSurfaceId[renderSurfaceId] = h;
|
||||
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
|
||||
width = decoded.Width; height = decoded.Height;
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alpha-channel histogram for one decoded texture. Used to diagnose
|
||||
/// "why are clouds not transparent" — if cloud textures come out with
|
||||
|
|
@ -557,19 +476,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
return composed;
|
||||
}
|
||||
|
||||
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
|
||||
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
|
||||
/// The returned handle is tracked in <see cref="_adhocHandles"/> and deleted by
|
||||
/// <see cref="Dispose"/>. Callers must NOT also store the handle in any of the
|
||||
/// keyed caches — that would cause a double-delete on Dispose.</summary>
|
||||
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
|
||||
{
|
||||
uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest);
|
||||
_adhocHandles.Add(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
|
||||
private uint UploadRgba8(DecodedTexture decoded)
|
||||
{
|
||||
uint tex = _gl.GenTexture();
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
|
@ -586,11 +493,8 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
PixelType.UnsignedByte,
|
||||
p);
|
||||
|
||||
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
|
||||
// font's small glyphs. Other surfaces use bilinear.
|
||||
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
||||
|
||||
|
|
@ -678,17 +582,5 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
_gl.DeleteTexture(_magentaHandle);
|
||||
_magentaHandle = 0;
|
||||
}
|
||||
|
||||
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
|
||||
// by GetOrUploadRenderSurface but was not swept here before this fix.
|
||||
foreach (var h in _handlesByRenderSurfaceId.Values)
|
||||
_gl.DeleteTexture(h);
|
||||
_handlesByRenderSurfaceId.Clear();
|
||||
|
||||
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
|
||||
// (IconComposer composited icons). Not stored in any keyed cache.
|
||||
foreach (var h in _adhocHandles)
|
||||
_gl.DeleteTexture(h);
|
||||
_adhocHandles.Clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,17 +88,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
private uint _clipSlotBuffer;
|
||||
private uint[] _clipSlotData = Array.Empty<uint>();
|
||||
|
||||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
|
||||
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
|
||||
// left bound. binding=4 = global point-light snapshot (same data/indices as the
|
||||
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
|
||||
private uint _globalLightsSsbo; // binding=4
|
||||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||||
private uint _instLightSetSsbo; // binding=5
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||||
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
|
||||
|
||||
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
|
||||
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
|
||||
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
|
||||
|
|
@ -242,18 +231,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
|
||||
|
||||
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
|
||||
_gl.GenBuffers(1, out _globalLightsSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.GenBuffers(1, out _instLightSetSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||||
}
|
||||
|
|
@ -285,17 +262,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
|
||||
=> _cellIdToSlot = cellIdToSlot;
|
||||
|
||||
/// <summary>
|
||||
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
|
||||
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
|
||||
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
|
||||
/// reference this snapshot, which is also uploaded to binding=4 here, so the
|
||||
/// pass is self-contained. Null/empty -> shells receive no point lights.
|
||||
/// </summary>
|
||||
public void SetPointSnapshot(
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
|
||||
=> _pointSnapshot = snapshot;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetEnvCellGeomId
|
||||
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
|
||||
|
|
@ -877,7 +843,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
|
||||
_shader.SetInt("uRenderPass", (int)renderPass);
|
||||
_shader.SetInt("uFilterByCell", 0);
|
||||
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
|
||||
|
||||
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
|
||||
// moving"): upload uViewProjection HERE rather than inheriting it from
|
||||
|
|
@ -1032,35 +997,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetCellLightSet (A7 Fix D D-2 helper)
|
||||
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
|
||||
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
|
||||
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
|
||||
// Cached per frame; unused slots are -1 (shader adds no point light there).
|
||||
private int[] GetCellLightSet(uint cellId)
|
||||
{
|
||||
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
|
||||
|
||||
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
|
||||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||||
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
|
||||
}
|
||||
_cellLightSetCache[cellId] = set;
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderModernMDIInternal
|
||||
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
|
||||
|
|
@ -1080,15 +1016,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
int passIdx = (int)renderPass;
|
||||
if (passIdx < 0 || passIdx > 2) return;
|
||||
|
||||
// A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in
|
||||
// GetCellLightSet below). Clear once here so each cell gets a fresh lookup
|
||||
// using this frame's _pointSnapshot. Called for EVERY pass (opaque AND
|
||||
// transparent); the cache entries are stable within a frame since PointSnapshot
|
||||
// doesn't change between Render calls, so clearing once (at the opaque pass)
|
||||
// and leaving stale entries for the transparent pass would also be correct, but
|
||||
// clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet.
|
||||
_cellLightSetCache.Clear();
|
||||
|
||||
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
|
||||
// Without the global VAO nothing can draw, and returning AFTER the pass state
|
||||
// was established leaked it (same early-out shape as the totalDraws==0 leak —
|
||||
|
|
@ -1286,35 +1213,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
|
||||
}
|
||||
|
||||
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
|
||||
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
|
||||
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
|
||||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||||
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
{
|
||||
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
||||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
|
||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
fixed (float* gp = _globalLightData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
|
||||
fixed (int* lp = _lightSetData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||||
|
||||
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
|
||||
// (globalVao validated at the top of the method — a return here would leak the
|
||||
// pass state established above.)
|
||||
|
|
@ -1330,8 +1228,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
|
||||
BindClipRegionBinding2();
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
|
||||
|
||||
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
|
||||
|
|
@ -1547,7 +1443,5 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
|
||||
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
|
||||
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
|
||||
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
|
||||
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Terrain;
|
||||
|
|
@ -133,24 +132,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
private uint _clipSlotSsbo;
|
||||
private uint[] _clipSlotData = new uint[256];
|
||||
|
||||
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
|
||||
// SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot
|
||||
// lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4)
|
||||
// holds the per-frame point-light snapshot (LightManager.PointSnapshot);
|
||||
// _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per
|
||||
// instance INTO it (-1 = unused), laid out parallel to _instanceSsbo.
|
||||
private uint _globalLightsSsbo;
|
||||
private uint _instLightSetSsbo;
|
||||
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
|
||||
private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight
|
||||
// This frame's point-light snapshot, handed in by GameWindow before Draw via
|
||||
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
|
||||
private IReadOnlyList<LightSource>? _pointSnapshot;
|
||||
// This entity's selected point/spot light set — computed ONCE per entity at
|
||||
// the isNewEntity site (constant across the entity's parts/tuples), exactly
|
||||
// like _currentEntitySlot. -1 = unused slot.
|
||||
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
|
||||
|
||||
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the
|
||||
// GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
|
||||
// (not yet wired), we bind our OWN fallback no-clip region buffer below so the
|
||||
|
|
@ -348,21 +329,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_batchSsbo = _gl.GenBuffer();
|
||||
_indirectBuffer = _gl.GenBuffer();
|
||||
_clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3
|
||||
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
|
||||
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
|
||||
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
|
||||
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
|
||||
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
|
||||
/// by the object's bounding sphere — camera-independent. Pass null/empty to
|
||||
/// disable per-object point lights (only ambient + sun render).
|
||||
/// </summary>
|
||||
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
|
||||
=> _pointSnapshot = pointSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
|
||||
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The
|
||||
|
|
@ -893,9 +861,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_indoorProbeFrameCounter++;
|
||||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
|
||||
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
|
||||
_shader.SetInt("uLightingMode", 0);
|
||||
|
||||
// #128 self-heal: fresh re-request dedup per Draw pass.
|
||||
_missRequested.Clear();
|
||||
|
|
@ -923,7 +888,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
camPos = invView.Translation;
|
||||
|
||||
// ── Phase 1: clear groups, walk entities, build groups ──────────────
|
||||
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); }
|
||||
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); }
|
||||
|
||||
var metaTable = _meshAdapter.MetadataTable;
|
||||
uint anyVao = 0;
|
||||
|
|
@ -1088,11 +1053,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_currentEntityCulled)
|
||||
probeCulledEntities++;
|
||||
|
||||
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
|
||||
// is constant across the entity's parts/tuples), by the entity's
|
||||
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
|
||||
ComputeEntityLightSet(entity);
|
||||
|
||||
// #119 decisive probe: one-shot dump (+ change re-emission) for
|
||||
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
|
||||
// so a routed-out entity still reports its state.
|
||||
|
|
@ -1390,13 +1350,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_clipSlotData.Length < totalInstances)
|
||||
_clipSlotData = new uint[totalInstances + 256];
|
||||
|
||||
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
|
||||
// instance, laid out in the SAME group order / cursor as _instanceData
|
||||
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
|
||||
// Instances[instanceIndex] (binding=0).
|
||||
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
|
||||
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
|
||||
|
||||
_opaqueDraws.Clear();
|
||||
_translucentDraws.Clear();
|
||||
|
||||
|
|
@ -1422,13 +1375,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Slots[] is parallel to Matrices[] within the group; write the
|
||||
// slot at the same cursor so binding=3 stays aligned with binding=0.
|
||||
_clipSlotData[cursor] = grp.Slots[i];
|
||||
// Fix B: LightSets[] holds 8 ints per instance, parallel to
|
||||
// Matrices[]; copy this instance's block to the same cursor so
|
||||
// binding=5 stays aligned with binding=0.
|
||||
int lsDst = cursor * LightManager.MaxLightsPerObject;
|
||||
int lsSrc = i * LightManager.MaxLightsPerObject;
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
|
@ -1514,15 +1460,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
fixed (uint* sp = _clipSlotData)
|
||||
UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint));
|
||||
|
||||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
||||
// per-instance buffer holds 8 int indices into it per instance, laid out
|
||||
// parallel to _instanceData in Phase 3. Both bound with ≥1 element so the
|
||||
// shader never reads an unbound SSBO on a no-lights frame.
|
||||
UploadGlobalLights();
|
||||
fixed (int* lp = _lightSetData)
|
||||
UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
|
||||
|
||||
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||||
|
|
@ -1806,23 +1743,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
|
||||
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
|
||||
/// matching mesh_modern.vert's <c>GlobalLight</c>). Always uploads ≥1 element
|
||||
/// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is
|
||||
/// a zeroed dummy that no instance set references (all sets are -1).
|
||||
/// </summary>
|
||||
private unsafe void UploadGlobalLights()
|
||||
{
|
||||
int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int count = n > 0 ? n : 1; // never zero-size
|
||||
// Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats.
|
||||
fixed (float* gp = _globalLightData)
|
||||
UploadSsbo(_globalLightsSsbo, 4, gp,
|
||||
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
|
||||
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
|
||||
|
|
@ -2016,75 +1936,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
|
||||
/// reused by every part/instance of it), by the entity's world bounding
|
||||
/// sphere. Camera-independent (<see cref="LightManager.SelectForObject"/>), so
|
||||
/// a static building's torches stay constant as the viewer moves. Fills
|
||||
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
|
||||
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
|
||||
///
|
||||
/// <para>
|
||||
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
|
||||
/// ambient ONLY — never the static wall torches. The per-object torch step
|
||||
/// (<c>minimize_object_lighting</c>, 0x0054d480) runs ONLY in the indoor stage:
|
||||
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398) calls it under
|
||||
/// <c>if (Render::useSunlight == 0)</c>, and the outdoor landscape stage runs
|
||||
/// <c>Render::useSunlightSet(1)</c> (<c>PView::DrawCells</c> 0x005a485a, right
|
||||
/// before <c>LScape::draw</c> which draws buildings/scenery). So a building
|
||||
/// EXTERIOR shell (<see cref="WorldEntity.IsBuildingShell"/>,
|
||||
/// <see cref="WorldEntity.ParentCellId"/> = null) and all outdoor scenery /
|
||||
/// creatures get the sun, not torches. We mirror that: only objects parented to
|
||||
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
|
||||
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
|
||||
/// facade wash out warm — the dat's intensity-100 wall torches (range
|
||||
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
|
||||
/// The indoor "no sun" half is already handled by the global sun kill when the
|
||||
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
|
||||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ComputeEntityLightSet(WorldEntity entity)
|
||||
{
|
||||
Array.Fill(_currentEntityLightSet, -1);
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is null || snap.Count == 0) return;
|
||||
|
||||
// Retail useSunlight gate: outdoor objects receive no per-object torches.
|
||||
if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return;
|
||||
|
||||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
|
||||
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
|
||||
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>useSunlight</c> gate for per-object torch lighting, as a pure
|
||||
/// predicate. An object receives the static wall torches (the indoor
|
||||
/// <c>minimize_object_lighting</c> pass) ONLY when it is parented to an EnvCell
|
||||
/// — an interior cell, by the AC convention <c>(cellId & 0xFFFF) >= 0x0100</c>.
|
||||
/// Outdoor objects (building shells with null <paramref name="parentCellId"/>,
|
||||
/// outdoor scenery in a land sub-cell <c>0x0001..0x00FF</c>, outdoor creatures)
|
||||
/// are sun-lit only and return false. Mirrors
|
||||
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398): torches enabled iff
|
||||
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
|
||||
/// </summary>
|
||||
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
|
||||
=> parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u;
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: append the current entity's 8-slot light set to a group's
|
||||
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
|
||||
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
|
||||
/// </summary>
|
||||
private void AppendCurrentLightSet(InstanceGroup grp)
|
||||
{
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
grp.LightSets.Add(_currentEntityLightSet[k]);
|
||||
}
|
||||
|
||||
private void ClassifyBatches(
|
||||
|
|
@ -2142,7 +1993,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||||
}
|
||||
}
|
||||
|
|
@ -2222,8 +2072,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_gl.DeleteBuffer(_indirectBuffer);
|
||||
if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3
|
||||
if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3
|
||||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
|
||||
if (_gpuQueriesInitialized)
|
||||
{
|
||||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||||
|
|
@ -2409,13 +2257,5 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
|
||||
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
|
||||
public readonly List<uint> Slots = new();
|
||||
|
||||
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
|
||||
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
|
||||
// light index block for the instance whose matrix is Matrices[i]). At
|
||||
// layout time the dispatcher copies each block into _lightSetData at the
|
||||
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
|
||||
// instance. -1 = unused slot.
|
||||
public readonly List<int> LightSets = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,7 @@ public sealed record RuntimeOptions(
|
|||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
bool DumpLiveSpawns,
|
||||
int? LegacyStreamRadius,
|
||||
bool RetailUi,
|
||||
string? AcDir)
|
||||
int? LegacyStreamRadius)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -83,9 +81,7 @@ public sealed record RuntimeOptions(
|
|||
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
||||
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")));
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
|
|
|
|||
|
|
@ -14,16 +14,6 @@ public abstract record LandblockStreamJob(uint LandblockId)
|
|||
{
|
||||
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
||||
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
|
||||
|
||||
/// <summary>
|
||||
/// Control job: drop every queued (not-yet-started) Load from the worker's
|
||||
/// priority queues, keeping Unloads. Posted by
|
||||
/// <see cref="LandblockStreamer.ClearPendingLoads"/> when the player enters a
|
||||
/// dungeon and the in-flight outdoor/neighbor window load must be cancelled
|
||||
/// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
|
||||
/// convention; readers pattern-match on the type.
|
||||
/// </summary>
|
||||
public sealed record ClearLoads() : LandblockStreamJob(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -141,22 +141,6 @@ public sealed class LandblockStreamer : IDisposable
|
|||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel every queued-but-not-started Load. Posts a
|
||||
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
|
||||
/// honours at read time, dropping all pending Loads from both priority
|
||||
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
|
||||
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
|
||||
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
|
||||
/// complete; the StreamingController's collapsed-sweep unloads those few.
|
||||
/// </summary>
|
||||
public void ClearPendingLoads()
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain up to <paramref name="maxBatchSize"/> completed results.
|
||||
/// Non-blocking. Call from the render thread once per OnUpdate.
|
||||
|
|
@ -196,18 +180,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
}
|
||||
|
||||
while (_inbox.Reader.TryRead(out var job))
|
||||
{
|
||||
if (job is LandblockStreamJob.ClearLoads)
|
||||
{
|
||||
// Dungeon-entry cancellation: drop every queued Load,
|
||||
// keep Unloads. Handled at read time so it supersedes
|
||||
// Loads sitting in the priority queues ahead of it.
|
||||
DropLoadJobs(highPriority);
|
||||
DropLoadJobs(lowPriority);
|
||||
continue;
|
||||
}
|
||||
EnqueuePrioritized(job, highPriority, lowPriority);
|
||||
}
|
||||
|
||||
if (highPriority.Count == 0 && lowPriority.Count == 0)
|
||||
continue;
|
||||
|
|
@ -260,22 +233,6 @@ public sealed class LandblockStreamer : IDisposable
|
|||
lowPriority.Enqueue(job);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every <see cref="LandblockStreamJob.Load"/> from a priority queue,
|
||||
/// preserving Unloads (and any other control jobs). Rotates the queue once
|
||||
/// in place. Used by the <see cref="LandblockStreamJob.ClearLoads"/> path.
|
||||
/// </summary>
|
||||
private static void DropLoadJobs(Queue<LandblockStreamJob> queue)
|
||||
{
|
||||
int count = queue.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var job = queue.Dequeue();
|
||||
if (job is not LandblockStreamJob.Load)
|
||||
queue.Enqueue(job);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveLowPriorityJobsForLandblock(
|
||||
Queue<LandblockStreamJob> queue,
|
||||
uint landblockId,
|
||||
|
|
|
|||
|
|
@ -22,24 +22,9 @@ public sealed class StreamingController
|
|||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||||
private readonly Action<uint>? _removeTerrain;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
private readonly GpuWorldState _state;
|
||||
private StreamingRegion? _region;
|
||||
|
||||
// True while streaming is collapsed to the single dungeon landblock the
|
||||
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
||||
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
|
||||
// are never visible, so we stop loading the 25×25 window entirely.
|
||||
private bool _collapsed;
|
||||
|
||||
// The dungeon landblock id we collapsed onto. Once collapsed we key the
|
||||
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
|
||||
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
|
||||
// expand on that flicker thrashes collapse↔expand (reload storms + a light
|
||||
// leak). We only expand when the observer actually moves to a different
|
||||
// landblock (teleport/portal out).
|
||||
private uint _collapsedCenter;
|
||||
|
||||
/// <summary>
|
||||
/// Near-tier radius (LBs from observer that load full detail: terrain +
|
||||
/// scenery + entities). Set at construction; readable thereafter.
|
||||
|
|
@ -86,15 +71,13 @@ public sealed class StreamingController
|
|||
GpuWorldState state,
|
||||
int nearRadius,
|
||||
int farRadius,
|
||||
Action<uint>? removeTerrain = null,
|
||||
Action? clearPendingLoads = null)
|
||||
Action<uint>? removeTerrain = null)
|
||||
{
|
||||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
_drainCompletions = drainCompletions;
|
||||
_applyTerrain = applyTerrain;
|
||||
_removeTerrain = removeTerrain;
|
||||
_clearPendingLoads = clearPendingLoads;
|
||||
_state = state;
|
||||
NearRadius = nearRadius;
|
||||
FarRadius = farRadius;
|
||||
|
|
@ -114,76 +97,7 @@ public sealed class StreamingController
|
|||
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||||
{
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||
|
||||
if (_collapsed)
|
||||
{
|
||||
// Hysteresis. Cases:
|
||||
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
|
||||
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
|
||||
// → re-collapse onto it.
|
||||
// - CurrCell flickered null but the player hasn't gone anywhere: the
|
||||
// observer landblock reverts to the position-derived value, which for a
|
||||
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
|
||||
// local Y). Hold — never expand on an adjacent flicker.
|
||||
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
|
||||
// from the ocean-grid dungeon block) → expand.
|
||||
if (insideDungeon && centerId != _collapsedCenter)
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
|
||||
ExitDungeonExpand(observerCx, observerCy);
|
||||
else
|
||||
SweepCollapsed();
|
||||
}
|
||||
else if (insideDungeon)
|
||||
{
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
NormalTick(observerCx, observerCy);
|
||||
}
|
||||
|
||||
DrainAndApply();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
|
||||
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
|
||||
/// from the login / teleport spawn path the instant the streaming center is
|
||||
/// recentered onto a SEALED dungeon landblock.
|
||||
///
|
||||
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
|
||||
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
|
||||
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
|
||||
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
|
||||
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
|
||||
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
|
||||
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
|
||||
///
|
||||
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
|
||||
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
|
||||
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
|
||||
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
|
||||
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
|
||||
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
|
||||
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
|
||||
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
|
||||
/// same as <see cref="Tick"/>.</para>
|
||||
/// </summary>
|
||||
public void PreCollapseToDungeon(int cx, int cy)
|
||||
{
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
|
||||
if (_collapsed && _collapsedCenter == centerId) return;
|
||||
EnterDungeonCollapse(cx, cy, centerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor / building-interior streaming — the original two-tier model.
|
||||
/// </summary>
|
||||
private void NormalTick(int observerCx, int observerCy)
|
||||
public void Tick(int observerCx, int observerCy)
|
||||
{
|
||||
if (_region is null)
|
||||
{
|
||||
|
|
@ -202,88 +116,9 @@ public sealed class StreamingController
|
|||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon-entry edge: cancel the in-flight window load, unload every
|
||||
/// resident neighbor, and pin streaming to the player's single dungeon
|
||||
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
|
||||
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
|
||||
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
|
||||
/// their thousands of emitters (#133 FPS). Unloading them also tears down
|
||||
/// their lights, shrinking the static-light set toward retail's ≤40.
|
||||
/// </summary>
|
||||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||||
{
|
||||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
_clearPendingLoads?.Invoke();
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (id != centerId) _enqueueUnload(id);
|
||||
|
||||
// Pin a radius-0 region so RecenterTo never re-expands while inside,
|
||||
// and so the post-exit rebuild starts from a clean, consistent state.
|
||||
_region = new StreamingRegion(cx, cy, 0, 0);
|
||||
_region.MarkResidentFromBootstrap();
|
||||
|
||||
// The dungeon landblock itself must be (or become) loaded. If a prior
|
||||
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
||||
if (!_state.IsLoaded(centerId))
|
||||
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While collapsed, unload any landblock that finished loading after the
|
||||
/// collapse edge — a Load the worker had already dequeued before the
|
||||
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
|
||||
/// effect. At steady state only the dungeon landblock is resident, so this
|
||||
/// is a no-op.
|
||||
/// </summary>
|
||||
private void SweepCollapsed()
|
||||
{
|
||||
// Always preserve the true dungeon landblock (_collapsedCenter), never the
|
||||
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (id != _collapsedCenter) _enqueueUnload(id);
|
||||
}
|
||||
|
||||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||||
private static int ChebyshevLandblocks(uint a, uint b)
|
||||
{
|
||||
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
|
||||
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
|
||||
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||||
/// two-tier window at the new center and unload anything resident from the
|
||||
/// collapsed state that falls outside it.
|
||||
/// </summary>
|
||||
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||||
{
|
||||
_collapsed = false;
|
||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
||||
|
||||
var boot = rebuilt.ComputeFirstTickDiff();
|
||||
foreach (var id in boot.ToLoadNear)
|
||||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in boot.ToLoadFar)
|
||||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
rebuilt.MarkResidentFromBootstrap();
|
||||
_region = rebuilt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
||||
/// upload time. Remaining completions wait for the next frame.
|
||||
/// </summary>
|
||||
private void DrainAndApply()
|
||||
{
|
||||
// Drain up to N completions per frame so a big diff doesn't spike
|
||||
// GPU upload time. Remaining completions wait for the next frame.
|
||||
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
||||
foreach (var result in drained)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal reader for retail's <c>controls.ini</c> — a flat INI with one
|
||||
/// <c>[section]</c> per element type. Colors are <c>#AARRGGBB</c> (alpha
|
||||
/// first). Optional: a missing file yields an empty sheet (callers fall back
|
||||
/// to hardcoded defaults). See the D.2b spec §7.
|
||||
/// </summary>
|
||||
public sealed class ControlsIni
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _sections;
|
||||
|
||||
private ControlsIni(Dictionary<string, Dictionary<string, string>> s) => _sections = s;
|
||||
|
||||
/// <summary>Load from disk; returns an empty sheet if the file is absent.</summary>
|
||||
public static ControlsIni Load(string path)
|
||||
=> System.IO.File.Exists(path)
|
||||
? Parse(System.IO.File.ReadAllText(path))
|
||||
: new ControlsIni(new());
|
||||
|
||||
public static ControlsIni Parse(string text)
|
||||
{
|
||||
var sections = new Dictionary<string, Dictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, string>? cur = null;
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue;
|
||||
if (line[0] == '[' && line[^1] == ']')
|
||||
{
|
||||
var name = line[1..^1].Trim();
|
||||
cur = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
sections[name] = cur;
|
||||
continue;
|
||||
}
|
||||
int eq = line.IndexOf('=');
|
||||
if (eq <= 0 || cur is null) continue;
|
||||
cur[line[..eq].Trim()] = line[(eq + 1)..].Trim();
|
||||
}
|
||||
return new ControlsIni(sections);
|
||||
}
|
||||
|
||||
public string? Get(string section, string key)
|
||||
=> _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null;
|
||||
|
||||
/// <summary>Parse a <c>#AARRGGBB</c> token into an RGBA <see cref="Vector4"/>.</summary>
|
||||
public bool TryColor(string section, string key, out Vector4 color)
|
||||
{
|
||||
color = default;
|
||||
var v = Get(section, key);
|
||||
if (v is null || v.Length != 9 || v[0] != '#') return false;
|
||||
if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb))
|
||||
return false;
|
||||
float a = ((argb >> 24) & 0xFF) / 255f;
|
||||
float r = ((argb >> 16) & 0xFF) / 255f;
|
||||
float g = ((argb >> 8) & 0xFF) / 255f;
|
||||
float b = (argb & 0xFF) / 255f;
|
||||
color = new Vector4(r, g, b, a);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Textures;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32
|
||||
/// texture, mirroring retail IconData::RenderIcons (decomp 407524) and
|
||||
/// DBCache::GetDIDFromEnum (0x413940). Each layer is a 0x06 RenderSurface decoded
|
||||
/// DIRECTLY (the D.2b RenderSurface-vs-Surface rule).
|
||||
///
|
||||
/// Layer order (bottom → top), matching retail:
|
||||
/// 1. type-default underlay (OPAQUE backing; resolved via EnumIDMap 0x10000004 from
|
||||
/// the portal MasterMap) — <see cref="ResolveUnderlayDid"/>
|
||||
/// 2. item custom underlay (e.g. "magic" tint strip)
|
||||
/// 3. base icon
|
||||
/// 4. item custom overlay (e.g. "enchanted" sparkle)
|
||||
///
|
||||
/// The type-default underlay is the key to non-transparent filled slots: because it
|
||||
/// is fully opaque and is layer 0, <see cref="Compose"/> sizes the output to it and
|
||||
/// the alpha-over pass fills every pixel. The overlay ReplaceColor tint and the effect
|
||||
/// overlay (RenderIcons 407546) remain out of scope (paperdoll phase).
|
||||
///
|
||||
/// Composited textures are cached by their (typeUnderlay, underlay, base, overlay) tuple.
|
||||
/// </summary>
|
||||
public sealed class IconComposer
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly TextureCache _cache;
|
||||
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
|
||||
|
||||
// ── type-default underlay resolve (EnumIDMap 0x10000004) ─────────────────
|
||||
// Portal MasterMap (0x25000000) maps enum 0x10000004 → submap DID (0x25000008).
|
||||
// Submap maps index → 0x06 RenderSurface DID. index = LSB(itemType)+1, or 0x21.
|
||||
// Refs: IconData::RenderIcons 0058d214–0058d22c; DBCache::GetDIDFromEnum 0x413940.
|
||||
private EnumIDMap? _underlaySubMap;
|
||||
private bool _underlayResolveTried;
|
||||
private readonly Dictionary<uint, uint> _underlayDidByIndex = new();
|
||||
|
||||
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
|
||||
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
|
||||
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
|
||||
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
|
||||
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
|
||||
private EnumIDMap? _effectSubMap;
|
||||
private bool _effectResolveTried;
|
||||
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
|
||||
private readonly Dictionary<uint, DecodedTexture> _effectTileByDid = new();
|
||||
|
||||
public IconComposer(DatCollection dats, TextureCache cache)
|
||||
{
|
||||
_dats = dats;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the type-default underlay DID for <paramref name="itemType"/> via the
|
||||
/// two-level EnumIDMap chain (retail: IconData::RenderIcons 0058d214–0058d22c +
|
||||
/// DBCache::GetDIDFromEnum 0x413940).
|
||||
///
|
||||
/// <para>index = LowestSetBit(itemType) + 1, or 0x21 when itemType has no bits set.</para>
|
||||
///
|
||||
/// <para>NOTE: retail RenderIcons (407546) has a special paperdoll IsThePlayer case
|
||||
/// that uses GetDIDByEnum(0x10000004, 7) + TYPE_CONTAINER for the player doll — that
|
||||
/// path is out of scope here (paperdoll phase).</para>
|
||||
/// </summary>
|
||||
internal uint ResolveUnderlayDid(ItemType itemType)
|
||||
{
|
||||
uint raw = (uint)itemType;
|
||||
int lsb = raw == 0 ? -1 : BitOperations.TrailingZeroCount(raw);
|
||||
uint index = lsb < 0 ? 0x21u : (uint)(lsb + 1);
|
||||
if (_underlayDidByIndex.TryGetValue(index, out var cached)) return cached;
|
||||
EnsureUnderlaySubMap();
|
||||
uint did = 0;
|
||||
if (_underlaySubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
|
||||
_underlayDidByIndex[index] = did;
|
||||
return did;
|
||||
}
|
||||
|
||||
private void EnsureUnderlaySubMap()
|
||||
{
|
||||
if (_underlayResolveTried) return;
|
||||
_underlayResolveTried = true;
|
||||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
|
||||
if (masterDid == 0) return;
|
||||
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
|
||||
if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008
|
||||
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _underlaySubMap = sub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
|
||||
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
|
||||
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
|
||||
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
|
||||
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
|
||||
/// </summary>
|
||||
internal uint ResolveEffectDid(uint effects)
|
||||
{
|
||||
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
|
||||
uint index = (uint)(lsb + 1);
|
||||
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
|
||||
EnsureEffectSubMap();
|
||||
uint did = 0;
|
||||
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
|
||||
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
|
||||
did = fb;
|
||||
_effectDidByIndex[index] = did;
|
||||
return did;
|
||||
}
|
||||
|
||||
private void EnsureEffectSubMap()
|
||||
{
|
||||
if (_effectResolveTried) return;
|
||||
_effectResolveTried = true;
|
||||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
|
||||
if (masterDid == 0) return;
|
||||
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
|
||||
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
|
||||
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SurfaceWindow::ReplaceColor</c> SURFACE overload (0x004415b0): for every
|
||||
/// pixel in <paramref name="dst"/> that equals pure-white-opaque (RGBAColor(1,1,1,1) →
|
||||
/// 0xFFFFFFFF), copy the SAME (x,y) pixel from the source effect tile. This preserves
|
||||
/// the effect tile's texture/gradient (NOT a flat color). Retail requires the source to
|
||||
/// cover the dest (it does — both are 32x32); out-of-range pixels are left unchanged.
|
||||
/// Mutates <paramref name="dst"/> in place.
|
||||
/// </summary>
|
||||
internal static void ReplaceWhiteFromSurface(byte[] dst, int dw, int dh, byte[] src, int sw, int sh)
|
||||
{
|
||||
for (int y = 0; y < dh; y++)
|
||||
for (int x = 0; x < dw; x++)
|
||||
{
|
||||
int di = (y * dw + x) * 4;
|
||||
if (dst[di] == 255 && dst[di + 1] == 255 && dst[di + 2] == 255 && dst[di + 3] == 255
|
||||
&& x < sw && y < sh)
|
||||
{
|
||||
int si = (y * sw + x) * 4;
|
||||
dst[di] = src[si]; dst[di + 1] = src[si + 1];
|
||||
dst[di + 2] = src[si + 2]; dst[di + 3] = src[si + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decoded effect tile for <paramref name="effects"/> (enum 0x10000005). The tile is
|
||||
/// a 32x32 textured RenderSurface whose pixels ARE the per-effect coloring (blue=Magical,
|
||||
/// green=Poisoned, …; the 0x21 fallback is solid black). Retail copies it per-pixel into
|
||||
/// the icon's white pixels (gradient), so we need the whole tile, not a representative
|
||||
/// color. Cached per DID.
|
||||
/// </summary>
|
||||
internal bool TryGetEffectTile(uint effects, out DecodedTexture tile)
|
||||
{
|
||||
tile = null!;
|
||||
uint did = ResolveEffectDid(effects);
|
||||
if (did == 0) return false;
|
||||
if (_effectTileByDid.TryGetValue(did, out var cached)) { tile = cached; return true; }
|
||||
if (!TryDecode(did, out var d)) return false;
|
||||
_effectTileByDid[did] = d;
|
||||
tile = d;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded)
|
||||
{
|
||||
decoded = null!;
|
||||
if (renderSurfaceId == 0) return false;
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
|
||||
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
return false;
|
||||
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Pure alpha-over composite, bottom->top. Layers may differ in size;
|
||||
/// the result is sized to the FIRST (bottom) layer and upper layers are sampled
|
||||
/// top-left aligned (all icon layers are 32x32 in practice).</summary>
|
||||
public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers)
|
||||
{
|
||||
if (layers.Count == 0) return (Array.Empty<byte>(), 0, 0);
|
||||
var (baseRgba, w, h) = layers[0];
|
||||
var outp = (byte[])baseRgba.Clone();
|
||||
for (int li = 1; li < layers.Count; li++)
|
||||
{
|
||||
var (src, sw, sh) = layers[li];
|
||||
int cw = Math.Min(w, sw), ch = Math.Min(h, sh);
|
||||
for (int y = 0; y < ch; y++)
|
||||
for (int x = 0; x < cw; x++)
|
||||
{
|
||||
int di = (y * w + x) * 4, si = (y * sw + x) * 4;
|
||||
float sa = src[si + 3] / 255f;
|
||||
if (sa <= 0f) continue;
|
||||
float da = 1f - sa;
|
||||
outp[di] = (byte)(src[si] * sa + outp[di] * da);
|
||||
outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da);
|
||||
outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da);
|
||||
outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da);
|
||||
}
|
||||
}
|
||||
return (outp, w, h);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve (and cache) the composited GL texture for an item's icon state.
|
||||
/// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180):
|
||||
/// a DRAG composite (base + custom overlay + effect recolor) blitted over the
|
||||
/// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a
|
||||
/// ReplaceColor tint SOURCE, not a blit layer (DR-1). The recolor runs for ALL items:
|
||||
/// effects==0 resolves to the 0x21 solid-black fallback tile, so pure-white pixels become
|
||||
/// black (matching retail); magical items take the per-effect hue instead.
|
||||
/// </summary>
|
||||
public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
|
||||
{
|
||||
if (iconId == 0) return 0;
|
||||
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
|
||||
var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects);
|
||||
if (_byTuple.TryGetValue(key, out var tex)) return tex;
|
||||
|
||||
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
|
||||
var dragLayers = new List<(byte[] rgba, int w, int h)>();
|
||||
AddLayer(dragLayers, iconId);
|
||||
AddLayer(dragLayers, overlayId);
|
||||
(byte[] rgba, int w, int h)? drag = null;
|
||||
if (dragLayers.Count > 0)
|
||||
{
|
||||
var composed = Compose(dragLayers);
|
||||
// Effect recolor — ALWAYS, matching retail IconData::RenderIcons (0x0058d180):
|
||||
// the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null
|
||||
// even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5). Retail's RenderIcons
|
||||
// calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying
|
||||
// the textured effect tile per-pixel into the icon's pure-white pixels — so
|
||||
// magical items take the tile's GRADIENT hue and mundane items go solid black.
|
||||
// (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a
|
||||
// gradient, not a flat tint, and the no-mana scroll's edges are black.)
|
||||
if (TryGetEffectTile(effects, out var tile))
|
||||
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
|
||||
tile.Rgba8, tile.Width, tile.Height);
|
||||
drag = composed;
|
||||
}
|
||||
|
||||
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
|
||||
var layers = new List<(byte[] rgba, int w, int h)>();
|
||||
AddLayer(layers, typeUnderlayDid);
|
||||
AddLayer(layers, underlayId);
|
||||
if (drag is { } d) layers.Add(d);
|
||||
if (layers.Count == 0) return 0;
|
||||
|
||||
var (rgba, w, h) = Compose(layers);
|
||||
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
|
||||
_byTuple[key] = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId)
|
||||
{
|
||||
if (renderSurfaceId == 0) return;
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
|
||||
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
return;
|
||||
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,472 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||||
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
|
||||
/// by the factory; this controller binds its live data provider in place. The input
|
||||
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
|
||||
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
|
||||
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
|
||||
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
|
||||
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
|
||||
/// and bound in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatWindowController
|
||||
{
|
||||
public const uint LayoutId = 0x21000006u;
|
||||
|
||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||
private const uint RootId = 0x1000000Eu;
|
||||
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
private const uint MenuId = 0x10000014u;
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField
|
||||
private const uint SendId = 0x10000019u;
|
||||
private const uint MaxMinId = 0x1000046Fu;
|
||||
|
||||
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
|
||||
private const uint TrackSprite = 0x06004C5Fu;
|
||||
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
|
||||
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
|
||||
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
|
||||
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
|
||||
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
|
||||
|
||||
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
|
||||
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
|
||||
|
||||
// Channel menu sprite ids (confirmed in chat element dump).
|
||||
private const uint MenuNormal = 0x06004D65u; // button face
|
||||
private const uint MenuPressed = 0x06004D66u; // button pressed
|
||||
private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C)
|
||||
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
|
||||
private const uint MenuItemSelected = 0x0600124Du; // active channel row
|
||||
|
||||
// ── Public surface ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
|
||||
public UiElement Root { get; private set; } = null!;
|
||||
|
||||
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||
public UiText Transcript { get; private set; } = null!;
|
||||
|
||||
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||
public UiField Input { get; private set; } = null!;
|
||||
|
||||
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
|
||||
public UiScrollbar Scrollbar { get; private set; } = null!;
|
||||
|
||||
/// <summary>Channel-selector menu widget.</summary>
|
||||
public UiMenu Menu { get; private set; } = null!;
|
||||
|
||||
// ── Private state ──────────────────────────────────────────────────────
|
||||
|
||||
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
||||
|
||||
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
||||
|
||||
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||
{
|
||||
("Squelch (ignore)", null),
|
||||
("Tell to Selected", null),
|
||||
("Chat to All", ChatChannelKind.Say),
|
||||
("Tell to Fellows", ChatChannelKind.Fellowship),
|
||||
("Tell to General Chat", ChatChannelKind.General),
|
||||
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
||||
("Tell to Society Chat", ChatChannelKind.Society),
|
||||
("Tell to Monarch", ChatChannelKind.Monarch),
|
||||
("Tell to Patron", ChatChannelKind.Patron),
|
||||
("Tell to Vassals", ChatChannelKind.Vassals),
|
||||
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
||||
("Tell to Trade Chat", ChatChannelKind.Trade),
|
||||
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
||||
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
||||
{
|
||||
ChatChannelKind.Say => "Chat",
|
||||
ChatChannelKind.General => "General",
|
||||
ChatChannelKind.Trade => "Trade",
|
||||
ChatChannelKind.Lfg => "LFG",
|
||||
ChatChannelKind.Fellowship => "Fellow",
|
||||
ChatChannelKind.Allegiance => "Alleg",
|
||||
ChatChannelKind.Patron => "Patron",
|
||||
ChatChannelKind.Vassals => "Vassals",
|
||||
ChatChannelKind.Monarch => "Monarch",
|
||||
ChatChannelKind.Roleplay => "Roleplay",
|
||||
ChatChannelKind.Society => "Society",
|
||||
ChatChannelKind.Olthoi => "Olthoi",
|
||||
_ => "Chat",
|
||||
};
|
||||
|
||||
private static bool ChannelAvailable(ChatChannelKind k)
|
||||
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
||||
|
||||
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
|
||||
private float _normalHeight;
|
||||
/// <summary>Window top before maximize.</summary>
|
||||
private float _normalTop;
|
||||
private bool _maximized;
|
||||
|
||||
// ── Factory ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported chat layout to live behavior.
|
||||
///
|
||||
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
|
||||
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
|
||||
/// so rects in the info tree match the widget geometry in the layout tree.
|
||||
///
|
||||
/// Returns <c>null</c> if the essential transcript/input panels are missing from
|
||||
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
|
||||
/// </summary>
|
||||
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
|
||||
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
|
||||
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
||||
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
|
||||
/// <param name="busProvider">Factory that returns the live command bus at submit time.
|
||||
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
|
||||
/// even when the live session is established AFTER <see cref="Bind"/> runs
|
||||
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
|
||||
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
|
||||
/// <param name="debugFont">Fallback debug bitmap font (used when
|
||||
/// <paramref name="datFont"/> is null).</param>
|
||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
||||
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
|
||||
public static ChatWindowController? Bind(
|
||||
ElementInfo rootInfo,
|
||||
ImportedLayout layout,
|
||||
ChatVM vm,
|
||||
Func<ICommandBus> busProvider,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
// The transcript is built as a UiText by the factory (Type 12).
|
||||
// The input node (0x10000016) is also Type-12 → UiText, but the controller replaces
|
||||
// it with a UiField. Read its rect from the raw ElementInfo tree first.
|
||||
var iInfo = FindInfo(rootInfo, InputId);
|
||||
|
||||
// Their parent panels must exist as real widgets in the layout tree.
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
var inputBar = layout.FindElement(InputBarId);
|
||||
|
||||
if (iInfo is null || transcriptPanel is null || inputBar is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
||||
$"(iInfo={iInfo is not null}, " +
|
||||
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||
$"chat window will not be interactive.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
|
||||
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
|
||||
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
||||
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
|
||||
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
|
||||
// so using layout.Root would render the strays overlapping the real window (the
|
||||
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
|
||||
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
||||
// orphaning the strays so they never draw.
|
||||
var window = layout.FindElement(RootId) ?? layout.Root;
|
||||
var c = new ChatWindowController { Root = window };
|
||||
|
||||
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
|
||||
// juts out of the content-width window. The host wraps this content in the
|
||||
// universal nine-slice chrome, whose grips provide the resize affordance.
|
||||
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
|
||||
rbParent.RemoveChild(resizeBar);
|
||||
|
||||
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
|
||||
// grow the transcript panel up to the window top so its dark bg fills the strip.
|
||||
// Otherwise the root element's brown bg shows through as a sliver along the top.
|
||||
transcriptPanel.Top = 0f;
|
||||
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
||||
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
||||
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
c.Transcript.Font = debugFont;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it
|
||||
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry
|
||||
// is a controller-placed UiField at the same rect — drop the placeholder, add the field.
|
||||
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder)
|
||||
inParent.RemoveChild(inputPlaceholder);
|
||||
c.Input = new UiField
|
||||
{
|
||||
Left = iInfo.X,
|
||||
Top = iInfo.Y,
|
||||
Width = iInfo.Width,
|
||||
Height = iInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||
DatFont = datFont,
|
||||
Font = debugFont,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field
|
||||
SpriteResolve = resolve,
|
||||
FocusFieldSprite = InputFocusField,
|
||||
};
|
||||
inputBar.AddChild(c.Input);
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
var track = layout.FindElement(TrackId);
|
||||
if (track is UiScrollbar bar)
|
||||
{
|
||||
float oldTop = bar.Top;
|
||||
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||
bar.Height = bar.Height + oldTop;
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve = resolve;
|
||||
bar.TrackSprite = TrackSprite;
|
||||
bar.ThumbSprite = ThumbSprite;
|
||||
bar.ThumbTopSprite = ThumbTopSprite;
|
||||
bar.ThumbBotSprite = ThumbBotSprite;
|
||||
bar.UpSprite = UpSprite;
|
||||
bar.DownSprite = DownSprite;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
|
||||
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
||||
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||
{
|
||||
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
||||
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
||||
menu.PopupBgSprite = MenuPopupBg;
|
||||
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
||||
menu.Items = System.Array.ConvertAll(ChannelItems,
|
||||
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
||||
menu.Selected = (object?)c._activeChannel;
|
||||
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
|
||||
// retail; only the talk-CHANNEL items grey when unavailable.
|
||||
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
||||
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
||||
// The widget reports the pick; the controller owns Selected. Only a talk-channel
|
||||
// payload updates the active channel + highlight — the null-payload specials are
|
||||
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
|
||||
menu.OnSelect = p =>
|
||||
{
|
||||
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
||||
};
|
||||
c.Menu = menu;
|
||||
}
|
||||
|
||||
// ── Send button — Enter-alternate submit trigger ──────────────────
|
||||
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
|
||||
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||
{
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
// The Send sprite is a blank gold button — retail draws the caption as text.
|
||||
sendEl.Label = "Send";
|
||||
sendEl.LabelFont = datFont;
|
||||
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
}
|
||||
|
||||
// ── Size the channel button to its label + reflow the input field ─
|
||||
// Retail's talk-focus button autosizes to the selected channel name; the input
|
||||
// field then fills the gap from the button's right edge to the Send button. The
|
||||
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
|
||||
// arrow are accounted for), so widen it to its content and shift the input.
|
||||
// Recompute on every channel change (the button grows/shrinks with the label).
|
||||
if (c.Menu is not null)
|
||||
{
|
||||
float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge
|
||||
void ReflowInputRow()
|
||||
{
|
||||
c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth());
|
||||
c.Menu.ResetAnchorCapture();
|
||||
c.Input.Left = c.Menu.Left + c.Menu.Width;
|
||||
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
|
||||
c.Input.ResetAnchorCapture();
|
||||
}
|
||||
var onSelect = c.Menu.OnSelect;
|
||||
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
||||
ReflowInputRow();
|
||||
}
|
||||
|
||||
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
||||
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
||||
{
|
||||
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
||||
// right-anchored), so at content width they overlap. Retail shows max/min
|
||||
// just LEFT of the scrollbar column — shift it one button-width left.
|
||||
if (track is not null)
|
||||
maxMinEl.Left = track.Left - maxMinEl.Width;
|
||||
maxMinEl.OnClick = c.ToggleMaximize;
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Max/min implementation ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Toggle between the normal chat window height and an expanded 320px height.
|
||||
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
|
||||
/// retail stores the pre-maximize height and restores it on a second click.
|
||||
/// The 320px expanded size is the approximate retail maximized chat height.
|
||||
/// </summary>
|
||||
private void ToggleMaximize()
|
||||
{
|
||||
if (!_maximized)
|
||||
{
|
||||
_normalHeight = Root.Height;
|
||||
_normalTop = Root.Top;
|
||||
// Expand upward: move the top edge up so the bottom stays anchored.
|
||||
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
|
||||
Root.Height = 320f;
|
||||
_maximized = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Root.Top = _normalTop;
|
||||
Root.Height = _normalHeight;
|
||||
_maximized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
|
||||
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
|
||||
/// </summary>
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the ChatVM's detailed lines to the transcript's
|
||||
/// <see cref="UiText.Line"/> record format, applying retail-faithful
|
||||
/// per-<see cref="ChatKind"/> colors.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiText.Line> BuildLines(
|
||||
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
|
||||
{
|
||||
var detailed = vm.RecentLinesDetailed();
|
||||
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
|
||||
|
||||
// Word-wrap each message to the transcript's current pixel width (ports retail
|
||||
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
||||
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
|
||||
float maxW = view.Width - 2f * view.Padding;
|
||||
Func<string, float> measure =
|
||||
datFont is { } df ? s => df.MeasureWidth(s)
|
||||
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
||||
: s => s.Length * 7f;
|
||||
|
||||
var result = new List<UiText.Line>(detailed.Count);
|
||||
foreach (var d in detailed)
|
||||
{
|
||||
var color = RetailChatColor(d.Kind);
|
||||
foreach (var frag in WrapText(d.Text, maxW, measure))
|
||||
result.Add(new UiText.Line(frag, color));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
||||
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
||||
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
||||
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
||||
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
||||
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
||||
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
||||
/// </summary>
|
||||
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
|
||||
{
|
||||
yield return text ?? string.Empty;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var line = new System.Text.StringBuilder();
|
||||
foreach (var word in text.Split(' '))
|
||||
{
|
||||
string sep = line.Length > 0 ? " " : string.Empty;
|
||||
if (measure(line.ToString() + sep + word) <= maxW)
|
||||
{
|
||||
line.Append(sep).Append(word); // fits on the current line
|
||||
continue;
|
||||
}
|
||||
if (line.Length > 0 && measure(word) <= maxW)
|
||||
{
|
||||
yield return line.ToString(); // word fits alone → push to a new line
|
||||
line.Clear();
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
// Word too long for any single line: char-wrap it, packing onto the current
|
||||
// line's remaining space first (keeps the prefix with the message start).
|
||||
if (line.Length > 0) line.Append(' ');
|
||||
foreach (char ch in word)
|
||||
{
|
||||
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
|
||||
{
|
||||
yield return line.ToString();
|
||||
line.Clear();
|
||||
}
|
||||
line.Append(ch);
|
||||
}
|
||||
}
|
||||
if (line.Length > 0) yield return line.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
|
||||
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
|
||||
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
|
||||
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
|
||||
/// The four common kinds (speech/tell/channel/system) are confirmed by the named
|
||||
/// symbols + universal AC convention; the rarer kinds map to the nearest named color.
|
||||
/// </summary>
|
||||
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
|
||||
{
|
||||
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite
|
||||
ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout)
|
||||
ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue
|
||||
ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple
|
||||
ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen
|
||||
ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast)
|
||||
ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
||||
ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
||||
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
|
||||
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
|
||||
};
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
||||
/// algorithm ports); everything else (and unknown Types) falls back to
|
||||
/// <see cref="UiDatElement"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
|
||||
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
|
||||
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
|
||||
/// prototype elements (no state media, no controller binding) draw nothing because
|
||||
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
|
||||
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
|
||||
/// walks two layers down to extract them: the two Type-3 container children
|
||||
/// ordered by <see cref="ElementInfo.ReadOrder"/> (back behind = lower, front
|
||||
/// on top = higher), then within each container the image children that carry
|
||||
/// a DirectState ("" key) sprite, ordered by their X position to obtain
|
||||
/// left-cap / center-tile / right-cap.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The expand-detail overlay present in the front container carries ONLY named
|
||||
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
||||
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
||||
/// automatically.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DatWidgetFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
|
||||
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
|
||||
/// </summary>
|
||||
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
|
||||
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
|
||||
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
|
||||
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
||||
public static UiElement? Create(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
||||
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
||||
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
|
||||
// editable fields — retail draws those as inert media-bearing Fields, which our
|
||||
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
|
||||
// affordance a UiField would add). The one true editable field, the chat input
|
||||
// (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3
|
||||
// stays on the generic fallback here; register it as UiField only when a window
|
||||
// actually carries a factory-built editable Type-3 field (and UiField grows a
|
||||
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
|
||||
// still ships — it just isn't wired into the factory switch yet.
|
||||
UiElement e = info.Type switch
|
||||
{
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
|
||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
};
|
||||
|
||||
// Propagate position + size (pixel-exact from the dat).
|
||||
e.Left = info.X;
|
||||
e.Top = info.Y;
|
||||
e.Width = info.Width;
|
||||
e.Height = info.Height;
|
||||
|
||||
// Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly.
|
||||
e.ZOrder = (int)info.ReadOrder;
|
||||
|
||||
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
||||
// UI layout engine uses for reflow.
|
||||
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Meter ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
|
||||
/// child/grandchild elements (format doc §11). Two shapes are handled:
|
||||
///
|
||||
/// <para>
|
||||
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
|
||||
/// <code>
|
||||
/// meter (Type 7)
|
||||
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
|
||||
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
|
||||
/// │ ├── center image (DirectState "" → File = back-tile sprite)
|
||||
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
|
||||
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
|
||||
/// │ ├── left-cap image (→ front-left sprite)
|
||||
/// │ ├── center image (→ front-tile sprite)
|
||||
/// │ ├── right-cap image (→ front-right sprite)
|
||||
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
|
||||
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3
|
||||
/// child, no grandchildren): the back-track sprite is on the meter element's own DirectState;
|
||||
/// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the
|
||||
/// TILE slot (Back/FrontTile) with left/right caps 0, so <see cref="UiMeter.DrawHBar"/> tiles
|
||||
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
|
||||
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
|
||||
/// <code>
|
||||
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
|
||||
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
|
||||
/// They are bound to the live stat providers by the controller (VitalsController /
|
||||
/// SelectedObjectController).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static UiMeter BuildMeter(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var m = new UiMeter
|
||||
{
|
||||
SpriteResolve = resolve,
|
||||
DatFont = datFont,
|
||||
};
|
||||
|
||||
// The two 3-slice containers are Type-3 children of the meter element.
|
||||
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
|
||||
// (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
|
||||
var containers = info.Children
|
||||
.Where(c => c.Type == 3)
|
||||
.OrderBy(c => c.ReadOrder)
|
||||
.ToList();
|
||||
|
||||
if (containers.Count >= 2)
|
||||
{
|
||||
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
|
||||
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
|
||||
var (bl, bt, br) = SliceIds(containers[0]);
|
||||
m.BackLeft = bl;
|
||||
m.BackTile = bt;
|
||||
m.BackRight = br;
|
||||
|
||||
var (fl, ft, fr) = SliceIds(containers[1]);
|
||||
m.FrontLeft = fl;
|
||||
m.FrontTile = ft;
|
||||
m.FrontRight = fr;
|
||||
}
|
||||
else if (containers.Count == 1)
|
||||
{
|
||||
// Single-image shape used by the toolbar selected-object meters
|
||||
// (health 0x100001A1, mana 0x100001A2).
|
||||
// - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of
|
||||
// info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E.
|
||||
// - The fill sprite lives on the single Type-3 child's own DirectState ("" key of
|
||||
// containers[0].StateMedia). e.g. health fill = 0x0600193F.
|
||||
// The fill child has NO image grandchildren, so SliceIds would return all-zero —
|
||||
// read the container's StateMedia directly instead.
|
||||
//
|
||||
// These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal,
|
||||
// which retail renders as "tile at native width to fill the full element geometry"
|
||||
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
|
||||
// DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction).
|
||||
// With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the
|
||||
// sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and
|
||||
// mapping the fill fraction to native width when nativeW < 140. The tile slot makes
|
||||
// midW = full bar width, so the back tiles across all 140px and the front clips to
|
||||
// 140*fraction correctly for any native sprite width (left/right caps unused = 0).
|
||||
// (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574)
|
||||
m.BackLeft = 0;
|
||||
m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u;
|
||||
m.BackRight = 0;
|
||||
|
||||
m.FrontLeft = 0;
|
||||
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
|
||||
m.FrontRight = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Count == 0: no Type-3 containers at all — genuinely malformed meter dat.
|
||||
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback.");
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
|
||||
/// extracting them from the container's image children that carry a DirectState
|
||||
/// ("" key) with a non-zero file id, ordered left-to-right by their X position.
|
||||
///
|
||||
/// <para>
|
||||
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
|
||||
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
|
||||
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
|
||||
/// false.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static (uint left, uint tile, uint right) SliceIds(ElementInfo container)
|
||||
{
|
||||
// Only children that have a non-zero DirectState image are slice candidates.
|
||||
// The expand-detail overlay has NO DirectState entry, so it's excluded here.
|
||||
// Project the File during filtering to avoid a second TryGetValue lookup.
|
||||
// Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins.
|
||||
var slices = container.Children
|
||||
.Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0)
|
||||
.Select(c => (c.X, File: c.StateMedia[""].File))
|
||||
.OrderBy(t => t.X)
|
||||
.ToList();
|
||||
|
||||
uint left = slices.Count > 0 ? slices[0].File : 0u;
|
||||
uint tile = slices.Count > 1 ? slices[1].File : 0u;
|
||||
uint right = slices.Count > 2 ? slices[2].File : 0u;
|
||||
|
||||
return (left, tile, right);
|
||||
}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The element's
|
||||
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
|
||||
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
|
||||
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
|
||||
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? m.File : 0u;
|
||||
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// GL-free, dat-free snapshot of a resolved layout element.
|
||||
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
|
||||
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
|
||||
/// operate on this type so they can be unit-tested without the dats or OpenGL.
|
||||
///
|
||||
/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without
|
||||
/// updating the plan spec and downstream consumers.
|
||||
/// </summary>
|
||||
public sealed class ElementInfo
|
||||
{
|
||||
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
|
||||
public uint Id;
|
||||
|
||||
/// <summary>
|
||||
/// Raw element class id as a uint.
|
||||
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
|
||||
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
|
||||
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
|
||||
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
|
||||
/// </summary>
|
||||
public uint Type;
|
||||
|
||||
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
|
||||
public float X, Y, Width, Height;
|
||||
|
||||
/// <summary>
|
||||
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
|
||||
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
|
||||
/// Values 0–4; map to <see cref="AnchorEdges"/> bit-flags via
|
||||
/// <see cref="ElementReader.ToAnchors"/>.
|
||||
/// </summary>
|
||||
public uint Left, Top, Right, Bottom;
|
||||
|
||||
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
|
||||
public uint ReadOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
|
||||
/// </summary>
|
||||
public uint FontDid;
|
||||
|
||||
/// <summary>
|
||||
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
|
||||
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
|
||||
/// Named states use the <c>UIStateId.ToString()</c> value as the key
|
||||
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
|
||||
/// </summary>
|
||||
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||
|
||||
/// <summary>
|
||||
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
|
||||
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
|
||||
/// Used by <see cref="UiDatElement"/> to pick which state's sprite to render initially.
|
||||
/// Examples: <c>"Normal"</c> (Send button), <c>"Minimized"</c> (max/min button), <c>""</c> (DirectState).
|
||||
/// </summary>
|
||||
public string DefaultStateName = "";
|
||||
|
||||
/// <summary>
|
||||
/// Resolved child elements (populated by the importer in Task 5).
|
||||
/// Children come from the derived element's own tree, not the base element's.
|
||||
/// </summary>
|
||||
public List<ElementInfo> Children = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
|
||||
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
|
||||
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
|
||||
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
|
||||
/// </summary>
|
||||
public static class ElementReader
|
||||
{
|
||||
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange
|
||||
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the
|
||||
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right
|
||||
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 =
|
||||
/// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier
|
||||
/// format-doc §4 reading, which was wrong (it made every piece fixed-width).</summary>
|
||||
/// <param name="left">LeftEdge dat field value (0–4).</param>
|
||||
/// <param name="top">TopEdge dat field value (0–4).</param>
|
||||
/// <param name="right">RightEdge dat field value (0–4).</param>
|
||||
/// <param name="bottom">BottomEdge dat field value (0–4).</param>
|
||||
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
||||
{
|
||||
var a = AnchorEdges.None;
|
||||
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
||||
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
|
||||
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
||||
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
|
||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a base element snapshot with a derived element snapshot, mirroring
|
||||
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
|
||||
///
|
||||
/// <para>
|
||||
/// Rules:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
|
||||
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
|
||||
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
|
||||
/// inherited from base.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
|
||||
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
|
||||
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
|
||||
/// (derived placement, not the base prototype's geometry).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
|
||||
/// entries override (or add) per state name key.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
|
||||
{
|
||||
var m = new ElementInfo
|
||||
{
|
||||
Id = derived.Id != 0 ? derived.Id : base_.Id,
|
||||
// Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type.
|
||||
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
|
||||
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
|
||||
// numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
|
||||
// A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
|
||||
// Width/Height, or explicit handling of Type 0 before the merge).
|
||||
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||
X = derived.X,
|
||||
Y = derived.Y,
|
||||
// NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This
|
||||
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
|
||||
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
|
||||
// If a real zero-size derived element ever needs to override a non-zero base in
|
||||
// Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5).
|
||||
Width = derived.Width != 0 ? derived.Width : base_.Width,
|
||||
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||
Left = derived.Left,
|
||||
Top = derived.Top,
|
||||
Right = derived.Right,
|
||||
Bottom = derived.Bottom,
|
||||
ReadOrder = derived.ReadOrder,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// Children come from the derived element's own tree, not the base prototype's.
|
||||
// Defensive copy: prevent a later mutation of either the merged result or the input
|
||||
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully
|
||||
// populated by the recursive importer BEFORE Merge is called and never mutated after).
|
||||
Children = new List<ElementInfo>(derived.Children),
|
||||
};
|
||||
// Start with base StateMedia as defaults, then let derived entries override.
|
||||
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
|
||||
foreach (var kv in derived.StateMedia)
|
||||
m.StateMedia[kv.Key] = kv.Value;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The result of importing a retail LayoutDesc: a <see cref="UiElement"/> tree with
|
||||
/// an O(1) lookup table for finding any element by its dat id.
|
||||
/// </summary>
|
||||
public sealed class ImportedLayout
|
||||
{
|
||||
/// <summary>Root widget of the imported tree.</summary>
|
||||
public UiElement Root { get; }
|
||||
|
||||
private readonly Dictionary<uint, UiElement> _byId;
|
||||
|
||||
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
Root = root;
|
||||
_byId = byId;
|
||||
}
|
||||
|
||||
/// <summary>Find a widget by its dat element id (e.g. <c>0x100000E6</c>).
|
||||
/// Returns null if the id was skipped (Type-12 prototype) or not present.</summary>
|
||||
public UiElement? FindElement(uint id)
|
||||
=> _byId.TryGetValue(id, out var e) ? e : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-layer layout importer for retail LayoutDesc dat objects.
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Pure layer</strong> (<see cref="Build"/> / <see cref="BuildFromInfos"/>):
|
||||
/// converts a pre-resolved <see cref="ElementInfo"/> tree into a <see cref="UiElement"/>
|
||||
/// tree via <see cref="DatWidgetFactory"/>. Testable without dats or OpenGL — all tests
|
||||
/// in <c>LayoutImporterTests.cs</c> exercise this layer only.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Dat shell</strong> (<see cref="Import"/>): reads a <see cref="LayoutDesc"/>,
|
||||
/// converts each top-level <see cref="ElementDesc"/> to a fully resolved
|
||||
/// <see cref="ElementInfo"/> (applying <c>BaseElement</c> / <c>BaseLayoutId</c>
|
||||
/// inheritance with a cycle guard), then delegates to <see cref="Build"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Meter elements (Type 7) consume their own dat-children: <see cref="DatWidgetFactory"/>
|
||||
/// reads the grandchild slice-sprite ids during <see cref="UiMeter"/> construction, so the
|
||||
/// children must NOT be added as separate <see cref="UiElement"/> nodes in the tree.
|
||||
/// Every other element type recurses its children generically.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LayoutImporter
|
||||
{
|
||||
// ── Pure layer ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Convenience for tests: attach <paramref name="children"/> to
|
||||
/// <paramref name="rootInfo"/>, then call <see cref="Build"/>.
|
||||
/// The children list is set directly on <paramref name="rootInfo"/>;
|
||||
/// any existing children are replaced.
|
||||
/// </summary>
|
||||
public static ImportedLayout BuildFromInfos(
|
||||
ElementInfo rootInfo,
|
||||
IEnumerable<ElementInfo> children,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
rootInfo.Children = new List<ElementInfo>(children);
|
||||
return Build(rootInfo, resolve, datFont);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure builder: produce the widget tree from a fully resolved
|
||||
/// <see cref="ElementInfo"/> tree (children already attached).
|
||||
/// </summary>
|
||||
public static ImportedLayout Build(
|
||||
ElementInfo rootInfo,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
// Root is never a Type-12 prototype in practice; fall back to a generic
|
||||
// container if the factory returns null for an exotic root type.
|
||||
var root = BuildWidget(rootInfo, resolve, datFont, byId);
|
||||
if (root is null)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
|
||||
root = new UiDatElement(rootInfo, resolve);
|
||||
}
|
||||
return new ImportedLayout(root, byId);
|
||||
}
|
||||
|
||||
private static UiElement? BuildWidget(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont);
|
||||
if (w is null) return null; // Type-12 style prototype — skip
|
||||
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
|
||||
// Behavioral widgets that draw their full appearance + reproduce their dat
|
||||
// sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps,
|
||||
// Button labels, Scrollbar arrows) CONSUME their dat children — building those as
|
||||
// separate widgets double-draws and lets an invisible child steal pointer/focus
|
||||
// from the behavioral widget (e.g. the channel Menu's label child intercepting the
|
||||
// button click). Only generic containers (UiDatElement, panels) recurse. See
|
||||
// UiElement.ConsumesDatChildren.
|
||||
if (!w.ConsumesDatChildren)
|
||||
{
|
||||
foreach (var child in info.Children)
|
||||
{
|
||||
var cw = BuildWidget(child, resolve, datFont, byId);
|
||||
if (cw is not null) w.AddChild(cw);
|
||||
}
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
// ── Dat shell ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the
|
||||
/// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests.
|
||||
/// Returns null if the layout is missing.
|
||||
/// </summary>
|
||||
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
|
||||
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
|
||||
public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId)
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) return null;
|
||||
|
||||
// Collect the set of element ids that are referenced as a BaseElement by ANY
|
||||
// element in THIS layout (where BaseLayoutId == layoutId). Such elements are
|
||||
// purely inheritance templates ("prototypes") — retail never instantiates them
|
||||
// as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc
|
||||
// 0x21000016, which all 18 slot elements inherit from and which has no own media.
|
||||
//
|
||||
// NOTE: the Resolve path reads BaseElement from the raw dat directly (via
|
||||
// dats.Get<LayoutDesc>), so the prototype never needs to appear in the built
|
||||
// widget tree for inheritance to work. Skipping it here is safe.
|
||||
var referencedAsBase = new HashSet<uint>();
|
||||
foreach (var kv in ld.Elements)
|
||||
CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase);
|
||||
|
||||
var tops = new List<ElementInfo>();
|
||||
foreach (var kv in ld.Elements)
|
||||
{
|
||||
// Skip pure prototype elements: top-level elements that are referenced as a
|
||||
// base template by another element in this same layout AND have no own state
|
||||
// media (so they draw nothing and contribute nothing but their inherited shape).
|
||||
var d = kv.Value;
|
||||
if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d))
|
||||
{
|
||||
Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement).");
|
||||
continue;
|
||||
}
|
||||
|
||||
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
|
||||
}
|
||||
|
||||
return tops.Count == 1
|
||||
? tops[0]
|
||||
: new ElementInfo { Id = 0, Type = 3, Children = tops };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
|
||||
/// element, and build the widget tree. Returns null if the layout is absent
|
||||
/// from the dats.
|
||||
/// </summary>
|
||||
public static ImportedLayout? Import(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
var rootInfo = ImportInfos(dats, layoutId);
|
||||
if (rootInfo is null) return null;
|
||||
return Build(rootInfo, resolve, datFont);
|
||||
}
|
||||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
|
||||
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
|
||||
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
|
||||
/// </summary>
|
||||
private static ElementInfo Resolve(
|
||||
DatCollection dats,
|
||||
ElementDesc d,
|
||||
HashSet<(uint layoutId, uint elementId)> baseChain)
|
||||
{
|
||||
// Read this element's own fields + media (no inheritance, no children yet).
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
|
||||
// Apply BaseElement / BaseLayoutId inheritance if present.
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0
|
||||
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
|
||||
{
|
||||
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
|
||||
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
|
||||
if (baseDesc is not null)
|
||||
{
|
||||
// Recurse the base chain (already guarded by the HashSet add above).
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
// Derived fields override the base; result.Children is still empty here
|
||||
// — children are attached below from the DERIVED element's own tree.
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
foreach (var kv in d.Children)
|
||||
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
|
||||
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
|
||||
/// attached (the caller handles those).
|
||||
/// </summary>
|
||||
private static ElementInfo ToInfo(ElementDesc d)
|
||||
{
|
||||
// Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when
|
||||
// no default is set; map those to "" so UiDatElement treats them as "no preference".
|
||||
var defState = d.DefaultState.ToString();
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = d.ElementId,
|
||||
Type = d.Type,
|
||||
X = (float)d.X,
|
||||
Y = (float)d.Y,
|
||||
Width = (float)d.Width,
|
||||
Height = (float)d.Height,
|
||||
Left = d.LeftEdge,
|
||||
Top = d.TopEdge,
|
||||
Right = d.RightEdge,
|
||||
Bottom = d.BottomEdge,
|
||||
ReadOrder = d.ReadOrder,
|
||||
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
|
||||
};
|
||||
|
||||
// DirectState (unnamed, key "").
|
||||
if (d.StateDesc is not null)
|
||||
ReadState(d.StateDesc, "", info);
|
||||
|
||||
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
|
||||
foreach (var s in d.States)
|
||||
ReadState(s.Value, s.Key.ToString(), info);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
|
||||
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
||||
/// </summary>
|
||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||
{
|
||||
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
|
||||
// are intentionally skipped — cursor behavior is Plan 2.
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (m is MediaDescImage img && img.File != 0)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
|
||||
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
|
||||
if (info.FontDid == 0 && sd.Properties is not null
|
||||
&& sd.Properties.TryGetValue(0x1Au, out var raw)
|
||||
&& raw is ArrayBaseProperty arr && arr.Value.Count > 0
|
||||
&& arr.Value[0] is DataIdBaseProperty did)
|
||||
{
|
||||
info.FontDid = did.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prototype detection helpers ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Recursively walks <paramref name="d"/> and all its children, adding to
|
||||
/// <paramref name="result"/> the <c>BaseElement</c> of every descriptor that
|
||||
/// references this layout (<c>BaseLayoutId == layoutId</c>). Used by
|
||||
/// <see cref="ImportInfos"/> to identify pure prototype/template elements that
|
||||
/// should not be instantiated as live widgets.
|
||||
/// </summary>
|
||||
private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet<uint> result)
|
||||
{
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId == layoutId)
|
||||
result.Add(d.BaseElement);
|
||||
foreach (var kv in d.Children)
|
||||
CollectBaseRefsInDesc(kv.Value, layoutId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when <paramref name="d"/> carries no own state media — i.e. its
|
||||
/// <c>StateDesc</c> (DirectState) and <c>States</c> (named states) yield no
|
||||
/// <see cref="MediaDescImage"/> entries with a non-zero file id.
|
||||
/// Such elements are pure inheritance templates with no rendering content.
|
||||
/// </summary>
|
||||
private static bool HasNoOwnMedia(ElementDesc d)
|
||||
{
|
||||
// Re-use ToInfo's media extraction: if the resulting StateMedia is empty the
|
||||
// element has no renderable image in any state.
|
||||
var info = ToInfo(d);
|
||||
return info.StateMedia.Count == 0;
|
||||
}
|
||||
|
||||
// ── Element tree search ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Find an <see cref="ElementDesc"/> by id anywhere in the top-level tree of
|
||||
/// <paramref name="ld"/> (depth-first). Returns null if not found.
|
||||
/// </summary>
|
||||
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
|
||||
{
|
||||
foreach (var kv in ld.Elements)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
|
||||
{
|
||||
if (d.ElementId == id) return d;
|
||||
foreach (var kv in d.Children)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A1).
|
||||
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
|
||||
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
|
||||
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// On selection change: clears the strip (name, overlay flash, health meter), then if a
|
||||
/// guid is provided it sets the name, flashes the selection overlay briefly, and (for
|
||||
/// health-bearing targets) sends a <c>QueryHealth (0x01BF)</c> request. The Health meter
|
||||
/// becomes visible only when the server actually reports health for the selected guid —
|
||||
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
|
||||
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) or the value is already
|
||||
/// cached. So a friendly NPC you have not assessed shows name-only (no bar), and a
|
||||
/// monster's bar appears after damage / a successful assess — matching retail.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Retail element roles</strong> (PostInit, <c>:198119</c>): <c>m_pSelObjectField</c>
|
||||
/// is the container <c>0x1000019E</c> whose <c>SetState(0x1000000b/0c)</c> drives a
|
||||
/// 0.25s <c>Pause→Normal</c> flash that cascades to the overlay child's green frame.
|
||||
/// acdream has no state-cascade / transition-animation system, so this controller drives
|
||||
/// the overlay element <c>0x100001A0</c> directly and reverts it after the same
|
||||
/// <see cref="FlashSeconds"/> to reproduce the brief flash. The name element
|
||||
/// <c>0x1000019F</c> is bumped to the top of the strip's z-order so it draws OVER the
|
||||
/// overlay frame and the health bar (retail draws the name over the bar — see the
|
||||
/// "Drudge Slinker" reference shot).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Divergence — health-target gate approximation.</strong>
|
||||
/// Retail sends <c>Event_QueryHealth</c> for <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>
|
||||
/// (<c>:198754</c>). acdream uses <c>IsLiveCreatureTarget</c> (the <c>ItemType.Creature</c>
|
||||
/// flag) to gate the QueryHealth send. Visibility itself is health-data-driven (above), so
|
||||
/// the gate only affects whether we proactively query; recorded in the divergence register.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SelectedObjectController
|
||||
{
|
||||
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
|
||||
/// <summary>Selected-object container / field element id (retail m_pSelObjectField).</summary>
|
||||
public const uint ContainerId = 0x1000019E;
|
||||
/// <summary>Selected-object name element id (retail m_pSelObjectName, UIElement_Text).</summary>
|
||||
public const uint NameId = 0x1000019F;
|
||||
/// <summary>Selected-object overlay element id (states: ObjectSelected / StackedItemSelected).</summary>
|
||||
public const uint OverlayId = 0x100001A0;
|
||||
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
|
||||
public const uint HealthMeterId = 0x100001A1;
|
||||
|
||||
/// <summary>Selection-overlay flash duration — retail's container ObjectSelected state is a
|
||||
/// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E).</summary>
|
||||
private const double FlashSeconds = 0.25;
|
||||
|
||||
/// <summary>Z-order for the name so it draws OVER the overlay frame + health bar.
|
||||
/// The strip's other children sit at ReadOrder 1–4; this floats the name to the top.</summary>
|
||||
private const int NameZOrderOnTop = 1_000_000;
|
||||
|
||||
/// <summary>Z-order for the selection-flash overlay — above the health meter (so the green
|
||||
/// flash isn't hidden by the bar) but below the name (so the name stays readable).</summary>
|
||||
private const int OverlayZOrder = NameZOrderOnTop - 1;
|
||||
|
||||
/// <summary>Height (px) of the black name band at the top of the 31px bar sprite. The name
|
||||
/// label is constrained to this band (top-aligned) so the health bar shows below it —
|
||||
/// retail "name on the black, bar below". The bar sprite's colored region starts ~y14.</summary>
|
||||
private const float NameBandHeight = 15f;
|
||||
|
||||
// ── Found elements (any may be null for partial/test layouts) ───────────
|
||||
private readonly UiElement? _name;
|
||||
private readonly UiDatElement? _overlay;
|
||||
private readonly UiMeter? _healthMeter;
|
||||
|
||||
// ── Captured delegates ───────────────────────────────────────────────────
|
||||
private readonly Func<uint, bool> _isHealthTarget;
|
||||
private readonly Func<uint, string?> _resolveName;
|
||||
private readonly Func<uint, float> _healthPercent;
|
||||
private readonly Func<uint, bool> _hasHealth;
|
||||
private readonly Func<uint, uint> _stackSize;
|
||||
private readonly Action<uint> _sendQueryHealth;
|
||||
|
||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||
private uint? _current;
|
||||
private string? _currentName;
|
||||
private double _flashRemaining; // > 0 while the selection overlay is flashing
|
||||
|
||||
/// <summary>White label color for the name line.</summary>
|
||||
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private SelectedObjectController(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged,
|
||||
Action<Action<uint, float>> subscribeHealthChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
Func<uint, string?> name,
|
||||
Func<uint, float> healthPercent,
|
||||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
_isHealthTarget = isHealthTarget;
|
||||
_resolveName = name;
|
||||
_healthPercent = healthPercent;
|
||||
_hasHealth = hasHealth;
|
||||
_stackSize = stackSize;
|
||||
_sendQueryHealth = sendQueryHealth;
|
||||
|
||||
// Find elements — silently skip absent ones (partial/test layouts).
|
||||
_name = layout.FindElement(NameId);
|
||||
_overlay = layout.FindElement(OverlayId) as UiDatElement;
|
||||
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
|
||||
|
||||
// The selection-flash overlay must draw OVER the health meter (which spans the whole
|
||||
// strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e.
|
||||
// for players/monsters). Float it just below the name so the name stays readable.
|
||||
if (_overlay is not null) _overlay.ZOrder = OverlayZOrder;
|
||||
|
||||
// This controller owns the health meter's initial-hidden state.
|
||||
if (_healthMeter is not null)
|
||||
{
|
||||
_healthMeter.Visible = false;
|
||||
// Fill polls live: _current holds the currently-selected guid (or null).
|
||||
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
|
||||
}
|
||||
|
||||
// Attach a centered UiText child to the name element for the object name display.
|
||||
// Mirrors VitalsController.BindMeter's number attach. The name is floated to the
|
||||
// top of the strip's z-order so it draws OVER the overlay frame and the health bar
|
||||
// (retail renders the object name over the bar).
|
||||
//
|
||||
// The bar sprite (0x0600193E/F, 146x31) carries a ~14px BLACK name band across its
|
||||
// TOP with the colored bar in the lower portion (confirmed from the dat). Retail
|
||||
// draws the object name in that black band with the health bar BELOW it — so the
|
||||
// label is TOP-aligned by constraining its height to the band, not centered over the
|
||||
// whole 31px strip (which overlapped the bar's middle).
|
||||
if (_name is not null)
|
||||
{
|
||||
_name.ZOrder = NameZOrderOnTop;
|
||||
var label = new UiText
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Centered = true,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var n = _currentName;
|
||||
return string.IsNullOrEmpty(n)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(n, NameColor) };
|
||||
},
|
||||
};
|
||||
_name.AddChild(label);
|
||||
}
|
||||
|
||||
// Register the handlers LAST so the initial state is fully set up first.
|
||||
subscribeSelectionChanged(OnSelectionChanged);
|
||||
subscribeHealthChanged(OnHealthChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and bind a <see cref="SelectedObjectController"/> to <paramref name="layout"/>.
|
||||
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
||||
/// <param name="subscribeSelectionChanged">Called once with <see cref="OnSelectionChanged"/>
|
||||
/// (typical host: <c>h => SelectionChanged += h</c>).</param>
|
||||
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
|
||||
/// (typical host: <c>h => Combat.HealthChanged += h</c>) — drives meter visibility.</param>
|
||||
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
|
||||
/// (proxy for retail's <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>).</param>
|
||||
/// <param name="name">Returns the display name for a given guid (or null if unknown).</param>
|
||||
/// <param name="healthPercent">Returns the health fill fraction [0..1] for a given guid.</param>
|
||||
/// <param name="hasHealth">Returns true if real health has been received for a guid
|
||||
/// (so a re-selected, already-known target shows its bar immediately).</param>
|
||||
/// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param>
|
||||
/// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param>
|
||||
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
|
||||
public static SelectedObjectController Bind(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged,
|
||||
Action<Action<uint, float>> subscribeHealthChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
Func<uint, string?> name,
|
||||
Func<uint, float> healthPercent,
|
||||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
=> new SelectedObjectController(
|
||||
layout, subscribeSelectionChanged, subscribeHealthChanged,
|
||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||
/// clear-then-populate the selected-object strip on any selection change.
|
||||
/// </summary>
|
||||
public void OnSelectionChanged(uint? guid)
|
||||
{
|
||||
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
||||
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
||||
if (_healthMeter is not null) _healthMeter.Visible = false;
|
||||
_currentName = null;
|
||||
_current = guid;
|
||||
|
||||
if (guid is null)
|
||||
{
|
||||
// Deselect: clear the overlay flash immediately too.
|
||||
SetOverlayState("");
|
||||
_flashRemaining = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
uint g = guid.Value;
|
||||
|
||||
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
|
||||
_currentName = _resolveName(g);
|
||||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected");
|
||||
_flashRemaining = FlashSeconds;
|
||||
|
||||
// ── 4. Health: query, and show the meter only if real health is already known.
|
||||
// Otherwise the meter appears when OnHealthChanged fires for this guid
|
||||
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
|
||||
if (_isHealthTarget(g))
|
||||
{
|
||||
_sendQueryHealth(g);
|
||||
if (_hasHealth(g) && _healthMeter is not null)
|
||||
_healthMeter.Visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>): when the
|
||||
/// server reports health for the currently-selected guid, make the Health meter visible.
|
||||
/// The fill value is read live by the meter's <see cref="UiMeter.Fill"/> provider.
|
||||
/// </summary>
|
||||
public void OnHealthChanged(uint guid, float percent)
|
||||
{
|
||||
if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null)
|
||||
_healthMeter.Visible = true;
|
||||
}
|
||||
|
||||
/// <summary>Per-frame tick: reverts the selection overlay after the brief flash window.</summary>
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (_flashRemaining <= 0) return;
|
||||
_flashRemaining -= deltaSeconds;
|
||||
if (_flashRemaining <= 0)
|
||||
SetOverlayState(""); // flash done → overlay back to blank
|
||||
}
|
||||
|
||||
private void SetOverlayState(string state)
|
||||
{
|
||||
if (_overlay is not null) _overlay.ActiveState = state;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data —
|
||||
/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id,
|
||||
/// populates them from the persisted PlayerDescription shortcuts
|
||||
/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject
|
||||
/// arrives (SetDelayedShortcutNum), and on click uses the bound item
|
||||
/// (UseShortcut -> ItemHolder::UseObject -> use-item callback).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail reference: <c>gmToolbarUI::PostInit</c> grabs each slot widget by its
|
||||
/// id, calls <c>UpdateFromPlayerDesc</c> to flush-and-bind shortcuts from the
|
||||
/// PlayerDescription trailer, and hooks <c>OnEvent</c> for the Click case to fire
|
||||
/// <c>UseShortcut</c>. The deferred-rebind path matches
|
||||
/// <c>gmToolbarUI::SetDelayedShortcutNum</c> which re-tries binding after
|
||||
/// <c>CreateObject</c> resolves a formerly-unknown guid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ToolbarController
|
||||
{
|
||||
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
|
||||
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
|
||||
private static readonly uint[] SlotIds =
|
||||
{
|
||||
0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB,
|
||||
0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF,
|
||||
0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB,
|
||||
0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
|
||||
};
|
||||
|
||||
// Elements hidden by default in retail gmToolbarUI::PostInit.
|
||||
// Ids confirmed from the toolbar LayoutDesc dump.
|
||||
// 0x100001A1 (health meter) is now OWNED by SelectedObjectController (D.5.3a) —
|
||||
// it hides A1 at bind and shows it on a health-target selection, so A1 is removed
|
||||
// from here to avoid double-ownership. 0x100001A2 (mana meter), 0x100001A3 (stack-size
|
||||
// entry box) and 0x100001A4 (stack slider) are DEFERRED features (mana #140, stack-split
|
||||
// UI) with no controller yet, so they stay hidden here — otherwise their dat sprites
|
||||
// render as stray bars / a black box on the toolbar. Retail hides A3/A4 in
|
||||
// gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742),
|
||||
// showing them only for a stacked-item selection.
|
||||
private static readonly uint[] HiddenIds = { 0x100001A2, 0x100001A3, 0x100001A4 };
|
||||
|
||||
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
|
||||
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
|
||||
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669)
|
||||
// SetVisible's exactly one element depending on the incoming mode.
|
||||
private static readonly uint[] CombatIndicatorIds =
|
||||
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||
|
||||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||||
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||||
private readonly ClientObjectTable _repo;
|
||||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||||
|
||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||||
// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
|
||||
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance.
|
||||
// Occupancy branch (decomp 229481):
|
||||
// occupied → peace 0x10000042 / war 0x10000043 (split by stance)
|
||||
// empty → background digit 0x1000005e (stance-independent)
|
||||
private uint[]? _peaceDigits;
|
||||
private uint[]? _warDigits;
|
||||
private uint[]? _emptyDigits;
|
||||
private bool _peace = true; // true = NonCombat (peace), false = any war stance
|
||||
|
||||
private ToolbarController(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable repo,
|
||||
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Action<uint> useItem,
|
||||
CombatState? combatState,
|
||||
uint[]? peaceDigits,
|
||||
uint[]? warDigits,
|
||||
uint[]? emptyDigits)
|
||||
{
|
||||
_repo = repo;
|
||||
_shortcuts = shortcuts;
|
||||
_iconIds = iconIds;
|
||||
_useItem = useItem;
|
||||
_peaceDigits = peaceDigits;
|
||||
_warDigits = warDigits;
|
||||
_emptyDigits = emptyDigits;
|
||||
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
|
||||
if (_slots[i] is { } list)
|
||||
WireClick(list);
|
||||
}
|
||||
|
||||
// Cache the four mutually-exclusive combat-mode indicator elements.
|
||||
for (int i = 0; i < CombatIndicatorIds.Length; i++)
|
||||
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
|
||||
|
||||
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
|
||||
foreach (var id in HiddenIds)
|
||||
if (layout.FindElement(id) is { } e) e.Visible = false;
|
||||
|
||||
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
|
||||
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
|
||||
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.
|
||||
SetCombatMode(CombatMode.NonCombat);
|
||||
|
||||
// Wire live combat-mode changes if a CombatState was provided.
|
||||
if (combatState is not null)
|
||||
combatState.CombatModeChanged += SetCombatMode;
|
||||
|
||||
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
|
||||
// to our 18 shortcut guids — else every creature spawn in a busy zone
|
||||
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
|
||||
repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
|
||||
repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
|
||||
repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
|
||||
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
|
||||
/// </summary>
|
||||
private bool IsShortcutGuid(uint guid)
|
||||
{
|
||||
foreach (var sc in _shortcuts())
|
||||
if (sc.ObjectGuid == guid) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and bind a <see cref="ToolbarController"/> to <paramref name="layout"/>.
|
||||
/// Calls <see cref="Populate"/> immediately (binds whatever items are in the repo now).
|
||||
/// Returns the controller so the caller can call <see cref="Populate"/> again
|
||||
/// if the shortcut list is refreshed outside the repo-event path.
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
||||
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
|
||||
/// <param name="shortcuts">Provider for the current shortcut bar list.</param>
|
||||
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param>
|
||||
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
|
||||
/// <param name="combatState">
|
||||
/// Optional live combat state — when provided, the toolbar subscribes to
|
||||
/// <see cref="CombatState.CombatModeChanged"/> and updates the four mutually-exclusive
|
||||
/// combat-mode indicator elements accordingly.
|
||||
/// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator).
|
||||
/// </param>
|
||||
/// <param name="peaceDigits">
|
||||
/// Peace-mode digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element
|
||||
/// 0x1000034A under composite 0x10000346). Index i → slot label digit (i+1) RenderSurface id.
|
||||
/// Null if the dat lookup failed (no digits drawn). Retail reference:
|
||||
/// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||
/// </param>
|
||||
/// <param name="warDigits">War-mode digit DID array (property 0x10000043, same element).</param>
|
||||
/// <param name="emptyDigits">
|
||||
/// Empty-slot background digit DID array (property 0x1000005e, stance-independent).
|
||||
/// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
|
||||
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty state).
|
||||
/// Null if the dat lookup failed (empty slots draw no digit, which is safe).
|
||||
/// </param>
|
||||
public static ToolbarController Bind(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable repo,
|
||||
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Action<uint> useItem,
|
||||
CombatState? combatState = null,
|
||||
uint[]? peaceDigits = null,
|
||||
uint[]? warDigits = null,
|
||||
uint[]? emptyDigits = null)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||
peaceDigits, warDigits, emptyDigits);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
|
||||
/// each shortcut entry that has a resolved item in the repository.
|
||||
/// Entries whose item is not yet in the repo are silently skipped here; the
|
||||
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
|
||||
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
|
||||
/// </summary>
|
||||
public void Populate()
|
||||
{
|
||||
// Clear all slot cells first (flush).
|
||||
foreach (var list in _slots) list?.Cell.Clear();
|
||||
|
||||
foreach (var sc in _shortcuts())
|
||||
{
|
||||
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
|
||||
if (sc.Index >= (uint)_slots.Length) continue;
|
||||
var list = _slots[(int)sc.Index];
|
||||
if (list is null) continue;
|
||||
|
||||
var item = _repo.Get(sc.ObjectGuid);
|
||||
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
|
||||
|
||||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
list.Cell.SetItem(sc.ObjectGuid, tex);
|
||||
}
|
||||
|
||||
// Re-stamp slot number labels after any item change.
|
||||
// Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481):
|
||||
// occupied → peace 0x10000042 / war 0x10000043; empty → background 0x1000005e.
|
||||
// The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511).
|
||||
RestampShortcutNumbers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::RecvNotice_SetCombatMode</c>
|
||||
/// (acclient_2013_pseudo_c.txt:196632-196669): show exactly one of the four
|
||||
/// mutually-exclusive combat-mode indicator elements and hide the other three.
|
||||
/// Called at bind-time with <see cref="CombatMode.NonCombat"/> (the player
|
||||
/// always starts in peace mode) and subsequently whenever
|
||||
/// <see cref="CombatState.CombatModeChanged"/> fires.
|
||||
/// </summary>
|
||||
public void SetCombatMode(CombatMode mode)
|
||||
{
|
||||
// Index → mode mapping matches CombatIndicatorIds declaration order:
|
||||
// 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
|
||||
bool[] show =
|
||||
{
|
||||
mode == CombatMode.NonCombat,
|
||||
mode == CombatMode.Melee,
|
||||
mode == CombatMode.Missile,
|
||||
mode == CombatMode.Magic,
|
||||
};
|
||||
|
||||
for (int i = 0; i < _combatIndicators.Length; i++)
|
||||
{
|
||||
if (_combatIndicators[i] is { } e)
|
||||
e.Visible = show[i];
|
||||
}
|
||||
|
||||
// Re-stamp digit set: peace glyphs in NonCombat, war glyphs in any combat stance.
|
||||
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196610-196621).
|
||||
_peace = (mode == CombatMode.NonCombat);
|
||||
RestampShortcutNumbers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push digit-array references and shortcut-number state into every slot cell.
|
||||
/// Top row (indices 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).
|
||||
/// </summary>
|
||||
private void RestampShortcutNumbers()
|
||||
{
|
||||
for (int i = 0; i < _slots.Length; i++)
|
||||
{
|
||||
var cell = _slots[i]?.Cell;
|
||||
if (cell is null) continue;
|
||||
cell.PeaceDigits = _peaceDigits;
|
||||
cell.WarDigits = _warDigits;
|
||||
cell.EmptyDigits = _emptyDigits;
|
||||
if (i < 9)
|
||||
cell.SetShortcutNum(i, _peace); // top row: slot label digits 1–9 always shown
|
||||
else
|
||||
cell.ClearShortcutNum(); // bottom row: no slot labels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the <see cref="UiItemSlot.Clicked"/> callback on a slot cell so that
|
||||
/// clicking a bound item fires <see cref="_useItem"/> with the slot's current guid.
|
||||
/// Mirrors retail's <c>gmToolbarUI</c> click → <c>UseShortcut</c> dispatch.
|
||||
/// </summary>
|
||||
private void WireClick(UiItemList list)
|
||||
{
|
||||
list.Cell.Clicked = () =>
|
||||
{
|
||||
if (list.Cell.ItemId != 0)
|
||||
_useItem(list.Cell.ItemId);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Generic dat element: draws its active state's media by DrawMode (Normal=tile,
|
||||
/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type
|
||||
/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips);
|
||||
/// faithful because retail's base element render is exactly "stamp the media per draw-mode".
|
||||
///
|
||||
/// <para>
|
||||
/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the
|
||||
/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named
|
||||
/// constants document the real enum for Plan 2.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// DrawModeType (DatReaderWriter.Enums), stored as int in <see cref="ElementInfo"/> to
|
||||
/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6:
|
||||
/// <c>Undefined=0, Normal=1, Overlay=2, Alphablend=3</c>. There is no Stretch mode.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Tiling uses UV-repeat on BOTH axes (<c>Width/tw</c>, <c>Height/th</c>) so vertical
|
||||
/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too.
|
||||
/// <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/> sets
|
||||
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiDatElement : UiElement
|
||||
{
|
||||
// DrawModeType enum values from DatReaderWriter.Enums.
|
||||
// See docs/research/2026-06-15-layoutdesc-format.md §6.
|
||||
#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2
|
||||
private const int DrawUndefined = 0;
|
||||
private const int DrawNormal = 1;
|
||||
private const int DrawOverlay = 2;
|
||||
private const int DrawAlphablend = 3;
|
||||
#pragma warning restore IDE0051
|
||||
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
|
||||
/// Falls back to DirectState if the named state is absent.</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
public UiDatElement(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_info = info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = true; // generic decoration; behavioral widgets opt back in
|
||||
|
||||
// Pick the initial active state: retail applies DefaultState when set; falls back
|
||||
// to "Normal" when the element has a Normal-state sprite (retail's implicit default
|
||||
// for stateful elements like tabs and buttons); else the unnamed DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (File, DrawMode) for the current <see cref="ActiveState"/>,
|
||||
/// falling back to the DirectState (<c>""</c> key) if the named state is absent.
|
||||
/// Returns (0, 0) if neither exists.
|
||||
/// </summary>
|
||||
// exposed for unit testing
|
||||
public (uint File, int DrawMode) ActiveMedia()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d
|
||||
: (0u, 0);
|
||||
|
||||
/// <summary>Optional click handler. Set by a controller for interactive dat
|
||||
/// elements (e.g. the chat Send / max-min buttons). Requires
|
||||
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Optional centered text label drawn over the sprite (e.g. the "Send"
|
||||
/// button face whose dat sprite is a blank frame). Null = sprite only.</summary>
|
||||
public string? Label { get; set; }
|
||||
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
/// <summary>Label color (default white).</summary>
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var (file, _) = ActiveMedia();
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
{
|
||||
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI
|
||||
// texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the
|
||||
// sprite shader already alpha-blends). No Stretch mode exists in DrawModeType.
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
// Centered text label over the sprite (retail draws button captions as text;
|
||||
// their dat sprites are blank frames).
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C).
|
||||
/// Mirrors retail <c>gmVitalsUI::PostInit</c>: grab the three meter elements
|
||||
/// by their dat element ids and bind live data providers (fill fraction + cur/max
|
||||
/// text) to each. This is the ONLY per-window code in the whole importer — pure
|
||||
/// data wiring, not graphics.
|
||||
///
|
||||
/// <para>The slice sprites + dat font on each <see cref="UiMeter"/> are already
|
||||
/// set by <see cref="DatWidgetFactory"/> during tree construction; this controller
|
||||
/// only binds the dynamic vitals data. Do not touch meter rendering fields here.</para>
|
||||
///
|
||||
/// <para>Element ids confirmed from
|
||||
/// <c>docs/research/2026-06-15-layoutdesc-format.md §11</c>
|
||||
/// (vitals window 0x2100006C dump).</para>
|
||||
/// </summary>
|
||||
public static class VitalsController
|
||||
{
|
||||
/// <summary>Dat element id for the Health meter (0x100000E6).</summary>
|
||||
public const uint Health = 0x100000E6;
|
||||
/// <summary>Dat element id for the Stamina meter (0x100000EC).</summary>
|
||||
public const uint Stamina = 0x100000EC;
|
||||
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
|
||||
public const uint Mana = 0x100000EE;
|
||||
|
||||
/// <summary>
|
||||
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
|
||||
/// elements found in <paramref name="layout"/>. Any meter whose id is absent
|
||||
/// from the layout is silently skipped — partial layouts (e.g. test fakes)
|
||||
/// do not cause errors.
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported vitals layout tree.</param>
|
||||
/// <param name="healthPct">Provider returning Health fill fraction [0..1].</param>
|
||||
/// <param name="staminaPct">Provider returning Stamina fill fraction [0..1].</param>
|
||||
/// <param name="manaPct">Provider returning Mana fill fraction [0..1].</param>
|
||||
/// <param name="healthText">Provider returning Health "cur/max" overlay text.</param>
|
||||
/// <param name="staminaText">Provider returning Stamina "cur/max" overlay text.</param>
|
||||
/// <param name="manaText">Provider returning Mana "cur/max" overlay text.</param>
|
||||
public static void Bind(
|
||||
ImportedLayout layout,
|
||||
Func<float> healthPct,
|
||||
Func<float> staminaPct,
|
||||
Func<float> manaPct,
|
||||
Func<string> healthText,
|
||||
Func<string> staminaText,
|
||||
Func<string> manaText)
|
||||
{
|
||||
BindMeter(layout, Health, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, manaPct, manaText);
|
||||
}
|
||||
|
||||
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
|
||||
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private static void BindMeter(
|
||||
ImportedLayout layout, uint id,
|
||||
Func<float> pct,
|
||||
Func<string> text)
|
||||
{
|
||||
// Silently skip if the id is absent — missing meters are not an error (partial layouts).
|
||||
if (layout.FindElement(id) is not UiMeter m) return;
|
||||
|
||||
m.Fill = () => pct();
|
||||
|
||||
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
|
||||
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
|
||||
// decoration) that fills + stretches with the meter, and stop the meter drawing its
|
||||
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
|
||||
// so the numbers stay pixel-identical (locked by the visual gate).
|
||||
m.Label = () => null;
|
||||
|
||||
var number = new UiText
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
DatFont = m.DatFont, // the same dat font the meter used for its label
|
||||
ClickThrough = true, // decoration: no focus / selection / drag
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, NumberColor) };
|
||||
},
|
||||
};
|
||||
m.AddChild(number);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
|
||||
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> attribute
|
||||
/// values resolve against a supplied object by property name (reflection).
|
||||
/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7.
|
||||
/// </summary>
|
||||
public static class MarkupDocument
|
||||
{
|
||||
/// <param name="xml">Raw XML markup for a single panel.</param>
|
||||
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
|
||||
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
|
||||
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
|
||||
public static UiNineSlicePanel Build(
|
||||
string xml, object binding, Func<uint, (uint, int, int)> resolve,
|
||||
ControlsIni? style = null)
|
||||
{
|
||||
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
|
||||
if (root.Name.LocalName != "panel")
|
||||
throw new FormatException($"root must be <panel>, got <{root.Name.LocalName}>");
|
||||
|
||||
var panel = new UiNineSlicePanel(resolve)
|
||||
{
|
||||
Left = F(root, "x"),
|
||||
Top = F(root, "y"),
|
||||
Width = F(root, "w"),
|
||||
Height = F(root, "h"),
|
||||
};
|
||||
|
||||
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
|
||||
string? resize = (string?)root.Attribute("resize");
|
||||
if (resize is not null)
|
||||
{
|
||||
panel.ResizeX = resize is "x" or "both";
|
||||
panel.ResizeY = resize is "y" or "both";
|
||||
}
|
||||
|
||||
string? title = (string?)root.Attribute("title");
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One;
|
||||
panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc });
|
||||
}
|
||||
|
||||
foreach (var el in root.Elements())
|
||||
{
|
||||
switch (el.Name.LocalName)
|
||||
{
|
||||
case "meter":
|
||||
var cur = BindUint((string?)el.Attribute("cur"), binding);
|
||||
var max = BindUint((string?)el.Attribute("max"), binding);
|
||||
panel.AddChild(new UiMeter
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
BarColor = Color((string?)el.Attribute("color")),
|
||||
Fill = BindFloat((string?)el.Attribute("fill"), binding),
|
||||
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
|
||||
Anchors = Anchor((string?)el.Attribute("anchor")),
|
||||
SpriteResolve = resolve,
|
||||
BackLeft = Hex((string?)el.Attribute("backleft")),
|
||||
BackTile = Hex((string?)el.Attribute("backtile")),
|
||||
BackRight = Hex((string?)el.Attribute("backright")),
|
||||
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
||||
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
||||
FrontRight = Hex((string?)el.Attribute("frontright")),
|
||||
});
|
||||
break;
|
||||
// future element kinds (label, button, image) added here
|
||||
}
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static float F(XElement e, string attr)
|
||||
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var v) ? v : 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
|
||||
/// controls.ini convention). Falls back to opaque white on bad input.
|
||||
/// </summary>
|
||||
private static Vector4 Color(string? hex)
|
||||
{
|
||||
if (hex is { Length: 9 } && hex[0] == '#'
|
||||
&& uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber,
|
||||
CultureInfo.InvariantCulture, out uint argb))
|
||||
return new Vector4(
|
||||
((argb >> 16) & 0xFF) / 255f,
|
||||
((argb >> 8) & 0xFF) / 255f,
|
||||
(argb & 0xFF) / 255f,
|
||||
((argb >> 24) & 0xFF) / 255f);
|
||||
return Vector4.One;
|
||||
}
|
||||
|
||||
private static Func<float?> BindFloat(string? expr, object binding)
|
||||
{
|
||||
var pi = Prop(expr, binding);
|
||||
if (pi is null) return () => 0f;
|
||||
return () => pi.GetValue(binding) switch
|
||||
{
|
||||
float f => f,
|
||||
null => (float?)null,
|
||||
var v => Convert.ToSingle(v, CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
|
||||
private static Func<uint?> BindUint(string? expr, object binding)
|
||||
{
|
||||
var pi = Prop(expr, binding);
|
||||
if (pi is null) return () => null;
|
||||
return () => pi.GetValue(binding) switch
|
||||
{
|
||||
uint u => u,
|
||||
null => (uint?)null,
|
||||
var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
|
||||
private static PropertyInfo? Prop(string? expr, object binding)
|
||||
{
|
||||
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
|
||||
return binding.GetType().GetProperty(expr[1..^1]);
|
||||
}
|
||||
|
||||
private static uint Hex(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return 0;
|
||||
var t = s.Trim();
|
||||
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
|
||||
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
||||
}
|
||||
|
||||
private static AnchorEdges Anchor(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
|
||||
var a = AnchorEdges.None;
|
||||
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
|
||||
a |= part.ToLowerInvariant() switch
|
||||
{
|
||||
"left" => AnchorEdges.Left,
|
||||
"top" => AnchorEdges.Top,
|
||||
"right" => AnchorEdges.Right,
|
||||
"bottom" => AnchorEdges.Bottom,
|
||||
_ => AnchorEdges.None,
|
||||
};
|
||||
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0
|
||||
/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded
|
||||
/// DIRECTLY (<see cref="Rendering.TextureCache.GetOrUploadRenderSurface"/>), NOT
|
||||
/// through the Surface→SurfaceTexture chain.
|
||||
///
|
||||
/// <para>
|
||||
/// The universal floating-window bevel is an <b>8-piece border</b> (4 corners
|
||||
/// 5×5 + 4 edges) drawn around a tiled center fill — it is NOT a single
|
||||
/// 9-slice texture. Decoded sizes are in the comments (from the prove-out).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The edge/corner → position mapping below is a reasonable guess pending the
|
||||
/// LayoutDesc 0x21000040 parse (sub-project 3) and is confirmed visually in the
|
||||
/// first vitals-panel render. If a corner's bevel highlight looks wrong, swap
|
||||
/// the four corner constants; if top/bottom or left/right look inverted, swap
|
||||
/// those edge pairs.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailChromeSprites
|
||||
{
|
||||
/// <summary>Tiled interior fill — the shared panel background (48×48).</summary>
|
||||
public const uint CenterFill = 0x06004CC2;
|
||||
|
||||
/// <summary>Horizontal top edge (10×5, tiled across the top span).</summary>
|
||||
public const uint TopEdge = 0x060074BF;
|
||||
/// <summary>Horizontal bottom edge (10×5).</summary>
|
||||
public const uint BottomEdge = 0x060074C1;
|
||||
/// <summary>Vertical left edge (5×10).</summary>
|
||||
public const uint LeftEdge = 0x060074C0;
|
||||
/// <summary>Vertical right edge (5×10).</summary>
|
||||
public const uint RightEdge = 0x060074C2;
|
||||
|
||||
/// <summary>Top-left corner (5×5).</summary>
|
||||
public const uint CornerTL = 0x060074C3;
|
||||
/// <summary>Top-right corner (5×5).</summary>
|
||||
public const uint CornerTR = 0x060074C4;
|
||||
/// <summary>Bottom-left corner (5×5).</summary>
|
||||
public const uint CornerBL = 0x060074C5;
|
||||
/// <summary>Bottom-right corner (5×5).</summary>
|
||||
public const uint CornerBR = 0x060074C6;
|
||||
|
||||
/// <summary>Border thickness in pixels = the corner/edge sprite size (5px).</summary>
|
||||
public const int Border = 5;
|
||||
|
||||
// ── Resize-grip overlay ──────────────────────────────────────────────
|
||||
// A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged
|
||||
// accents + square corner studs that frame a resizable retail window. From
|
||||
// the vitals LayoutDesc 0x2100006C (elements 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.
|
||||
/// <summary>Corner grip stud, all four corners (5×5).</summary>
|
||||
public const uint GripCorner = 0x06006129;
|
||||
/// <summary>Top edge grip (10×5, tiled across).</summary>
|
||||
public const uint GripTop = 0x0600612A;
|
||||
/// <summary>Left edge grip (5×10, tiled down).</summary>
|
||||
public const uint GripLeft = 0x0600612B;
|
||||
/// <summary>Bottom edge grip (10×5).</summary>
|
||||
public const uint GripBottom = 0x0600612C;
|
||||
/// <summary>Right edge grip (5×10).</summary>
|
||||
public const uint GripRight = 0x0600612D;
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic dat-widget button — the production replacement for any dat element of
|
||||
/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create)
|
||||
/// @ acclient_2013_pseudo_c.txt:125828).
|
||||
///
|
||||
/// <para>
|
||||
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
|
||||
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
|
||||
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
|
||||
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
|
||||
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
|
||||
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
|
||||
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
|
||||
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
|
||||
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
|
||||
/// earlier dev-scaffold widget with no dat sprites.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiButton : UiElement
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
|
||||
public string? Label { get; set; }
|
||||
|
||||
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
|
||||
/// <summary>Label color (default white).</summary>
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>
|
||||
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
|
||||
/// Matches <see cref="UiDatElement.ActiveState"/>.
|
||||
/// </summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_info = info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
||||
|
||||
// State defaulting matches UiDatElement exactly:
|
||||
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
}
|
||||
|
||||
/// <summary>The button draws its own face + label; any dat label child is reproduced
|
||||
/// procedurally, so the importer must not build the button's children as widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
|
||||
/// the DirectState ("" key) if the named state is absent.
|
||||
/// Returns 0 if neither exists.
|
||||
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
||||
/// </summary>
|
||||
private uint ActiveFile()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
uint file = ActiveFile();
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
{
|
||||
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
||||
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for
|
||||
/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels +
|
||||
/// background outline/shadow), the per-glyph descriptor table, and the line
|
||||
/// metrics, so <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
|
||||
/// as two textured quads exactly the way the retail client does.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail render model — <c>SurfaceWindow::DrawCharacter</c>
|
||||
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for
|
||||
/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the
|
||||
/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the
|
||||
/// requested text color. The pen advances by
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
|
||||
/// return value, accumulated by the string loop at 0x00467ed4
|
||||
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
|
||||
/// <c>penX + HorizontalOffsetBefore</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
|
||||
/// PFID_A8 — alpha-only. Our <c>SurfaceDecoder</c> expands A8 to RGBA as
|
||||
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
|
||||
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
|
||||
/// (<c>texture(uTex,vUv) * vColor</c>), so tinting a white+alpha glyph by a
|
||||
/// color gives that color with the glyph's alpha — black for the outline pass,
|
||||
/// text color for the fill pass. No shader change was needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiDatFont
|
||||
{
|
||||
/// <summary>Retail UI font id (Latin-1, 16x16 max, with outline atlas).</summary>
|
||||
public const uint DefaultFontId = 0x40000000u;
|
||||
|
||||
/// <summary>Foreground (glyph pixels) GL texture handle + atlas pixel size.</summary>
|
||||
public uint ForegroundTexture { get; }
|
||||
public int ForegroundWidth { get; }
|
||||
public int ForegroundHeight { get; }
|
||||
|
||||
/// <summary>Background (outline/shadow) GL texture handle + atlas pixel size.
|
||||
/// 0 when the font has no background atlas (then the outline pass is skipped).</summary>
|
||||
public uint BackgroundTexture { get; }
|
||||
public int BackgroundWidth { get; }
|
||||
public int BackgroundHeight { get; }
|
||||
|
||||
/// <summary>Vertical advance between lines (retail MaxCharHeight).</summary>
|
||||
public float LineHeight { get; }
|
||||
|
||||
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
|
||||
public float BaselineOffset { get; }
|
||||
|
||||
private readonly Dictionary<char, FontCharDesc> _glyphs;
|
||||
|
||||
private UiDatFont(
|
||||
uint fgTex, int fgW, int fgH,
|
||||
uint bgTex, int bgW, int bgH,
|
||||
float lineHeight, float baselineOffset,
|
||||
Dictionary<char, FontCharDesc> glyphs)
|
||||
{
|
||||
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
|
||||
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
|
||||
LineHeight = lineHeight;
|
||||
BaselineOffset = baselineOffset;
|
||||
_glyphs = glyphs;
|
||||
}
|
||||
|
||||
/// <summary>True if this font carries a separate outline/shadow atlas
|
||||
/// (retail's <c>m_pBackgroundSurface</c>). When false the outline pass is
|
||||
/// skipped and only the foreground (fill) glyphs are drawn.</summary>
|
||||
public bool HasBackground => BackgroundTexture != 0;
|
||||
|
||||
/// <summary>Look up a glyph descriptor for a character. Returns false for
|
||||
/// characters not present in the font's table (callers skip them).</summary>
|
||||
public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
|
||||
|
||||
/// <summary>
|
||||
/// Load Font <paramref name="fontId"/> from the dat collection and upload
|
||||
/// both atlases through the texture cache (the same direct-RenderSurface
|
||||
/// path the D.2b chrome sprites use). Returns null if the Font DBObj is
|
||||
/// missing — callers fall back to the debug bitmap font.
|
||||
/// </summary>
|
||||
public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
|
||||
if (!dats.TryGet<Font>(fontId, out var font) || font is null)
|
||||
return null;
|
||||
|
||||
// Foreground atlas is required; without it there are no glyph pixels.
|
||||
if (font.ForegroundSurfaceDataId == 0)
|
||||
return null;
|
||||
|
||||
// Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp;
|
||||
// bilinear softens the dat font noticeably (the chat menu/button text "blur").
|
||||
uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true);
|
||||
|
||||
uint bgTex = 0; int bgW = 0, bgH = 0;
|
||||
if (font.BackgroundSurfaceDataId != 0)
|
||||
bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true);
|
||||
|
||||
// Build the char->descriptor lookup. FontCharDesc.Unicode is the code
|
||||
// point; for Latin-1 fonts this is a direct char cast. Last write wins
|
||||
// on the rare duplicate (retail's Font::GetCharDesc does a linear scan
|
||||
// and returns the first match, but the dat tables have no duplicates).
|
||||
var glyphs = new Dictionary<char, FontCharDesc>(font.CharDescs.Count);
|
||||
foreach (var cd in font.CharDescs)
|
||||
glyphs[(char)cd.Unicode] = cd;
|
||||
|
||||
return new UiDatFont(
|
||||
fgTex, fgW, fgH,
|
||||
bgTex, bgW, bgH,
|
||||
lineHeight: font.MaxCharHeight,
|
||||
baselineOffset: font.BaselineOffset,
|
||||
glyphs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total pen advance (in pixels) for <paramref name="text"/>, summing each
|
||||
/// glyph's retail advance. Characters not in the font contribute nothing.
|
||||
/// </summary>
|
||||
public float MeasureWidth(string text)
|
||||
=> MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null);
|
||||
|
||||
/// <summary>
|
||||
/// Pure pen-advance summation seam: total width of <paramref name="text"/>
|
||||
/// given a <paramref name="lookup"/> that maps each char to its descriptor
|
||||
/// (null = not in the font → contributes nothing). Lets the advance math be
|
||||
/// unit-tested with synthetic glyphs, with no GL or dat dependency.
|
||||
/// </summary>
|
||||
public static float MeasureWidth(string? text, Func<char, FontCharDesc?> lookup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lookup);
|
||||
if (string.IsNullOrEmpty(text)) return 0f;
|
||||
float w = 0f;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
if (lookup(text[i]) is { } g)
|
||||
w += GlyphAdvance(g);
|
||||
return w;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retail per-glyph horizontal advance:
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>. This is the
|
||||
/// value <c>SurfaceWindow::DrawCharacter</c> returns for proportional text
|
||||
/// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates
|
||||
/// into the pen. Pulled out as a pure static so the math is unit-testable
|
||||
/// without GL or the dat.
|
||||
/// </summary>
|
||||
public static float GlyphAdvance(FontCharDesc g)
|
||||
=> g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
|
||||
}
|
||||
|
|
@ -4,11 +4,6 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
|
||||
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
|
||||
[System.Flags]
|
||||
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
||||
|
||||
/// <summary>
|
||||
/// Base class for every UI widget in the retained-mode tree.
|
||||
///
|
||||
|
|
@ -93,39 +88,6 @@ public abstract class UiElement
|
|||
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
||||
public int ZOrder { get; set; }
|
||||
|
||||
/// <summary>Window opacity (0..1) multiplied into this element's and its
|
||||
/// descendants' background + sprite draws (text stays opaque). 1 = fully opaque.
|
||||
/// Set on a top-level window (e.g. the chat frame) for retail's translucent chat.</summary>
|
||||
public float Opacity { get; set; } = 1f;
|
||||
|
||||
/// <summary>If true, a left-drag on this element (or a non-draggable child of
|
||||
/// it) repositions it as a movable window. Intended for top-level panels,
|
||||
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
|
||||
public bool Draggable { get; set; }
|
||||
|
||||
/// <summary>If true, a left-drag starting near this element's edge/corner
|
||||
/// resizes it (window resize). Intended for top-level panels.</summary>
|
||||
public bool Resizable { get; set; }
|
||||
|
||||
/// <summary>If true, a left-drag starting on this element is delivered to the
|
||||
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
|
||||
/// Edge resize on a resizable ancestor still wins — only the interior move /
|
||||
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
|
||||
public bool CapturesPointerDrag { get; set; }
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
||||
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeX { get; set; } = true;
|
||||
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeY { get; set; } = true;
|
||||
|
||||
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
||||
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
||||
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
||||
|
||||
// ── Tree structure ──────────────────────────────────────────────────
|
||||
public UiElement? Parent { get; private set; }
|
||||
|
||||
|
|
@ -146,19 +108,6 @@ public abstract class UiElement
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if this widget draws its full appearance itself and REPRODUCES its dat
|
||||
/// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup
|
||||
/// rows…) — so the <see cref="AcDream.App.UI.Layout.LayoutImporter"/> must NOT build
|
||||
/// those dat child elements as separate widgets (they would double-draw and, worse,
|
||||
/// steal pointer/focus from the behavioral widget). All registered behavioral widgets
|
||||
/// (Meter/Menu/Button/Scrollbar/Text/Field) return <c>true</c>; the generic container
|
||||
/// (<see cref="AcDream.App.UI.Layout.UiDatElement"/>) and panels return <c>false</c>
|
||||
/// and recurse their children normally. Mirrors retail, where each
|
||||
/// <c>UIElement_X::DrawSelf</c> owns its internal structure.
|
||||
/// </summary>
|
||||
public virtual bool ConsumesDatChildren => false;
|
||||
|
||||
// ── Virtual overrides ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -167,25 +116,6 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
protected virtual void OnDraw(UiRenderContext ctx) { }
|
||||
|
||||
/// <summary>
|
||||
/// Draw AFTER this element's own children, but still within this element's
|
||||
/// transform/alpha (NOT a global pass like <see cref="OnDrawOverlay"/>). Use for a
|
||||
/// window FRAME border, which must be the outermost layer drawn OVER its content's
|
||||
/// edges (so content can't poke through the frame), while the frame's center fill
|
||||
/// stays a background in <see cref="OnDraw"/>. Default: nothing.
|
||||
/// </summary>
|
||||
protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { }
|
||||
|
||||
/// <summary>
|
||||
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
|
||||
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
|
||||
/// a SECOND traversal after the whole tree's <see cref="OnDraw"/> pass, with the
|
||||
/// same accumulated transform/alpha this element had during its normal draw.
|
||||
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
|
||||
/// this reason; this is the equivalent without reparenting. Default: nothing.
|
||||
/// </summary>
|
||||
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
|
||||
|
||||
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
||||
protected virtual void OnTick(double deltaSeconds) { }
|
||||
|
||||
|
|
@ -216,18 +146,12 @@ public abstract class UiElement
|
|||
{
|
||||
if (!Visible) return;
|
||||
|
||||
// Translate into our local space + push this window's opacity (multiplies into
|
||||
// descendants' sprite/rect draws; text bypasses the alpha so it stays sharp).
|
||||
// Translate into our local space.
|
||||
ctx.PushTransform(Left, Top);
|
||||
ctx.PushAlpha(Opacity);
|
||||
try
|
||||
{
|
||||
OnDraw(ctx);
|
||||
|
||||
// Anchor layout: reflow children to this element's current size.
|
||||
for (int i = 0; i < _children.Count; i++)
|
||||
_children[i].ApplyAnchor(Width, Height);
|
||||
|
||||
// Children painted back-to-front (lowest ZOrder first).
|
||||
if (_children.Count > 0)
|
||||
{
|
||||
|
|
@ -237,42 +161,9 @@ public abstract class UiElement
|
|||
for (int i = 0; i < ordered.Length; i++)
|
||||
ordered[i].DrawSelfAndChildren(ctx);
|
||||
}
|
||||
|
||||
// Foreground pass for this element (e.g. a window frame's border drawn
|
||||
// OVER its content's edges). Default no-op for ordinary elements.
|
||||
OnDrawAfterChildren(ctx);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.PopAlpha();
|
||||
ctx.PopTransform();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Second draw traversal: re-walks the tree applying the same
|
||||
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
|
||||
/// <see cref="OnDrawOverlay"/> on each element, so popups composite on top of
|
||||
/// everything drawn in the main pass (dat-font glyphs and sprites share one
|
||||
/// submission-ordered bucket, so later submissions win).</summary>
|
||||
internal void DrawOverlays(UiRenderContext ctx)
|
||||
{
|
||||
if (!Visible) return;
|
||||
ctx.PushTransform(Left, Top);
|
||||
ctx.PushAlpha(Opacity);
|
||||
try
|
||||
{
|
||||
OnDrawOverlay(ctx);
|
||||
if (_children.Count > 0)
|
||||
{
|
||||
var ordered = _children.ToArray();
|
||||
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
||||
for (int i = 0; i < ordered.Length; i++)
|
||||
ordered[i].DrawOverlays(ctx);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.PopAlpha();
|
||||
ctx.PopTransform();
|
||||
}
|
||||
}
|
||||
|
|
@ -292,14 +183,9 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
internal UiElement? HitTest(float localX, float localY)
|
||||
{
|
||||
if (!Visible || !Enabled) return null;
|
||||
if (!Visible || !Enabled || ClickThrough) return null;
|
||||
|
||||
// Children first, in reverse Z-order (topmost first). ClickThrough means
|
||||
// THIS element is transparent to the pointer — but its children are NOT.
|
||||
// A ClickThrough container (e.g. a UiDatElement panel that hosts the chat
|
||||
// input / transcript) must still let the pointer reach its behavioral
|
||||
// children, so the ClickThrough check happens AFTER the child walk, gating
|
||||
// only whether THIS element claims the hit.
|
||||
// Children first, in reverse Z-order (topmost first).
|
||||
if (_children.Count > 0)
|
||||
{
|
||||
var ordered = _children.ToArray();
|
||||
|
|
@ -312,70 +198,6 @@ public abstract class UiElement
|
|||
}
|
||||
}
|
||||
|
||||
if (ClickThrough) return null;
|
||||
return OnHitTest(localX, localY) ? this : null;
|
||||
}
|
||||
|
||||
// ── Anchor layout ────────────────────────────────────────────────────
|
||||
|
||||
private bool _anchorCaptured;
|
||||
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
|
||||
|
||||
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
|
||||
/// the margins captured (at first layout / design size) to each anchored edge.
|
||||
/// Called by the parent each frame before drawing children.</summary>
|
||||
internal void ApplyAnchor(float parentW, float parentH)
|
||||
{
|
||||
if (Anchors == AnchorEdges.None) return;
|
||||
if (!_anchorCaptured)
|
||||
{
|
||||
_amL = Left; _amT = Top;
|
||||
_amR = parentW - (Left + Width);
|
||||
_amB = parentH - (Top + Height);
|
||||
_aw0 = Width; _ah0 = Height;
|
||||
_anchorCaptured = true;
|
||||
}
|
||||
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
|
||||
Left = x; Top = y; Width = w; Height = h;
|
||||
}
|
||||
|
||||
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
|
||||
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
|
||||
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
|
||||
/// button width changes) so the new rect becomes the anchor baseline.</summary>
|
||||
internal void ResetAnchorCapture() => _anchorCaptured = false;
|
||||
|
||||
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
|
||||
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
|
||||
/// a chat input blurring itself (exiting write mode) after submit.</summary>
|
||||
internal UiRoot? FindRoot()
|
||||
{
|
||||
UiElement e = this;
|
||||
while (e.Parent is not null) e = e.Parent;
|
||||
return e as UiRoot;
|
||||
}
|
||||
|
||||
/// <summary>Compute an anchored child rect. Left&Right ⇒ stretch width
|
||||
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
|
||||
/// pin left at fixed width. Same logic vertically.</summary>
|
||||
public static (float x, float y, float w, float h) ComputeAnchoredRect(
|
||||
AnchorEdges a, float mL, float mT, float mR, float mB,
|
||||
float w0, float h0, float parentW, float parentH)
|
||||
{
|
||||
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
|
||||
float x, w;
|
||||
if (l && r) { x = mL; w = parentW - mR - mL; }
|
||||
else if (r) { w = w0; x = parentW - mR - w0; }
|
||||
else { x = mL; w = w0; }
|
||||
|
||||
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
|
||||
float y, h;
|
||||
if (t && b) { y = mT; h = parentH - mB - mT; }
|
||||
else if (b) { h = h0; y = parentH - mB - h0; }
|
||||
else { y = mT; h = h0; }
|
||||
|
||||
if (w < 0) w = 0;
|
||||
if (h < 0) h = 0;
|
||||
return (x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,420 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
|
||||
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
|
||||
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
|
||||
/// as stubs for future item-window use.
|
||||
///
|
||||
/// <para>
|
||||
/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the
|
||||
/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and
|
||||
/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send)
|
||||
/// fires <see cref="OnSubmit"/>, clears, and pushes history (100-entry cap,
|
||||
/// sentinel 0xFFFFFFFF — port of <c>ChatInterface::ProcessCommand @0x4f5100</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40.
|
||||
/// </summary>
|
||||
public sealed class UiField : UiElement
|
||||
{
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
|
||||
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
|
||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||
public float Padding { get; set; } = 4f;
|
||||
public int MaxCharacters { get; set; } = 0xFFFF;
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
|
||||
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||
|
||||
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
|
||||
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
|
||||
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
|
||||
public uint FocusFieldSprite { get; set; }
|
||||
|
||||
public Action<string>? OnSubmit { get; set; }
|
||||
|
||||
private string _text = "";
|
||||
private int _caret;
|
||||
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
|
||||
public string Text => _text;
|
||||
public int CaretPos => _caret;
|
||||
|
||||
private readonly List<string> _history = new();
|
||||
private int _historyIndex = -1;
|
||||
public int HistoryCount => _history.Count;
|
||||
|
||||
private bool _focused;
|
||||
private bool _selecting; // mouse drag in progress
|
||||
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
|
||||
|
||||
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
|
||||
private Silk.NET.Input.Key? _repeatKey;
|
||||
private double _repeatTimer;
|
||||
private const double RepeatDelay = 0.40; // s before the first repeat
|
||||
private const double RepeatRate = 0.04; // s between repeats (~25/s)
|
||||
|
||||
public UiField()
|
||||
{
|
||||
AcceptsFocus = true;
|
||||
IsEditControl = true;
|
||||
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||
}
|
||||
|
||||
/// <summary>The field draws its own background + caret + caps; its dat cap sub-elements
|
||||
/// are reproduced procedurally, so the importer must not build them as widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
// ── Editing primitives ──────────────────────────────────────────────
|
||||
|
||||
public void InsertChar(char c)
|
||||
{
|
||||
if (c < 0x20 || c == 0x7F) return;
|
||||
DeleteSelection();
|
||||
if (_text.Length >= MaxCharacters) return;
|
||||
_text = _text.Insert(_caret, c.ToString());
|
||||
_caret++;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
public void Backspace()
|
||||
{
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret == 0) return;
|
||||
_text = _text.Remove(_caret - 1, 1);
|
||||
_caret--;
|
||||
}
|
||||
|
||||
public void DeleteForward()
|
||||
{
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret >= _text.Length) return;
|
||||
_text = _text.Remove(_caret, 1);
|
||||
}
|
||||
|
||||
private void MoveCaretTo(int target, bool shift)
|
||||
{
|
||||
target = Math.Clamp(target, 0, _text.Length);
|
||||
if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret
|
||||
else _selAnchor = null; // plain move collapses any selection
|
||||
_caret = target;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
/// <summary>Move the caret left (negative) or right (positive) by <paramref name="delta"/>
|
||||
/// glyph positions without extending a selection. Public for test access.</summary>
|
||||
public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false);
|
||||
|
||||
private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift);
|
||||
|
||||
// ── Selection ────────────────────────────────────────────────────────
|
||||
|
||||
private (int lo, int hi) SelSpan()
|
||||
{
|
||||
if (_selAnchor is not { } a || a == _caret) return (_caret, _caret);
|
||||
return (Math.Min(a, _caret), Math.Max(a, _caret));
|
||||
}
|
||||
|
||||
private bool HasSelection => _selAnchor is { } a && a != _caret;
|
||||
|
||||
private string SelectedText()
|
||||
{
|
||||
var (lo, hi) = SelSpan();
|
||||
return hi > lo ? _text.Substring(lo, hi - lo) : "";
|
||||
}
|
||||
|
||||
/// <summary>Remove the selected span (if any). Returns true if it removed anything.</summary>
|
||||
private bool DeleteSelection()
|
||||
{
|
||||
if (!HasSelection) { _selAnchor = null; return false; }
|
||||
var (lo, hi) = SelSpan();
|
||||
_text = _text.Remove(lo, hi - lo);
|
||||
_caret = lo;
|
||||
_selAnchor = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SelectAll()
|
||||
{
|
||||
if (_text.Length == 0) { _selAnchor = null; return; }
|
||||
_selAnchor = 0;
|
||||
_caret = _text.Length;
|
||||
}
|
||||
|
||||
private void CopySelection()
|
||||
{
|
||||
var s = SelectedText();
|
||||
if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s;
|
||||
}
|
||||
|
||||
private void CutSelection()
|
||||
{
|
||||
if (!HasSelection) return;
|
||||
CopySelection();
|
||||
DeleteSelection();
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
private void Paste()
|
||||
{
|
||||
if (Keyboard is null) return;
|
||||
string clip = Keyboard.ClipboardText ?? "";
|
||||
if (clip.Length == 0) return;
|
||||
|
||||
// Single-line field: strip control chars (newlines/tabs) from pasted text.
|
||||
var sb = new System.Text.StringBuilder(clip.Length);
|
||||
foreach (char ch in clip)
|
||||
if (ch >= 0x20 && ch != 0x7F) sb.Append(ch);
|
||||
if (sb.Length == 0) return;
|
||||
|
||||
DeleteSelection();
|
||||
int room = MaxCharacters - _text.Length;
|
||||
if (room <= 0) return;
|
||||
string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString();
|
||||
_text = _text.Insert(_caret, ins);
|
||||
_caret += ins.Length;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
// ── Submit + history ─────────────────────────────────────────────────
|
||||
|
||||
public void Submit()
|
||||
{
|
||||
var t = _text;
|
||||
if (t.Trim().Length == 0) { Clear(); return; }
|
||||
OnSubmit?.Invoke(t);
|
||||
PushHistory(t);
|
||||
Clear();
|
||||
}
|
||||
|
||||
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; }
|
||||
|
||||
private void PushHistory(string t)
|
||||
{
|
||||
_history.Add(t);
|
||||
if (_history.Count > 100) _history.RemoveAt(0);
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
public void HistoryPrev()
|
||||
{
|
||||
if (_history.Count == 0) return;
|
||||
_historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1);
|
||||
SetTextFromHistory();
|
||||
}
|
||||
|
||||
public void HistoryNext()
|
||||
{
|
||||
if (_historyIndex < 0) return;
|
||||
_historyIndex++;
|
||||
if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; }
|
||||
SetTextFromHistory();
|
||||
}
|
||||
|
||||
private void SetTextFromHistory()
|
||||
{
|
||||
_text = _history[_historyIndex];
|
||||
_caret = _text.Length;
|
||||
_selAnchor = null;
|
||||
}
|
||||
|
||||
// ── Geometry ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Pixel-X of the caret (Σ glyph advances to <paramref name="i"/>).</summary>
|
||||
private float MeasureTo(int i)
|
||||
{
|
||||
if (i <= 0) return 0f;
|
||||
string s = _text.Substring(0, Math.Min(i, _text.Length));
|
||||
return DatFont is { } df ? df.MeasureWidth(s)
|
||||
: Font is { } bf ? bf.MeasureWidth(s) : 0f;
|
||||
}
|
||||
|
||||
public float CaretPixelX() => MeasureTo(_caret);
|
||||
|
||||
/// <summary>Map a local X (click) to the nearest caret index — retail
|
||||
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
|
||||
private int HitCharX(float localX)
|
||||
{
|
||||
float target = localX - Padding + _scrollX;
|
||||
if (target <= 0f) return 0;
|
||||
int best = 0;
|
||||
float bestDist = float.MaxValue;
|
||||
for (int i = 0; i <= _text.Length; i++)
|
||||
{
|
||||
float d = MathF.Abs(MeasureTo(i) - target);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Draw ─────────────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed).
|
||||
// Unfocused: the flat translucent rect. Both go through the sprite bucket
|
||||
// (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top.
|
||||
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
|
||||
if (lit)
|
||||
{
|
||||
var (tex, tw, th) = SpriteResolve!(FocusFieldSprite);
|
||||
if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
else lit = false;
|
||||
}
|
||||
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
float ty = (Height - lh) * 0.5f;
|
||||
float visibleW = MathF.Max(1f, Width - 2f * Padding);
|
||||
|
||||
// Horizontal scroll: keep the caret inside the field; clamp so we never scroll past
|
||||
// the text. Then draw only the glyph window that lands inside the field — a single-
|
||||
// line text box clips + scrolls (retail UIElement_Text) rather than overflowing the
|
||||
// field (which previously spilled the text out into the 3D world).
|
||||
float caretX = MeasureTo(_caret);
|
||||
float fullW = MeasureTo(_text.Length);
|
||||
if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW;
|
||||
if (caretX < _scrollX) _scrollX = caretX;
|
||||
_scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW));
|
||||
|
||||
// Visible character window [start, end).
|
||||
int start = 0;
|
||||
while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++;
|
||||
int end = start;
|
||||
while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++;
|
||||
|
||||
// Selection highlight BEHIND the text, clipped to the field.
|
||||
if (HasSelection)
|
||||
{
|
||||
var (lo, hi) = SelSpan();
|
||||
float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f);
|
||||
float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW);
|
||||
if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor);
|
||||
}
|
||||
|
||||
if (end > start)
|
||||
{
|
||||
string vis = _text.Substring(start, end - start);
|
||||
float vx = Padding + (MeasureTo(start) - _scrollX);
|
||||
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
|
||||
else ctx.DrawString(vis, vx, ty, TextColor, Font);
|
||||
}
|
||||
|
||||
if (_focused)
|
||||
{
|
||||
// Caret on TOP of the text → submitted after the text in the same bucket.
|
||||
float cx = Padding + (caretX - _scrollX);
|
||||
if (cx >= Padding - 1f && cx <= Width - Padding + 1f)
|
||||
ctx.DrawFill(cx, ty, 1f, lh, TextColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-repeat ──────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
if (_repeatKey is not { } k) return;
|
||||
_repeatTimer -= deltaSeconds;
|
||||
if (_repeatTimer > 0) return;
|
||||
_repeatTimer = RepeatRate;
|
||||
bool shift = ShiftHeld();
|
||||
switch (k)
|
||||
{
|
||||
case Silk.NET.Input.Key.Backspace: Backspace(); break;
|
||||
case Silk.NET.Input.Key.Delete: DeleteForward(); break;
|
||||
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break;
|
||||
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break;
|
||||
default: _repeatKey = null; break;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; }
|
||||
|
||||
private bool CtrlHeld() => Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
|
||||
|
||||
private bool ShiftHeld() => Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight));
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.FocusGained: _focused = true; return true;
|
||||
case UiEventType.FocusLost:
|
||||
_focused = false; _historyIndex = -1;
|
||||
_selAnchor = null; _selecting = false; _repeatKey = null;
|
||||
return true;
|
||||
|
||||
case UiEventType.Char:
|
||||
InsertChar((char)e.Data0);
|
||||
return true;
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
_caret = HitCharX(e.Data1);
|
||||
_selAnchor = _caret; // anchor; a drag will extend, a plain click won't
|
||||
_selecting = true;
|
||||
return true;
|
||||
case UiEventType.MouseMove:
|
||||
if (_selecting) _caret = HitCharX(e.Data1);
|
||||
return true;
|
||||
case UiEventType.MouseUp:
|
||||
_selecting = false;
|
||||
return true;
|
||||
|
||||
case UiEventType.KeyUp:
|
||||
if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null;
|
||||
return true;
|
||||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
if (CtrlHeld())
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.A: SelectAll(); return true;
|
||||
case Silk.NET.Input.Key.C: CopySelection(); return true;
|
||||
case Silk.NET.Input.Key.X: CutSelection(); return true;
|
||||
case Silk.NET.Input.Key.V: Paste(); return true;
|
||||
}
|
||||
return true; // swallow other Ctrl combos while typing
|
||||
}
|
||||
|
||||
bool shift = ShiftHeld();
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.Enter:
|
||||
case Silk.NET.Input.Key.KeypadEnter:
|
||||
Submit();
|
||||
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
|
||||
return true;
|
||||
case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true;
|
||||
case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true;
|
||||
case Silk.NET.Input.Key.Up: HistoryPrev(); return true;
|
||||
case Silk.NET.Input.Key.Down: HistoryNext(); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,13 +39,6 @@ public sealed class UiHost : System.IDisposable
|
|||
public UiRoot Root { get; } = new();
|
||||
public TextRenderer TextRenderer { get; }
|
||||
public BitmapFont? DefaultFont { get; set; }
|
||||
|
||||
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
|
||||
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
|
||||
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiText"/>'s
|
||||
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
|
||||
public IKeyboard? Keyboard { get; private set; }
|
||||
|
||||
private long _startTicks = System.Environment.TickCount64;
|
||||
|
||||
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||
|
|
@ -89,7 +82,6 @@ public sealed class UiHost : System.IDisposable
|
|||
|
||||
public void WireKeyboard(IKeyboard kb)
|
||||
{
|
||||
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
||||
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
|
||||
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
|
||||
kb.KeyChar += (_, c) => Root.OnChar(c);
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
|
||||
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
|
||||
/// LayoutImporter must NOT build dat children. The toolbar uses single-cell
|
||||
/// instances (one slot); the inventory phase will grow this to an N-cell grid.
|
||||
/// </summary>
|
||||
public sealed class UiItemList : UiElement
|
||||
{
|
||||
private readonly List<UiItemSlot> _cells = new();
|
||||
|
||||
public UiItemList(Func<uint, (uint tex, int w, int h)>? spriteResolve = null)
|
||||
{
|
||||
SpriteResolve = spriteResolve;
|
||||
// Single-cell default: every toolbar slot always shows one cell (empty or filled).
|
||||
AddItem(new UiItemSlot { SpriteResolve = spriteResolve });
|
||||
}
|
||||
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
|
||||
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
|
||||
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
|
||||
/// runs, so use <see cref="GetItem"/> there instead.</summary>
|
||||
/// <exception cref="InvalidOperationException">the list has no cells (e.g. after Flush).</exception>
|
||||
public UiItemSlot Cell => _cells.Count > 0
|
||||
? _cells[0]
|
||||
: throw new InvalidOperationException("UiItemList has no cells; call AddItem first or use GetItem(index).");
|
||||
|
||||
public int GetNumUIItems() => _cells.Count;
|
||||
|
||||
public UiItemSlot? GetItem(int index)
|
||||
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
|
||||
|
||||
public void AddItem(UiItemSlot cell)
|
||||
{
|
||||
cell.SpriteResolve ??= SpriteResolve;
|
||||
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
|
||||
_cells.Add(cell);
|
||||
AddChild(cell);
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
foreach (var c in _cells) RemoveChild(c);
|
||||
_cells.Clear();
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// The factory sets THIS list's Width/Height AFTER construction, so the cell
|
||||
// (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the
|
||||
// cell sized to the list each frame; the cell paints itself in the children
|
||||
// pass that follows. (N-cell grid layout is the inventory phase.)
|
||||
if (_cells.Count > 0)
|
||||
{
|
||||
var cell = _cells[0];
|
||||
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// One item-in-a-slot cell (port of retail UIElement_UIItem, class 0x10000032).
|
||||
/// A behavioral LEAF: it draws the empty-slot sprite when unbound, else a
|
||||
/// pre-composited icon texture (set by the controller). Holds the bound weenie
|
||||
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
|
||||
/// </summary>
|
||||
public sealed class UiItemSlot : UiElement
|
||||
{
|
||||
public UiItemSlot() { ClickThrough = false; }
|
||||
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
|
||||
public uint ItemId { get; private set; }
|
||||
|
||||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||
public uint IconTexture { get; private set; }
|
||||
|
||||
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
||||
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
||||
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
||||
public uint EmptySprite { get; set; } = 0x060074CFu;
|
||||
|
||||
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
public void SetItem(uint itemId, uint iconTexture)
|
||||
{
|
||||
ItemId = itemId;
|
||||
IconTexture = iconTexture;
|
||||
}
|
||||
|
||||
public void Clear() { ItemId = 0; IconTexture = 0; }
|
||||
|
||||
// ── Shortcut number (slot label) ─────────────────────────────────────────
|
||||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
|
||||
// digit image from a DID-array property: 0x10000042 (peace) / 0x10000043 (war),
|
||||
// indexed by slot position. Each digit is a 32×32 PFID_A8R8G8B8 RenderSurface
|
||||
// with the digit baked into the top-left corner (rest alpha=0), drawn Alphablend.
|
||||
|
||||
/// <summary>Slot position in the shortcut bar (0-indexed). -1 = no number (retail
|
||||
/// SetVisible(0) when edi < 0). Top row: 0..8 → digits 1..9. Bottom row: -1.</summary>
|
||||
public int ShortcutNum { get; private set; } = -1;
|
||||
|
||||
/// <summary>True = draw peace digit set; false = war digit set.</summary>
|
||||
public bool ShortcutPeace { get; private set; } = true;
|
||||
|
||||
/// <summary>Peace digit DID array. Index i → digit (i+1) sprite RenderSurface id.
|
||||
/// Injected by the controller after reading LayoutDesc 0x21000037.
|
||||
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — occupied slot picks
|
||||
/// property 0x10000042 (peace) or 0x10000043 (war) by stance.</summary>
|
||||
public uint[]? PeaceDigits { get; set; }
|
||||
|
||||
/// <summary>War digit DID array. Same layout as PeaceDigits.
|
||||
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance.</summary>
|
||||
public uint[]? WarDigits { get; set; }
|
||||
|
||||
/// <summary>Empty-slot digit DID array (property 0x1000005e, stance-independent).
|
||||
/// Used when the slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
|
||||
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty).</summary>
|
||||
public uint[]? EmptyDigits { get; set; }
|
||||
|
||||
/// <summary>Set the slot's shortcut position and combat stance so the correct digit
|
||||
/// is drawn. Call with index 0..8 for the top row; pass peace=true for NonCombat.</summary>
|
||||
public void SetShortcutNum(int index, bool peace)
|
||||
{
|
||||
ShortcutNum = index;
|
||||
ShortcutPeace = peace;
|
||||
}
|
||||
|
||||
/// <summary>Clear the shortcut number label (hides the digit).</summary>
|
||||
public void ClearShortcutNum() { ShortcutNum = -1; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the digit DID array that OnDraw will use, following the retail occupancy
|
||||
/// branch in UIElement_UIItem::SetShortcutNum (decomp 229481):
|
||||
/// occupied (ItemId != 0) → ShortcutPeace ? PeaceDigits : WarDigits (0x10000042/43)
|
||||
/// empty (ItemId == 0) → EmptyDigits (0x1000005e, stance-independent)
|
||||
/// Exposed as an internal method so unit tests can assert array selection without
|
||||
/// needing a real render context.
|
||||
/// </summary>
|
||||
internal uint[]? ActiveDigitArray()
|
||||
{
|
||||
bool occupied = ItemId != 0;
|
||||
return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits;
|
||||
}
|
||||
|
||||
// ── Events / draw ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Invoked by <see cref="OnEvent"/> when a left-button-down lands on
|
||||
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
|
||||
public Action? Clicked { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Draw the icon (filled slot) or the empty-slot border. Both paths fall through
|
||||
// to the digit draw below; the slot label always shows on top-row slots.
|
||||
if (ItemId != 0 && IconTexture != 0)
|
||||
{
|
||||
ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
else if (SpriteResolve is not null && EmptySprite != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(EmptySprite);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
// Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||
// Occupancy branch (decomp 229481):
|
||||
// occupied (ItemId != 0) → peace/war digit set 0x10000042/43, split by stance
|
||||
// empty (ItemId == 0) → background digit set 0x1000005e, stance-independent
|
||||
// Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn
|
||||
// full-cell Alphablend so the transparent region is invisible.
|
||||
if (ShortcutNum >= 0 && SpriteResolve is not null)
|
||||
{
|
||||
var arr = ActiveDigitArray();
|
||||
if (arr is not null && ShortcutNum < arr.Length)
|
||||
{
|
||||
uint did = arr[ShortcutNum];
|
||||
if (did != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(did);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic dropdown menu. Ports retail <c>UIElement_Menu</c>
|
||||
/// (<c>RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163</c>) +
|
||||
/// <c>UIElement_Menu::MakePopup @0x46d310</c>: the button is labelled with
|
||||
/// the active target; clicking opens a column-major popup on the dat-driven menu
|
||||
/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel
|
||||
/// knowledge are populated by the controller, not baked into this widget. Built
|
||||
/// by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/> for Type-6 elements.
|
||||
/// </summary>
|
||||
public sealed class UiMenu : UiElement
|
||||
{
|
||||
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
|
||||
public readonly record struct MenuItem(string Label, object? Payload);
|
||||
|
||||
/// <summary>The rows, populated by the controller. Laid out column-major:
|
||||
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
|
||||
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
|
||||
|
||||
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
|
||||
public object? Selected { get; set; }
|
||||
|
||||
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||
public Action<object?>? OnSelect { get; set; }
|
||||
|
||||
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
|
||||
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||
|
||||
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||
public Func<string>? ButtonLabelProvider { get; set; }
|
||||
|
||||
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
|
||||
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
||||
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
|
||||
|
||||
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
|
||||
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
|
||||
// square; the label starts just past it (box width + small gap) so text aligns with
|
||||
// the box instead of overlapping it.
|
||||
private const float TextIndent = 19f;
|
||||
// The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its
|
||||
// left socket (~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<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
// Button face sprites (dat menu element 0x10000014).
|
||||
public uint NormalSprite { get; set; }
|
||||
public uint PressedSprite { get; set; }
|
||||
// Popup chrome sprites (dat menu popup template, layout 0x21000006).
|
||||
public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles)
|
||||
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
|
||||
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
|
||||
|
||||
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
|
||||
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
|
||||
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
|
||||
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
/// <summary>Disabled/unavailable item text — retail GREYS these (UIElement state 0xd
|
||||
/// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that
|
||||
/// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat
|
||||
/// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump.</summary>
|
||||
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
|
||||
|
||||
private bool _open;
|
||||
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
|
||||
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
|
||||
private float InteriorW => ColumnCount * ColumnWidth;
|
||||
private float InteriorH => RowsPerColumn * RowHeight;
|
||||
private float OuterW => InteriorW + 2 * Border;
|
||||
private float OuterH => InteriorH + 2 * Border;
|
||||
|
||||
public UiMenu() { CapturesPointerDrag = true; }
|
||||
|
||||
/// <summary>The menu draws its own button face + popup; its dat label/row children
|
||||
/// must NOT be built (an invisible label child would intercept the button click).</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var resolve = SpriteResolve;
|
||||
|
||||
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
|
||||
if (resolve is not null)
|
||||
{
|
||||
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
|
||||
}
|
||||
DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor);
|
||||
}
|
||||
|
||||
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
|
||||
// round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow
|
||||
// point. Slicing keeps the LED + arrow undistorted when the button widens to its label.
|
||||
private const float FaceCapL = 20f, FaceCapR = 12f;
|
||||
|
||||
private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw)
|
||||
{
|
||||
float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw;
|
||||
float midDest = Width - FaceCapL - FaceCapR;
|
||||
ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap
|
||||
if (midDest > 0f)
|
||||
ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched)
|
||||
ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap
|
||||
}
|
||||
|
||||
/// <summary>The button width that fits "LED cap + channel label + arrow cap" — retail
|
||||
/// sizes the talk-focus button to its selected label. The controller widens the button
|
||||
/// to this and reflows the input field to start after it.</summary>
|
||||
public float NaturalButtonWidth()
|
||||
{
|
||||
string text = ButtonLabelProvider?.Invoke() ?? "";
|
||||
float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f;
|
||||
return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap
|
||||
}
|
||||
|
||||
/// <summary>The open popup draws in the OVERLAY pass so it sits on top of the whole
|
||||
/// UI — otherwise the translucent chat panel (drawn after this element in the main
|
||||
/// pass) greys out the part of the popup that overlaps it.</summary>
|
||||
protected override void OnDrawOverlay(UiRenderContext ctx)
|
||||
{
|
||||
var resolve = SpriteResolve;
|
||||
if (!_open || resolve is null) return;
|
||||
|
||||
// Column-major popup opening UPWARD from the button, wrapped in the universal
|
||||
// 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a
|
||||
// bevelled floating window). Force OPAQUE (a menu reads solid even though the
|
||||
// chat window is translucent). Draw bevel → panel fill → row sprites → labels,
|
||||
// all through the sprite bucket in submission order so labels land on top.
|
||||
ctx.PushAlphaAbsolute(1f);
|
||||
try
|
||||
{
|
||||
float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
|
||||
float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
|
||||
|
||||
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
|
||||
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
|
||||
|
||||
for (int i = 0; i < Items.Count; i++)
|
||||
{
|
||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||
float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
|
||||
bool selected = Equals(Items[i].Payload, Selected);
|
||||
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
|
||||
}
|
||||
|
||||
float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
|
||||
for (int i = 0; i < Items.Count; i++)
|
||||
{
|
||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
|
||||
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
|
||||
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
|
||||
avail ? TextColorAvailable : TextColorGhosted);
|
||||
}
|
||||
}
|
||||
finally { ctx.PopAlpha(); }
|
||||
}
|
||||
|
||||
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
|
||||
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
|
||||
/// <paramref name="w"/>,<paramref name="h"/>). Reuses the same geometry +
|
||||
/// <see cref="RetailChromeSprites"/> ids as <see cref="UiNineSlicePanel"/>; no resize
|
||||
/// grips (a menu popup is not resizable).</summary>
|
||||
private void DrawBevel(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
float x, float y, float w, float h)
|
||||
{
|
||||
var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border);
|
||||
void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H);
|
||||
P(RetailChromeSprites.CenterFill, r.Center);
|
||||
P(RetailChromeSprites.TopEdge, r.Top);
|
||||
P(RetailChromeSprites.BottomEdge, r.Bottom);
|
||||
P(RetailChromeSprites.LeftEdge, r.Left);
|
||||
P(RetailChromeSprites.RightEdge, r.Right);
|
||||
P(RetailChromeSprites.CornerTL, r.TL);
|
||||
P(RetailChromeSprites.CornerTR, r.TR);
|
||||
P(RetailChromeSprites.CornerBL, r.BL);
|
||||
P(RetailChromeSprites.CornerBR, r.BR);
|
||||
}
|
||||
|
||||
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
|
||||
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0) return;
|
||||
var (tex, tw, th) = resolve(id);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
// Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1).
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||
}
|
||||
|
||||
private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color)
|
||||
{
|
||||
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color);
|
||||
else ctx.DrawString(s, x, y, color, Font);
|
||||
}
|
||||
|
||||
protected override bool OnHitTest(float lx, float ly)
|
||||
=> _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height)
|
||||
: base.OnHitTest(lx, ly);
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type != UiEventType.MouseDown) return false;
|
||||
|
||||
float lx = e.Data1, ly = e.Data2;
|
||||
if (_open && ly < 0) // clicked inside the upward popup
|
||||
{
|
||||
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
|
||||
// (outside the interior) just close the menu.
|
||||
float ix = lx - Border, iy = ly - (-OuterH + Border);
|
||||
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
|
||||
{
|
||||
int col = (int)(ix / ColumnWidth);
|
||||
int row = (int)(iy / RowHeight);
|
||||
int idx = col * RowsPerColumn + row;
|
||||
// Only pick enabled items.
|
||||
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
|
||||
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||
{
|
||||
// The widget REPORTS the pick; the controller owns Selected (it sets
|
||||
// Selected only for payloads it acts on). This mirrors retail
|
||||
// UIElement_Menu::NewSelection delegating to the owner rather than
|
||||
// self-selecting — so a deferred/no-op item (e.g. the Squelch /
|
||||
// Tell-to-Selected specials, null payload) leaves the current
|
||||
// selection + highlight unchanged when the controller ignores it.
|
||||
OnSelect?.Invoke(Items[idx].Payload);
|
||||
}
|
||||
}
|
||||
_open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
_open = !_open; // toggle on button click
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
|
||||
/// partial-width solid fill, and an optional centered "current/max" numeric
|
||||
/// overlay. <see cref="Fill"/> returns 0..1 (null = no data → empty bar);
|
||||
/// <see cref="Label"/> returns the overlay text (null = no number).
|
||||
///
|
||||
/// <para>
|
||||
/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite
|
||||
/// (glassy center highlight) and the retail dat font are a later polish pass —
|
||||
/// retail's vitals are bars exactly like this, just sprited.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiMeter : UiElement
|
||||
{
|
||||
|
||||
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
||||
public Func<float?> Fill { get; set; } = () => 0f;
|
||||
/// <summary>Centered overlay text provider (e.g. "291/291"); null = none.</summary>
|
||||
public Func<string?> Label { get; set; } = () => null;
|
||||
public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f);
|
||||
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
|
||||
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
|
||||
/// <summary>Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
|
||||
/// set, the label renders through the dat-font two-pass blit (outline + fill);
|
||||
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font
|
||||
/// is used instead. Set by the host when the retail UI is active.</summary>
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
||||
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set
|
||||
/// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
|
||||
// a TILED gradient middle (the "fill-tile" repeats at native width — it does not
|
||||
// stretch), and a fixed-width right-cap. The "back" slice is the empty track
|
||||
// (drawn full width); the "front" slice is the coloured fill (drawn full-geometry
|
||||
// but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's
|
||||
// shows through when partial). Ids come from the stacked vitals LayoutDesc
|
||||
// (0x2100006C) via the dump-vitals-layout CLI; 0 = none.
|
||||
/// <summary>Empty-track left-cap RenderSurface id.</summary>
|
||||
public uint BackLeft { get; set; }
|
||||
/// <summary>Empty-track middle (tiled gradient) RenderSurface id.</summary>
|
||||
public uint BackTile { get; set; }
|
||||
/// <summary>Empty-track right-cap RenderSurface id.</summary>
|
||||
public uint BackRight { get; set; }
|
||||
/// <summary>Coloured-fill left-cap RenderSurface id.</summary>
|
||||
public uint FrontLeft { get; set; }
|
||||
/// <summary>Coloured-fill middle (tiled gradient) RenderSurface id.</summary>
|
||||
public uint FrontTile { get; set; }
|
||||
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
||||
public uint FrontRight { get; set; }
|
||||
|
||||
public UiMeter() { ClickThrough = true; }
|
||||
|
||||
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
|
||||
/// grandchild slice/text elements as separate widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
|
||||
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
|
||||
public static (float x, float y, float w, float h) ComputeFillRect(
|
||||
float pct, float w, float h)
|
||||
{
|
||||
if (pct < 0f) pct = 0f;
|
||||
if (pct > 1f) pct = 1f;
|
||||
return (0f, 0f, w * pct, h);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
float? pct = Fill();
|
||||
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
|
||||
|
||||
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
||||
{
|
||||
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
|
||||
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
|
||||
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
|
||||
// The front carries its own right-cap (shown at 100%); clipping below 100%
|
||||
// removes it and reveals the back track's right-cap — retail's scissor-fill.
|
||||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Placeholder solid-color fallback.
|
||||
ctx.DrawRect(0, 0, Width, Height, BgColor);
|
||||
if (pct is not null && p > 0f)
|
||||
{
|
||||
var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height);
|
||||
if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor);
|
||||
}
|
||||
}
|
||||
|
||||
string? label = Label();
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
{
|
||||
if (DatFont is { } datFont)
|
||||
{
|
||||
// Retail path: centered cur/max via the dat font's two-pass blit.
|
||||
float tw = datFont.MeasureWidth(label);
|
||||
float tx = (Width - tw) * 0.5f;
|
||||
float ty = (Height - datFont.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
|
||||
}
|
||||
else if (ctx.DefaultFont is { } font)
|
||||
{
|
||||
// Fallback: debug bitmap font (no dat font available).
|
||||
float tw = font.MeasureWidth(label);
|
||||
float tx = (Width - tw) * 0.5f;
|
||||
float ty = (Height - font.LineHeight) * 0.5f;
|
||||
ctx.DrawString(label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the full-width horizontal 3-slice (native-width left-cap, stretched
|
||||
/// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED
|
||||
/// so nothing past <paramref name="clipW"/> (local px from the left) is drawn.
|
||||
/// The back track passes <c>clipW = Width</c>; the front fill passes
|
||||
/// <c>clipW = Width * fraction</c>. Clipping UV-crops each slice proportionally,
|
||||
/// so the fill ends cleanly and the back's right-cap shows through when partial.
|
||||
/// A 0 id skips that slice.
|
||||
/// </summary>
|
||||
private void DrawHBar(
|
||||
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint leftId, uint midId, uint rightId, float clipW)
|
||||
{
|
||||
if (clipW <= 0f) return;
|
||||
float w = Width, h = Height;
|
||||
// Only resolve a slice when its id is non-zero. resolve(0) returns the 1x1 MAGENTA
|
||||
// placeholder with a NON-ZERO GL handle, so resolving a zero (absent) cap id and then
|
||||
// testing `tex != 0` would draw a 1px magenta cap. The single-image meter (toolbar
|
||||
// selected-object bar) has no left/right caps (ids 0); the 3-slice vitals meter sets
|
||||
// all six ids. Guard on the id, not the resolved handle.
|
||||
var (lt, lw, _) = leftId != 0 ? resolve(leftId) : (0u, 0, 0);
|
||||
var (mt, mw, _) = midId != 0 ? resolve(midId) : (0u, 0, 0);
|
||||
var (rt, rw, _) = rightId != 0 ? resolve(rightId) : (0u, 0, 0);
|
||||
|
||||
float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
|
||||
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
|
||||
float midW = w - capL - capR;
|
||||
|
||||
// Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI
|
||||
// texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their
|
||||
// own native width → a single 1:1 copy. The wide middle spans many native
|
||||
// widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather
|
||||
// than stretching one copy. (Same UV-repeat the chrome border already uses.)
|
||||
DrawPiece(ctx, lt, 0f, capL, lw, h, clipW);
|
||||
DrawPiece(ctx, mt, capL, midW, mw, h, clipW);
|
||||
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
|
||||
}
|
||||
|
||||
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
||||
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
||||
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
||||
/// Clipped so nothing past <paramref name="clipW"/> shows. For a cap (span == native)
|
||||
/// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is
|
||||
/// UV-cropped.</summary>
|
||||
private static void DrawPiece(
|
||||
UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW)
|
||||
{
|
||||
if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
|
||||
float visibleW = MathF.Min(pieceW, clipW - pieceX);
|
||||
if (visibleW <= 0f) return;
|
||||
float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy
|
||||
ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="UiPanel"/> whose background is the retail 8-piece window bevel
|
||||
/// (<see cref="RetailChromeSprites"/>): 4 corners + 4 edges around a tiled
|
||||
/// center fill. Retires the flat translucent rect (divergence row TS-30).
|
||||
/// Sprites resolve to (GL handle, width, height) via an injected delegate so
|
||||
/// the widget is testable without GL. In production:
|
||||
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
|
||||
/// </summary>
|
||||
public sealed class UiNineSlicePanel : UiPanel
|
||||
{
|
||||
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
|
||||
public readonly record struct Rect(float X, float Y, float W, float H);
|
||||
|
||||
/// <summary>The nine destination rects for an 8-piece border + center.</summary>
|
||||
public readonly record struct FrameRects(
|
||||
Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right,
|
||||
Rect TL, Rect TR, Rect BL, Rect BR);
|
||||
|
||||
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
_resolve = resolve;
|
||||
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
|
||||
BorderColor = Vector4.Zero;
|
||||
Draggable = true; // retail windows are movable
|
||||
Resizable = true; // retail windows are resizable
|
||||
// A top-level window is USER-positioned: it must NOT be anchor-managed
|
||||
// by its parent (UiRoot), or the per-frame anchor pass would reset its
|
||||
// Left/Top/Width/Height every frame and undo move/resize. Children
|
||||
// INSIDE the window still anchor to it (the bars stretch with width).
|
||||
Anchors = AnchorEdges.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destination rects (local px) for a frame of (<paramref name="w"/>,
|
||||
/// <paramref name="h"/>) with border thickness <paramref name="b"/>:
|
||||
/// b×b corners, top/bottom edges spanning the interior width at height b,
|
||||
/// left/right edges spanning the interior height at width b, center fills
|
||||
/// the interior.
|
||||
/// </summary>
|
||||
public static FrameRects ComputeFrameRects(float w, float h, int b)
|
||||
{
|
||||
float innerW = w - 2 * b;
|
||||
float innerH = h - 2 * b;
|
||||
return new FrameRects(
|
||||
Center: new Rect(b, b, innerW, innerH),
|
||||
Top: new Rect(b, 0, innerW, b),
|
||||
Bottom: new Rect(b, h - b, innerW, b),
|
||||
Left: new Rect(0, b, b, innerH),
|
||||
Right: new Rect(w - b, b, b, innerH),
|
||||
TL: new Rect(0, 0, b, b),
|
||||
TR: new Rect(w - b, 0, b, b),
|
||||
BL: new Rect(0, h - b, b, b),
|
||||
BR: new Rect(w - b, h - b, b, b));
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Center fill is the window BACKGROUND — it must sit UNDER the content, so it
|
||||
// draws here (before children). The bevel border + grip is the OUTERMOST layer
|
||||
// and draws in OnDrawAfterChildren (over the content's edges) so content can
|
||||
// never poke through the frame (e.g. the toolbar's 2px bottom-right cap overhang).
|
||||
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
|
||||
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
|
||||
}
|
||||
|
||||
protected override void OnDrawAfterChildren(UiRenderContext ctx)
|
||||
{
|
||||
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
|
||||
// 8-piece bevel: edges tile (UV repeat); corners stretch 1:1.
|
||||
DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top);
|
||||
DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom);
|
||||
DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left);
|
||||
DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
|
||||
|
||||
// Resize-grip overlay (gold ridged edges + square corner studs) on top of the
|
||||
// bevel — the second border layer the vitals LayoutDesc carries (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,17 +57,14 @@ public class UiLabel : UiElement
|
|||
/// callback. Retail equivalent is Keystone's button widget, driven by
|
||||
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
|
||||
/// disabled) from the panel layout.
|
||||
/// Note: the dat-widget button (Type 1 / UIElement_Button) is <see cref="AcDream.App.UI.UiButton"/>
|
||||
/// in <c>UiButton.cs</c> — that is the production widget used by D.2b panels.
|
||||
/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
|
||||
/// </summary>
|
||||
public class UiSimpleButton : UiPanel
|
||||
public class UiButton : UiPanel
|
||||
{
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
public event System.Action? Click;
|
||||
|
||||
public UiSimpleButton()
|
||||
public UiButton()
|
||||
{
|
||||
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
|
||||
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
|
||||
|
|
|
|||
|
|
@ -22,29 +22,6 @@ public sealed class UiRenderContext
|
|||
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
||||
private Vector2 _current;
|
||||
|
||||
// Alpha (opacity) stack — a window pushes its Opacity so its background/sprite
|
||||
// draws fade (retail's translucent-chat effect). Text draws bypass this (they go
|
||||
// straight to TextRenderer), so text stays sharp over a translucent background.
|
||||
private readonly System.Collections.Generic.List<float> _alphaStack = new();
|
||||
private float _alpha = 1f;
|
||||
|
||||
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
|
||||
public float AlphaMod => _alpha;
|
||||
|
||||
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
|
||||
public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; }
|
||||
|
||||
/// <summary>Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays
|
||||
/// that must stay opaque even inside a translucent window. Pair with <see cref="PopAlpha"/>.</summary>
|
||||
public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; }
|
||||
|
||||
public void PopAlpha()
|
||||
{
|
||||
if (_alphaStack.Count == 0) return;
|
||||
_alpha = _alphaStack[^1];
|
||||
_alphaStack.RemoveAt(_alphaStack.Count - 1);
|
||||
}
|
||||
|
||||
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
|
||||
{
|
||||
TextRenderer = tr;
|
||||
|
|
@ -68,33 +45,13 @@ public sealed class UiRenderContext
|
|||
|
||||
public Vector2 CurrentOrigin => _current;
|
||||
|
||||
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
|
||||
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
|
||||
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
|
||||
public void EndOverlayLayer() => TextRenderer.OverlayMode = false;
|
||||
|
||||
// ── Pass-through draw helpers (add current translate) ──────────────
|
||||
|
||||
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
||||
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
|
||||
|
||||
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
|
||||
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
|
||||
/// all sprites and would cover the text — use this for backgrounds, that for foreground
|
||||
/// fills (carets, vital bars).</summary>
|
||||
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
||||
=> TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
|
||||
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color);
|
||||
|
||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
|
||||
|
||||
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
||||
=> TextRenderer.DrawSprite(texture,
|
||||
_current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint));
|
||||
|
||||
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
|
||||
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
|
||||
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness);
|
||||
|
||||
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
|
||||
{
|
||||
|
|
@ -102,101 +59,4 @@ public sealed class UiRenderContext
|
|||
if (f is null) return;
|
||||
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
|
||||
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
|
||||
/// typographic block (in this element's local space). Mirrors retail's
|
||||
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
|
||||
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
|
||||
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
|
||||
/// fill). The pen advances by
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
|
||||
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
|
||||
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
|
||||
/// glyph's OffsetY into the atlas.
|
||||
///
|
||||
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
|
||||
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
|
||||
/// runs the outline pass only when <c>m_bitField & 0x10</c> is set — i.e. the
|
||||
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
|
||||
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
|
||||
/// always-on outline shows as a grey halo over the solid menu panel. Pass
|
||||
/// <c>outline:true</c> only for elements retail outlines.</para>
|
||||
/// </summary>
|
||||
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
|
||||
{
|
||||
if (font is null || string.IsNullOrEmpty(text)) return;
|
||||
|
||||
// Baseline of this line in local space; retail draws glyphs whose
|
||||
// descriptor OffsetY already places them relative to the line top, so we
|
||||
// anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
|
||||
float originX = _current.X + x;
|
||||
float originY = _current.Y + y;
|
||||
float pen = originX;
|
||||
|
||||
// Snap the LINE baseline to a whole pixel ONCE. Retail's
|
||||
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
|
||||
// (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every
|
||||
// glyph on a line shares one integer baseline. If we instead round EACH glyph's
|
||||
// Y independently and the caller passes a fractional line Y (e.g. a channel-menu
|
||||
// item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round
|
||||
// to different rows and the line looks crooked ("letters dip down"). The vitals
|
||||
// digits never showed it because their bar baseline lands on an integer; chat text
|
||||
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
|
||||
// line on one row and pixel-aligned.
|
||||
float baseY = System.MathF.Round(originY);
|
||||
|
||||
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
|
||||
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (!font.TryGetGlyph(text[i], out var g))
|
||||
continue;
|
||||
|
||||
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
|
||||
// true fractional advance). Vertical: integer baseline + integer per-glyph
|
||||
// offset — never an independent per-glyph round (see baseY note above).
|
||||
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
|
||||
float gy = baseY + g.VerticalOffsetBefore;
|
||||
float gw = g.Width;
|
||||
float gh = g.Height;
|
||||
|
||||
if (gw > 0f && gh > 0f)
|
||||
{
|
||||
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
|
||||
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
|
||||
// text is crisp fill-only and free of the grey halo over solid panels.
|
||||
if (outline && font.BackgroundTexture != 0)
|
||||
{
|
||||
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||
font.BackgroundWidth, font.BackgroundHeight);
|
||||
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
|
||||
}
|
||||
|
||||
// Foreground (fill) atlas pass, tinted with the requested color.
|
||||
var (fu0, fv0, fu1, fv1) = AtlasUv(
|
||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||
font.ForegroundWidth, font.ForegroundHeight);
|
||||
TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color);
|
||||
}
|
||||
|
||||
pen += UiDatFont.GlyphAdvance(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
|
||||
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
|
||||
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>
|
||||
private static (float u0, float v0, float u1, float v1) AtlasUv(
|
||||
int offsetX, int offsetY, int width, int height, int atlasW, int atlasH)
|
||||
{
|
||||
if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f);
|
||||
float u0 = offsetX / (float)atlasW;
|
||||
float v0 = offsetY / (float)atlasH;
|
||||
float u1 = (offsetX + width) / (float)atlasW;
|
||||
float v1 = (offsetY + height) / (float)atlasH;
|
||||
return (u0, v0, u1, v1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
|
||||
[System.Flags]
|
||||
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
|
||||
|
||||
/// <summary>
|
||||
/// Top-level UI container. Implements the retail "Device" responsibilities
|
||||
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
|
||||
|
|
@ -44,10 +40,6 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Widget currently receiving keyboard events.</summary>
|
||||
public UiElement? KeyboardFocus { get; private set; }
|
||||
|
||||
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
|
||||
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
|
||||
public UiElement? DefaultTextInput { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Single modal overlay; while set, mouse clicks outside its rect
|
||||
/// are ignored. Retail sets this via Device vtable +0x48.
|
||||
|
|
@ -57,30 +49,12 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Widget with mouse capture (during click-drag).</summary>
|
||||
public UiElement? Captured { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the pointer is over a widget OR a widget holds mouse capture.
|
||||
/// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
|
||||
/// actions (movement, world-pick) are suppressed while the user interacts with
|
||||
/// a retail window — mirrors ImGui's WantCaptureMouse.
|
||||
/// </summary>
|
||||
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
|
||||
|
||||
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
|
||||
public bool WantsKeyboard => KeyboardFocus is not null;
|
||||
|
||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||
public UiElement? DragSource { get; private set; }
|
||||
public object? DragPayload { get; private set; }
|
||||
private UiElement? _lastDragHoverTarget;
|
||||
private int _pressX, _pressY;
|
||||
private bool _dragCandidate;
|
||||
private UiElement? _windowDragTarget;
|
||||
private int _windowDragOffX, _windowDragOffY;
|
||||
private UiElement? _resizeTarget;
|
||||
private ResizeEdges _resizeEdges;
|
||||
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
|
||||
private int _resizeMouseX, _resizeMouseY;
|
||||
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
|
||||
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
||||
|
||||
// Hover / tooltip tracking.
|
||||
|
|
@ -135,13 +109,6 @@ public sealed class UiRoot : UiElement
|
|||
// Render children (panels) sorted by z-order — modal last so it
|
||||
// sits on top.
|
||||
DrawSelfAndChildren(ctx);
|
||||
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
|
||||
// chat channel menu isn't greyed by the translucent chat panel that draws
|
||||
// after it in the main pass). Routed to the renderer's overlay layer so it
|
||||
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
||||
ctx.BeginOverlayLayer();
|
||||
DrawOverlays(ctx);
|
||||
ctx.EndOverlayLayer();
|
||||
}
|
||||
|
||||
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
||||
|
|
@ -153,26 +120,6 @@ public sealed class UiRoot : UiElement
|
|||
MouseX = x;
|
||||
MouseY = y;
|
||||
|
||||
// Window resize takes precedence over move / drag-drop / hover.
|
||||
if (_resizeTarget is not null)
|
||||
{
|
||||
var (nx, ny, nw, nh) = ResizeRect(
|
||||
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
||||
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
|
||||
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
||||
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
||||
return;
|
||||
}
|
||||
|
||||
// Window-move drag takes precedence over drag-drop / hover / fall-through.
|
||||
if (_windowDragTarget is not null)
|
||||
{
|
||||
_windowDragTarget.Left = x - _windowDragOffX;
|
||||
_windowDragTarget.Top = y - _windowDragOffY;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have capture, deliver MouseMove to the captured widget
|
||||
// AND drive drag state machine; do NOT fall through.
|
||||
if (Captured is not null)
|
||||
|
|
@ -208,68 +155,19 @@ public sealed class UiRoot : UiElement
|
|||
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
||||
return;
|
||||
|
||||
var (target, _, _) = HitTestTopDown(x, y);
|
||||
var (target, lx, ly) = HitTestTopDown(x, y);
|
||||
if (target is null)
|
||||
{
|
||||
// Clicking the 3D world exits write mode (no submit) and returns control to
|
||||
// the character — retail blurs the chat input on an outside click.
|
||||
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
|
||||
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keyboard focus follows a left click: the input bar (an edit control) takes
|
||||
// focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
|
||||
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
|
||||
if (btn == UiMouseButton.Left)
|
||||
SetKeyboardFocus(target.AcceptsFocus ? target : null);
|
||||
// Set keyboard focus if target accepts it.
|
||||
if (target.AcceptsFocus) SetKeyboardFocus(target);
|
||||
|
||||
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
|
||||
SetCapture(target);
|
||||
|
||||
// Window resize / move: find the window (Draggable or Resizable ancestor).
|
||||
// A left-drag starting near an edge resizes; interior drag repositions;
|
||||
// otherwise it's a normal drag-drop candidate.
|
||||
var window = FindWindow(target);
|
||||
if (btn == UiMouseButton.Left && window is not null)
|
||||
{
|
||||
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
||||
if (edges != ResizeEdges.None)
|
||||
{
|
||||
// Edge resize still wins, even over a CapturesPointerDrag child:
|
||||
// a resizable chat window can be resized from its frame.
|
||||
_resizeTarget = window;
|
||||
_resizeEdges = edges;
|
||||
_resizeStartX = window.Left; _resizeStartY = window.Top;
|
||||
_resizeStartW = window.Width; _resizeStartH = window.Height;
|
||||
_resizeMouseX = x; _resizeMouseY = y;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
{
|
||||
// The pressed widget owns interior drags (e.g. text selection):
|
||||
// do NOT move the ancestor window. The already-dispatched MouseDown
|
||||
// event + SetCapture(target) let the target drive its own drag via
|
||||
// the MouseMove events it receives while captured.
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (window.Draggable)
|
||||
{
|
||||
_windowDragTarget = window;
|
||||
_windowDragOffX = x - (int)window.Left;
|
||||
_windowDragOffY = y - (int)window.Top;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else { _dragCandidate = true; }
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
{
|
||||
// No window ancestor, but the target still owns its interior drag.
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dragCandidate = true;
|
||||
}
|
||||
_dragCandidate = true;
|
||||
|
||||
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
|
||||
int rawType = btn switch
|
||||
|
|
@ -279,13 +177,8 @@ public sealed class UiRoot : UiElement
|
|||
UiMouseButton.Middle => UiEventType.MiddleDown,
|
||||
_ => UiEventType.MouseDown,
|
||||
};
|
||||
// Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use
|
||||
// target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL
|
||||
// child, so for a nested target (e.g. the chat view inset inside its window)
|
||||
// they'd be offset by the child's position — which mis-anchored drag-select.
|
||||
var sp = target.ScreenPosition;
|
||||
var e = new UiEvent(target.EventId, target, rawType,
|
||||
Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly);
|
||||
BubbleEvent(target, in e);
|
||||
}
|
||||
|
||||
|
|
@ -294,20 +187,6 @@ public sealed class UiRoot : UiElement
|
|||
MouseX = x; MouseY = y;
|
||||
UpdateButtonFlag(btn, down: false);
|
||||
|
||||
if (_resizeTarget is not null)
|
||||
{
|
||||
_resizeTarget = null;
|
||||
ReleaseCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_windowDragTarget is not null)
|
||||
{
|
||||
_windowDragTarget = null;
|
||||
ReleaseCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DragSource is not null)
|
||||
{
|
||||
FinishDrag(x, y);
|
||||
|
|
@ -372,18 +251,6 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public void OnKeyDown(int vk, uint lparam = 0)
|
||||
{
|
||||
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
|
||||
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
|
||||
// also fall through to a game hotkey.
|
||||
if (KeyboardFocus is null && DefaultTextInput is not null
|
||||
&& (vk == (int)Silk.NET.Input.Key.Tab
|
||||
|| vk == (int)Silk.NET.Input.Key.Enter
|
||||
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
|
||||
{
|
||||
SetKeyboardFocus(DefaultTextInput);
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus widget first.
|
||||
if (KeyboardFocus is not null)
|
||||
{
|
||||
|
|
@ -569,48 +436,6 @@ public sealed class UiRoot : UiElement
|
|||
return (null, 0, 0);
|
||||
}
|
||||
|
||||
private static UiElement? FindWindow(UiElement? e)
|
||||
{
|
||||
while (e is not null)
|
||||
{
|
||||
if (e.Draggable || e.Resizable) return e;
|
||||
e = e.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Which edges of <paramref name="w"/>'s screen rect the point
|
||||
/// (<paramref name="x"/>,<paramref name="y"/>) is within <paramref name="grip"/> px of.
|
||||
/// None if the point is outside the grip-expanded box entirely.</summary>
|
||||
internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip)
|
||||
{
|
||||
float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height;
|
||||
if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None;
|
||||
var e = ResizeEdges.None;
|
||||
if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left;
|
||||
if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right;
|
||||
if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top;
|
||||
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
|
||||
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
||||
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
|
||||
/// move the origin so the opposite edge stays put.</summary>
|
||||
public static (float x, float y, float w, float h) ResizeRect(
|
||||
float startX, float startY, float startW, float startH,
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH)
|
||||
{
|
||||
float x = startX, y = startY, w = startW, h = startH;
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
private static bool ContainsAbsolute(UiElement e, int x, int y)
|
||||
{
|
||||
var sp = e.ScreenPosition;
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel-based vertical scroll model. Port of retail <c>UIElement_Scrollable</c>:
|
||||
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) clamped to
|
||||
/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position
|
||||
/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and
|
||||
/// shared by the transcript (UiText) and the scrollbar (UiScrollbar).
|
||||
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
|
||||
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
|
||||
/// </summary>
|
||||
public sealed class UiScrollable
|
||||
{
|
||||
/// <summary>Total wrapped content height in px (m_iScrollableHeight).</summary>
|
||||
public int ContentHeight { get; set; }
|
||||
/// <summary>Visible viewport height in px.</summary>
|
||||
public int ViewHeight { get; set; }
|
||||
/// <summary>Pixels per text line (scroll quantum). InqScrollDelta line case.</summary>
|
||||
public int LineHeight { get; set; } = 16;
|
||||
|
||||
private int _scrollY;
|
||||
/// <summary>Current scroll offset in px from the top of the content.</summary>
|
||||
public int ScrollY => _scrollY;
|
||||
|
||||
/// <summary>Max scroll = max(0, content - view).</summary>
|
||||
public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
|
||||
|
||||
/// <summary>True when content exceeds the view (a scrollbar is warranted).</summary>
|
||||
public bool HasOverflow => ContentHeight > ViewHeight;
|
||||
|
||||
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
|
||||
public bool AtEnd => _scrollY >= MaxScroll;
|
||||
|
||||
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
|
||||
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
|
||||
|
||||
/// <summary>Pin to the bottom (newest content visible).</summary>
|
||||
public void ScrollToEnd() => _scrollY = MaxScroll;
|
||||
|
||||
/// <summary>Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).</summary>
|
||||
public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
|
||||
|
||||
/// <summary>Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).</summary>
|
||||
public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
|
||||
|
||||
/// <summary>Inverse of PositionRatio — used when the user drags the thumb.</summary>
|
||||
public void SetPositionRatio(float ratio)
|
||||
=> SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
|
||||
|
||||
/// <summary>Scroll by whole lines (sign: +down/newer, -up/older).</summary>
|
||||
public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
|
||||
|
||||
/// <summary>Scroll by a page = one view height (InqScrollDelta page case).</summary>
|
||||
public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight);
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic scrollbar. Ports retail <c>UIElement_Scrollbar</c>
|
||||
/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137);
|
||||
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
|
||||
/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains
|
||||
/// the full scrollbar widget with distinct up/down button children:
|
||||
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
|
||||
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
|
||||
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
|
||||
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
|
||||
/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
|
||||
/// rendered scrollbar's height; the widget responds to those regions directly via hit
|
||||
/// comparison in OnEvent without requiring separate child elements.
|
||||
/// </remarks>
|
||||
public sealed class UiScrollbar : UiElement
|
||||
{
|
||||
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
|
||||
public UiScrollable? Model { get; set; }
|
||||
|
||||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
|
||||
public uint TrackSprite { get; set; }
|
||||
|
||||
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
|
||||
public uint ThumbSprite { get; set; }
|
||||
|
||||
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
|
||||
public uint ThumbTopSprite { get; set; }
|
||||
|
||||
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
|
||||
public uint ThumbBotSprite { get; set; }
|
||||
|
||||
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
|
||||
public uint UpSprite { get; set; }
|
||||
|
||||
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
|
||||
public uint DownSprite { get; set; }
|
||||
|
||||
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
|
||||
private const float MinThumb = 8f;
|
||||
|
||||
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
|
||||
private const float CapH = 3f;
|
||||
|
||||
/// <summary>Up/down button height in pixels. Matches element height 16px from
|
||||
/// the up/down button children in base layout 0x2100003E.</summary>
|
||||
private const float ButtonH = 16f;
|
||||
|
||||
private bool _draggingThumb;
|
||||
private float _dragOffsetY;
|
||||
|
||||
public UiScrollbar() { CapturesPointerDrag = true; }
|
||||
|
||||
/// <summary>The scrollbar draws its own track/thumb/arrows; its dat up/down button
|
||||
/// children are reproduced procedurally, so the importer must not build them.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the thumb rectangle (local y origin and height) within the track area
|
||||
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
|
||||
/// @0x4710d0</c>: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
|
||||
/// offset = trackTop + (trackLen - thumbH) * PositionRatio.
|
||||
/// </summary>
|
||||
/// <param name="m">The scroll model.</param>
|
||||
/// <param name="trackTop">Y of the top of the usable track area (below up-button).</param>
|
||||
/// <param name="trackLen">Pixel length of the usable track area (between up and down buttons).</param>
|
||||
/// <returns>Local Y of the thumb's top edge, and its pixel height.</returns>
|
||||
public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen)
|
||||
{
|
||||
float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
|
||||
float travel = trackLen - h;
|
||||
float y = trackTop + travel * m.PositionRatio;
|
||||
return (y, h);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (Model is not { } m || SpriteResolve is not { } resolve) return;
|
||||
|
||||
// Track background — TILED vertically (retail DrawMode=Normal). The native track
|
||||
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
|
||||
// Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1.
|
||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
|
||||
|
||||
// Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art.
|
||||
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
|
||||
|
||||
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
|
||||
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
|
||||
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
|
||||
// or the thumb is too short to hold both caps.
|
||||
if (m.HasOverflow)
|
||||
{
|
||||
float trackTop = ButtonH;
|
||||
float trackLen = Height - 2f * ButtonH;
|
||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
|
||||
{
|
||||
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
|
||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
|
||||
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
|
||||
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, _, _) = resolve(id);
|
||||
if (tex == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
|
||||
/// the top scroll button's (down-art) arrow upward.</summary>
|
||||
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, _, _) = resolve(id);
|
||||
if (tex == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
|
||||
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
|
||||
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, tw, th) = resolve(id);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (Model is not { } m) return false;
|
||||
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
|
||||
float ly = e.Data2;
|
||||
|
||||
// Up-button region: top ButtonH rows.
|
||||
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
|
||||
|
||||
// Down-button region: bottom ButtonH rows.
|
||||
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
|
||||
|
||||
// Track interior: start a thumb drag or page-scroll.
|
||||
float trackTop = ButtonH;
|
||||
float trackLen = Height - 2f * ButtonH;
|
||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||
|
||||
if (ly >= ty && ly <= ty + th)
|
||||
{
|
||||
// Clicked inside the thumb — begin drag with offset from thumb top.
|
||||
_draggingThumb = true;
|
||||
_dragOffsetY = ly - ty;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clicked above or below thumb — page scroll (HandleButtonClick page case).
|
||||
m.ScrollByPage(ly < ty ? -1 : 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove when _draggingThumb:
|
||||
{
|
||||
// Map current local Y (minus drag offset from thumb top) back to a
|
||||
// position ratio across the available travel distance.
|
||||
float trackTop = ButtonH;
|
||||
float trackLen = Height - 2f * ButtonH;
|
||||
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
|
||||
float travel = MathF.Max(1f, trackLen - thumbH);
|
||||
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
|
||||
m.SetPositionRatio(newRatio);
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,448 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Scrollable text view for retail UIElement_Text elements
|
||||
/// (<c>RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655</c>).
|
||||
/// Renders the lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
|
||||
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
|
||||
/// text inside the window.
|
||||
///
|
||||
/// <para>
|
||||
/// Supports Windows-like text selection: a left-click-drag inside the transcript
|
||||
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
|
||||
/// stops that interior drag from moving the host window), and Ctrl+C copies the
|
||||
/// selected span to the clipboard. Ctrl+A selects everything.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiText : UiElement
|
||||
{
|
||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||
public readonly record struct Line(string Text, Vector4 Color);
|
||||
|
||||
/// <summary>A caret position: a line index into the cached line list plus a
|
||||
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
|
||||
public readonly record struct Pos(int Line, int Col);
|
||||
|
||||
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
|
||||
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
|
||||
|
||||
/// <summary>Font for the transcript; falls back to the context default.</summary>
|
||||
public BitmapFont? Font { get; set; }
|
||||
|
||||
/// <summary>Retail dat font (0x40000000) for the transcript. When set, glyphs
|
||||
/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph
|
||||
/// advance; when null, the debug BitmapFont path is used. Set by the controller.</summary>
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
|
||||
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||
|
||||
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
|
||||
/// UiText (no controller) draws nothing. Set to the retail translucent value by
|
||||
/// the controller (e.g. <c>ChatWindowController</c>).</summary>
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
|
||||
|
||||
/// <summary>Optional dat state-sprite background (the element's own media), drawn
|
||||
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
|
||||
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
|
||||
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||
|
||||
/// <summary>Inner text inset from the view edges, px.</summary>
|
||||
public float Padding { get; set; } = 4f;
|
||||
|
||||
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
|
||||
/// justification): draws the FIRST line centered horizontally AND vertically in the
|
||||
/// element rect, with NO scroll/selection machinery. Used for static labels such as
|
||||
/// the vitals cur/max numbers. The centering formula is IDENTICAL to
|
||||
/// <see cref="UiMeter"/>'s former number overlay so those numbers stay pixel-identical
|
||||
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
||||
public bool Centered { get; set; }
|
||||
|
||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
|
||||
private bool _pinBottom = true;
|
||||
|
||||
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
|
||||
|
||||
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
|
||||
private IReadOnlyList<Line> _lastLines = Array.Empty<Line>();
|
||||
private BitmapFont? _lastFont;
|
||||
private UiDatFont? _lastDatFont;
|
||||
private float _lastLineHeight = 16f;
|
||||
private float _lastBaseY; // top Y of line 0 in local space
|
||||
private float _lastPadding = 4f;
|
||||
|
||||
// ── Selection state ──────────────────────────────────────────────────
|
||||
private Pos? _selAnchor; // where the drag started
|
||||
private Pos? _selCaret; // where the drag currently is
|
||||
private bool _selecting;
|
||||
|
||||
public UiText()
|
||||
{
|
||||
AcceptsFocus = true;
|
||||
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
||||
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||
}
|
||||
|
||||
/// <summary>The text view draws its own lines + background; any dat sub-elements
|
||||
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>
|
||||
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
|
||||
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.
|
||||
/// </summary>
|
||||
public static float ClampScroll(float scroll, float contentHeight, float viewHeight)
|
||||
{
|
||||
float max = Math.Max(0f, contentHeight - viewHeight);
|
||||
if (scroll < 0f) return 0f;
|
||||
return scroll > max ? max : scroll;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Optional dat state-sprite background drawn UNDER everything else.
|
||||
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
|
||||
// Background must draw UNDER the transcript text. DrawStringDat emits into the
|
||||
// sprite bucket which flushes BEFORE rects, so a DrawRect background would wash
|
||||
// over the text. DrawFill routes the background through the sprite bucket too,
|
||||
// submitted first → text on top.
|
||||
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
|
||||
// line centered H+V with the SAME formula UIElement_Meter used for its label, then
|
||||
// skip the scroll/selection machinery entirely.
|
||||
if (Centered)
|
||||
{
|
||||
var cLines = LinesProvider();
|
||||
if (cLines.Count == 0) return;
|
||||
var line0 = cLines[0];
|
||||
if (DatFont is { } cdf)
|
||||
{
|
||||
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
|
||||
float cy = (Height - cdf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
|
||||
}
|
||||
else if ((Font ?? ctx.DefaultFont) is { } cbf)
|
||||
{
|
||||
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
|
||||
float cy = (Height - cbf.LineHeight) * 0.5f;
|
||||
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the retail dat font when set; fall back to BitmapFont.
|
||||
var datFont = DatFont;
|
||||
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
|
||||
if (datFont is null && bitmapFont is null) return;
|
||||
|
||||
var lines = LinesProvider();
|
||||
|
||||
// Cache the geometry OnEvent will hit-test against. Even when there are no
|
||||
// lines we record the font/padding so a stray hit-test is harmless.
|
||||
_lastLines = lines;
|
||||
_lastDatFont = datFont;
|
||||
_lastFont = bitmapFont;
|
||||
_lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight;
|
||||
_lastPadding = Padding;
|
||||
|
||||
if (lines.Count == 0) return;
|
||||
|
||||
float lh = _lastLineHeight;
|
||||
float top = Padding, bottom = Height - Padding;
|
||||
float innerH = bottom - top;
|
||||
float contentH = lines.Count * lh;
|
||||
|
||||
// Drive the shared scroll model with the current geometry.
|
||||
Scroll.LineHeight = (int)MathF.Round(lh);
|
||||
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
|
||||
Scroll.ViewHeight = (int)MathF.Floor(innerH);
|
||||
if (_pinBottom) Scroll.ScrollToEnd();
|
||||
|
||||
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
|
||||
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
|
||||
// Invert: baseY = bottom - contentH + (MaxScroll - ScrollY).
|
||||
// With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓
|
||||
// Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓
|
||||
float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY);
|
||||
_lastBaseY = baseY;
|
||||
|
||||
// Normalised selection span (start <= end), if any.
|
||||
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
|
||||
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
float y = baseY + i * lh;
|
||||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
||||
|
||||
string text = lines[i].Text;
|
||||
|
||||
// Selection highlight behind this line's selected character span.
|
||||
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
|
||||
{
|
||||
int c0 = i == selStart.Line ? selStart.Col : 0;
|
||||
int c1 = i == selEnd.Line ? selEnd.Col : text.Length;
|
||||
c0 = Math.Clamp(c0, 0, text.Length);
|
||||
c1 = Math.Clamp(c1, 0, text.Length);
|
||||
if (c1 > c0)
|
||||
{
|
||||
float hx, hw;
|
||||
if (datFont is not null)
|
||||
{
|
||||
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
|
||||
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
else
|
||||
{
|
||||
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
|
||||
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
// Highlight sits BEHIND the line's text → sprite bucket, submitted
|
||||
// before this line's DrawStringDat.
|
||||
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (datFont is not null)
|
||||
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
|
||||
else
|
||||
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.Scroll:
|
||||
{
|
||||
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
|
||||
// ScrollByLines sign: +down/newer, -up/older.
|
||||
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
|
||||
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
|
||||
_pinBottom = Scroll.AtEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
|
||||
var p = HitChar(e.Data1, e.Data2);
|
||||
_selAnchor = p;
|
||||
_selCaret = p;
|
||||
_selecting = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove:
|
||||
{
|
||||
if (_selecting)
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
|
||||
_selCaret = HitChar(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
{
|
||||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
bool ctrl = Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
|
||||
if (ctrl && key == Silk.NET.Input.Key.C)
|
||||
{
|
||||
// Only touch the clipboard when there's a selection — an empty
|
||||
// copy must NOT clobber what the user previously copied.
|
||||
if (Keyboard is not null)
|
||||
{
|
||||
string sel = SelectedText();
|
||||
if (sel.Length > 0) Keyboard.ClipboardText = sel;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ctrl && key == Silk.NET.Input.Key.A)
|
||||
{
|
||||
SelectAll();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Selection helpers ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Select the entire cached transcript (Ctrl+A).</summary>
|
||||
private void SelectAll()
|
||||
{
|
||||
var lines = _lastLines;
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
_selAnchor = _selCaret = null;
|
||||
return;
|
||||
}
|
||||
int last = lines.Count - 1;
|
||||
_selAnchor = new Pos(0, 0);
|
||||
_selCaret = new Pos(last, lines[last].Text.Length);
|
||||
}
|
||||
|
||||
/// <summary>Normalise (anchor, caret) into ordered (start, end). False if no
|
||||
/// selection or it is empty (anchor == caret).</summary>
|
||||
private bool TryGetOrderedSelection(out Pos start, out Pos end)
|
||||
{
|
||||
start = default; end = default;
|
||||
if (_selAnchor is not { } a || _selCaret is not { } c) return false;
|
||||
(start, end) = Order(a, c);
|
||||
return !(start.Line == end.Line && start.Col == end.Col);
|
||||
}
|
||||
|
||||
/// <summary>The currently-selected text against the cached lines. Empty when
|
||||
/// nothing is selected.</summary>
|
||||
public string SelectedText()
|
||||
{
|
||||
if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty;
|
||||
return SelectedText(_lastLines, start, end);
|
||||
}
|
||||
|
||||
// ── Pure, testable logic (no GL / no font texture) ───────────────────
|
||||
|
||||
/// <summary>Order two caret positions so the first is <= the second (by line,
|
||||
/// then column).</summary>
|
||||
public static (Pos start, Pos end) Order(Pos a, Pos b)
|
||||
{
|
||||
if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b);
|
||||
return (b, a);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemble the selected substring spanning <paramref name="start"/> ..
|
||||
/// <paramref name="end"/> (inclusive of start.Col, exclusive of end.Col) from
|
||||
/// <paramref name="lines"/>. Multi-line selections are joined with "\n":
|
||||
/// the first line from start.Col to its end, whole middle lines, and the last
|
||||
/// line up to end.Col. Pure — unit-testable without GL.
|
||||
/// </summary>
|
||||
public static string SelectedText(IReadOnlyList<Line> lines, Pos start, Pos end)
|
||||
{
|
||||
if (lines.Count == 0) return string.Empty;
|
||||
(start, end) = Order(start, end);
|
||||
|
||||
int sl = Math.Clamp(start.Line, 0, lines.Count - 1);
|
||||
int el = Math.Clamp(end.Line, 0, lines.Count - 1);
|
||||
|
||||
if (sl == el)
|
||||
{
|
||||
string t = lines[sl].Text;
|
||||
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||
if (c1 <= c0) return string.Empty;
|
||||
return t.Substring(c0, c1 - c0);
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// First line: from start.Col to its end.
|
||||
{
|
||||
string t = lines[sl].Text;
|
||||
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||
sb.Append(t.AsSpan(c0));
|
||||
}
|
||||
|
||||
// Whole middle lines.
|
||||
for (int i = sl + 1; i < el; i++)
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(lines[i].Text);
|
||||
}
|
||||
|
||||
// Last line: up to end.Col.
|
||||
{
|
||||
sb.Append('\n');
|
||||
string t = lines[el].Text;
|
||||
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||
sb.Append(t.AsSpan(0, c1));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a local-space point to a caret <see cref="Pos"/> against the cached
|
||||
/// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped
|
||||
/// to the line range; col via <see cref="CharIndexAt"/>.
|
||||
/// </summary>
|
||||
private Pos HitChar(float localX, float localY)
|
||||
{
|
||||
var lines = _lastLines;
|
||||
if (lines.Count == 0) return new Pos(0, 0);
|
||||
|
||||
float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight;
|
||||
int line = (int)MathF.Floor((localY - _lastBaseY) / lh);
|
||||
line = Math.Clamp(line, 0, lines.Count - 1);
|
||||
|
||||
string text = lines[line].Text;
|
||||
int col = _lastDatFont is { } df
|
||||
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
|
||||
localX - _lastPadding)
|
||||
: (_lastFont is { } bf
|
||||
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
|
||||
localX - _lastPadding)
|
||||
: 0);
|
||||
return new Pos(line, col);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caret column for a horizontal position <paramref name="x"/> (already
|
||||
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
|
||||
/// string accumulating each glyph's advance and snaps the caret to whichever
|
||||
/// side of the glyph midpoint <paramref name="x"/> falls on — natural
|
||||
/// Windows-like caret placement. Pure — unit-testable with a synthetic advance.
|
||||
/// </summary>
|
||||
/// <param name="text">The line text.</param>
|
||||
/// <param name="advanceOf">Per-character advance (pixels) lookup.</param>
|
||||
/// <param name="x">Horizontal position relative to the text's left edge.</param>
|
||||
public static int CharIndexAt(string text, Func<char, float> advanceOf, float x)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || x <= 0f) return 0;
|
||||
|
||||
float cursor = 0f;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
float adv = advanceOf(text[i]);
|
||||
float mid = cursor + adv * 0.5f;
|
||||
if (x < mid) return i; // caret sits before this glyph
|
||||
cursor += adv;
|
||||
}
|
||||
return text.Length; // past the last glyph → end caret
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
|
||||
public enum ArrivalReadiness
|
||||
{
|
||||
/// <summary>Destination not yet hydrated; keep holding.</summary>
|
||||
NotReady,
|
||||
|
||||
/// <summary>Destination terrain + cell are ready; place now.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
|
||||
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
|
||||
/// demote rather than hold forever.</summary>
|
||||
Impossible,
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle of a single teleport arrival.</summary>
|
||||
public enum TeleportArrivalPhase { Idle, Holding }
|
||||
|
||||
/// <summary>
|
||||
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
|
||||
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
|
||||
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
|
||||
/// arrival against the resident (old) landblocks before the destination hydrated
|
||||
/// and landed the player in ocean.
|
||||
///
|
||||
/// <para>The controller is pure: readiness and placement are injected delegates,
|
||||
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
|
||||
/// player stays input-frozen while this is Holding because the GameWindow keeps
|
||||
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
|
||||
/// InWorld.</para>
|
||||
///
|
||||
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
|
||||
/// needs no external clock; it is a loud safety net for a never-hydrating
|
||||
/// destination, not a precise deadline.</para>
|
||||
/// </summary>
|
||||
public sealed class TeleportArrivalController
|
||||
{
|
||||
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
|
||||
public const int DefaultMaxHoldFrames = 600;
|
||||
|
||||
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
|
||||
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
|
||||
private readonly int _maxHoldFrames;
|
||||
|
||||
private Vector3 _destPos;
|
||||
private uint _destCell;
|
||||
private int _heldFrames;
|
||||
|
||||
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
|
||||
|
||||
public TeleportArrivalController(
|
||||
Func<Vector3, uint, ArrivalReadiness> readiness,
|
||||
Action<Vector3, uint, bool> place,
|
||||
int maxHoldFrames = DefaultMaxHoldFrames)
|
||||
{
|
||||
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
|
||||
_place = place ?? throw new ArgumentNullException(nameof(place));
|
||||
_maxHoldFrames = maxHoldFrames;
|
||||
}
|
||||
|
||||
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
|
||||
/// AFTER the streaming origin has been recentered on the destination landblock.
|
||||
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
|
||||
public void BeginArrival(Vector3 destPos, uint destCell)
|
||||
{
|
||||
_destPos = destPos;
|
||||
_destCell = destCell;
|
||||
_heldFrames = 0;
|
||||
Phase = TeleportArrivalPhase.Holding;
|
||||
}
|
||||
|
||||
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
|
||||
/// No-op when Idle.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (Phase != TeleportArrivalPhase.Holding) return;
|
||||
_heldFrames++;
|
||||
|
||||
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
|
||||
if (verdict == ArrivalReadiness.Ready)
|
||||
{
|
||||
Place(forced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
|
||||
{
|
||||
Place(forced: true);
|
||||
}
|
||||
// else NotReady -> keep holding
|
||||
}
|
||||
|
||||
private void Place(bool forced)
|
||||
{
|
||||
// Flip to Idle BEFORE invoking the placement delegate so the machine
|
||||
// reflects "done holding" even if the delegate were to re-enter Tick.
|
||||
Phase = TeleportArrivalPhase.Idle;
|
||||
_place(_destPos, _destCell, forced);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,14 +9,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
|
||||
<!-- render-vitals-mockup: SurfaceDecoder (Core) + ImageSharp for a headless
|
||||
PNG composite of the retail vital bars, so the 3-slice assembly can be
|
||||
verified without launching the client. -->
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
using AcDream.Core.Textures;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace AcDream.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes:
|
||||
/// • <c><out>-fg.png</c> — foreground (fill) atlas, alpha→luminance (white on black)
|
||||
/// • <c><out>-bg.png</c> — background (outline) atlas, alpha→luminance
|
||||
/// • <c><out>-sample.png</c> — a sample string composited EXACTLY the way
|
||||
/// <c>UiRenderContext.DrawStringDat</c> does it (black outline pass behind,
|
||||
/// colored fill pass on top) onto the dark chat-panel colour, at native 1:1
|
||||
/// and at 6× nearest zoom side by side.
|
||||
///
|
||||
/// The sample reproduces our client's glyph math deterministically so the
|
||||
/// "not sharp" artifact can be judged offline: if the 1:1 sample is crisp, the
|
||||
/// softness is downstream (a post-process / scale); if the sample itself is
|
||||
/// soft, the cause is the atlas or the two-pass outline.
|
||||
/// </summary>
|
||||
public static class FontAtlasDump
|
||||
{
|
||||
public static int Run(string datDir, string? fontIdText, string? sampleText, string outBase)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
uint fontId = string.IsNullOrWhiteSpace(fontIdText) ? 0x40000000u : ParseHex(fontIdText);
|
||||
string sample = string.IsNullOrEmpty(sampleText) ? "Chat Send 12345 ghpqy" : sampleText;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var font = dats.Get<Font>(fontId);
|
||||
if (font is null) { Console.Error.WriteLine($"error: Font 0x{fontId:X8} not found"); return 1; }
|
||||
|
||||
Console.WriteLine($"Font 0x{fontId:X8}: fg=0x{font.ForegroundSurfaceDataId:X8} bg=0x{font.BackgroundSurfaceDataId:X8} " +
|
||||
$"MaxCharHeight={font.MaxCharHeight} Baseline={font.BaselineOffset} glyphs={font.CharDescs.Count}");
|
||||
|
||||
DecodedTexture fg = DecodeRs(dats, font.ForegroundSurfaceDataId);
|
||||
DecodedTexture? bg = font.BackgroundSurfaceDataId != 0 ? DecodeRs(dats, font.BackgroundSurfaceDataId) : null;
|
||||
Console.WriteLine($" fg atlas {fg.Width}x{fg.Height}" + (bg is { } b ? $" bg atlas {b.Width}x{b.Height}" : " (no bg atlas)"));
|
||||
|
||||
AlphaLuma(fg).SaveAsPng($"{outBase}-fg.png");
|
||||
Console.WriteLine($"wrote {outBase}-fg.png");
|
||||
if (bg is { } bgt) { AlphaLuma(bgt).SaveAsPng($"{outBase}-bg.png"); Console.WriteLine($"wrote {outBase}-bg.png"); }
|
||||
|
||||
// Build a glyph lookup.
|
||||
var glyphs = new Dictionary<char, FontCharDesc>();
|
||||
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
|
||||
|
||||
// Render the sample the way DrawStringDat does, onto the dark chat panel colour.
|
||||
var panel = new Rgba32(28, 28, 32, 255);
|
||||
var fill = new Rgba32(255, 255, 255, 255); // white fill, like System default-ish
|
||||
var outline = new Rgba32(0, 0, 0, 255);
|
||||
|
||||
int lineH = Math.Max((int)font.MaxCharHeight, 8);
|
||||
|
||||
// (a) integer baseline, per-glyph round (works — like the vitals digits).
|
||||
using var native = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0f, snapOnce: false);
|
||||
Save6x(native, $"{outBase}-sample");
|
||||
|
||||
// (b) FRACTIONAL baseline (textY=0.5, like a menu item centered in a 17px row over
|
||||
// a 16px font) with the OLD per-glyph rounding → reproduces the "letters dip down"
|
||||
// jitter the user reported.
|
||||
using var jitter = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: false);
|
||||
Save6x(jitter, $"{outBase}-jitter");
|
||||
|
||||
// (c) Same fractional baseline, but the line baseline is snapped to a whole pixel ONCE
|
||||
// before adding the integer per-glyph offsets → the fix. Should be straight again.
|
||||
using var fixed_ = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: true);
|
||||
Save6x(fixed_, $"{outBase}-fixed");
|
||||
|
||||
Console.WriteLine($"wrote {outBase}-sample-6x.png (ok), {outBase}-jitter-6x.png (bug repro), {outBase}-fixed-6x.png (fix)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Composite the sample string with the two-pass outline+fill model,
|
||||
/// blitting atlas sub-rects 1:1. <paramref name="originYExtra"/> adds a fractional
|
||||
/// line origin; <paramref name="snapOnce"/> selects the FIX (snap the line baseline
|
||||
/// to a whole pixel once) vs the BUG (round each glyph's Y independently).</summary>
|
||||
private static Image<Rgba32> RenderSample(
|
||||
string text, Dictionary<char, FontCharDesc> glyphs,
|
||||
DecodedTexture fg, DecodedTexture? bg, int lineH,
|
||||
Rgba32 panel, Rgba32 fill, Rgba32 outline, float originYExtra, bool snapOnce)
|
||||
{
|
||||
// First pass: measure pen width.
|
||||
float pen = 0; float maxX = 0;
|
||||
foreach (char ch in text)
|
||||
if (glyphs.TryGetValue(ch, out var g)) { maxX = Math.Max(maxX, pen + g.HorizontalOffsetBefore + g.Width); pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; }
|
||||
int w = Math.Max(8, (int)MathF.Ceiling(Math.Max(maxX, pen)) + 4);
|
||||
int h = lineH + 6;
|
||||
var img = new Image<Rgba32>(w, h, panel);
|
||||
|
||||
float originY = 3f + originYExtra;
|
||||
float baseY = MathF.Round(originY); // snapped line baseline (the fix)
|
||||
pen = 2;
|
||||
foreach (char ch in text)
|
||||
{
|
||||
if (!glyphs.TryGetValue(ch, out var g)) { continue; }
|
||||
float gx = MathF.Round(pen + g.HorizontalOffsetBefore);
|
||||
float gy = snapOnce
|
||||
? baseY + g.VerticalOffsetBefore // fix: integer baseline + integer offset
|
||||
: MathF.Round(originY + g.VerticalOffsetBefore); // bug: independent per-glyph rounding
|
||||
if (g.Width > 0 && g.Height > 0)
|
||||
{
|
||||
if (bg is { } bgt) BlitGlyph(img, bgt, g, (int)gx, (int)gy, outline);
|
||||
BlitGlyph(img, fg, g, (int)gx, (int)gy, fill);
|
||||
}
|
||||
pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
private static void Save6x(Image<Rgba32> native, string outBase)
|
||||
{
|
||||
using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor));
|
||||
zoom.SaveAsPng($"{outBase}-6x.png");
|
||||
}
|
||||
|
||||
/// <summary>Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha
|
||||
/// as coverage, tinted by <paramref name="tint"/>. 1:1 (no scaling), so this is the
|
||||
/// pixel-exact result GL_NEAREST + native-size quad produces.</summary>
|
||||
private static void BlitGlyph(Image<Rgba32> dst, DecodedTexture atlas, FontCharDesc g, int dx, int dy, Rgba32 tint)
|
||||
{
|
||||
for (int sy = 0; sy < g.Height; sy++)
|
||||
{
|
||||
int py = dy + sy;
|
||||
if (py < 0 || py >= dst.Height) continue;
|
||||
int ay = g.OffsetY + sy;
|
||||
if (ay < 0 || ay >= atlas.Height) continue;
|
||||
for (int sx = 0; sx < g.Width; sx++)
|
||||
{
|
||||
int px = dx + sx;
|
||||
if (px < 0 || px >= dst.Width) continue;
|
||||
int ax = g.OffsetX + sx;
|
||||
if (ax < 0 || ax >= atlas.Width) continue;
|
||||
int idx = (ay * atlas.Width + ax) * 4;
|
||||
// Atlas is A8 expanded to (255,255,255,alpha); coverage = alpha.
|
||||
float cov = atlas.Rgba8[idx + 3] / 255f;
|
||||
if (cov <= 0f) continue;
|
||||
var bgpx = dst[px, py];
|
||||
dst[px, py] = new Rgba32(
|
||||
(byte)(tint.R * cov + bgpx.R * (1 - cov)),
|
||||
(byte)(tint.G * cov + bgpx.G * (1 - cov)),
|
||||
(byte)(tint.B * cov + bgpx.B * (1 - cov)),
|
||||
255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Render an A8/RGBA atlas's ALPHA channel as opaque white-on-black luminance,
|
||||
/// zoomed 4× nearest, so the glyph shapes are visible regardless of PNG viewer alpha.</summary>
|
||||
private static Image<Rgba32> AlphaLuma(DecodedTexture t)
|
||||
{
|
||||
var img = new Image<Rgba32>(t.Width, t.Height);
|
||||
for (int y = 0; y < t.Height; y++)
|
||||
for (int x = 0; x < t.Width; x++)
|
||||
{
|
||||
byte a = t.Rgba8[(y * t.Width + x) * 4 + 3];
|
||||
img[x, y] = new Rgba32(a, a, a, 255);
|
||||
}
|
||||
img.Mutate(c => c.Resize(t.Width * 4, t.Height * 4, KnownResamplers.NearestNeighbor));
|
||||
return img;
|
||||
}
|
||||
|
||||
private static DecodedTexture DecodeRs(DatCollection dats, uint id)
|
||||
{
|
||||
var rs = dats.Get<RenderSurface>(id);
|
||||
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return DecodedTexture.Magenta; }
|
||||
return SurfaceDecoder.DecodeRenderSurface(rs);
|
||||
}
|
||||
|
||||
private static uint ParseHex(string s)
|
||||
{
|
||||
s = s.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..];
|
||||
return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
using System.Reflection;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only research diagnostic: index EVERY UI <see cref="LayoutDesc"/> in the
|
||||
/// dat by its root element's <c>Type</c> + size + an element-Type histogram, so a
|
||||
/// panel re-drive can locate its layout from the decomp-registered class id
|
||||
/// (e.g. <c>gmMainChatUI</c> registers type <c>0x10000041</c> → the chat window
|
||||
/// is the layout whose root element has Type 0x10000041). Optionally filter to a
|
||||
/// single root Type. No writes; purely a console dump used during brainstorming.
|
||||
/// </summary>
|
||||
public static class LayoutIndexDump
|
||||
{
|
||||
public static int Run(string datDir, string? rootTypeText)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
uint? filter = null;
|
||||
if (!string.IsNullOrWhiteSpace(rootTypeText))
|
||||
{
|
||||
var t = rootTypeText.Trim();
|
||||
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
|
||||
if (uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var f)) filter = f;
|
||||
}
|
||||
|
||||
Console.WriteLine(filter is { } ff
|
||||
? $"=== LayoutDescs with a root element of Type 0x{ff:X8} ==="
|
||||
: "=== All LayoutDescs (id : root element Type : size : #elements : type histogram) ===");
|
||||
|
||||
int total = 0, shown = 0;
|
||||
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
||||
{
|
||||
var l = dats.Get<LayoutDesc>(id);
|
||||
if (l is null) continue;
|
||||
total++;
|
||||
|
||||
// The root is the single top-level element (or, if several, the largest).
|
||||
ElementDesc? root = null;
|
||||
foreach (var kv in l.Elements)
|
||||
if (root is null || Area(kv.Value) > Area(root)) root = kv.Value;
|
||||
if (root is null) continue;
|
||||
|
||||
if (filter is { } want && root.Type != want) continue;
|
||||
shown++;
|
||||
|
||||
var hist = new SortedDictionary<uint, int>();
|
||||
int count = 0;
|
||||
CountTypes(root, hist, ref count);
|
||||
string h = string.Join(" ", hist.Select(kv => $"{TypeName(kv.Key)}×{kv.Value}"));
|
||||
Console.WriteLine(
|
||||
$" 0x{id:X8} root=0x{root.ElementId:X8} type=0x{root.Type:X8}({TypeName(root.Type)}) " +
|
||||
$"{root.Width}x{root.Height} n={count} [{h}]");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"shown {shown} / {total} LayoutDescs.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static long Area(ElementDesc e) => (long)e.Width * e.Height;
|
||||
|
||||
private static void CountTypes(ElementDesc e, SortedDictionary<uint, int> hist, ref int count)
|
||||
{
|
||||
count++;
|
||||
hist[e.Type] = hist.TryGetValue(e.Type, out var c) ? c + 1 : 1;
|
||||
foreach (var kv in e.Children)
|
||||
CountTypes(kv.Value, hist, ref count);
|
||||
}
|
||||
|
||||
private static string TypeName(uint t) => t switch
|
||||
{
|
||||
0 => "Text0",
|
||||
1 => "Button",
|
||||
2 => "Dragbar",
|
||||
3 => "Field",
|
||||
5 => "ListBox",
|
||||
6 => "Menu",
|
||||
7 => "Meter",
|
||||
8 => "Panel",
|
||||
9 => "Resizebar",
|
||||
0xB => "Scrollbar",
|
||||
0xC => "Text",
|
||||
0xD => "Viewport",
|
||||
0xE => "Browser",
|
||||
0x10 => "ColorPicker",
|
||||
0x11 => "GroupBox",
|
||||
0x12 => "Proto",
|
||||
0x10000041 => "gmMainChatUI",
|
||||
0x10000040 => "gmFloatyChatUI",
|
||||
0x10000050 => "gmFloatyMainChatUI",
|
||||
0x10000042 => "gmChatOptionsUI",
|
||||
0x10000009 => "gmVitalsUI",
|
||||
_ => $"0x{t:X}",
|
||||
};
|
||||
}
|
||||
|
|
@ -1,139 +1,10 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Cli;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
using Env = System.Environment;
|
||||
|
||||
// ─── subcommand dispatch ────────────────────────────────────────────────────
|
||||
if (args.Length >= 1 && args[0] == "dump-vitals-bars")
|
||||
{
|
||||
string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (string.IsNullOrWhiteSpace(dvbDatDir))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-bars <dat-directory>");
|
||||
return 2;
|
||||
}
|
||||
return DumpVitalsBars(dvbDatDir);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "dump-vitals-layout")
|
||||
{
|
||||
string? dvlDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? dvlLayout = args.ElementAtOrDefault(2);
|
||||
if (string.IsNullOrWhiteSpace(dvlDatDir))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-layout <dat-directory> [0xLayoutId]");
|
||||
return 2;
|
||||
}
|
||||
return VitalsLayoutDump.Run(dvlDatDir, dvlLayout);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "list-ui-layouts")
|
||||
{
|
||||
string? luiDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? luiRootType = args.ElementAtOrDefault(2);
|
||||
if (string.IsNullOrWhiteSpace(luiDatDir))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli list-ui-layouts <dat-directory> [0xRootType]");
|
||||
return 2;
|
||||
}
|
||||
return LayoutIndexDump.Run(luiDatDir, luiRootType);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "render-vitals-mockup")
|
||||
{
|
||||
string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png";
|
||||
if (string.IsNullOrWhiteSpace(rvmDatDir))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup <dat-directory> [out.png]");
|
||||
return 2;
|
||||
}
|
||||
return VitalsMockup.Render(rvmDatDir, rvmOut);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "dump-sprite-sheet")
|
||||
{
|
||||
string? dssDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? dssIds = args.ElementAtOrDefault(2);
|
||||
string dssOut = args.ElementAtOrDefault(3) ?? "sprite-sheet.png";
|
||||
if (string.IsNullOrWhiteSpace(dssDir) || string.IsNullOrWhiteSpace(dssIds))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli dump-sprite-sheet <dat-directory> <0xId,0xId,...> [out.png]");
|
||||
return 2;
|
||||
}
|
||||
return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "dump-font-atlas")
|
||||
{
|
||||
string? dfaDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? dfaFont = args.ElementAtOrDefault(2); // 0xFontId (default 0x40000000)
|
||||
string? dfaSample = args.ElementAtOrDefault(3); // sample string
|
||||
string dfaOut = args.ElementAtOrDefault(4) ?? "font-atlas";
|
||||
if (string.IsNullOrWhiteSpace(dfaDir))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli dump-font-atlas <dat-directory> [0xFontId] [sample] [outBase]");
|
||||
return 2;
|
||||
}
|
||||
return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "probe")
|
||||
{
|
||||
// probe <in.png> <x0> <y0> <x1> <y1>
|
||||
if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe <in.png> <x0> <y0> <x1> <y1>"); return 2; }
|
||||
return VitalsMockup.Probe(args[1], int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]));
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "crop")
|
||||
{
|
||||
// crop <in.png> <x> <y> <w> <h> <zoom> <out.png>
|
||||
if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop <in.png> <x> <y> <w> <h> <zoom> <out.png>"); return 2; }
|
||||
return VitalsMockup.Crop(args[1],
|
||||
int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]), int.Parse(args[6]), args[7]);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "dump-edges")
|
||||
{
|
||||
string? deDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? deId = args.ElementAtOrDefault(2);
|
||||
if (string.IsNullOrWhiteSpace(deDir) || string.IsNullOrWhiteSpace(deId))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli dump-edges <dat-directory> <0xId>");
|
||||
return 2;
|
||||
}
|
||||
return VitalsMockup.DumpEdges(deDir, deId);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "mock-selbar")
|
||||
{
|
||||
string? msbDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string msbOut = args.ElementAtOrDefault(2) ?? "selbar.png";
|
||||
if (string.IsNullOrWhiteSpace(msbDir))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli mock-selbar <dat-directory> [out.png]");
|
||||
return 2;
|
||||
}
|
||||
return VitalsMockup.MockSelBar(msbDir, msbOut);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "export-ui-sprite")
|
||||
{
|
||||
string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? eusId = args.ElementAtOrDefault(2);
|
||||
string eusOut = args.ElementAtOrDefault(3) ?? "sprite.png";
|
||||
if (string.IsNullOrWhiteSpace(eusDatDir) || string.IsNullOrWhiteSpace(eusId))
|
||||
{
|
||||
Console.Error.WriteLine("usage: AcDream.Cli export-ui-sprite <dat-directory> <0xId> [out.png]");
|
||||
return 2;
|
||||
}
|
||||
return VitalsMockup.ExportSprite(eusDatDir, eusId, eusOut);
|
||||
}
|
||||
|
||||
// Phase 0: open the four AC dat files and print how many of each asset type live in them.
|
||||
// This proves DatReaderWriter works on our retail dats and gives us a baseline inventory
|
||||
// to compare against what a future renderer needs.
|
||||
|
|
@ -289,146 +160,3 @@ static (string Name, Func<int> Count)[] CountCellByLow16(DatCollection dats)
|
|||
("Region", () => dats.GetAllIdsOfType<Region>().Count()),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the
|
||||
/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter
|
||||
/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual
|
||||
/// (front-bar and back-bar/track), each containing:
|
||||
/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite)
|
||||
/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite)
|
||||
/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite)
|
||||
/// - elem 0x100000EA (DirectStateDesc = right-edge sprite)
|
||||
///
|
||||
/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014.
|
||||
/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt.
|
||||
/// </summary>
|
||||
static int DumpVitalsBars(string dvbDatDir)
|
||||
{
|
||||
const uint HEALTH_ELEM_ID = 0x100000E6u;
|
||||
const uint STAMINA_ELEM_ID = 0x100000ECu;
|
||||
const uint MANA_ELEM_ID = 0x100000EEu;
|
||||
|
||||
if (!Directory.Exists(dvbDatDir))
|
||||
{
|
||||
Console.Error.WriteLine($"error: directory not found: {dvbDatDir}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(dvbDatDir, DatAccessType.Read);
|
||||
|
||||
// Find the vitals layout: scan all LayoutDescs for one containing the health meter element.
|
||||
Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)...");
|
||||
uint? vitalsId = null;
|
||||
LayoutDesc? vitalsLayout = null;
|
||||
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>())
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(id);
|
||||
if (ld is null) continue;
|
||||
if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; }
|
||||
}
|
||||
|
||||
if (vitalsLayout is null)
|
||||
{
|
||||
Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter).");
|
||||
return 1;
|
||||
}
|
||||
Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}");
|
||||
Console.WriteLine();
|
||||
|
||||
// For each vital meter, collect all MediaDescImage DataIds from its sub-tree.
|
||||
var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") };
|
||||
foreach (var (eid, vitalName) in meters)
|
||||
{
|
||||
Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:");
|
||||
var meterElem = VbFindElement(vitalsLayout!, eid);
|
||||
if (meterElem is null) { Console.WriteLine(" <element not found>"); continue; }
|
||||
|
||||
var sprites = new List<(string Role, uint DataId, string DrawMode)>();
|
||||
VbCollectSprites(meterElem, sprites, 0);
|
||||
|
||||
if (sprites.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" <no sprites found in sub-tree>");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (role, dataId, drawMode) in sprites)
|
||||
Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── dump-vitals-bars helpers ───────────────────────────────────────────────
|
||||
|
||||
static bool VbContainsElementId(LayoutDesc ld, uint targetId)
|
||||
{
|
||||
var elems = ld.Elements;
|
||||
foreach (var kvp in elems)
|
||||
{
|
||||
if (kvp.Key == targetId) return true;
|
||||
if (VbChildContains(kvp.Value, targetId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool VbChildContains(ElementDesc elem, uint targetId)
|
||||
{
|
||||
foreach (var kvp in elem.Children)
|
||||
{
|
||||
if (kvp.Key == targetId) return true;
|
||||
if (VbChildContains(kvp.Value, targetId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId)
|
||||
{
|
||||
foreach (var kvp in ld.Elements)
|
||||
{
|
||||
if (kvp.Key == targetId) return kvp.Value;
|
||||
var found = VbFindChild(kvp.Value, targetId);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ElementDesc? VbFindChild(ElementDesc elem, uint targetId)
|
||||
{
|
||||
foreach (var kvp in elem.Children)
|
||||
{
|
||||
if (kvp.Key == targetId) return kvp.Value;
|
||||
var found = VbFindChild(kvp.Value, targetId);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth)
|
||||
{
|
||||
string indent = new string(' ', depth * 2);
|
||||
|
||||
// Check the element's direct StateDesc
|
||||
if (elem.StateDesc is not null)
|
||||
VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_);
|
||||
|
||||
// Check each named state
|
||||
foreach (var kvp in elem.States)
|
||||
VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_);
|
||||
|
||||
// Recurse into children
|
||||
foreach (var kvp in elem.Children)
|
||||
VbCollectSprites(kvp.Value, out_, depth + 1);
|
||||
}
|
||||
|
||||
static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_)
|
||||
{
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (m is MediaDescImage img && img.File != 0)
|
||||
out_.Add((role, img.File, img.DrawMode.ToString()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Full reflective dump of a vitals LayoutDesc element tree: every scalar
|
||||
/// property (position/size/flags) of each ElementDesc + its state sprites,
|
||||
/// so the real bar rects + spacing + window size can be read from the dat
|
||||
/// instead of guessed. Uses reflection so it doesn't depend on knowing the
|
||||
/// DatReaderWriter property names ahead of time.
|
||||
/// </summary>
|
||||
public static class VitalsLayoutDump
|
||||
{
|
||||
public static int Run(string datDir, string? layoutIdText)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// Default to the vitals layout dump-vitals-bars found; allow override.
|
||||
uint layoutId = 0x21000014u;
|
||||
if (!string.IsNullOrWhiteSpace(layoutIdText))
|
||||
{
|
||||
var t = layoutIdText.Trim();
|
||||
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
|
||||
uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId);
|
||||
}
|
||||
|
||||
// First: scan ALL LayoutDescs that contain a vitals meter element, with root size,
|
||||
// so we can tell whether 0x21000014 is the one the user sees (row vs stacked).
|
||||
Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ===");
|
||||
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>())
|
||||
{
|
||||
var l = dats.Get<LayoutDesc>(id);
|
||||
if (l is null) continue;
|
||||
if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue;
|
||||
Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; }
|
||||
|
||||
Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ===");
|
||||
DumpScalars("LayoutDesc", ld, 0);
|
||||
foreach (var kv in ld.Elements)
|
||||
DumpElement(kv.Value, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(LayoutDesc l, params uint[] ids)
|
||||
{
|
||||
foreach (var kv in l.Elements)
|
||||
if (ElemContains(kv.Value, ids)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ElemContains(ElementDesc e, uint[] ids)
|
||||
{
|
||||
if (Array.IndexOf(ids, e.ElementId) >= 0) return true;
|
||||
foreach (var kv in e.Children)
|
||||
if (ElemContains(kv.Value, ids)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string RootSizeSummary(LayoutDesc l)
|
||||
{
|
||||
// Print any LayoutDesc-level scalar that looks like a size.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var p in l.GetType().GetProperties())
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
if (p.Name is "Elements") continue;
|
||||
object? v; try { v = p.GetValue(l); } catch { continue; }
|
||||
if (v is null) continue;
|
||||
if (IsScalar(v)) sb.Append($"{p.Name}={v} ");
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static void DumpElement(ElementDesc e, int depth)
|
||||
{
|
||||
string ind = new string(' ', depth * 2);
|
||||
Console.WriteLine($"{ind}element 0x{e.ElementId:X8}");
|
||||
DumpScalars(ind + " ", e, depth);
|
||||
|
||||
if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc);
|
||||
foreach (var s in e.States)
|
||||
DumpMedia($"{ind} [state {s.Key}]", s.Value);
|
||||
|
||||
foreach (var c in e.Children)
|
||||
DumpElement(c.Value, depth + 1);
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" };
|
||||
|
||||
private static void DumpScalars(string label, object o, int depth)
|
||||
{
|
||||
foreach (var (name, val) in Members(o))
|
||||
{
|
||||
if (Skip.Contains(name)) continue;
|
||||
if (IsScalar(val))
|
||||
Console.WriteLine($"{label} {name} = {Fmt(name, val)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DumpMedia(string label, StateDesc sd)
|
||||
{
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var (name, val) in Members(m))
|
||||
if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} ");
|
||||
Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enumerate public properties AND public fields (the DatReaderWriter
|
||||
/// generated types expose geometry/file ids as fields, not properties).</summary>
|
||||
private static IEnumerable<(string name, object val)> Members(object o)
|
||||
{
|
||||
var t = o.GetType();
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
object? v; try { v = p.GetValue(o); } catch { continue; }
|
||||
if (v is not null) yield return (p.Name, v);
|
||||
}
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
object? v; try { v = f.GetValue(o); } catch { continue; }
|
||||
if (v is not null) yield return (f.Name, v);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Fmt(string name, object v) =>
|
||||
name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? "";
|
||||
|
||||
private static bool IsScalar(object v)
|
||||
{
|
||||
var t = v.GetType();
|
||||
if (v is string) return true;
|
||||
if (t.IsPrimitive || t.IsEnum) return true;
|
||||
if (v is IEnumerable) return false;
|
||||
// value-type structs (Rectangle/Point/etc.) — print via ToString
|
||||
return t.IsValueType;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
using AcDream.Core.Textures;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace AcDream.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc
|
||||
/// 0x2100006C). Renders the window WIDENED, twice: once with the middle slice
|
||||
/// STRETCHED (acdream's current behaviour) and once TILED (retail behaviour —
|
||||
/// the "fill-tile" element is repeated at native width, last copy clipped).
|
||||
/// Lets the stretch-vs-tile difference be judged by eye before touching the
|
||||
/// client. Bars = back 3-slice (empty track, full) + front 3-slice (fill).
|
||||
/// </summary>
|
||||
public static class VitalsMockup
|
||||
{
|
||||
// 8-piece chrome border (dat-verified in 0x2100006C; 5px).
|
||||
private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4;
|
||||
private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2;
|
||||
private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6;
|
||||
|
||||
private readonly record struct Vital(
|
||||
string Name, float Frac,
|
||||
uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR);
|
||||
|
||||
private static readonly Vital[] Vitals =
|
||||
{
|
||||
new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
|
||||
new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
|
||||
new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F),
|
||||
};
|
||||
|
||||
private const uint CenterFill = 0x06004CC2; // dark interior panel (UiNineSlicePanel draws this)
|
||||
private const int Border = 5, BarH = 16, Zoom = 6;
|
||||
private const int BarW = 150; // default vitals window bar width (0x2100006C)
|
||||
private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior
|
||||
|
||||
public static int Render(string datDir, string outPath)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
int winW = BarW + 2 * Border; // 160
|
||||
int winH = 3 * BarH + 2 * Border; // 58
|
||||
using var canvas = new Image<Rgba32>(winW, winH, new Rgba32(20, 20, 24, 255));
|
||||
|
||||
DrawWindow(canvas, dats, 0, winW, winH, tileMid: true);
|
||||
|
||||
canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor));
|
||||
canvas.SaveAsPng(outPath);
|
||||
Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — faithful default vitals window 0x2100006C");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void DrawWindow(Image<Rgba32> canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid)
|
||||
{
|
||||
// Dark interior fill (matches UiNineSlicePanel's CenterFill behind the bars).
|
||||
using (var cf = Load(dats, CenterFill))
|
||||
Blit(canvas, cf, Border, offY + Border, winW - 2 * Border, winH - 2 * Border);
|
||||
|
||||
// 8-piece chrome border (corners native 5x5, edges stretched for this preview).
|
||||
using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR))
|
||||
using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT))
|
||||
using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR))
|
||||
{
|
||||
Blit(canvas, tl, 0, offY, Border, Border);
|
||||
Blit(canvas, top, Border, offY, winW - 2 * Border, Border);
|
||||
Blit(canvas, tr, winW - Border, offY, Border, Border);
|
||||
Blit(canvas, le, 0, offY + Border, Border, winH - 2 * Border);
|
||||
Blit(canvas, ri, winW - Border, offY + Border, Border, winH - 2 * Border);
|
||||
Blit(canvas, bl, 0, offY + winH - Border, Border, Border);
|
||||
Blit(canvas, bo, Border, offY + winH - Border, winW - 2 * Border, Border);
|
||||
Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border);
|
||||
}
|
||||
|
||||
// Resize-grip overlay: gold ridged edge strips + square corner studs, on
|
||||
// top of the bevel (vitals LayoutDesc 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED
|
||||
/// per <paramref name="tileMid"/>), native-width right-cap; clipped to clipW.</summary>
|
||||
private static void DrawHBar(
|
||||
Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> mid, Image<Rgba32> right,
|
||||
int x, int y, int w, int h, int clipW, bool tileMid)
|
||||
{
|
||||
if (w <= 0 || clipW <= 0) return;
|
||||
int capL = Math.Min(left.Width, w);
|
||||
int capR = Math.Min(right.Width, w - capL);
|
||||
int midW = w - capL - capR;
|
||||
|
||||
DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); // left cap (once, native)
|
||||
if (tileMid) TileMiddle(canvas, mid, x, y, capL, midW, h, clipW); // repeat native-width copies
|
||||
else DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); // stretch across the span
|
||||
DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); // right cap (once, native)
|
||||
}
|
||||
|
||||
/// <summary>Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at
|
||||
/// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW.</summary>
|
||||
private static void TileMiddle(
|
||||
Image<Rgba32> canvas, Image<Rgba32> mid, int x, int y, int midLocalX, int midW, int h, int clipW)
|
||||
{
|
||||
int tileW = Math.Max(1, mid.Width);
|
||||
for (int mx = 0; mx < midW; mx += tileW)
|
||||
{
|
||||
int localX = midLocalX + mx;
|
||||
int segW = Math.Min(tileW, midW - mx); // last copy may be partial
|
||||
int visible = Math.Min(segW, clipW - localX); // fill-fraction clip
|
||||
if (visible <= 0) break;
|
||||
// 1:1 — crop the source to `visible` px (no resize-stretch), draw at native scale.
|
||||
int cropW = Math.Min(visible, mid.Width);
|
||||
using var seg = mid.Clone(c => c.Crop(new Rectangle(0, 0, cropW, mid.Height)).Resize(visible, h));
|
||||
canvas.Mutate(c => c.DrawImage(seg, new Point(x + localX, y), 1f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped
|
||||
/// (proportionally) so nothing past clipW shows.</summary>
|
||||
private static void DrawClippedPiece(
|
||||
Image<Rgba32> canvas, Image<Rgba32> src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW)
|
||||
{
|
||||
if (pieceW <= 0) return;
|
||||
int visibleW = Math.Min(pieceW, clipW - pieceLocalX);
|
||||
if (visibleW <= 0) return;
|
||||
int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW)));
|
||||
srcCropW = Math.Min(srcCropW, src.Width);
|
||||
using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h));
|
||||
canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f));
|
||||
}
|
||||
|
||||
private static void Blit(Image<Rgba32> canvas, Image<Rgba32> src, int x, int y, int dw, int dh)
|
||||
{
|
||||
if (dw <= 0 || dh <= 0) return;
|
||||
using var s = src.Clone(c => c.Resize(dw, dh));
|
||||
canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f));
|
||||
}
|
||||
|
||||
/// <summary>Composite a comma-separated list of sprite ids into one row, magnified,
|
||||
/// on a neutral background — so the exact chrome/bar graphics can be eyeballed.</summary>
|
||||
public static int ExportSheet(string datDir, string idsCsv, string outPath)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var ids = idsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(ParseHex).Where(x => x != 0).ToArray();
|
||||
if (ids.Length == 0) { Console.Error.WriteLine("no valid ids"); return 2; }
|
||||
|
||||
var imgs = ids.Select(id => Load(dats, id)).ToArray();
|
||||
const int pad = 6, zoom = 10;
|
||||
int totalW = pad + imgs.Sum(i => i.Width + pad);
|
||||
int maxH = imgs.Max(i => i.Height);
|
||||
using var canvas = new Image<Rgba32>(totalW, maxH + 2 * pad, new Rgba32(64, 64, 72, 255));
|
||||
int x = pad;
|
||||
foreach (var im in imgs)
|
||||
{
|
||||
canvas.Mutate(c => c.DrawImage(im, new Point(x, pad), 1f));
|
||||
x += im.Width + pad;
|
||||
}
|
||||
canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor));
|
||||
canvas.SaveAsPng(outPath);
|
||||
Console.WriteLine("order (L→R): " + string.Join(" ", ids.Zip(imgs, (id, im) => $"0x{id:X8}={im.Width}x{im.Height}")));
|
||||
foreach (var im in imgs) im.Dispose();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite the selected-object health bar (back-track 0x0600193E + red fill 0x0600193F)
|
||||
/// the same way the in-game UiMeter draws it: the 146px sprite mapped 1:1 into the 140px
|
||||
/// meter element (right 6px cropped), back drawn full, fill drawn over the left
|
||||
/// fraction*width. Rendered at several health fractions stacked so the end-caps / purple
|
||||
/// can be eyeballed offline (D.5.3a purple-end investigation).
|
||||
/// </summary>
|
||||
public static int MockSelBar(string datDir, string outPath)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var back = Load(dats, 0x0600193E);
|
||||
using var fill = Load(dats, 0x0600193F);
|
||||
|
||||
const int elemW = 140, zoom = 8, gap = 4;
|
||||
int elemH = Math.Min(back.Height, fill.Height);
|
||||
float[] fracs = { 1.0f, 0.9f, 0.7f, 0.5f, 0.0f };
|
||||
int rowH = elemH + gap;
|
||||
using var canvas = new Image<Rgba32>(elemW, rowH * fracs.Length, new Rgba32(20, 20, 24, 255));
|
||||
|
||||
for (int i = 0; i < fracs.Length; i++)
|
||||
{
|
||||
int y = i * rowH;
|
||||
float p = fracs[i];
|
||||
int backCrop = Math.Min(elemW, back.Width);
|
||||
using (var b = back.Clone(c => c.Crop(new Rectangle(0, 0, backCrop, elemH))))
|
||||
canvas.Mutate(c => c.DrawImage(b, new Point(0, y), 1f));
|
||||
int fillW = (int)MathF.Round(elemW * p);
|
||||
if (fillW > 0)
|
||||
{
|
||||
int fillCrop = Math.Min(fillW, fill.Width);
|
||||
using var f = fill.Clone(c => c.Crop(new Rectangle(0, 0, fillCrop, elemH)));
|
||||
canvas.Mutate(c => c.DrawImage(f, new Point(0, y), 1f));
|
||||
}
|
||||
}
|
||||
|
||||
canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor));
|
||||
canvas.SaveAsPng(outPath);
|
||||
Console.WriteLine($"wrote {outPath} — selbar composite, rows = health 1.0 / 0.9 / 0.7 / 0.5 / 0.0");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Print the RGB of a rectangular block of pixels from a PNG (framebuffer probe).</summary>
|
||||
public static int Probe(string inPath, int x0, int y0, int x1, int y1)
|
||||
{
|
||||
if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; }
|
||||
using var img = Image.Load<Rgba32>(inPath);
|
||||
x0 = Math.Clamp(x0, 0, img.Width - 1); x1 = Math.Clamp(x1, 0, img.Width - 1);
|
||||
y0 = Math.Clamp(y0, 0, img.Height - 1); y1 = Math.Clamp(y1, 0, img.Height - 1);
|
||||
Console.WriteLine($"{inPath} {img.Width}x{img.Height} cols x={x0}..{x1}");
|
||||
for (int y = y0; y <= y1; y++)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder($"y={y,4}: ");
|
||||
for (int x = x0; x <= x1; x++) { var p = img[x, y]; sb.Append($"{p.R:X2}{p.G:X2}{p.B:X2} "); }
|
||||
Console.WriteLine(sb.ToString());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Crop a region of a PNG and upscale (nearest) — for zooming into a framebuffer dump.</summary>
|
||||
public static int Crop(string inPath, int x, int y, int w, int h, int zoom, string outPath)
|
||||
{
|
||||
if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; }
|
||||
using var img = Image.Load<Rgba32>(inPath);
|
||||
x = Math.Clamp(x, 0, img.Width - 1);
|
||||
y = Math.Clamp(y, 0, img.Height - 1);
|
||||
w = Math.Clamp(w, 1, img.Width - x);
|
||||
h = Math.Clamp(h, 1, img.Height - y);
|
||||
if (zoom < 1) zoom = 1;
|
||||
img.Mutate(c => c.Crop(new Rectangle(x, y, w, h)).Resize(w * zoom, h * zoom, KnownResamplers.NearestNeighbor));
|
||||
img.SaveAsPng(outPath);
|
||||
Console.WriteLine($"wrote {outPath} ({w * zoom}x{h * zoom}) from {inPath} region ({x},{y},{w},{h})");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Print the RGB of the first/last few columns of a sprite at every row, so the
|
||||
/// end-cap colors can be inspected (D.5.3a purple-end investigation).</summary>
|
||||
public static int DumpEdges(string datDir, string idText)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
uint id = ParseHex(idText);
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var rs = dats.Get<RenderSurface>(id);
|
||||
if (rs is null) { Console.Error.WriteLine($"no RenderSurface 0x{id:X8}"); return 1; }
|
||||
var pal = rs.DefaultPaletteId != 0 ? dats.Get<Palette>(rs.DefaultPaletteId) : null;
|
||||
var dec = SurfaceDecoder.DecodeRenderSurface(rs, pal);
|
||||
Console.WriteLine($"0x{id:X8} {rs.Format} {dec.Width}x{dec.Height}");
|
||||
int[] cols = { 0, 1, 2, 3, dec.Width - 4, dec.Width - 3, dec.Width - 2, dec.Width - 1 };
|
||||
foreach (int cx in cols)
|
||||
{
|
||||
if (cx < 0 || cx >= dec.Width) continue;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (int y = 0; y < dec.Height; y++)
|
||||
{
|
||||
int i = (y * dec.Width + cx) * 4;
|
||||
sb.Append($"{dec.Rgba8[i]:X2}{dec.Rgba8[i + 1]:X2}{dec.Rgba8[i + 2]:X2} ");
|
||||
}
|
||||
Console.WriteLine($"x={cx,3}: {sb}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int ExportSprite(string datDir, string idText, string outPath)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
uint id = ParseHex(idText);
|
||||
if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var img = Load(dats, id);
|
||||
img.SaveAsPng(outPath);
|
||||
Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Image<Rgba32> Load(DatCollection dats, uint id)
|
||||
{
|
||||
var rs = dats.Get<RenderSurface>(id);
|
||||
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image<Rgba32>(1, 1); }
|
||||
var dt = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||
return Image.LoadPixelData<Rgba32>(dt.Rgba8, dt.Width, dt.Height);
|
||||
}
|
||||
|
||||
private static uint ParseHex(string s)
|
||||
{
|
||||
s = s.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..];
|
||||
return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ namespace AcDream.Core.Net;
|
|||
/// <summary>
|
||||
/// Central registration point that wires every parsed GameEvent from
|
||||
/// <see cref="GameEventDispatcher"/> into the appropriate Core state
|
||||
/// class (<see cref="ClientObjectTable"/>, <see cref="CombatState"/>,
|
||||
/// class (<see cref="ItemRepository"/>, <see cref="CombatState"/>,
|
||||
/// <see cref="Spellbook"/>, <see cref="ChatLog"/>).
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -32,7 +32,7 @@ public static class GameEventWiring
|
|||
{
|
||||
public static void WireAll(
|
||||
GameEventDispatcher dispatcher,
|
||||
ClientObjectTable items,
|
||||
ItemRepository items,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
ChatLog chat,
|
||||
|
|
@ -61,11 +61,7 @@ public static class GameEventWiring
|
|||
// (matching ACE's CreatureSkill.Current minus
|
||||
// augs/multipliers/vitae which we still don't model).
|
||||
Action<int /*runSkill*/, int /*jumpSkill*/>? onSkillsUpdated = null,
|
||||
Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null,
|
||||
// D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
|
||||
// toolbar can populate itself at login without keeping a parser reference.
|
||||
// Optional so all existing callers and tests compile unchanged.
|
||||
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null)
|
||||
Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -251,7 +247,7 @@ public static class GameEventWiring
|
|||
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
|
||||
if (p is null || !p.Value.Success) return;
|
||||
// Merge parsed properties into the item if we know about it.
|
||||
if (items.Get(p.Value.Guid) is not null)
|
||||
if (items.GetItem(p.Value.Guid) is not null)
|
||||
items.UpdateProperties(p.Value.Guid, p.Value.Properties);
|
||||
// Spellbook from appraise: for caster items / scrolls this is
|
||||
// the cast-on-use list. The local player's full learned
|
||||
|
|
@ -400,19 +396,40 @@ public static class GameEventWiring
|
|||
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
|
||||
}
|
||||
|
||||
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
|
||||
// source. Record existence (+ equip slot); CreateObject fills the
|
||||
// actual weenie data via ObjectTableWiring. (Previously this seeded
|
||||
// stubs with WeenieClassId = ContainerType, a misuse — ContainerType
|
||||
// is a 0/1/2 container-kind discriminator, not a weenie class id.)
|
||||
// Issue #13 — register inventory entries with ItemRepository so
|
||||
// panels (inventory, paperdoll, hotbars) light up after login.
|
||||
// Equipped entries share the same ObjectId as inventory entries
|
||||
// (an equipped item is also in inventory) — register both, but
|
||||
// the equipped record carries the slot mask which we surface via
|
||||
// MoveItem so paperdoll can render.
|
||||
foreach (var inv in p.Value.Inventory)
|
||||
items.RecordMembership(inv.Guid);
|
||||
{
|
||||
if (items.GetItem(inv.Guid) is null)
|
||||
{
|
||||
items.AddOrUpdate(new ItemInstance
|
||||
{
|
||||
ObjectId = inv.Guid,
|
||||
WeenieClassId = inv.ContainerType,
|
||||
});
|
||||
}
|
||||
}
|
||||
foreach (var eq in p.Value.Equipped)
|
||||
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);
|
||||
|
||||
// D.5.1 Task 4: forward shortcut bar entries to the caller so the
|
||||
// toolbar can read them without holding a parser reference.
|
||||
onShortcuts?.Invoke(p.Value.Shortcuts);
|
||||
{
|
||||
if (items.GetItem(eq.Guid) is null)
|
||||
{
|
||||
items.AddOrUpdate(new ItemInstance
|
||||
{
|
||||
ObjectId = eq.Guid,
|
||||
WeenieClassId = 0,
|
||||
});
|
||||
}
|
||||
// Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation.
|
||||
items.MoveItem(
|
||||
itemId: eq.Guid,
|
||||
newContainerId: 0,
|
||||
newSlot: -1,
|
||||
newEquipLocation: (EquipMask)eq.EquipLocation);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,12 +127,6 @@ public static class CreateObject
|
|||
// defaults (0.05f elasticity, 0.5f friction).
|
||||
float? Friction = null,
|
||||
float? Elasticity = null,
|
||||
// D.5.1 (2026-06-16): icon dat id (0x06xxxxxx) from the WeenieHeader
|
||||
// fixed prefix. Previously discarded at cs:516; surfaced so the action
|
||||
// bar / equipment UI can display the correct icon sprite without a
|
||||
// separate dat lookup. Zero means "not sent" (packed zero sentinel in
|
||||
// ReadPackedDwordOfKnownType preserves 0 as-is).
|
||||
uint IconId = 0,
|
||||
// 2026-05-15: optional WeenieHeader tail. The retail
|
||||
// `ITEM_USEABLE _useability` (acclient.h:6478) — gates whether the
|
||||
// R-key Use action does anything. <c>(Useability & USEABLE_REMOTE
|
||||
|
|
@ -145,45 +139,7 @@ public static class CreateObject
|
|||
// a sizing hint for selection indicators on entities that
|
||||
// publish it.
|
||||
uint? Useability = null,
|
||||
float? UseRadius = null,
|
||||
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
|
||||
// WeenieHeader optional tail. IconOverlayId is gated by
|
||||
// WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
|
||||
// IconUnderlayId is gated by WeenieHeaderFlag2.IconUnderlay (0x01)
|
||||
// in weenieFlags2 (present when objDescFlags bit 0x04000000 is set).
|
||||
// Sourced from ACE WorldObject_Networking.cs:202-206. Zero when
|
||||
// the server did not send the field (most entities have neither).
|
||||
// IconComposer.GetIcon already composites these layers in the correct
|
||||
// retail order (underlay / base / overlay+tint / effect).
|
||||
uint IconOverlayId = 0,
|
||||
uint IconUnderlayId = 0,
|
||||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
|
||||
// effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect
|
||||
// state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise).
|
||||
// Previously read + discarded at the UiEffects skip. 0 = no effect.
|
||||
uint UiEffects = 0,
|
||||
// D.5.4 (2026-06-18): full item field set from the WeenieHeader tail —
|
||||
// previously walked-past. Wire bits per r06 §4 / PublicWeenieDesc.
|
||||
// Quantity fields are int? to match ClientObject storage (ACE PropertyInt
|
||||
// convention; the wire ushort/byte values widen losslessly); id/mask
|
||||
// fields are uint?. null = the gated flag was absent (don't clobber on
|
||||
// merge). WeenieClassId is the fixed-prefix class id (was discarded at
|
||||
// cs:538); it is non-nullable — 0 means the prefix was absent/zero.
|
||||
uint WeenieClassId = 0,
|
||||
int? Value = null,
|
||||
int? StackSize = null,
|
||||
int? StackSizeMax = null,
|
||||
int? Burden = null,
|
||||
int? ItemsCapacity = null,
|
||||
int? ContainersCapacity = null,
|
||||
uint? ContainerId = null,
|
||||
uint? WielderId = null,
|
||||
uint? ValidLocations = null,
|
||||
uint? CurrentWieldedLocation = null,
|
||||
uint? Priority = null,
|
||||
int? Structure = null,
|
||||
int? MaxStructure = null,
|
||||
float? Workmanship = null);
|
||||
float? UseRadius = null);
|
||||
|
||||
/// <summary>
|
||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||
|
|
@ -550,30 +506,14 @@ public static class CreateObject
|
|||
string? name = null;
|
||||
uint? itemType = null;
|
||||
uint weenieFlags = 0;
|
||||
uint iconId = 0;
|
||||
uint weenieClassId = 0;
|
||||
int? wValue = null;
|
||||
int? wStackSize = null;
|
||||
int? wMaxStackSize = null;
|
||||
int? wBurden = null;
|
||||
int? wItemsCapacity = null;
|
||||
int? wContainersCapacity = null;
|
||||
uint? wContainerId = null;
|
||||
uint? wWielderId = null;
|
||||
uint? wValidLocations = null;
|
||||
uint? wCurrentWieldedLocation = null;
|
||||
uint? wPriority = null;
|
||||
int? wStructure = null;
|
||||
int? wMaxStructure = null;
|
||||
float? wWorkmanship = null;
|
||||
if (body.Length - pos >= 4)
|
||||
{
|
||||
weenieFlags = ReadU32(body, ref pos);
|
||||
try
|
||||
{
|
||||
name = ReadString16L(body, ref pos);
|
||||
weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded)
|
||||
iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||||
_ = ReadPackedDword(body, ref pos); // WeenieClassId
|
||||
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||||
if (body.Length - pos >= 4)
|
||||
itemType = ReadU32(body, ref pos);
|
||||
if (body.Length - pos >= 4)
|
||||
|
|
@ -592,63 +532,31 @@ public static class CreateObject
|
|||
catch { /* truncated name — partial result is still useful */ }
|
||||
}
|
||||
|
||||
// --- WeenieHeader optional tail: walk every conditional field
|
||||
// in EXACT ACE write order (WorldObject_Networking.cs:87-219)
|
||||
// so the cursor reaches IconOverlay + IconUnderlay.
|
||||
// --- WeenieHeader optional tail (2026-05-15): walk the
|
||||
// conditional fields up through Useability + UseRadius.
|
||||
//
|
||||
// We MUST skip every field that precedes IconOverlay even when
|
||||
// we don't need its value — each one occupies bytes on the wire
|
||||
// and a cursor error here would desync ALL downstream optional
|
||||
// reads for the rest of this entity's packet.
|
||||
// Wire order is fixed by ACE WorldObject_Networking.cs:87-114
|
||||
// and matches retail PWD::Pack order. We MUST skip every
|
||||
// preceding optional field (even those we don't care about)
|
||||
// because each one moves the parse cursor.
|
||||
//
|
||||
// Wire order (verified against ACE WorldObject_Networking.cs):
|
||||
// bit field width
|
||||
// --------- ------------------ -----
|
||||
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
|
||||
// 0x00000001 PluralName String16L (skip)
|
||||
// 0x00000002 ItemsCapacity u8 (skip)
|
||||
// 0x00000004 ContainersCapacity u8 (skip)
|
||||
// 0x00000100 AmmoType u16 (skip)
|
||||
// 0x00000008 Value u32 (skip)
|
||||
// 0x00000010 Usable u32 KEPT
|
||||
// 0x00000020 UseRadius f32 KEPT
|
||||
// 0x00080000 TargetType u32 (skip)
|
||||
// 0x00000080 UiEffects u32 CAPTURE (D.5.2)
|
||||
// 0x00000200 CombatUse sbyte/1 byte (skip)
|
||||
// 0x00000400 Structure u16 (skip)
|
||||
// 0x00000800 MaxStructure u16 (skip)
|
||||
// 0x00001000 StackSize u16 (skip)
|
||||
// 0x00002000 MaxStackSize u16 (skip)
|
||||
// 0x00004000 Container u32 (skip)
|
||||
// 0x00008000 Wielder u32 (skip)
|
||||
// 0x00010000 ValidLocations u32 (skip)
|
||||
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
|
||||
// 0x00040000 Priority u32 (skip)
|
||||
// 0x00100000 RadarBlipColor u8 (skip)
|
||||
// 0x00800000 RadarBehavior u8 (skip)
|
||||
// 0x08000000 PScript u16 (skip)
|
||||
// 0x01000000 Workmanship f32 (skip)
|
||||
// 0x00200000 Burden u16 (skip)
|
||||
// 0x00400000 Spell u16 (skip)
|
||||
// 0x02000000 HouseOwner u32 (skip)
|
||||
// 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length)
|
||||
// 0x20000000 HookItemTypes u32 (skip)
|
||||
// 0x00000040 Monarch u32 (skip)
|
||||
// 0x10000000 HookType u16 (skip)
|
||||
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
|
||||
// weenieFlags2 bit 0x01:
|
||||
// IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE
|
||||
// Field bit width decoded?
|
||||
// ------- ------ -------- --------
|
||||
// weenieFlags2 conditional on objDescFlags & 0x80000000 (BF_INCLUDES_SECOND_HEADER)
|
||||
// u32 skipped
|
||||
// PluralName 0x1 String16L (variable, padded to 4) skipped
|
||||
// ItemCapacity 0x2 1 byte skipped
|
||||
// ContainerCap 0x4 1 byte skipped
|
||||
// AmmoType 0x100 u16 skipped
|
||||
// Value 0x8 u32 skipped
|
||||
// Useability 0x10 u32 KEPT
|
||||
// UseRadius 0x20 f32 KEPT
|
||||
//
|
||||
// The entire walk is inside try/catch. A truncated packet degrades
|
||||
// gracefully: whatever was parsed before the throw is kept, and
|
||||
// IconOverlayId/IconUnderlayId stay 0 (no overlay drawn). This is
|
||||
// SAFE because IconComposer early-returns on id==0 per layer.
|
||||
// Wrapped in try/catch — if a malformed entity truncates the
|
||||
// tail we still return the prefix fields. Most spawned entities
|
||||
// either have all of these or none of them.
|
||||
uint? useability = null;
|
||||
float? useRadius = null;
|
||||
uint iconOverlayId = 0;
|
||||
uint iconUnderlayId = 0;
|
||||
uint uiEffects = 0;
|
||||
uint weenieFlags2 = 0;
|
||||
try
|
||||
{
|
||||
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
|
||||
|
|
@ -656,28 +564,23 @@ public static class CreateObject
|
|||
// Earlier code had this as 0x80000000 — wrong bit, so the
|
||||
// weenieFlags2 4-byte skip never fired for entities that
|
||||
// actually had it set, corrupting downstream optional-tail
|
||||
// offsets. Now correct. We CAPTURE weenieFlags2 now (instead
|
||||
// of skipping) so we can gate IconUnderlay from bit 0x01.
|
||||
// offsets. Now correct.
|
||||
bool hasSecondHeader = objectDescriptionFlags.HasValue
|
||||
&& (objectDescriptionFlags.Value & 0x04000000u) != 0;
|
||||
if (hasSecondHeader)
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2");
|
||||
weenieFlags2 = ReadU32(body, ref pos);
|
||||
}
|
||||
if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
|
||||
|
||||
if ((weenieFlags & 0x00000001u) != 0) // PluralName
|
||||
_ = ReadString16L(body, ref pos);
|
||||
|
||||
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8
|
||||
if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
|
||||
wItemsCapacity = body[pos]; pos += 1;
|
||||
pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8
|
||||
if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
|
||||
wContainersCapacity = body[pos]; pos += 1;
|
||||
pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
|
||||
{
|
||||
|
|
@ -687,9 +590,9 @@ public static class CreateObject
|
|||
if ((weenieFlags & 0x00000008u) != 0) // Value u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Value");
|
||||
wValue = (int)ReadU32(body, ref pos);
|
||||
pos += 4;
|
||||
}
|
||||
if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP
|
||||
if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
|
||||
useability = ReadU32(body, ref pos);
|
||||
|
|
@ -700,147 +603,6 @@ public static class CreateObject
|
|||
useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
// ---- Extended walk: fields after UseRadius through IconOverlay ----
|
||||
// Source: ACE WorldObject_Networking.cs:108-206 (verified 2026-06-17).
|
||||
|
||||
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
|
||||
pos += 4;
|
||||
}
|
||||
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
|
||||
uiEffects = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
|
||||
pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc Structure");
|
||||
wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
|
||||
wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
|
||||
wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
|
||||
wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x00004000u) != 0) // Container u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Container");
|
||||
wContainerId = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
|
||||
wWielderId = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
|
||||
wValidLocations = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
|
||||
wCurrentWieldedLocation = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags & 0x00040000u) != 0) // Priority u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Priority");
|
||||
wPriority = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
|
||||
pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
|
||||
pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc PScript");
|
||||
pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Workmanship");
|
||||
wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4;
|
||||
}
|
||||
if ((weenieFlags & 0x00200000u) != 0) // Burden u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc Burden");
|
||||
wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
|
||||
pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
|
||||
pos += 4;
|
||||
}
|
||||
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB)
|
||||
{
|
||||
// Wire layout per ACE RestrictionDB + RestrictionDBExtensions.Write:
|
||||
// u32 Version, u32 OpenStatus, u32 MonarchId,
|
||||
// u16 count, u16 numBuckets, then count × (u32 guid + u32 value).
|
||||
// Fixed header = 12 bytes; PackableHashTable header = 4 bytes.
|
||||
// Total = 16 + count * 8.
|
||||
if (body.Length - pos < 16) throw new FormatException("trunc RestrictionDB header");
|
||||
// Version(4) + OpenStatus(4) + MonarchId(4) = 12 bytes
|
||||
pos += 12;
|
||||
ushort tableCount = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2; // count u16
|
||||
pos += 2; // numBuckets u16
|
||||
int entryBytes = tableCount * 8; // each entry: u32 guid + u32 value
|
||||
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
|
||||
pos += entryBytes;
|
||||
}
|
||||
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
|
||||
pos += 4;
|
||||
}
|
||||
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
|
||||
pos += 4;
|
||||
}
|
||||
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc HookType");
|
||||
pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
|
||||
{
|
||||
iconOverlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||||
}
|
||||
// IconUnderlay is gated by weenieFlags2 bit 0x01, not weenieFlags.
|
||||
// weenieFlags2 is only present when hasSecondHeader (captured above).
|
||||
if ((weenieFlags2 & 0x00000001u) != 0) // IconUnderlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
|
||||
{
|
||||
iconUnderlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||||
}
|
||||
}
|
||||
catch { /* truncated weenie tail — keep whatever we got. */ }
|
||||
|
||||
|
|
@ -849,17 +611,7 @@ public static class CreateObject
|
|||
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
|
||||
physicsState, objectDescriptionFlags,
|
||||
friction, elasticity,
|
||||
IconId: iconId,
|
||||
Useability: useability, UseRadius: useRadius,
|
||||
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
|
||||
UiEffects: uiEffects,
|
||||
WeenieClassId: weenieClassId,
|
||||
Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize,
|
||||
Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity,
|
||||
ContainerId: wContainerId, WielderId: wWielderId,
|
||||
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
|
||||
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
|
||||
Workmanship: wWorkmanship);
|
||||
useability, useRadius);
|
||||
|
||||
// Local helper: if we ran out of fields past PhysicsData, still
|
||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>PublicUpdatePropertyInt (0x02CE)</c> — the server updates one
|
||||
/// <c>PropertyInt</c> on a visible object (carries the object guid). Standalone
|
||||
/// GameMessage, dispatched like <see cref="PrivateUpdateVital"/> / CreateObject.
|
||||
///
|
||||
/// <para>
|
||||
/// The companion <c>PrivateUpdatePropertyInt (0x02CD)</c> targets the player's OWN
|
||||
/// object (no guid) and is not parsed here — it has no item-icon impact.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Wire layout (ACE <c>GameMessagePublicUpdatePropertyInt</c>, size hint 17):</para>
|
||||
/// <code>
|
||||
/// u32 opcode = 0x02CE
|
||||
/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital
|
||||
/// u32 guid
|
||||
/// u32 property // PropertyInt enum; UiEffects = 18
|
||||
/// i32 value
|
||||
/// </code>
|
||||
/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4).
|
||||
/// </summary>
|
||||
public static class PublicUpdatePropertyInt
|
||||
{
|
||||
public const uint Opcode = 0x02CEu;
|
||||
|
||||
public readonly record struct Parsed(uint Guid, uint Property, int Value);
|
||||
|
||||
/// <summary>Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation.</summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
|
||||
int pos = 4;
|
||||
pos += 1; // sequence byte (not honored)
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
|
||||
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
|
||||
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
|
||||
return new Parsed(guid, prop, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Wires WorldSession GameMessage-level object events into the client object
|
||||
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
|
||||
/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
|
||||
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
|
||||
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
|
||||
/// </summary>
|
||||
public static class ObjectTableWiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribe <paramref name="table"/> to the three object-lifecycle events
|
||||
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
|
||||
/// to EntitySpawned so the table is populated before the render path runs.
|
||||
/// </summary>
|
||||
public static void Wire(WorldSession session, ClientObjectTable table)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
|
||||
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
|
||||
session.EntityDeleted += d => table.Remove(d.Guid);
|
||||
session.ObjectIntPropertyUpdated += u =>
|
||||
{
|
||||
if (u.Property == ClientObjectTable.UiEffectsPropertyId)
|
||||
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Translate the wire spawn into the table's merge patch.</summary>
|
||||
public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
|
||||
Guid: s.Guid,
|
||||
Name: s.Name,
|
||||
Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null,
|
||||
WeenieClassId: s.WeenieClassId,
|
||||
IconId: s.IconId,
|
||||
IconOverlayId: s.IconOverlayId,
|
||||
IconUnderlayId: s.IconUnderlayId,
|
||||
Effects: s.UiEffects,
|
||||
Value: s.Value,
|
||||
StackSize: s.StackSize,
|
||||
StackSizeMax: s.StackSizeMax,
|
||||
Burden: s.Burden,
|
||||
ContainerId: s.ContainerId,
|
||||
WielderId: s.WielderId,
|
||||
ValidLocations: s.ValidLocations,
|
||||
CurrentWieldedLocation: s.CurrentWieldedLocation,
|
||||
Priority: s.Priority,
|
||||
ItemsCapacity: s.ItemsCapacity,
|
||||
ContainersCapacity: s.ContainersCapacity,
|
||||
Structure: s.Structure,
|
||||
MaxStructure: s.MaxStructure,
|
||||
Workmanship: s.Workmanship);
|
||||
}
|
||||
|
|
@ -80,35 +80,7 @@ public sealed class WorldSession : IDisposable
|
|||
// sizing hint for tall-scenery selection indicators when the
|
||||
// server publishes it for non-useable display entities.
|
||||
uint? Useability = null,
|
||||
float? UseRadius = null,
|
||||
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
|
||||
uint IconId = 0,
|
||||
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
|
||||
// WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay
|
||||
// (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively.
|
||||
// Zero when the server did not send the field (common for most entities).
|
||||
uint IconOverlayId = 0,
|
||||
uint IconUnderlayId = 0,
|
||||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
|
||||
// effect recolor. CreateObject-only; 0 = no effect.
|
||||
uint UiEffects = 0,
|
||||
// D.5.4 (2026-06-18): full item field set, forwarded to the object table.
|
||||
// Quantity fields int? (ACE PropertyInt convention); id/mask fields uint?.
|
||||
uint WeenieClassId = 0,
|
||||
int? Value = null,
|
||||
int? StackSize = null,
|
||||
int? StackSizeMax = null,
|
||||
int? Burden = null,
|
||||
int? ItemsCapacity = null,
|
||||
int? ContainersCapacity = null,
|
||||
uint? ContainerId = null,
|
||||
uint? WielderId = null,
|
||||
uint? ValidLocations = null,
|
||||
uint? CurrentWieldedLocation = null,
|
||||
uint? Priority = null,
|
||||
int? Structure = null,
|
||||
int? MaxStructure = null,
|
||||
float? Workmanship = null);
|
||||
float? UseRadius = null);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
@ -181,20 +153,6 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public event Action<SetState.Parsed>? StateUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for <see cref="ObjectIntPropertyUpdated"/>: a single PropertyInt change on
|
||||
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
|
||||
/// property to typed state (e.g. UiEffects → the item's icon effect).
|
||||
/// </summary>
|
||||
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
|
||||
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
|
||||
/// item repository so the icon re-composites live.
|
||||
/// </summary>
|
||||
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
|
||||
/// signalling that the player is entering portal space. The uint payload
|
||||
|
|
@ -758,26 +716,7 @@ public sealed class WorldSession : IDisposable
|
|||
parsed.Value.Friction,
|
||||
parsed.Value.Elasticity,
|
||||
parsed.Value.Useability,
|
||||
parsed.Value.UseRadius,
|
||||
parsed.Value.IconId,
|
||||
parsed.Value.IconOverlayId,
|
||||
parsed.Value.IconUnderlayId,
|
||||
parsed.Value.UiEffects,
|
||||
parsed.Value.WeenieClassId,
|
||||
parsed.Value.Value,
|
||||
parsed.Value.StackSize,
|
||||
parsed.Value.StackSizeMax,
|
||||
parsed.Value.Burden,
|
||||
parsed.Value.ItemsCapacity,
|
||||
parsed.Value.ContainersCapacity,
|
||||
parsed.Value.ContainerId,
|
||||
parsed.Value.WielderId,
|
||||
parsed.Value.ValidLocations,
|
||||
parsed.Value.CurrentWieldedLocation,
|
||||
parsed.Value.Priority,
|
||||
parsed.Value.Structure,
|
||||
parsed.Value.MaxStructure,
|
||||
parsed.Value.Workmanship));
|
||||
parsed.Value.UseRadius));
|
||||
}
|
||||
}
|
||||
else if (op == DeleteObject.Opcode)
|
||||
|
|
@ -951,13 +890,6 @@ public sealed class WorldSession : IDisposable
|
|||
if (parsed is not null)
|
||||
VitalCurrentUpdated?.Invoke(parsed.Value);
|
||||
}
|
||||
else if (op == PublicUpdatePropertyInt.Opcode)
|
||||
{
|
||||
var p = PublicUpdatePropertyInt.TryParse(body);
|
||||
if (p is not null)
|
||||
ObjectIntPropertyUpdated?.Invoke(
|
||||
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
|
||||
}
|
||||
else if (op == GameEventEnvelope.Opcode)
|
||||
{
|
||||
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
|
||||
|
|
@ -1140,18 +1072,6 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||
/// <remarks>
|
||||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||||
/// (docs/research/named-retail/acclient_2013_pseudo_c.txt).
|
||||
/// </remarks>
|
||||
public void SendQueryHealth(uint targetGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
|
||||
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -92,16 +92,6 @@ public sealed class CombatState
|
|||
public float GetHealthPercent(uint guid) =>
|
||||
_healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f;
|
||||
|
||||
/// <summary>
|
||||
/// True if an UpdateHealth (0x01C0) has ever been received for this guid — i.e. the
|
||||
/// server has reported real health for it (via damage broadcast or a successful
|
||||
/// assess/QueryHealth reply). Distinguishes a known value from the 1.0 default that
|
||||
/// <see cref="GetHealthPercent"/> returns for unseen guids. Used by the selected-object
|
||||
/// meter to gate visibility (retail shows the bar only once health is known —
|
||||
/// gmToolbarUI::RecvNotice_UpdateObjectHealth, acclient_2013_pseudo_c.txt:196213).
|
||||
/// </summary>
|
||||
public bool HasHealth(uint guid) => _healthByGuid.ContainsKey(guid);
|
||||
|
||||
public int TrackedTargetCount => _healthByGuid.Count;
|
||||
|
||||
// ── Inbound handlers (wired from WorldSession.GameEvents) ────────────────
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
||||
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail semantics (r06):
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Every object is a <see cref="ClientObject"/> with a unique
|
||||
/// <c>ObjectId</c>. CreateObject seeds it when the server tells us
|
||||
/// the item exists (in our inventory, on the ground, in a
|
||||
/// vendor's list, etc).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Moves happen via <see cref="GameEventType"/>-carrying messages:
|
||||
/// <c>WieldObject</c>, <c>InventoryPutObjInContainer</c>,
|
||||
/// <c>InventoryPutObjectIn3D</c>, <c>ViewContents</c>,
|
||||
/// <c>CloseGroundContainer</c>.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>InventoryServerSaveFailed</c> reverts a speculative local
|
||||
/// state change (e.g. when a drag-drop was rejected server-side).
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Thread safety: designed for single-threaded use from the render
|
||||
/// thread; the event delegates run synchronously on the caller's
|
||||
/// thread. A <see cref="ConcurrentDictionary{TKey,TValue}"/> backs the
|
||||
/// map so plugin code can look up items from any thread without
|
||||
/// corrupting state.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ClientObjectTable
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, ClientObject> _objects = new();
|
||||
private readonly ConcurrentDictionary<uint, Container> _containers = new();
|
||||
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
|
||||
|
||||
/// <summary>Fires when an object is first added to the session.</summary>
|
||||
public event Action<ClientObject>? ObjectAdded;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when an object's container / slot changes (moved between
|
||||
/// packs, equipped, unequipped, dropped on ground). Old and new
|
||||
/// container ids are 0 if origin or destination is "world" / "nowhere".
|
||||
/// </summary>
|
||||
public event Action<ClientObject, uint, uint>? ObjectMoved;
|
||||
|
||||
/// <summary>Fires when an object is removed from the session.</summary>
|
||||
public event Action<ClientObject>? ObjectRemoved;
|
||||
|
||||
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
||||
public event Action<ClientObject>? ObjectUpdated;
|
||||
|
||||
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
|
||||
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
|
||||
/// <see cref="ClientObject.Effects"/>.</summary>
|
||||
public const uint UiEffectsPropertyId = 18u;
|
||||
|
||||
public int ObjectCount => _objects.Count;
|
||||
public int ContainerCount => _containers.Count;
|
||||
|
||||
public IEnumerable<ClientObject> Objects => _objects.Values;
|
||||
public IEnumerable<Container> Containers => _containers.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Look up an object by its server-assigned <c>ObjectId</c>.
|
||||
/// </summary>
|
||||
public ClientObject? Get(uint objectId) =>
|
||||
_objects.TryGetValue(objectId, out var item) ? item : null;
|
||||
|
||||
/// <summary>
|
||||
/// Look up a container by object id, creating a lightweight stub if
|
||||
/// the id doesn't match any known container (defensive — avoids losing
|
||||
/// references when the server announces a move into a container it
|
||||
/// hasn't described yet).
|
||||
/// </summary>
|
||||
public Container? GetContainer(uint objectId) =>
|
||||
_containers.TryGetValue(objectId, out var c) ? c : null;
|
||||
|
||||
/// <summary>
|
||||
/// Register / refresh an object in the table. Called on
|
||||
/// CreateObject for item-typed weenies and on IdentifyObjectResponse
|
||||
/// to fill in detail properties.
|
||||
/// Does NOT update the container index — use Ingest for container-tracked objects.
|
||||
/// </summary>
|
||||
public void AddOrUpdate(ClientObject item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
bool existed = _objects.ContainsKey(item.ObjectId);
|
||||
_objects[item.ObjectId] = item;
|
||||
if (!existed) ObjectAdded?.Invoke(item);
|
||||
else ObjectUpdated?.Invoke(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a container. Idempotent.
|
||||
/// </summary>
|
||||
public void AddContainer(Container container)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
_containers[container.ObjectId] = container;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven move — called from
|
||||
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
|
||||
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
|
||||
/// and fires ObjectMoved.
|
||||
/// </summary>
|
||||
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
|
||||
EquipMask newEquipLocation = EquipMask.None)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
|
||||
uint oldContainer = item.ContainerId;
|
||||
item.ContainerId = newContainerId;
|
||||
item.ContainerSlot = newSlot;
|
||||
item.CurrentlyEquippedLocation = newEquipLocation;
|
||||
Reindex(item, oldContainer);
|
||||
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
||||
/// space, stolen, etc).
|
||||
/// </summary>
|
||||
public bool Remove(uint itemId)
|
||||
{
|
||||
if (!_objects.TryRemove(itemId, out var item)) return false;
|
||||
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
|
||||
l.Remove(itemId);
|
||||
ObjectRemoved?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
|
||||
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual
|
||||
/// keys in the incoming bundle overwrite existing values; keys not
|
||||
/// present are left untouched.
|
||||
/// </summary>
|
||||
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
|
||||
/// object: store it in the bundle and, for known typed ints, mirror to the typed
|
||||
/// field. Today: UiEffects (18) → <see cref="ClientObject.Effects"/>. Fires
|
||||
/// ObjectUpdated so bound widgets re-composite. Extensible hook for future
|
||||
/// typed PropertyInts (StackSize, Structure, …). False if the object is unknown.
|
||||
/// </summary>
|
||||
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
item.Properties.Ints[propertyId] = value;
|
||||
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CreateObject ingestion: create-if-absent, else patch the
|
||||
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
|
||||
/// PropertyBundle (appraise) and any field the wire didn't carry.
|
||||
/// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract.
|
||||
/// </summary>
|
||||
public ClientObject Ingest(WeenieData d)
|
||||
{
|
||||
bool existed = _objects.TryGetValue(d.Guid, out var obj);
|
||||
if (!existed || obj is null) // keep: satisfies nullable flow analysis
|
||||
{
|
||||
obj = new ClientObject { ObjectId = d.Guid };
|
||||
_objects[d.Guid] = obj;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
|
||||
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
|
||||
if (d.Type is { } t) obj.Type = t;
|
||||
// WeenieClassId arrives on every CreateObject (fixed prefix) and is never
|
||||
// legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known
|
||||
// class id with a spurious 0 (and leaves a PD stub's 0 until CreateObject fills it).
|
||||
if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId;
|
||||
if (d.IconId != 0) obj.IconId = d.IconId;
|
||||
if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId;
|
||||
if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId;
|
||||
obj.Effects = d.Effects; // D.5.2 contract
|
||||
if (d.Value is { } v) obj.Value = v;
|
||||
if (d.StackSize is { } s) obj.StackSize = s;
|
||||
if (d.StackSizeMax is { } sm) obj.StackSizeMax = sm;
|
||||
if (d.Burden is { } b) obj.Burden = b;
|
||||
if (d.ContainerId is { } c) obj.ContainerId = c;
|
||||
if (d.WielderId is { } w) obj.WielderId = w;
|
||||
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
|
||||
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
|
||||
if (d.Priority is { } pr) obj.Priority = pr;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
|
||||
if (d.Structure is { } st) obj.Structure = st;
|
||||
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
|
||||
if (d.Workmanship is { } wm) obj.Workmanship = wm;
|
||||
|
||||
Reindex(obj, oldContainer);
|
||||
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PlayerDescription manifest: record that this guid is the player's
|
||||
/// (in inventory or equipped at <paramref name="equip"/>), creating an
|
||||
/// empty entry if CreateObject hasn't arrived yet. Never touches
|
||||
/// icon/name/type/effects — that data comes from CreateObject.
|
||||
/// </summary>
|
||||
public ClientObject RecordMembership(uint guid, uint containerId = 0,
|
||||
EquipMask equip = EquipMask.None)
|
||||
{
|
||||
bool existed = _objects.TryGetValue(guid, out var obj);
|
||||
if (!existed || obj is null) // keep: satisfies nullable flow analysis
|
||||
{
|
||||
obj = new ClientObject { ObjectId = guid };
|
||||
_objects[guid] = obj;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
if (containerId != 0) obj.ContainerId = containerId;
|
||||
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
|
||||
Reindex(obj, oldContainer);
|
||||
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private void Reindex(ClientObject obj, uint oldContainerId)
|
||||
{
|
||||
if (oldContainerId != obj.ContainerId && oldContainerId != 0
|
||||
&& _containerIndex.TryGetValue(oldContainerId, out var oldList))
|
||||
oldList.Remove(obj.ObjectId);
|
||||
|
||||
if (obj.ContainerId != 0)
|
||||
{
|
||||
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
|
||||
_containerIndex[obj.ContainerId] = list = new List<uint>();
|
||||
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
|
||||
list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b)));
|
||||
}
|
||||
}
|
||||
|
||||
private int SlotOf(uint guid) =>
|
||||
_objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
|
||||
/// Returns a SNAPSHOT (safe to hold / read off-thread); empty for an unknown container.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> GetContents(uint containerId) =>
|
||||
_containerIndex.TryGetValue(containerId, out var l)
|
||||
? l.ToArray() : System.Array.Empty<uint>();
|
||||
|
||||
/// <summary>
|
||||
/// Flush the table — typically called on logoff or teleport
|
||||
/// that drops the session's object state.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_objects.Clear();
|
||||
_containers.Clear();
|
||||
_containerIndex.Clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -121,13 +121,14 @@ public sealed class PropertyBundle
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-object live state (the data side of every server object — items and creatures alike).
|
||||
/// Retail <c>ACCWeenieObject</c>.
|
||||
/// Per-item live state. The server owns item identity (ObjectId);
|
||||
/// acdream mirrors properties here on <c>CreateObject</c> and updates
|
||||
/// via <c>UpdateProperty*</c> messages.
|
||||
/// </summary>
|
||||
public sealed class ClientObject
|
||||
public sealed class ItemInstance
|
||||
{
|
||||
public uint ObjectId { get; init; }
|
||||
public uint WeenieClassId { get; set; } // "blueprint"
|
||||
public uint WeenieClassId { get; init; } // "blueprint"
|
||||
public string Name { get; set; } = "";
|
||||
public ItemType Type { get; set; }
|
||||
public EquipMask ValidLocations { get; set; }
|
||||
|
|
@ -135,13 +136,6 @@ public sealed class ClientObject
|
|||
public uint IconId { get; set; } // 0x06xxxxxx
|
||||
public uint IconUnderlayId{ get; set; } // "magic" underlay
|
||||
public uint IconOverlayId { get; set; } // "enchanted" overlay
|
||||
/// <summary>
|
||||
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
|
||||
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
|
||||
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
|
||||
/// appraise never carries it. 0 = no effect.
|
||||
/// </summary>
|
||||
public uint Effects { get; set; }
|
||||
public int StackSize { get; set; } = 1;
|
||||
public int StackSizeMax { get; set; } = 1;
|
||||
public int Burden { get; set; } // per-stack total
|
||||
|
|
@ -150,49 +144,9 @@ public sealed class ClientObject
|
|||
public int ContainerSlot { get; set; } = -1;
|
||||
public bool Attuned { get; set; }
|
||||
public bool Bonded { get; set; }
|
||||
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
|
||||
public int ItemsCapacity { get; set; } // main-pack slots (containers)
|
||||
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
|
||||
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
||||
public int Structure { get; set; } // charges/uses remaining
|
||||
public int MaxStructure { get; set; }
|
||||
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
|
||||
public PropertyBundle Properties { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The wire-delivered patch from a <c>CreateObject</c> (0xF745). Nullable fields
|
||||
/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert
|
||||
/// (ClientObjectTable.Ingest) leaves the existing value untouched
|
||||
/// for those, matching retail's <c>SetWeenieDesc</c> (patches only present fields).
|
||||
/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned
|
||||
/// unconditionally (0 clears) — the D.5.2 icon contract. Quantity fields are
|
||||
/// int? (ACE PropertyInt convention); id/mask fields are uint?.
|
||||
/// </summary>
|
||||
public readonly record struct WeenieData(
|
||||
uint Guid,
|
||||
string? Name,
|
||||
ItemType? Type,
|
||||
uint WeenieClassId,
|
||||
uint IconId,
|
||||
uint IconOverlayId,
|
||||
uint IconUnderlayId,
|
||||
uint Effects,
|
||||
int? Value,
|
||||
int? StackSize,
|
||||
int? StackSizeMax,
|
||||
int? Burden,
|
||||
uint? ContainerId,
|
||||
uint? WielderId,
|
||||
uint? ValidLocations,
|
||||
uint? CurrentWieldedLocation,
|
||||
uint? Priority,
|
||||
int? ItemsCapacity,
|
||||
int? ContainersCapacity,
|
||||
int? Structure,
|
||||
int? MaxStructure,
|
||||
float? Workmanship);
|
||||
|
||||
/// <summary>
|
||||
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
|
||||
/// → side packs; a side pack cannot hold another side pack (r06 §7).
|
||||
|
|
@ -203,7 +157,7 @@ public sealed class Container
|
|||
public int Capacity { get; set; } = 102; // main inv default
|
||||
public int SideCapacity { get; set; } = 0; // 0 for side-pack
|
||||
public int BurdenLimit { get; set; }
|
||||
public List<ClientObject> Items { get; } = new();
|
||||
public List<ItemInstance> Items { get; } = new();
|
||||
public List<Container> SidePacks { get; } = new(); // empty for side-pack
|
||||
public bool IsSidePack => SideCapacity == 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue