Compare commits

...
Sign in to create a new pull request.

228 commits

Author SHA1 Message Date
Erik
f7f3e0887b docs(lighting): indoor lighting regime handoff — file #142 (windowed-interior regime) + #143 (portal dynamic light)
Clean handoff for the next M1.5 "indoor world feels right" session, picking up
the two indoor-lighting gaps the user spotted at the #140 visual gate.

#142 (PRIMARY): windowed-building interiors + look-ins read "like outdoors".
Root cause grounded: retail's lighting regime is per-DRAW-STAGE (PView::DrawCells
draws ALL EnvCells in the useSunlightSet(0) interior stage — torch-lit, no sun,
regardless of SeenOutside), while acdream's is a per-FRAME global keyed on the
player's cell (playerInsideCell). So acdream's windowed interiors (SeenOutside)
+ look-ins stay in the outdoor regime. This is the AP-43 residual surfaced.
Fix direction: make sun+ambient per-draw like AP-43's torches (design fork laid
out for a brainstorm). Resolves AP-43.

#143 (SECONDARY): portal swirl casts no light. acdream registers only static
Setup.Lights; the portal is a retail DYNAMIC light (add_dynamic_light ->
minimize_envcell_lighting). Fix: register a dynamic LightSource for portals.

Handoff doc carries the verified retail decomp (useSunlightSet/PView::DrawCells
stages), current acdream line refs, the three gaps, the fix fork, validation
plan, and DO-NOT-RETRY. Neither issue is a regression from #140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:17:59 +02:00
Erik
31d7ffd253 merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:01:20 +02:00
Erik
711c2ea688 docs(D.5.3a): #140 — health+name+flash done & visually confirmed
Selected-object meter health half passed the visual gate (2026-06-20): name on
the black band, attackable-only health gate, UpdateHealth-driven bar, green flash,
no magenta. Mana (0x100001A2) + stack entry/slider (0x100001A3/A4) remain deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:39:42 +02:00
Erik
07965852e0 chore(cli): UI-debug apparatus — mock-selbar, dump-edges, crop, probe
Standalone AcDream.Cli subcommands built during the D.5.3a visual gate, kept as
reusable UI/sprite/framebuffer debugging apparatus (alongside the existing
export-ui-sprite / dump-sprite-sheet / render-vitals-mockup tools):
- mock-selbar: composite the selected-object health bar (back + fill at fractions)
- dump-edges: print a sprite's first/last column RGB at every row
- crop: crop + nearest-upscale a region of a PNG (zoom into a framebuffer dump)
- probe: print the RGB of a pixel block from a PNG

Dev-only (reached via explicit args[0]); no game-runtime impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:37:29 +02:00
Erik
8f627cce0e fix(D.5.3a): selected-object meter visual-gate fixes (name, gate, flash, magenta)
Visual gate against retail surfaced several fidelity gaps in the selected-object
strip; all fixed and user-confirmed. Faithful to gmToolbarUI::HandleSelectionChanged
(acclient_2013_pseudo_c.txt:198635) + RecvNotice_UpdateObjectHealth (:196213).

- UiMeter.DrawHBar: guard each slice on `id != 0` BEFORE resolve. resolve(0)
  returns the 1x1 magenta placeholder with a non-zero GL handle, so the single-
  image meter (caps id=0) was drawing 1px magenta caps at the bar's ends. The
  3-slice vitals meter (all ids set) was unaffected. (the magenta-lines bug)
- SelectedObjectController: meter visibility is now UpdateHealth-driven (shown when
  health is known for the selected guid — HasHealth at select or HealthChanged),
  not shown-on-select; brief green selection flash via Tick revert; overlay floated
  above the meter so the flash isn't hidden by the bar; name top-aligned into the
  bar sprite's black band (NameBandHeight) with the bar below.
- GameWindow.IsHealthBarTarget: gate the health bar on the server PWD bits
  BF_ATTACKABLE (0x10) | BF_PLAYER (0x8) — friendly/vendor NPCs and attackable
  Doors (Misc type) are name-only; players/monsters get the bar. Replaces the
  too-loose IsLiveCreatureTarget. Wired SelectedObjectController.Tick in OnUpdate.
- CombatState.HasHealth(guid): distinguishes a known health value from the 1.0
  default, so a re-selected already-assessed target shows its bar immediately.
- TextureCache.GetOrUploadRenderSurface: resolve the surface's DefaultPaletteId
  so paletted (P8/INDEX16) UI sprites decode instead of falling to magenta.
- ToolbarController.HiddenIds: also hide 0x100001A3 (stack-entry box) — retail
  hides it in HandleSelectionChanged; it was rendering as a stray black box.

Divergence register: AP-47 (meter-visible timing) retired (now faithful); AP-46
rewritten to the BF_ATTACKABLE/BF_PLAYER gate approximation. Full suite green
(2,688 passed / 4 skipped). User-confirmed: name on top, NPC name-only, monster
bar on assess, green flash, no magenta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:37:15 +02:00
Erik
c83fd02642 merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:29:53 +02:00
Erik
b7d655bce7 fix(lighting): A7 Fix D round 2 — outdoor objects get NO torches (retail useSunlight gate) (#140)
The Holtburg meeting-hall facade washed out warm/bright vs retail. The round-1
checkpoint blamed torch REACH (acdream Falloff 6×1.3=7.8m vs a supposed retail
Falloff 4). That theory is WRONG, and this commit fixes the real cause.

Empirical (HoltburgTorchFalloffProbeTests, headless dat dump via the production
LightInfoLoader): the orange entrance torch (setup 0x020005D8) is raw dat
Falloff 6 and acdream reads it FAITHFULLY — there is no Falloff-4 torch anywhere
in Holtburg. Both clients read the same dat float, so reach was never inflated.

Decomp (read verbatim + corroborated by an independent adversarial workflow):
retail's per-object torch binder minimize_object_lighting (0x0054d480) is gated
in RenderDeviceD3D::DrawMeshInternal (0x0059f398) by `if (Render::useSunlight == 0)`.
The outdoor landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a,
before LScape::draw), so the building EXTERIOR shell — drawn via
DrawBlock→DrawSortCell→DrawBuilding→CPhysicsPart::Draw→DrawMeshInternal — is lit
by SUN + ambient ONLY; torches are SKIPPED. The static bake
(SetStaticLightingVertexColors 0x0059cfe0) is EnvCell-only. So retail NEVER
torch-lights outdoor objects. This exactly explains the isolation test (object
point lights OFF → building matches retail).

Fix: WbDrawDispatcher.ComputeEntityLightSet gates per-object torch selection on
the object being INDOOR (ParentCellId is an EnvCell, (id&0xFFFF)>=0x0100) via the
pure predicate IndoorObjectReceivesTorches. Outdoor objects (building shells with
null ParentCellId, outdoor scenery, outdoor creatures) keep the all-(-1) light
set ⇒ sun + ambient only = retail. The indoor "no sun" half is already handled by
the global sun-kill when the player is inside a cell (UpdateSunFromSky). No
dungeon regression: EnvCell statics get ParentCellId set (keep torches).

Divergence register: AP-37 (residual: acdream keys sun/torch on the object's own
cell + a per-frame player-inside sun-kill, vs retail's per-draw-stage useSunlight;
only matters for through-doorway look-ins). The round-1 CHECKPOINT got a RESOLVED
banner correcting the reach theory.

Tests: WbDrawDispatcherTorchGateTests (7), HoltburgTorchFalloffProbeTests (dat
dump). App 280/1skip, Core 1486/2skip green. Held at the visual gate — not merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:56:49 +02:00
Erik
1e6fbff9bc docs(lighting): A7 Fix D round-2 CHECKPOINT — real cause is object torch REACH (#140)
Same-instant cdb proved acdream ambient (0.447) == retail (0.4465) and time/sun match,
so the building/character over-brightness is NOT the bake/wrap/EnvCell/clamp (D-1..D-4,
all correct but off-target) — those light the wrong surfaces. The Holtburg building
exterior is a mode-0 OBJECT (IsBuildingShell, not an EnvCell). Isolation (object point
lights gated OFF) made it match retail => cause is the torch REACH being too long
(acdream range 7.8 = Falloff 6x1.3 vs retail 5.2 = Falloff 4x1.3), flooding the small
facade. OPEN: confirm same-torch Falloff acdream-vs-retail before tightening the reach.
Diagnostic shader hack reverted (tree clean); D-1..D-4 kept. Branch not merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:22:50 +02:00
Erik
6636e50c2a feat(D.5.3a): selected-object meter — Health bar + name on the action bar
Port of gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635).
When the player selects a world object the action bar's bottom strip shows the
object name + (for player/pet/attackable targets) a live Health meter; deselect
clears it. Mana (#140) + stack slider deferred.

- SelectedObjectController (new): clear-then-populate on selection change; sets
  name (UiText child, VitalsController pattern), overlay state (ObjectSelected /
  StackedItemSelected via UiDatElement.ActiveState), shows the health meter and
  sends QueryHealth for health targets. Subscribes via a delegate seam (no
  GameWindow coupling).
- GameWindow: _selectedGuid field -> SelectedGuid property + SelectionChanged
  event (fires on actual change only); 3 write sites converted, reads untouched.
  All selection-write paths (LMB pick, Tab/Q, despawn-clear via Tick()) run on
  the render thread, so the event-driven UI mutation is single-threaded.
- WorldSession.SendQueryHealth (0x01BF) — wraps SocialActions.BuildQueryHealth.
- DatWidgetFactory.BuildMeter: handle the single-image toolbar meter shape
  (back-track on the element's own DirectState, fill on one Type-3 child). The
  sprites go in the TILE slot (DrawMode=Normal tiles to full bar geometry per
  UIElement_Meter::DrawChildren) — a left-cap assignment would gap/clamp a
  sub-140px sprite. Vitals 3-slice path unchanged.
- ToolbarController.HiddenIds: A1 (health) now owned by SelectedObjectController;
  A2 (mana) + A4 (stack) stay hidden (deferred) so their dat back-tracks don't
  render as stray empty bars.

Adversarial Opus review found + fixed: the mana-meter orphan (A2 left unhidden)
and the meter tile-vs-cap render bug (C1). Divergence rows AP-46 (health gate
approximation: IsLiveCreatureTarget vs IsPlayer||pet||attackable) + AP-47
(meter shown on select vs on UpdateHealth reply). Spec §5 corrected.

Build + full test suite green (2,684 passed / 4 skipped). Health meter render
fidelity (full-width fill + fraction mapping) pending the user's visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:47:24 +02:00
Erik
e8562fc4e2 docs(D.5.3a): spec + plan — selected-object meter (Stream A)
Brainstormed design for the action bar's bottom strip: name + Health meter
on selection (mana deferred #140). Decisions: SelectionChanged via property
setter; send QueryHealth(0x01BF) on select. Grounded in retail
gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635) —
clear-then-populate, overlay state 0x1000000b, health gate
IsPlayer||pet||attackable. Render-bug fix is BuildMeter-only (single-image
back+fill meter; UiMeter already renders it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:19:14 +02:00
Erik
0980bea48d fix(render): A7 Fix D D-3/D-4 — two-path lighting (objects plain-Lambert+sun, EnvCell wrap+no-sun) (#140)
mesh_modern unified all meshes into one calc_point_light path: it applied the
bake's half-Lambert wrap to objects (lighting character backs from a torch behind
them) and added the sun to EnvCell building shells (warm facade wash). Retail
splits these: objects = hardware plain Lambert max(0,N.L) + sun; EnvCell walls =
baked wrap, dynamics only, NO sun (minimize_envcell_lighting). Add a per-draw
uLightingMode (WbDrawDispatcher=0 object, EnvCellRenderer=1 envcell) selecting the
angular term (wrap vs plain Lambert) and gating the sun. Per-light cap + D-1 clamp
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:38:30 +02:00
Erik
d400bc6105 docs: handoff — finish the action bar (selected-object meter + shortcut drag) + start the inventory/paperdoll window
Next D.2b-UI work after D.5.4. 3 streams (spell bar deferred): selected-object
meter, shortcut drag/add/reorder/remove, inventory+paperdoll window. Current-code
anchors + dependency graph + build order + brainstorm questions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:29:04 +02:00
Erik
156dc453c9 docs(register): AP-35 drop false equivalence; AP-16 retarget to per-object/cell 8-light cap — A7 Fix D
AP-35: the "numerically equivalent" claim was false. Residual is now two
parts: (a) per-frame GPU evaluate vs retail's bake-once (architecture/perf
difference only; formula matches), and (b) SelectForObject 8-cap means a
surface reached by >8 point lights is dimmer than retail's uncapped bake.
Cross-references AP-16 for the cap ownership.

AP-16: the old "global nearest-8 viewer-distance into UBO" description was
stale — the UBO point-light path is now vestigial (mesh_modern.vert skips
posAndKind.w!=0 entries; point lights come exclusively from the per-object
SSBO binding 5). Retargeted to the current SelectForObject per-object/cell
8-cap mechanism with correct file:line (LightManager.cs:234), both call
sites (ComputeEntityLightSet + GetCellLightSet), and the retail oracle
distinction (hardware cap 0x0054d480 faithful; bake 0x0059cfe0 not).
Preserved the UBO-directional-only note inline rather than losing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:54:34 +02:00
Erik
b57a53edc4 docs(register): correct AP-35 (per-vertex+wrap+norm ported, point sum clamped) — A7 Fix D
Fix A (aa94ced) moved point lighting to per-vertex Gouraud and ported the
half-Lambert wrap + norm distance attenuation. Fix D D-1 added the separate
point-light accumulator clamped to [0,1] matching retail's
SetStaticLightingVertexColors bake clamp.

AP-35 previously stated the path was per-pixel (mesh_modern.frag:52) and
that wrap + normalization factor were "neither ported" — both wrong. Rewrite
to reflect current state: per-vertex in mesh_modern.vert (pointContribution),
wrap + norm ported, point sum clamped. Residual is architecture-only (per-
frame GPU evaluate vs retail bake-once), not a visual divergence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:47:09 +02:00
Erik
c62da825fe 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>
2026-06-18 17:35:33 +02:00
Erik
cf62793304 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>
2026-06-18 17:29:45 +02:00
Erik
39c70f00aa 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>
2026-06-18 17:26:52 +02:00
Erik
180b4af2a9 refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:25:11 +02:00
Erik
ad53180190 docs(plan): A7 Fix D implementation plan — 5 tasks (#140)
Task-by-task TDD plan: (1) extract GlobalLightPacker (Core, pure) + test + refactor
WbDrawDispatcher; (2) lock the bake contract via LightBake conformance test on the
captured golden torches; (3) D-1 clamp the point-light sum on its own in
mesh_modern.vert; (4) D-2 EnvCellRenderer binds its own per-cell light set (SSBO 4+5)
via SelectForObject over cell bounds; (5) correct register AP-35 + reconcile Fix B.
Concrete code + exact insertion points; visual verification is the acceptance gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:15:36 +02:00
Erik
c407104ab9 docs(lighting): A7 Fix D investigation RESOLVED + implementation spec (#140)
Resolve the Fix D contradiction with decomp (workflow wf_f660eb88 + adversarial
verify) + 4 live cdb captures. The D3D-FF model was the WRONG oracle: retail has
TWO light systems — STATIC torches BAKE into wall vertices (calc_point_light,
triple-clamped: range gate + per-channel min(scale*color,color) + per-vertex
[0,1] from black), DYNAMIC lights go D3D hardware. The captured intensity=100 is
the purple PORTAL (magenta, dynamic), not a wall torch. Ground truth: 38 static
warm torches (orange (1,0.588,0.314)/cream, intensity=100, falloff 3-5) + 2 dynamic.

acdream over-brightness = two confirmed bugs: D-1 mesh_modern.vert folds
ambient+sun+torches into one UNCLAMPED accumulator (single frag clamp) -> warm
blowout; D-2 EnvCellRenderer never binds SSBO 4/5 so the cell shell reads a leaked
light set. Spec: D-1 in-shader clamp-split (clamp the torch sum on its own before
ambient/sun); D-2 bind the shell's own per-cell light set (mirror WbDrawDispatcher);
LightBake.cs is the C# conformance oracle. Adds the 4 reusable cdb capture scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:08:27 +02:00
Erik
6eb0fbde46 test(D.5.4): lock creature Name/Type resolution via ClientObjectTable.Get (spec §8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:08:19 +02:00
Erik
85a2371e11 docs(D.5.4): roadmap shipped + divergence register (event model + deferred parent pre-queue)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:03:28 +02:00
Erik
a33e897400 perf(D.5.4): toolbar re-binds only on shortcut-guid object changes; clear on remove
Now that the object table holds ALL entities (creatures, NPCs, world objects),
filtering ObjectAdded/Updated/Removed to the 18 shortcut guids prevents the bar
from thrashing on every creature spawn in a busy zone.

Also subscribes to ObjectRemoved so a despawned/traded-away item clears its slot
(matching retail gmToolbarUI::SetDelayedShortcutNum's deferred-bind contract).

Four new unit tests (iconIds spy pattern) verify: non-shortcut ObjectAdded/Removed
do NOT invoke Populate; shortcut ObjectAdded deferred-binds; shortcut ObjectRemoved
clears the slot. 2671 tests, 4 skipped, 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:57:04 +02:00
Erik
a9d40addac refactor(D.5.4): retire _liveEntityInfoByGuid; selection resolves from ClientObjectTable
The one weenie table now holds every object's name+type, so the redundant
Name+ItemType dictionary is gone (retail: one weenie_object_table).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:48:13 +02:00
Erik
50cee50df1 refactor(D.5.4): delete EnrichItem (superseded by Ingest merge-upsert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:42:58 +02:00
Erik
cbbfe4cd49 feat(D.5.4): PlayerDescription = membership manifest; drop WeenieClassId=ContainerType misuse
The old seeding block set WeenieClassId = inv.ContainerType (a 0/1/2
container-kind discriminator, not a weenie class id) and used MoveItem
for the equipped block. Replace both loops with RecordMembership calls:
inventory guids get a bare stub (WeenieClassId stays 0); equipped guids
get the equip slot set directly. Weenie data arrives via CreateObject /
ObjectTableWiring, not PlayerDescription.

New test PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse proves:
(a) inv guid is registered, (b) WeenieClassId==0 not ContainerType, and
(c) equipped guid CurrentlyEquippedLocation is set to MeleeWeapon.
No existing tests pinned the old behavior; all 15 GameEventWiringTests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:38:54 +02:00
Erik
82f5968316 feat(D.5.4): ObjectTableWiring (CreateObject=upsert, Delete=evict, 0x02CE) off GameWindow
CreateObject ingestion moves to Core.Net; GameWindow drops the EnrichItem call +
inline 0x02CE handler. Fixes the Coldeve blank-icon root cause: items with no PD
stub are now created, not dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:34:56 +02:00
Erik
2e3f209707 feat(D.5.4): live container membership index (object_inventory_table)
Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:19:09 +02:00
Erik
d9c427cd6c feat(D.5.4): ClientObjectTable.Ingest merge-upsert + RecordMembership
Field-level merge (retail SetWeenieDesc): create-if-absent else patch present
fields, preserve PropertyBundle. Effects unconditional (D.5.2 contract).
RecordMembership = PD manifest. Locks the Coldeve no-prior-stub fix + out-of-order.
Renames _items→_objects throughout; Reindex stub wired (Task 6 fills it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:08:57 +02:00
Erik
b83f17a927 feat(D.5.4): add item fields to ClientObject + WeenieData ingest DTO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:57:12 +02:00
Erik
b00a373c5a feat(D.5.4): forward full item field set through WorldSession.EntitySpawn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:53:07 +02:00
Erik
e4dd37a3b8 docs(D.5.4): plan — StackSizeMax int? for downstream type consistency
Code-review follow-up from Task 2: align StackSizeMax with the other quantity
fields (int?, ACE PropertyInt convention) in Tasks 3/4/5; drop the (int) cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:51:29 +02:00
Erik
91970c4fe9 feat(D.5.4): capture full item field set in CreateObject parser
WeenieClassId + Value/StackSize/MaxStackSize/Burden/capacities/Container/Wielder/
ValidLocations/CurrentWieldedLocation/Priority/Structure/Workmanship. Nullable =
flag absent (don't clobber on merge). Cursor walk unchanged; +cursor-integrity test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:50:27 +02:00
Erik
6b562ad077 docs: file #140 (Fix D — outdoor objects too bright near torches) + register UN-7
A7 lighting Fix A/B/C shipped this session; Fix D (object torch over-brightness)
grounded but blocked on the render-path capture. Filed as #140 + divergence
register UN-7 (object point-light model unconfirmed). Detail in the 2026-06-18
handoff doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:37:02 +02:00
Erik
b506f53633 refactor(D.5.4): rename ItemRepository->ClientObjectTable, ItemInstance->ClientObject
Broaden naming to the data side of every server object (retail weenie_object_table
shape). Pure rename; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:36:16 +02:00
Erik
4795a6c7f3 merge: A7 lighting Fix C (sun-vector brightness) + handoff into main
Brings Fix C (57c1135, sun-vector magnitude / ~32% over-bright) + the A7 lighting
handoff doc onto main. Auto-merged clean against the D.2b line. Merged tree builds
green; 18/18 sky tests pass. Fix A/B already on main (37911ed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:35:25 +02:00
Erik
f384d036a3 docs: A7 lighting handoff — Fix A/B/C shipped, Fix D (object torch over-bright) open
Session handoff: live-cdb grounding shipped Fix A (point-light shape), Fix B
(per-object selection), Fix C (sun-vector magnitude / ~32% over-bright). Fix D
(outdoor objects too bright near torches) is fully grounded but BLOCKED on one
capture (the building's render path) — the D3D-FF math says it'd make objects
brighter, so not ported. Full cdb cheat-sheet + the contradiction + the next
capture in the doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:35:00 +02:00
Erik
2fc253d9ff docs(D.5.4): implementation plan (12 tasks, TDD, green-per-task)
Rename -> field capture -> EntitySpawn plumb -> ClientObject/WeenieData ->
Ingest merge-upsert -> container index -> ObjectTableWiring (off GameWindow) ->
PD manifest -> delete EnrichItem -> retire _liveEntityInfoByGuid -> toolbar
guid-filter -> bookkeeping+live run. Sequenced so every task builds green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:19:15 +02:00
Erik
57c11358b6 fix(sky): A7 — correct sun-vector magnitude (ambient + sun were ~32% too bright)
Outdoor lighting was ~32% too bright (washed-out, weak shading). Live cdb on
retail (SmartBox::SetWorldAmbientLight + SkyDesc::GetLighting + LScape::sunlight,
binary matches refs/acclient.pdb) pinned it: at the SAME game time + DayGroup,
acdream's ambient COLOR matched retail exactly (the purple is correct, authored
per-time-of-day in the sky dat) but the LEVEL was 0.607 vs retail's 0.459.

level = AmbBright + 0.2·|sunVec|, both AmbBright=0.40, so acdream's |sunVec|≈1.06
vs retail's ≈0.30. Retail's LScape::sunlight read live = (0.2238, ~0, 0.00352),
magnitude 0.224 = DirBright, y≈0.

RetailSunVector had `y = cos(P)` (≈1) — the raw PRE-transform value SkyDesc::
GetLighting writes to arg5 (0x00500ac9), before LScape::set_sky_position's
world transform. acdream ported the un-transformed vector, so the y=cos(P)≈1
term inflated |sunVec| to ~1.06. That magnitude feeds BOTH the ambient boost
(SkyKeyframe.AmbientColor) AND the sun colour (SkyKeyframe.SunColor =
DirColor×|sunVec|), over-brightening the whole scene (terrain, objects, sky)
~30% and also pointing the sun the wrong way.

Fix: RetailSunVector = DirBright × (cos(P)·sin(H), cos(P)·cos(H), sin(P)) — the
world-space spherical form LScape::sunlight actually holds; |sunVec| == DirBright
for all H/P. After: acdream ambient (0.353,0.176,0.449) vs retail (0.360,0.180,
0.459) — within ~2%, user-confirmed "better outside". Sun direction also corrected
(was pointing ~North from the bad y term).

Tests updated to the cdb-verified values (the prior tests pinned the inflated
magnitude). 18/18 sky tests green. reference-retail-ambient-values memory updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:08:52 +02:00
Erik
969e55350b docs(D.5.4): client object/item data model design (brainstorm spec)
Two guid-keyed tables (retail shape), CreateObject = canonical merge-upsert
for the data table (ACCWeenieObject-equivalent holding ALL server objects),
container membership index, retire _liveEntityInfoByGuid + EnrichItem. Settles
the handoff crux against the named decomp: retail is TWO tables, not one, so
acdream's WorldEntity + item-table split is already faithful — fix ingestion,
don't unify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:06:00 +02:00
Erik
5b568d000a docs(D.5): sub-phase ledger + item-model cold-start prompt
Roadmap: refresh the D.5.2 entry to its final shipped state (per-pixel gradient
surface overload 0x004415b0; AP-43/AP-44 retired by visual verification; range
419c3ac..fb288ad). Add an explicit D.5 sub-phase ledger: D.5.4 client object/item
data model (foundation, NEXT) -> D.5.3 selected-object + spell shortcuts -> window
manager -> D.5.5+ core panels. Handoff doc gains a paste-ready new-session prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:41:08 +02:00
Erik
9e0d2568cc docs: handoff for the client object/item data model (next phase after D.5.2)
Frames the root cause of the live-Coldeve 4/6-missing-hotbar-icons (acdream's
enrich-existing-only item model drops CreateObjects without a pre-seeded stub) and
the retail ClientObjMaintSystem model to port. CRUX to settle first: unify the
WorldEntity + ItemRepository tracks, or keep separate with shared ingestion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:33:31 +02:00
Erik
fb288ad852 fix(D.5.2): effect tint = per-pixel tile copy (surface ReplaceColor overload)
Visual verification (Coldeve, Energy Crystal) showed acdream's Magical blue as a
flat tint vs retail's gradient. Root cause: RenderIcons calls the SURFACE overload
of SurfaceWindow::ReplaceColor (0x004415b0), which copies the textured effect tile
pixel-by-pixel into the icon's pure-white pixels — not the flat color->color overload
(0x00441530) I'd approximated with the tile's mean color. Port the surface overload
exactly (dst[x,y]=src[x,y] where dst==white); confirmed via clean Ghidra decompile +
named decomp. Retires AP-43 (mean-color approximation); IA-18 updated to the surface op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:21:33 +02:00
Erik
40c97a53ac fix(D.5.2): always run effect recolor (effects==0 -> black) to match retail
Visual verification caught it: a no-mana scroll's icon edges are BLACK in retail
but rendered WHITE in acdream. Cause = the effects!=0 gate (registered AP-44) that
skipped retail's effects==0 recolor. Retail's effect tile is non-null even for
effects==0 (the 0x21 SOLID-BLACK fallback 0x060011C5), so RenderIcons recolors
pure-white pixels to black on mundane items and to the effect hue on magical ones.
Remove the gate (always recolor); retire AP-44 (now faithful). TryGetEffectColor
made internal + a golden test pins effects==0 -> ~black.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:54:15 +02:00
Erik
702d6e1e90 test(D.5.2): lock effects-clears-to-zero contract (final-review polish)
The 'item with mana vs out of mana' core promise: a draining item whose
UiEffects clears to 0 returns to its base icon. Guards EnrichItem +
UpdateIntProperty unconditional-assign against a future != 0 regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:02:03 +02:00
Erik
73adc3768c docs(D.5.2): retire IA-16, add IA-18/AP-43..45, roadmap + memory
Divergence register:
- Retire IA-16 (item-icon composite PARTIAL — D.5.2 now complete).
- Add IA-18 (effect overlay = ReplaceColor tint SOURCE, faithful retail
  behavior; anti-regression guard — do NOT re-implement as a blit layer;
  cites IconData::RenderIcons 0x0058d180 + ReplaceColor 0x00441530).
- Add AP-43 (effect tint = mean-opaque color; exact retail byte
  decompiler-ambiguous, visual/cdb confirmation pending).
- Add AP-44 (effects==0 black-fallback recolor skipped; regression-risk
  avoidance, pending visual/cdb confirm).
- Add AP-45 (0x02CE sequence byte not honored, latest-wins).
Section header counts updated: IA 15→17, AP 41→44.

Roadmap: mark D.5.2 shipped (419c3ac..2f789da; appraise dropped as no-op;
effect recolor + live 0x02CE).

Tests: update ToolbarControllerTests iconIds lambda arity 4→5 to match the
D.5.2 GetIcon signature change (was caught by the build).

Memory: project_d2b_retail_ui.md updated with D.5.2 shipped entry
(via claude-memory symlink to ~/.claude/projects/.../memory/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:52:15 +02:00
Erik
2f789da73d feat(D.5.2): route live UiEffects updates (0x02CE) to the item icon
Subscribe ObjectIntPropertyUpdated (added in Group C Task 4) in GameWindow
next to the existing VitalUpdated/VitalCurrentUpdated subscriptions. Routes
PublicUpdatePropertyInt(0x02CE) UiEffects (property 18) → ItemRepository.
UpdateIntProperty → ItemInstance.Effects → ItemPropertiesUpdated → UiItemSlot
re-composites the icon in real time. The end-to-end path is the visual-
verification acceptance test (live ACE server + a draining magical item).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:47:54 +02:00
Erik
e0dce5aa9f feat(D.5.2): IconComposer 2-stage effect composite + 5-arg GetIcon
Widen the cache key to (typeUnderlay, icon, underlay, overlay, effects).
GetIcon is now a 2-stage composite mirroring retail IconData::RenderIcons
(0x0058d180): Stage 1 builds the drag composite (base + overlay) and,
when effects != 0, ReplaceColorWhite tints it with the effect tile's
mean-opaque color (DR-1: tint SOURCE, not blit; DR-3: zero-effects
black path skipped). Stage 2 blits typeUnderlay + custom underlay +
drag into the final cached GL texture.

Both callers updated: ToolbarController Func arity widened to 6-arg
(passes item.Effects); GameWindow closure and OnLiveEntitySpawned
EnrichItem call pass spawn.UiEffects. Tree builds with 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:40:37 +02:00
Erik
3e019e408a feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers
ReplaceColorWhite (retail SurfaceWindow::ReplaceColor 0x00441530):
replaces only pure-white-opaque (RGBA 255,255,255,255) pixels in place.
TryGetEffectColor: resolves the effect tile DID via ResolveEffectDid,
decodes the RenderSurface, and returns the mean-opaque RGB as the tint
color (divergence DR-2: exact retail color byte is decompiler-ambiguous).
TryDecode: shared RenderSurface decode helper for the effect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:51 +02:00
Erik
75ac51ac23 feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)
Add effect-overlay submap resolve: EnsureEffectSubMap walks the portal
MasterMap (0x25000000) → EnumIDMap 0x10000005 → submap 0x25000009;
ResolveEffectDid(effects) maps LowestSetBit(effects)+1 → RenderSurface
DID with fallback to index 0x21. Golden test validates all 6 cases
(Magical/Poisoned/BoostHealth/BoostStamina/Nether/zero) against the
live dat. Retail ref: IconData::RenderIcons 0x0058d180.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:40 +02:00
Erik
e7b6e83cf8 feat(D.5.2): thread UiEffects through EntitySpawn + route 0x02CE PublicUpdatePropertyInt
- EntitySpawn record: add UiEffects = 0 field (after IconUnderlayId)
- EntitySpawn construction site: thread parsed.Value.UiEffects as the new tail arg
- WorldSession: declare ObjectIntPropertyUpdate payload record + ObjectIntPropertyUpdated event (after StateUpdated)
- Message loop: add else-if branch for PublicUpdatePropertyInt.Opcode (0x02CE), parses + fires ObjectIntPropertyUpdated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:33:18 +02:00
Erik
242bc9286d feat(D.5.2): PublicUpdatePropertyInt (0x02CE) parser
New standalone parser for the server's live PropertyInt update targeting
a VISIBLE object (carries guid). Wire layout: u32 opcode + u8 sequence +
u32 guid + u32 property + i32 value (17 bytes total).

The sequence byte is parsed-past but not honored (latest-wins; DR-4).
The companion PrivateUpdatePropertyInt (0x02CD) targets the player's own
object (no guid) and is not parsed here.

Three tests: uiEffectsUpdate (round-trip guid/prop/value), wrongOpcode
(returns null), truncated (returns null on 16-byte input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:29:23 +02:00
Erik
8df0b64676 feat(D.5.2): capture UiEffects from CreateObject weenie header
Previously, weenieFlags bit 0x80 (UiEffects) was read + discarded with
`pos += 4`. Now it is captured into `uiEffects` and surfaced as
`Parsed.UiEffects` — the sole wire path for the effect bitfield since
PropertyInt.UiEffects (18) has no [AssessmentProperty] and never appears
in appraise responses.

Test builder gains `uint uiEffects = 0` param; write line updated to use
it. Three new parse tests: UiEffects_Captured, UiEffectsThenIconOverlay
(cursor-arithmetic regression), and NoUiEffectsBit_LeavesUiEffectsZero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:28:31 +02:00
Erik
5a2af61508 refactor(D.5.2): hoist UiEffectsPropertyId to fields + use it in tests (review polish)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:26:28 +02:00
Erik
77f64d7925 feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:23:20 +02:00
Erik
52306d9268 docs(D.5.2): implementation plan (9 TDD tasks) + spec wiring fix
Bite-sized TDD plan for the stateful item-icon system. Corrects spec 5.8:
the live 0x02CE event binds in GameWindow (next to VitalUpdated), not
GameEventWiring (which only handles the 0xF7B0 GameEvent dispatcher).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:19:26 +02:00
Erik
419c3ac40c docs(D.5.2): stateful item-icon spec + RESOLVED research
Research basis (clean Ghidra decompile via MCP + live-dat probe + ACE oracle)
overturns two handoff hypotheses:
  - Appraise carries NO icon/UiEffects data (Icon/IconOverlay/IconUnderlay +
    PropertyInt.UiEffects all lack [AssessmentProperty]); every icon input is
    CreateObject-only. The "wire appraise -> enrichment" item is a no-op.
  - The effect overlay (enum 0x10000005) is a ReplaceColor tint SOURCE, not a
    blit layer (RenderIcons 0x0058d180 + ReplaceColor 0x00441530); effect tiles
    are 32x32 fully-opaque colored squares.

Design (user-approved): capture UiEffects (weenieFlags 0x80, currently discarded)
-> ItemInstance.Effects; faithful 2-stage IconComposer recolor (white pixels ->
effect hue); live PublicUpdatePropertyInt(0x02CE) wire-up so the icon updates as
state changes ("item with mana vs out of mana"). Drops the appraise no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:12:45 +02:00
Erik
6770381fc3 docs(D.5.2): stateful icon-system handoff + roadmap (D.5.1 shipped, D.5.2/D.5.3 next)
Extensive handoff for the next session to build the full stateful item-icon system (the 5-layer IconData::RenderIcons composite + effect layer 0x10000005 + overlay ReplaceColor tint + appraise-driven enrichment/re-composition). D.5.1 toolbar flipped to SHIPPED; D.5.2 (icon system) + D.5.3 (toolbar interactivity / selected-object display) registered as next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:24:03 +02:00
Erik
b1e45bee1c docs(D.5.1): divergence rows IA-16/IA-17 + ISSUES toolbar-interactivity entry
IA-16: partial icon composite (layers 1-4 only; effect glow + ReplaceColor tint
deferred to D.5.2). IA-17: per-window UiNineSlicePanel chrome vs window-manager-
owned bevel (retail toolbar has no baked frame in LayoutDesc 0x21000016).

#140: toolbar interactivity (selected-object meters 0x100001A1/A2 + stack slider
0x100001A4 + name line) deferred to roadmap D.5.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:21:19 +02:00
Erik
0e7a083da6 chore(D.5.1): remove temp geometry probe + add RestrictionDB-skip parse test
Task 1: remove the [D.5.1 PROBE] bottom-right rect-dump block from the
toolbar mount in GameWindow.cs. The block iterated 7 element ids and
logged ScreenPosition/Width/Height/Type; it was marked temporary and is
now superseded by the chrome window-frame fix. The kept [D.5.1] startup
diagnostic Console.WriteLines (digit arrays, toolbar ready, window from
LayoutDesc) are untouched.

Task 2: add TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured to
CreateObjectTests.cs. Exercises the variable-length RestrictionDB skip
(weenieFlags bit 0x04000000: 12-byte fixed header + 4-byte hash-table
header + count*8 entries) followed immediately by IconOverlay (0x40000000)
and IconUnderlay (weenieFlags2 0x01 via IncludesSecondHeader 0x04000000).
Proves the skip lands the cursor at the right position for both capture
fields. 301/301 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:13:46 +02:00
Erik
ceef739e1d fix(D.5.1): draw window-frame border over content (OnDrawAfterChildren)
UiNineSlicePanel drew its full chrome in OnDraw, before children, so content painted OVER the frame. The toolbar's row-2 right cap (0x100006C0, W=8) extends 2px past the 300px content and was poking over the frame's bottom-right border (the 'missing frame' the user circled). Split the panel: center fill stays in OnDraw (background, under content); the bevel border + grip move to a new UiElement.OnDrawAfterChildren hook (foreground, over content edges) so the frame is the outermost layer. Chat is unaffected (its content is inset 5px, so the border never overlaps it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:05:50 +02:00
Erik
b37db79a23 feat(D.5.1): wrap toolbar in UiNineSlicePanel chrome frame (mirrors chat window)
The toolbar LayoutDesc (0x21000016, 300x122) was mounted bare — no window
chrome. This commit wraps it in a UiNineSlicePanel (the same 8-piece bevel +
gold grip chrome used by the vitals and chat windows), matching the pattern at
GameWindow.cs ~line 1885 verbatim.

- toolbarFrame is the top-level UiNineSlicePanel (Draggable=true, Anchors=None
  per UiNineSlicePanel ctor defaults). Outer size = 310x132 (300+2*5 x 122+2*5).
- toolbarRoot sits inside at offset (5,5) — the border thickness — with all-edge
  anchors so it reflows if the frame is resized. Draggable=false, Resizable=false
  on the content (only the frame is the drag handle).
- The frame's right border (x=305..310 screen) covers the row-2 right cap
  overhang (~2px past the content edge at x=300..302), since the border region
  starts at content_right=300 and extends to frame_right=310.
- Probe block untouched: still calls toolbarLayout.FindElement for diagnostic ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:53:53 +02:00
Erik
8d49042909 fix(D.5.1): read empty-slot background digits from composite 0x10000341 (0x1000005e)
The empty/background digit array (property 0x1000005e) lives under cell composite 0x10000341, not 0x10000346 where peace/war are read; reading it from the wrong composite returned 0 entries so empty top-row slots showed no number. Live dat probe confirmed: 0x10000341 element 0x1000034A property 0x1000005e = 0x060010FA..0x06001102 (digits 1-9) + 0x060074CF (bottom row). Now empty top-row slots show the faint background number, occupied show the dark-box peace/war digit (decomp UIElement_UIItem::SetShortcutNum:229481/229493).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:34:18 +02:00
Erik
8a42066192 feat(D.5.1): parse item IconOverlay/IconUnderlay from CreateObject -> faithful icon overlay layer
The CreateObject optional-tail walker previously stopped at UseRadius (~20 fields
before IconOverlay). This left ItemInstance.IconOverlayId/IconUnderlayId always 0,
so IconComposer's underlay/overlay layers were never drawn on toolbar icons.

Exact field order verified against ACE WorldObject_Networking.cs:87-219 (the
serializer is the authority; acdream connects to a local ACE server):
  UseRadius → TargetType(u32) → UiEffects(u32) → CombatUse(sbyte) →
  Structure(u16) → MaxStructure(u16) → StackSize(u16) → MaxStackSize(u16) →
  Container(u32) → Wielder(u32) → ValidLocations(u32) →
  CurrentlyWieldedLocation(u32) → Priority(u32) → RadarBlipColor(u8) →
  RadarBehavior(u8) → PScript(u16) → Workmanship(f32) → Burden(u16) →
  Spell(u16) → HouseOwner(u32) → HouseRestrictions(variable RestrictionDB) →
  HookItemTypes(u32) → Monarch(u32) → HookType(u16) →
  IconOverlay(PackedDwordKnownType) ← CAPTURE →
  IconUnderlay from weenieFlags2 bit 0x01 ← CAPTURE

RestrictionDB handled correctly: Version(u32) + OpenStatus(u32) + MonarchId(u32)
+ count(u16) + numBuckets(u16) + count×8 bytes entries. Length-aware skip, not a
fixed constant.

weenieFlags2 is now CAPTURED (not skipped) when IncludesSecondHeader
(objDescFlags bit 0x04000000) is set, so the IconUnderlay bit can be tested.

The entire extended walk is inside try/catch: truncated packets degrade to
IconOverlayId=0 / IconUnderlayId=0 (no overlay drawn), never corrupting.

Threading: CreateObject.Parsed → WorldSession.EntitySpawn → GameWindow
OnLiveEntitySpawned → Items.EnrichItem — both ids thread through all three
seams. EnrichItem extended with optional iconOverlayId + iconUnderlayId params
(defaulted 0, backward-compatible).

No change to IconComposer or ToolbarController (they already consume the ids).

Tests: 4 new CreateObject tests (IconOverlay only, overlay+underlay, no-overlay
regression, intermediate-fields cursor arithmetic). Full suite: 0 failures,
2636 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:34:47 +02:00
Erik
a7cad5566b fix(D.5.1): occupancy-gated slot numbers (empty=0x1000005e bg digit) + bottom-right rect probe
FIX 1: UIElement_UIItem::SetShortcutNum (decomp 229481) has a three-way source
branch: occupied+peace -> 0x10000042 (peace digit set), occupied+war -> 0x10000043
(war digit set), empty (ItemId==0) -> 0x1000005e (background digit, stance-independent).
acdream previously only had the peace/war pair and drew them regardless of occupancy.

Changes:
- GameWindow.cs: read property 0x1000005e into toolbarEmptyDigits (no fallback;
  null is safe). Logs entry count. Passes emptyDigits to Bind. Adds [D.5.1 probe]
  block logging screen pos + size of 7 bottom-right element ids via ScreenPosition.
- ToolbarController.cs: adds _emptyDigits field, emptyDigits ctor+Bind param (null
  default). RestampShortcutNumbers sets cell.EmptyDigits. Comments cite decomp 229481.
- UiItemSlot.cs: adds EmptyDigits property + ActiveDigitArray() internal testable seam
  (occupied -> peace/war by stance; empty -> EmptyDigits). OnDraw uses it. Comment
  updated with three-way source table.
- Tests: 5 new UiItemSlotTests (ActiveDigitArray occupancy), 2 new
  ToolbarControllerTests (emptyDigits injection + null-safe).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:27:27 +02:00
Erik
7d5a88cd15 fix(D.5.1): toolbar digit-array log reports post-fallback final state (review) 2026-06-17 13:58:07 +02:00
Erik
b2a812d1fa feat(D.5.1): faithful toolbar slot numbers 1-9 (SetShortcutNum digit sprites, peace/war)
Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465) and
gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). The 9 top-row toolbar slots
show digit labels 1-9 at all times (even when empty — confirmed from the user''s
retail screenshot). The digit sprites are real 32x32 PFID_A8R8G8B8 RenderSurfaces
with glyphs baked into the top-left corner (rest alpha=0), drawn Alphablend over
the slot icon/empty sprite.

Digit DID arrays (peace: property 0x10000042, war: 0x10000043) are read at startup
from LayoutDesc 0x21000037 element 0x1000034A under composite 0x10000346 using the
same ArrayBaseProperty{DataIdBaseProperty} pattern as LayoutImporter.ReadState.
A cited-constant fallback (same confirmed dat ids) is used if the dat navigation
fails. The war glyph set (darker/golden glyphs) switches on any combat stance;
peace glyphs (lighter) restore on NonCombat — re-stamped by RestampShortcutNumbers()
called from both Populate() and SetCombatMode().

Changes:
- UiItemSlot: ShortcutNum/ShortcutPeace/PeaceDigits/WarDigits state; SetShortcutNum/
  ClearShortcutNum; OnDraw restructured (no early return) so digit draws after icon.
- ToolbarController: _peaceDigits/_warDigits/_peace fields; Bind() gains peaceDigits/
  warDigits optional params; RestampShortcutNumbers() helper; Populate() and
  SetCombatMode() both call RestampShortcutNumbers().
- GameWindow: reads digit arrays under _datLock from LayoutDesc 0x21000037, passes to
  Bind(); cited constants as fallback.
- Tests: 5 new UiItemSlotTests (SetShortcutNum/ClearShortcutNum state); 4 new
  ToolbarControllerTests (top-row/bottom-row labels, peace/war switch, array injection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:52:50 +02:00
Erik
f21dbfad80 feat(D.5.1): faithful item-icon type-default underlay (EnumIDMap 0x10000004) — opaque icon backing
Retail IconData::RenderIcons (decomp 407524) builds the icon layer stack bottom→top:
type-default underlay (OPAQUE, Blit_Normal) first, then custom underlay, base icon,
custom overlay.  acdream's IconComposer omitted the type-default underlay, leaving
filled toolbar slots with a transparent background.

Resolution via the two-level EnumIDMap chain that retail uses (DBCache::GetDIDFromEnum
0x413940): Portal.Header.MasterMapId (0x25000000) → master[0x10000004] → submap DID
(0x25000008) → submap[LSB(itemType)+1] → 0x06 RenderSurface underlay DID.  Golden
values confirmed against the live dats: MeleeWeapon→0x060011CB, Armor→0x060011CF,
Clothing→0x060011F3, Jewelry→0x060011D5, None(fallback 0x21)→0x060011D4.

Changes:
- IconComposer: add ResolveUnderlayDid(ItemType)/EnsureUnderlaySubMap (memoised);
  widen cache key from (uint,uint,uint)→(uint,uint,uint,uint); GetIcon gains ItemType
  param and prepends the opaque underlay as layer 0 (Compose sizes to it → fully opaque)
- ToolbarController: widen _iconIds Func from 3-arg to 4-arg; Populate passes item.Type
- GameWindow: update toolbar mount lambda to 4-arg form
- Tests: update ToolbarController test stubs to (_,_,_,_); add
  Compose_opaqueUnderlayFirst_resultIsFullyOpaque (dat-free) and
  ResolveUnderlayDid_goldenValues_matchDat (dat-gated, skip when dats absent)

No divergence-register row existed for this omission; none added (fully ported now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:37:53 +02:00
Erik
bfc452d610 fix(D.5.1): toolbar movable + chrome-grab + peace-only indicator + no prototype square
D1 — Toolbar not movable: toolbarRoot.Anchors = AnchorEdges.None (was Left|Top)
so ApplyAnchor early-returns and doesn't re-pin the window every frame.
Matches the vitalsRoot idiom exactly.

D2 — Cannot grab toolbar by chrome: toolbarRoot.ClickThrough = false
so HitTest succeeds over the UiDatElement chrome and the drag starts.
UiDatElement ctor defaults ClickThrough=true; vitalsRoot already overrides it.

C1 — All four combat-mode indicators visible at once (war/flame stacked on
peace): ports gmToolbarUI::RecvNotice_SetCombatMode
(acclient_2013_pseudo_c.txt:196632-196669). CombatIndicatorIds[] maps
index 0-3 to NonCombat/Melee/Missile/Magic; SetCombatMode shows exactly one
and hides the other three. Default to NonCombat at bind (player always
spawns in peace). Wires CombatState.CombatModeChanged for live updates.
Tests: CombatIndicator_defaultNonCombat_onlyPeaceVisible,
CombatIndicator_setCombatModeMelee_onlyMeleeVisible,
CombatIndicator_liveSignal_updatesWhenCombatStateChanges.

V1 — Blue empty-slot square at top-left (prototype 0x100001B2 materialized):
ImportInfos now skips top-level elements that are (a) referenced as a
BaseElement by another element in the same layout AND (b) have no own state
media. The CollectBaseRefsInDesc walk covers nested children; HasNoOwnMedia
re-uses ToInfo's media extraction. The Resolve path reads BaseElement from the
raw dat via dats.Get<LayoutDesc> — it never depends on the prototype being in
the built widget tree — so the skip is safe. Conformance tests (vitals, chat)
are unaffected (they exercise Build, not ImportInfos).
Test: BuildFromInfos_PrototypeSkipped_DerivedPresent_PrototypeAbsent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:03:07 +02:00
Erik
b3e5e8b0f7 fix(D.5.1): toolbar use-item gates on in-world + logs; store controller field (review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:52:28 +02:00
Erik
3b6f293dc8 feat(D.5.1): mount the toolbar window under ACDREAM_RETAIL_UI
Wire IconComposer + ToolbarController.Bind + the LayoutDesc 0x21000016
import into the if (_options.RetailUi) block in GameWindow, mirroring
the vitals/chat pattern. Add UseItemByGuid helper (direct send, no
proximity gate) near the B.4b use-item path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:44:52 +02:00
Erik
383a969c70 feat(D.5.1): ToolbarController — bind 18 slots, populate, deferred rebind, click-to-use
Port of gmToolbarUI::PostInit (slot wiring) + UpdateFromPlayerDesc (flush-and-bind
shortcuts from PlayerDescription) + SetDelayedShortcutNum (deferred ItemAdded rebind)
+ UseShortcut (click → useItem callback).

UiItemSlot gains Clicked (Action?) + OnEvent override (MouseDown → Clicked?.Invoke())
matching the retail UIElement_UIItem click dispatch pattern. UiEvent is a positional
record struct so the OnEvent override reads e.Type (int) against UiEventType.MouseDown
(const int 0x201) — confirmed from UiEvent.cs + UiText.cs before writing.

Three tests green (populate bound slot, deferred rebind on ItemAdded, click fires useItem).
Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:36:48 +02:00
Erik
9327fb64bf fix(D.5.1): UiItemList.Cell guards empty list with a diagnostic (review) 2026-06-16 22:32:53 +02:00
Erik
9c8db0d577 feat(D.5.1): UiItemList widget + factory branch for class 0x10000031
Ports retail UIElement_ItemList (class 0x10000031) as a behavioral-leaf
container that owns its UiItemSlot children procedurally. Single-cell
default covers every toolbar slot; N-cell grid is deferred to the inventory
phase. OnDraw syncs the cell rect to the list's Width/Height each frame so
the cell is sized and hit-testable from the first rendered frame, even
though the factory sets rect AFTER construction.

Factory: adds `0x10000031u => new UiItemList(resolve)` arm before the
fallback, so all 18 toolbar itemlist slots route to UiItemList instead of
UiDatElement.

Tests: 4 new (IsLeafWidget, StartsWithOneCell, Cell_returnsFirstSlot,
Create_buildsUiItemList_forItemListClassId). All 4 pass; full suite green
(415 pass / 2 skip in App.Tests; 0 fail total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:28:37 +02:00
Erik
28d5837309 test(D.5.1): cover UiItemSlot.Clear (review — hot path in ToolbarController) 2026-06-16 22:25:59 +02:00
Erik
1270596f30 feat(D.5.1): UiItemSlot widget (UIElement_UIItem cell port)
Behavioral leaf widget for the toolbar item cell. Draws the empty-slot
sprite (0x060074CF) when unbound; draws the pre-composited icon texture
when a weenie is bound via SetItem(). ConsumesDatChildren=true prevents
the LayoutImporter from double-building the dat sub-elements. SpriteResolve
is configurable so paperdoll equip slots can swap in per-slot silhouettes
later. No Clicked/OnEvent — that wiring comes in Task 8 (ToolbarController).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:21:21 +02:00
Erik
e9a5248972 fix(D.5.1): dispose IconComposer + RenderSurface GL handles (review)
Two GL texture leaks plugged, both found in code review of 6e82807:

1. _handlesByRenderSurfaceId (pre-existing gap): populated by
   GetOrUploadRenderSurface for UI sprites but absent from Dispose's
   Phase 3 sweep. Added foreach/_gl.DeleteTexture/Clear in Dispose.

2. _adhocHandles (new): the public UploadRgba8(byte[],int,int,bool)
   wrapper used by IconComposer stored composited icon handles nowhere,
   so they leaked. Added _adhocHandles list; wrapper now appends the
   returned GL name before returning. Dispose sweeps + clears the list.
   Tracking is intentionally in the PUBLIC wrapper only — the private
   UploadRgba8(DecodedTexture,bool) is shared by all keyed-cache paths
   and tracking there would cause double-deletes.

No behavior change to icon rendering. No GL-context unit test added
(no context in test projects); correctness is by-inspection + green
suite (2598 passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:18:20 +02:00
Erik
6e82807863 feat(D.5.1): IconComposer — CPU alpha-over icon composite + cache
Adds IconComposer (AcDream.App.UI) which mirrors retail IconData::RenderIcons
(decomp 407524): decodes each RenderSurface layer directly via SurfaceDecoder,
composites them bottom-to-top with Porter-Duff alpha-over, and uploads the
result to a GL texture via TextureCache. Composited handles are keyed by the
(iconId, underlayId, overlayId) tuple so each unique combo is uploaded once.

Adds a public TextureCache.UploadRgba8(byte[], int, int, bool) wrapper — a
thin shell around the existing private overload — so IconComposer can upload
its CPU-side composite without duplicating any GL state logic.

Pure Compose() path is covered by 2 unit tests (opaque top wins; transparent
top preserves bottom). Dat-decode + GL-upload exercised by the visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:09:41 +02:00
Erik
6c485c2f06 feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded)
Add optional `onShortcuts` callback to `GameEventWiring.WireAll`; invoke
it with `parsed.Shortcuts` after the inventory/equipped loops in the
PlayerDescription handler.  `GameWindow` holds the list in a new
`Shortcuts` property (initialized to empty) so the toolbar (D.5.1 Task 5)
can read hotbar slots without keeping a parser reference.  Existing callers
compile unchanged — the parameter defaults to null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:01:40 +02:00
Erik
5382d0a9d2 feat(D.5.1): thread CreateObject IconId into ItemRepository via spawn event
Added uint IconId = 0 (defaulted, last positional param) to the EntitySpawn
record so existing call sites outside WorldSession compile unchanged. The
WorldSession invoke now passes parsed.Value.IconId as the final arg.
OnLiveEntitySpawned calls Items.EnrichItem unconditionally — it's a no-op
for non-item spawns (players/NPCs/furniture aren't in the repo), so the call
is safe for every incoming CreateObject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:54:48 +02:00
Erik
998a0bd408 docs(D.5.1): clarify EnrichItem fires-on-found (review nit) 2026-06-16 21:52:15 +02:00
Erik
f8da98b67f feat(D.5.1): ItemRepository.EnrichItem (icon/name/type from CreateObject)
Adds EnrichItem(objectId, iconId, name, type) — enriches an existing
stub created from PlayerDescription with the fuller data carried by its
CreateObject message. Returns false when the item isn't tracked yet
(phase 1: enrich-existing only). Raises ItemPropertiesUpdated on success
so bound widgets (the toolbar) re-render.

Two xUnit tests: enrich-existing updates IconId/Name/raises event (true),
unknown-id returns false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:48:44 +02:00
Erik
da171cb4e3 feat(D.5.1): capture IconId in CreateObject.Parsed (was discarded at cs:516)
ReadPackedDwordOfKnownType at the old line 516 was throwing the icon dat
id away. Declare iconId before the try-block, assign it there, and pass
IconId: iconId in the Parsed initializer so downstream UI (action bar /
equipment panels) can read the 0x06xxxxxx dat id without a separate lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:44:01 +02:00
Erik
30b28c248a docs(D.5.1): register toolbar phase-1 in the roadmap 2026-06-16 21:40:46 +02:00
Erik
44fabd350e docs(D.5.1): toolbar phase-1 implementation plan (+ spec wiring-delta note)
12-task TDD plan: register D.5.1 -> CreateObject IconId capture -> ItemRepository.EnrichItem -> spawn-event icon wiring -> persist shortcuts -> IconComposer (CPU composite) -> UiItemSlot -> UiItemList + factory branch -> ToolbarController -> GameWindow mount -> visual gate -> bookkeeping. Concrete call sites pinned (WorldSession.cs:701 EntitySpawned, GameEventWiring.WireAll, GameWindow Items@598, BuildUse 0x0036). Synced the spec's CreateObject section with the wider-than-expected wiring found during planning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:27:49 +02:00
Erik
0b5e849325 docs(D.5.1): toolbar phase-1 design spec
First D.5 sub-phase: ship gmToolbarUI as the first data-driven game panel (18 slots from LayoutDesc 0x21000016, populated from the persisted PlayerDescription shortcuts, real composited icons, click-to-use). Minimal scope; faithful CPU icon pre-composite (IconData::RenderIcons port). Five bounded units: UiItemSlot, UiItemList, IconComposer, CreateObject IconId extension, ToolbarController. Roadmap registration of D.5.1 is plan step 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:05:04 +02:00
Erik
a5c5126e8d docs(D.5): action bar / inventory / paperdoll research drop
Five report-only deep-dives + synthesis for the next D.2b UI panels, built on the shipped widget toolkit. Confirms LayoutDesc ids (toolbar 0x21000016, inventory 0x21000023, backpack 0x21000022, paperdoll 0x21000024, 3ditems 0x21000021), the shared item-slot/item-list spine (UIElement_UIItem 0x10000032 / UIElement_ItemList 0x10000031), the 5-layer icon composite (IconData::RenderIcons @407524), the cross-panel wire catalog with acdream parse-status, and the dependency-ordered build plan.

Produced via a multi-agent research workflow; the spine agent died on a transient API error and was re-run as a focused follow-up with its decomp anchors verified against source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:04:57 +02:00
Erik
37911ed510 merge: A7 lighting (Fix A point-light shape + Fix B per-object selection) into main
Brings the worktree branch claude/thirsty-goldberg-51bb9b into main:
- aa94ced  per-vertex Gouraud + faithful calc_point_light (wrap + norm)
- 4345e77  per-OBJECT point-light selection (minimize_object_lighting)

Auto-merged cleanly against the D.2b retail-UI line (only GameWindow.cs
overlapped, resolved by git). Merged tree builds green; 35/35 Core lighting
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:50:22 +02:00
Erik
78c91875b8 docs: file #139 — D.2b retail UI polish (chat text colors + buttons)
Deferred cosmetic polish after the widget-generalization landing: tune the
per-ChatKind transcript text colors against retail, and add pressed/hover state
feedback to the chat buttons (UiButton draws only its default state today; the
dat carries Normal/Pressed/Highlight). Not a regression — the generalized chat
matches the prior hand-made build (user-confirmed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:01:50 +02:00
Erik
9e4faae9d2 docs(D.2b): roadmap — widget generalization (Plan 2) shipped
Record the D.2b widget-generalization landing: generic Type-registered widgets
built by DatWidgetFactory, thin find-by-id controllers, the ConsumesDatChildren
leaf rule, Type-3-not-registered decision, and the centered-UiText vitals numbers.
Both visual gates user-confirmed; 404 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:55:06 +02:00
Erik
89626cd400 feat(D.2b): vitals numbers as UiText (widget-generalization Task 8)
The vitals cur/max numbers now render through the generic UiText widget — retail
gmVitalsUI uses UIElement_Text for them, not a meter-internal label. VitalsController
attaches a centered, non-interactive UiText child to each meter and stops the meter
drawing its own label (UiMeter.Label -> null). New UiText.Centered draws the first line
centered H+V with the SAME formula UiMeter's overlay used, so the numbers are
pixel-identical — user-confirmed in the live client.

This completes the D.2b widget-generalization pass: every chat + vitals widget is now
built generically and registered to its retail Type (Button/Field*/Menu/Meter/Scrollbar/
Text), with thin find-by-id controllers. (*Field is controller-placed; Type 3 stays
UiDatElement for chrome.)

Divergence register: AP-37 vitals-numbers-via-UiMeter.Label clause retired. Full suite:
404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:52:42 +02:00
Erik
d7002552bc fix(D.2b): behavioral widgets are leaf — ConsumesDatChildren (chat menu open)
The generalized channel menu wouldn't open: the factory recursed the Type-6
menu element's dat children, building its invisible Type-12 label child as a
UiText. Hit-testing is children-first and UiText consumes MouseDown (selection),
so the label child swallowed the menu button click and the dropdown never opened.
The transcript similarly gained an invisible Ghosted-button child (a 16x16
selection dead-zone). The old hand-made build never had these — it skipped Type 12
and hand-placed the widgets with no children.

Fix: behavioral widgets (Meter/Menu/Button/Scrollbar/Text/Field) draw their full
appearance and reproduce their dat sub-elements procedurally, so they are LEAF —
the importer must not build their dat children as separate (click-stealing)
widgets. Add UiElement.ConsumesDatChildren (default false; the 6 behavioral
widgets override true) and gate LayoutImporter recursion on it (replacing the
UiMeter-only special case). Only generic containers (UiDatElement, panels) recurse.

Visually confirmed in the live client (channel menu opens; General/Trade selected
and sent). Vitals unchanged (UiMeter was already leaf). Full suite: 404 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:36:40 +02:00
Erik
83076cdbb6 docs(D.2b): spec correction — input is Variant B, Type 3 not registered
Record the two execution-time corrections to the design's registration
assumptions: the editable input resolves to Type 12 (Variant B, controller-placed
UiField), and Type 3 is NOT factory-registered (acdream's Type-3 elements are
chrome/containers, kept on the UiDatElement fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:54:52 +02:00
Erik
ee2e0fafa0 fix(D.2b): do NOT register Type 3 -> UiField (review fix for Task 6)
Task 6 registered Type 3 -> UiField globally, which broke acdream's Type-3 dat
elements: in these layouts Type 3 is sprite-bearing CHROME (the 8-piece bevel
corners, e.g. vitals 0x10000633 -> sprite 0x060074C3) and the transcript/input
CONTAINER panels — NOT editable fields. UiField draws no dat sprite, so the
vitals bevel corners would render empty; the regression was masked by weakening
VitalsTree_ChromeCornerHasExpectedSprite (UiDatElement+sprite -> UiField+exists).

Retail Type 3 IS UIElement_Field, but retail draws those chrome elements as inert
media-bearing Fields, which our UiDatElement reproduces pixel-for-pixel without a
spurious focus/edit affordance. The one true editable field — the chat input
0x10000016 — resolves to Type 12 and is controller-placed as a UiField (Variant B,
kept). So Type 3 stays on the generic fallback; register it as UiField only when a
window carries a factory-built editable Type-3 field (and UiField grows a
background-media draw + an opt-in editable flag then).

Restored the chrome-corner conformance test (asserts UiDatElement + sprite, an
early warning if Type 3 is ever wrongly routed to UiField). Kept the good Task-6
work: UiField rename + the Variant-B input wiring (stray Type-12 placeholder
removed). Full suite: 404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:53:56 +02:00
Erik
e059a3f6ef feat(D.2b): UiField (Type 3) — editable input as a generic field; remove the stray Type-12 input placeholder (widget-generalization Task 6)
- Rename UiChatInput → UiField (UIElement_Field, RegisterElementClass(3) @ :126190);
  update doc to cite retail's CatchDroppedItem/MouseOverTop drag-drop hooks for
  future item windows. BackgroundColor default → transparent (controller sets
  the translucent 0.35α value explicitly, matching UiText pattern).
- Register Type 3 in DatWidgetFactory.Create: `3 => new UiField()`.
- ChatWindowController.Bind (Variant B): factory now builds 0x10000016 as an
  invisible UiText placeholder (Type 12); Bind removes that placeholder via
  FindElement(InputId).Parent.RemoveChild and places a UiField at the same rect.
  Result: exactly ONE input widget in the input bar, no stray UiText duplicate.
- Input property type changed from UiChatInput to UiField; GameWindow.cs:1861
  UiField.Keyboard assignment compiles unchanged (field exists).
- Tests: UiChatInputTests → UiFieldTests (class + all ctor refs renamed);
  DatWidgetFactoryTests: new Type3_Field_MakesUiField test; ChatWindowControllerTests:
  updated stale "skipped by factory" comments; LayoutConformanceTests: updated
  VitalsTree_ChromeCornerHasExpectedSprite — Type-3 chrome-corner elements are
  now UiField (sprite rendering for Type-3 dat image elements is a known
  limitation, tracked for post-Task-8 UiField.BackgroundSprite follow-up).
- Full suite: 404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:48:51 +02:00
Erik
cb082b59e4 feat(D.2b): UiText (Type 12) -- generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)
Rename UiChatView -> UiText (the retail UIElement_Text class,
RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655).

Factory changes (DatWidgetFactory.cs):
- Remove the Type-12 skip (was: no-media -> null, with-media -> UiDatElement).
- Add Type 12 -> BuildText() -> UiText in the switch.
- BuildText extracts the element's Direct/Normal sprite as BackgroundSprite
  so any dat-media the element carried keeps rendering under the text.

UiText changes (renamed from UiChatView.cs):
- BackgroundColor default: (0,0,0,0.35) -> (0,0,0,0) (transparent).
  An unbound UiText draws nothing; the controller opts in to the translucent bg.
- New BackgroundSprite + SpriteResolve: optional dat state-sprite background
  drawn UNDER DrawFill+text (faithful UIElement_Text media support).

ChatWindowController.cs (Task 5 Step 8):
- Transcript property: UiChatView -> UiText.
- Bind() now uses layout.FindElement(TranscriptId) as UiText (factory-built)
  instead of manually constructing + AddChild-ing a new UiChatView.
- Sets BackgroundColor = (0,0,0,0.35) on the found widget (retail translucent bg).
- Removes the tInfo null-check from the early guard (transcript is factory-built;
  iInfo lookup kept for the input widget which is still manually constructed).
- BuildLines: UiChatView.Line -> UiText.Line throughout.

Vitals frozen: the Type-12 vitals number elements are meter children and are
never recursed by BuildWidget (the `if (w is not UiMeter)` gate), so they are
not built as widgets and keep rendering via UiMeter.Label. Vitals fixture
vitals_2100006C.json unchanged; LayoutConformanceTests + VitalsBindingTests green.

Tests:
- UiChatViewTests.cs -> UiTextTests.cs (class: UiTextTests, all UiChatView.* -> UiText.*)
- UiChatViewDatFontTests.cs -> UiTextDatFontTests.cs (same)
- DatWidgetFactoryTests: delete Type12_StylePrototype_ReturnsNull +
  DatWidgetFactory_Type12WithMedia_Renders; add Type12_Text_MakesUiText +
  DatWidgetFactory_Type12_AlwaysMakesUiText.
- LayoutImporterTests: BuildFromInfos_Type12Child_IsSkipped_Type3Present updated
  to assert IsType<UiText> (element is now in tree, transparent, not skipped).

Divergence register: AP-37 amended -- removed the "standalone Type-0 text
elements skipped / dat-text widget is Plan 2" clause (now shipped as UiText);
kept the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause.
AP-38/AP-39/AD-28 file references updated UiChatView.cs -> UiText.cs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:39:02 +02:00
Erik
67e5b8cff2 fix(D.2b): UiMenu — controller owns Selected (review fix for Task 4)
Review caught a behavior divergence: the generic UiMenu auto-set its own
Selected on any enabled pick, while the controller's EnabledProvider keeps the
null-payload specials (Squelch / Tell-to-Selected) enabled/white like retail.
So a special-item click set Selected=null and shifted the highlight onto the
deferred placeholders — and the menu tests masked it by using a different
(specials-disabled) gate than the controller ships.

Fix: clean MVC contract mirroring retail UIElement_Menu::NewSelection — the
widget REPORTS the pick via OnSelect; the controller OWNS Selected (it sets it
only for talk-channel payloads). A special-item click now fires OnSelect(null),
the controller ignores it, and the active channel + highlight stay put —
observably identical to the pre-generalization widget, and extensible for when
Squelch lands. Tests realigned to the controller's gate (specials white) and to
the controller-owns-Selected contract.

Full suite: 403 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:27:30 +02:00
Erik
955f7a69a8 feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)
UiChannelMenu → UiMenu: removed ChatChannelKind, the 14-item array, the
button-text map, and the availability default. Generic surface: MenuItem
(label + object? Payload), Selected (object?), OnSelect, EnabledProvider,
ButtonLabelProvider, RowsPerColumn/RowHeight/ColumnWidth (all settable).
All draw/event mechanics unchanged — same popup geometry, same click
coordinates, same 8-piece bevel, same 3-slice button face.

ChatWindowController gains ChannelItems[], ChannelButtonLabel(), and
ChannelAvailable() (verbatim from old widget), and populates the
factory-built Type-6 UiMenu via find-by-id rather than constructing a
replacement widget. The Menu property type is now UiMenu. OnChannelChanged
wrap replaced with the generic OnSelect wrap for the ReflowInputRow hook.

DatWidgetFactory registers Type 6 → new UiMenu().

Tests: UiChannelMenuTests → UiMenuTests (10 tests, all green); factory
Type6 test added; ChatWindowControllerTests updated to use OnSelect.
Divergence register: AP-42 added (flat item model vs retail nested-submenu
MakePopup @0x46d310 — latent, unreachable through the chat menu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:18:27 +02:00
Erik
805ab5f40b feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)
Introduces UiButton: a dedicated dat-widget button that ports UIElement_Button
(RegisterElementClass(1,...) @ acclient_2013_pseudo_c.txt:125828). State selection,
tiled DrawSprite, and label rendering mirror UiDatElement exactly so the chat Send
and Max/Min buttons have zero behavioral change.

DatWidgetFactory now maps Type 1 → UiButton (beside Type 7 → UiMeter, Type 11 →
UiScrollbar). ChatWindowController's Send and Max/Min bind blocks updated from
UiDatElement casts to UiButton casts; ClickThrough=false lines dropped (UiButton
is interactive by construction).

The old UiPanel.cs UiButton (a plain dev-scaffold rect+text button with no dat
sprites) is renamed UiSimpleButton to free the name — no production code
instantiated it.

Full suite: 402 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:07:58 +02:00
Erik
3593d6623d feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)
- git mv UiChatScrollbar.cs → UiScrollbar.cs; rename class + update doc summary to
  "Generic scrollbar. Ports retail UIElement_Scrollbar (RegisterElementClass(0xb) @
  acclient_2013_pseudo_c.txt:124137); thumb size = trackLen * ThumbRatio (min 8px); step ±1 line."
- git mv UiChatScrollbarTests.cs → UiScrollbarTests.cs; rename test class + replace
  every UiChatScrollbar reference with UiScrollbar (bodies unchanged).
- DatWidgetFactory: register Type 11 → new UiScrollbar() before the _ fallback case.
- ChatWindowController: change Scrollbar property type to UiScrollbar; replace the old
  "construct-remove-add" block with a "find factory-built UiScrollbar and bind in place"
  block (no RemoveChild/AddChild); keep `var track` assignment in scope so the Max/Min
  block's track.Left/track.Width reads still compile against UiElement?.
- AP-41 divergence register: update file:line to UiScrollbar.cs:35; narrow wording to
  "fallback only — single-tile drawn only when cap ids are unset; the chat controller
  passes all three cap ids so the 3-slice path is the active code path."
- Update inline UiChatScrollbar doc-comment references in UiScrollable.cs + UiChatView.cs.
- Full suite: 399 passed, 2 skipped (dat/tower fixture skips), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:02:49 +02:00
Erik
d1b13a7dbf test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)
- Add ChatLayoutFixtureGenerator.cs (Skip-by-default) to regenerate
  chat_21000006.json from the live portal.dat via LayoutImporter.ImportInfos
- Commit generated fixture chat_21000006.json (13 KB, 400 lines) — dat-free,
  auto-copied to test output via existing *.json csproj glob
- Refactor FixtureLoader: extract shared LoadInfos(fileName) helper; add
  LoadChat() + LoadChatInfos() mirroring the vitals pattern; LoadVitalsInfos()
  now delegates to the shared loader (behavior unchanged, vitals tests green)
- Add ChatLayoutConformanceTests: ResolvesKnownElements + ResolvedTypes_MatchRetailRegistry

Confirmed resolved Types from live dat:
  0x10000011 (transcript) → Type 12 (style-prototype, skipped by factory)
  0x10000016 (input)      → Type 12 (style-prototype, skipped by factory)
  0x10000014 (menu)       → Type 6
  0x10000012 (scrollbar)  → Type 11
  0x10000019 (send)       → Type 1
  0x1000046F (max/min)    → Type 1

Also fix pre-existing build break: UiChatInput.MoveCaret(int delta) was made
private in ce848c1 but UiChatInputTests.Backspace_DeletesBeforeCaret called it
as public. Expose a public MoveCaret(int) overload (no-shift) alongside the
private MoveCaret(int,bool) — restores the intended test surface.

Full suite: 398 passed, 2 skipped (generator + pre-existing), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:55:51 +02:00
Erik
34e79096f3 docs(D.2b): widget-generalization implementation plan
8-task TDD plan: chat golden fixture + resolved-Type conformance (Task 1,
empirically resolves the input's Type), then one-widget-per-commit migration —
UiScrollbar(11), UiButton(1), UiMenu(6), UiText(12)+the Type-12 flip,
UiField(3) — then thin the controller (Task 7, visual gate) and the gated
vitals UiText rewire (Task 8). Each task: failing test, register in the
factory switch, controller find-by-id binding, build+test green, commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:47:32 +02:00
Erik
56f5bc7aa1 docs(D.2b): add strategic-purpose section to widget-generalization design
Capture the 'why beyond chat' the user articulated: chat is the proving ground;
the real payoff is inventory/spell-bar/vendor/character-sheet/trade becoming
data-driven assembly + thin controller. Notes what carries forward (the generic
widget toolkit + the find-by-id controller pattern) vs what those windows still
need (ListBox/Panel + Field drag-drop, the window-manager half of Plan 2, and
per-domain item/container data).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:33:14 +02:00
Erik
b7f7e2b4ef docs(D.2b): widget-generalization design (Plan 2 widget piece)
Design for refactoring the hand-named chat widgets + Send/MaxMin click-wiring
into generic, Type-registered widgets built by DatWidgetFactory, collapsing
ChatWindowController (and, gated-last, VitalsController) to a thin retail
gm*UI::PostInit-style find-by-id binder.

Key finding that reframes the pass: the importer's base-chain Type resolution
is already retail-faithful, and Type 12 is UIElement_Text (a real behavioral
class), not a style prototype to skip — verified against
acclient_2013_pseudo_c.txt:115655. The generalization is therefore a
registration task (register Types 1/3/6/11/12 -> generic widgets, delete the
Type-12 skip), not a new mechanism.

Approved scope: full registry (bounded to the Types chat+vitals use; rest stays
UiDatElement fallback), chat-first, vitals rewire as the final separately-gated
step. 7-step one-widget-per-commit migration; new chat_21000006.json golden
fixture; vitals fixture stays frozen through steps 1-6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:26:32 +02:00
Erik
fed838847b chore(cli): dump-font-atlas tool for headless font inspection
A `dump-font-atlas` subcommand renders a dat Font's fg/bg atlases (alpha as
luminance) plus a sample string composited exactly the way DrawStringDat does
it (outline + fill, integer-snapped). Used to reproduce the glyph-baseline
jitter offline (fractional-origin bug vs the fix) without launching the client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:37 +02:00
Erik
ce848c154d feat(D.2b): chat wiring — menu/input sprites, button reflow, char-wrap, panel wash fix
- ChatWindowController: wires the menu chrome (popup bevel, row/checkbox
  sprites), the input focused-field sprite + keyboard, and autosizes the channel
  button + reflows the input field to start after it (anchor re-capture so the
  per-frame layout doesn't fight it). DefaultTextInput / write-mode focus hooked
  up.
- WrapText now breaks an over-long UNBROKEN token at character boundaries (no
  hyphen), packed onto the current line first — so a spaceless token wraps
  instead of overflowing, and a "You say," prefix stays on the same row as the
  start of the message.
- UiChatView: transcript background + selection highlight use DrawFill (sprite
  bucket) so the transcript text draws ON TOP instead of being dimmed by its own
  translucent rect background.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:30 +02:00
Erik
2284a376ae feat(D.2b): write-mode movement gate that preserves autorun
In chat write mode the keyboard belongs to the input — typing "swd" must not
walk the character — but AUTORUN must keep going (the user can chat while
running).

- InputDispatcher.IsActionHeld now returns false while WantCaptureKeyboard is
  set (a focused chat input), the polling-path twin of the existing gate on
  Fired actions. This SUPERSEDES the old per-frame OnUpdate early-return, which
  also killed autorun. Gating here instead lets the movement block keep running,
  so autorun — a separate latched bool ORed into Forward at the call site, not a
  polled key — survives. Test updated to encode the new contract.
- GameWindow: the movement suppress-guard reverts to ImGui-devtools-only (the
  retail write mode no longer early-returns); wires DefaultTextInput = the chat
  input (Tab/Enter activation) and Input.Keyboard for clipboard. Drops the
  one-shot UI-scale diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:19 +02:00
Erik
367a752078 feat(D.2b): chat input — write mode, selection, clipboard, key-repeat, scroll-clip
Port the retail UIElement_Text editable single-line field:

- Focused = "write mode": draws the gold lit field sprite (0x060011AB, the
  Normal_focussed state) instead of the flat translucent rect; Enter submits
  AND blurs (exits write mode).
- Single-line SELECTION: click-drag, Shift+Arrows, Shift+Home/End, Ctrl+A;
  translucent-blue highlight behind the span; typing/Backspace/Delete/Paste
  replace the selection first.
- CLIPBOARD: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the caret (control chars
  stripped — single-line). Wired to the keyboard device for clipboard + Ctrl/
  Shift state.
- Held-key AUTO-REPEAT (Silk delivers one KeyDown per press): Backspace /
  Delete / Left / Right repeat via a 0.4s-delay, ~25/s OnTick timer.
- Horizontal SCROLL + clip: keeps the caret in the field and draws only the
  glyph window that fits inside it, so long input scrolls within the box
  instead of spilling past Send into the 3D world.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:09 +02:00
Erik
260507e33c feat(D.2b): channel menu — retail colors, 8-piece border, checkbox align, autosize button
Match the talk-focus menu + button to retail (decomp-verified):

- Menu item text is FILL-ONLY (retail UIElement_Text outlines only when
  SetOutline(true); the talk-focus items don't) — kills the grey halo. Available
  items render white; UNAVAILABLE items render grey (not the salmon colorPink,
  which is a chat-MESSAGE color we'd misapplied). Special items (Squelch /
  Tell-to-Selected) render white. Labels indent past the baked checkbox in the
  row sprite (0600124E empty box / 0600124D white checkmark) instead of
  overlapping it.
- The popup is wrapped in the universal 8-piece window bevel (the menu sprite
  family has no border) and draws in OnDrawOverlay so the translucent chat
  panel no longer greys its right column.
- The button face (0600124D/66, a fixed 46px LED+arrow sprite) is now 3-sliced
  (LED cap / stretch / arrow cap) and autosizes to its label via
  NaturalButtonWidth, so "Chat" fits in the body instead of running into the
  arrow. The status LED (red Normal / green Pressed) is no longer overdrawn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:23:59 +02:00
Erik
ebfeaff840 feat(D.2b): UI render infra — overlay layer, DrawFill, crisp text, write-mode focus
The retail-look render + focus primitives this chat pass builds on:

- TextRenderer: an OVERLAY layer (sprite/rect/text buckets flushed AFTER the
  normal layer) so an open popup composites on top of everything incl. rect
  panel backgrounds; a DrawFill primitive (solid quad via a 1x1 white texture)
  routed through the SPRITE bucket so a panel background draws UNDER its text
  instead of being washed by the later rect bucket; and the text pass now
  disables SampleAlphaToCoverage + Multisample so glyph alpha edges aren't
  dithered into MSAA coverage (the "fuzzy text") — self-contained GL state
  per feedback_render_self_contained_gl_state.
- UiRenderContext.DrawStringDat: snap the line baseline to a whole pixel ONCE
  then add the integer per-glyph offset (retail DrawCharacter takes an int
  pen-Y + schar m_VerticalOffsetBefore) — fixes the "letters dip down" jitter
  at a fractional line origin. Outline pass is now opt-in (retail gates it per
  element via SetOutline; default off = crisp fill-only). Adds DrawFill +
  Begin/EndOverlayLayer.
- UiElement: OnDrawOverlay + DrawOverlays (second traversal), FindRoot (blur
  self), ResetAnchorCapture (re-baseline an anchored element after reflow).
- UiRoot: runs the overlay pass after the main tree; Tab/Enter focuses the
  DefaultTextInput (write-mode activation); a left click on a non-edit target
  blurs the focused input (exit write mode without submitting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:23:48 +02:00
Erik
828bec5fb5 @
fix(D.2b): point-sample the dat-font atlas so UI text is pixel-crisp

The font glyph atlas was uploaded with bilinear (Linear) min/mag filtering, which
softens the small dat-font glyphs (the menu/button text "blur"). Add a nearest-filter
path to UploadRgba8/GetOrUploadRenderSurface and use it for the font atlases only
(world + other UI surfaces keep bilinear). Combined with the existing per-glyph
pixel-snap, glyph texels now map 1:1 to screen pixels. Sharpens all dat-font text
(transcript, menu, Send/Chat buttons, vitals numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 12:02:07 +02:00
Erik
621a4ab468 @
fix(D.2b): arrow swap, centered menu text, scrollbar-to-top, Send caption

- scroll arrows: native sprites are opposite (0x06004C6C up / 0x06004C69 down) per live
  visual — swap the assignment, drop the V-flip.
- menu labels centered vertically in each 17px row (was top-aligned, looked corrupt).
- scrollbar pulled up to the panel top so the top arrow meets the window border and the
  max/min button lines up with it (the 6px dat offset left a gap after the resize-bar reclaim).
- Send button: the dat sprite 0x06001915 is a blank gold frame (export-confirmed), so add a
  generic optional Label/LabelFont to UiDatElement and draw "Send" centered on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 11:56:07 +02:00
Erik
bb983ae850 @
feat(D.2b): data-driven channel menu chrome + greying + scroll-arrow fix

Investigation found the menu popup is fully dat-driven (UIElement_Menu::MakePopup
@0x46d310 reads LayoutDesc 0x21000006 elements 0x1000001C/1D/1E — the "stray" top-level
elements). Render the popup from the real sprites instead of a flat rect:
- panel 0x0600124C, item row 0x0600124E, selected row 0x0600124D; 191x17 rows, 2 cols.
- drawing rows as SPRITES also fixes the z-order (a DrawRect bg composited OVER the
  labels; sprites share the labels submission bucket so text lands on top).
- item greying: available channels white, unavailable salmon (colorPink) — static
  approximation (Say/General/Trade/LFG) with an AvailabilityProvider hook for live
  TurbineChat state; unavailable items are inert on click. Ports ResetAllTalkFocusMenuButtons.
- scroll arrows: both dat sprites point down (export-confirmed); V-flip the top button
  so it points up.
Tabs confirmed to have NO digits in retail (blank gold frames) — acdream already matches.

Build + 392 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 11:33:38 +02:00
Erik
7094a1c847 @
fix(D.2b): channel menu popup opaque + button label tracks selected target

- the popup inherited the chat window 0.75 opacity so the transcript bled through;
  add UiRenderContext.PushAlphaAbsolute and draw the popup at absolute opacity.
- the "Chat" button was hardcoded; it now shows the active talk target (retail
  updates it on selection). Exact textured menu-panel sprite is a follow-up (the
  popup is a keystone UIElement_Menu construct, not in the chat LayoutDesc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 10:38:56 +02:00
Erik
ccaf188e41 @
feat(D.2b): exact retail chat colors from a live cdb dump

Attached cdb to a live retail acclient (PDB-matched) and read the named RGBAColor
constants at acclient 0x81c4a8+ (colorWhite/colorBrightPurple/colorLightBlue/
colorGreen/colorLightRed/colorGrey), used by ChatInterface::BuildChatColorLookupTable
@0x4f31c0. Replaced the approximated RetailChatColor palette with the ground-truth
values: speech=white, tell=colorBrightPurple(1,.498,1), channel=colorLightBlue
(.247,.749,1), system/popup=colorGreen(.5,1,.498), combat=colorLightRed, emote=colorGrey.
Capture scripts saved under tools/cdb/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 10:08:42 +02:00
Erik
1da697ec2a @
feat(D.2b): chat polish — typing fix, opacity, scrollbar 3-slice, retail channel menu

Visual-iteration batch (decomp-grounded), each fix verified against the retail screenshots:
- typing: UiElement.HitTest aborted on ClickThrough BEFORE walking children, so the
  ClickThrough UiDatElement panels blocked hit-testing to the input/transcript inside
  them. Check ClickThrough AFTER the child walk (it only gates whether THIS element
  claims the hit). Restores input focus + typing.
- opacity: UiElement.Opacity + a UiRenderContext alpha stack applied to sprite/rect
  draws (text bypasses it, stays sharp); chat frame Opacity=0.75 → translucent chat.
- brown sliver: grow the transcript panel up 9px to cover the dropped resize-bar strip.
- scrollbar: real 3-slice thumb (caps 0x06004C60/66 + tiled mid) + tiled track.
- max/min: shifted one button-width left of the scrollbar (dat right-anchors collide).
- system text now green (retail ChatMessageType 5; was yellow).
- word-wrap: transcript lines wrap to the panel width (greedy, ports GlyphList::Recalculate).
- channel menu reworked to retail gmMainChatUI::InitTalkFocusMenu: "Chat" button + a
  TWO-COLUMN popup of the 14 talk-focus items (Squelch, Tell to Selected, Chat to All,
  Tell to Fellows, ...) on a tan panel; channel items set the active outbound channel.

Build + 392 App tests green. Visual confirmation in progress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 09:37:40 +02:00
Erik
0ec36f6197 fix(D.2b): chat input resolves the live command bus lazily (was bound to null) + register thumb-3-slice row
The live session + its LiveCommandBus are created after the retail-UI block in
OnLoad, so binding the bus by value captured NullCommandBus and silently dropped
outbound chat. Pass a Func<ICommandBus> resolved at submit time (mirrors how the
ImGui ChatPanel re-reads the bus each frame).

AP-41: scrollbar thumb drawn as single stretched tile (0x06004C63) instead of
retail's 3-slice top-cap/middle/bottom-cap — acknowledged in UiChatScrollbar.cs:37,
registered per the divergence-register rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:24:44 +02:00
Erik
12ab9663d2 feat(D.2b): cut GameWindow over to the data-driven chat window
Replace the hand-authored chat block (UiNineSlicePanel + inline UiChatView
+ local BuildRetailChatLines/RetailChatColor statics) with
ChatWindowController.Bind(LayoutDesc 0x21000006) — the same LayoutImporter
path as the vitals window.  The controller places UiChatView (transcript) +
UiChatInput (text entry, on-submit) + UiChatScrollbar + UiChannelMenu inside
the dat-authored chrome.  The dead local statics are deleted.

Wired to _commandBus (same LiveCommandBus as the ImGui ChatPanel) so
type+Enter dispatches SendChatCmd server-ward.  Transcript keyboard set from
_uiHost.Keyboard (set by WireKeyboard above the chat block) for Ctrl+C/Ctrl+A.

Divergence register: added AD-28 (two-widget split vs UIElement_Text),
AP-38 (no in-element word-wrap), AP-39 (per-line colour vs per-glyph runs),
AP-40 (no opacity fade / shared vitalsDatFont), TS-30 (tab buttons no-op),
TS-31 (no squelch); updated IA-15 to cover both vitals + chat importer paths.

Build: 0 errors/warnings.  Tests: 392 passed, 1 skipped (expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:15:04 +02:00
Erik
9d9e036e4c feat(D.2b): ChatWindowController — bind chat LayoutDesc, place widgets, route chat
Implements Task G2: binds the imported chat LayoutDesc (0x21000006) to live
behavior, the acdream analogue of retail ChatInterface + gmMainChatUI::PostInit.

- UiDatElement: add OnClick hook + OnEvent override so Send/max-min buttons
  can be wired by a controller without needing a dedicated widget type.
- ChatWindowController.Bind: reads transcript (0x10000011) and input
  (0x10000016) rects from the raw ElementInfo tree (factory skips them as
  Type-12/no-media), places UiChatView under the transcript panel and
  UiChatInput under the input bar; replaces the imported scrollbar track
  (0x10000012) with UiChatScrollbar driving UiChatView.Scroll; replaces
  the channel menu placeholder (0x10000014) with UiChannelMenu; wires
  Send button and max/min toggle via the new OnClick hook.
  ChatCommandRouter.Submit routes all input through the existing pipeline.
- 6 smoke tests: Bind returns non-null, Transcript is child of panel,
  Input is child of bar, Input.OnSubmit publishes SendChatCmd, channel
  change updates submit channel, returns null when panels missing.

Build: 0 errors. Test suite: 392 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:04:57 +02:00
Erik
6e6339b026 feat(D.2b): importer renders Type-12-with-sprites + carries DefaultState
Task G1: two gaps blocked chat window static sprite elements from rendering.

Change 1 — DatWidgetFactory: only skip Type-12 elements that have no own
state media (pure style prototypes). A Type-12 element that carries sprites
(e.g. a chat Send button whose derived Type-0 element inherited Type 12 from
its base prototype) now renders as a UiDatElement.

Change 2 — ElementInfo: add DefaultStateName field (string, default "").

Change 3 — LayoutImporter.ToInfo: read ElementDesc.DefaultState.ToString()
into DefaultStateName; normalize Undef/Undefined/0 sentinels to "".

Change 4 — ElementReader.Merge: inherit DefaultStateName (derived wins if
non-empty, else base).

Change 5 — UiDatElement ctor: initialize ActiveState to DefaultStateName
when set; else "Normal" when a Normal-state sprite is present (retail's
implicit default for buttons/tabs); else "" (DirectState). This makes the
Send button, max/min button, and numbered tabs render their default sprite
without requiring explicit state assignment at runtime.

Vitals neutrality: all vitals chrome/grip elements carry DirectState-only
sprites with no "Normal" named state and DefaultStateName="" (Undef in dat),
so their ActiveState stays "" and their existing conformance tests are
unaffected. Vitals text labels (Type 0→12 via Merge, no StateMedia) are
still skipped by the refined Type-12 guard (StateMedia.Count==0).

Tests: 4 new tests (2 in DatWidgetFactoryTests, 3 in UiDatElementTests).
All 386 pass; 387 total (1 pre-existing skip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:54:37 +02:00
Erik
4345e77d62 fix(render): A7 Fix B — per-OBJECT point-light selection (minimize_object_lighting)
Outdoor objects brightened as the camera approached: lighting selected the
nearest 8 lights to the VIEWER and fed that one global set to everything
(LightManager.Tick), so a building's wall torches only lit it once the camera
got close enough for them to win the global top-8. Probe confirmed the scale of
the problem: a single Holtburg view registers 129 point lights — the global cap
of 8 was hopeless.

Retail selects up to 8 lights PER OBJECT by the object's own position
(minimize_object_lighting 0x0054d480), so a torch always lights the wall it
sits on, camera-independent. Ported faithfully:

- LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy
  (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus
  BuildPointLightSnapshot for the per-frame stable-indexed light list.
- mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the
  snapshot), binding=5 per-instance light SET (8 int indices into it, -1 =
  unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot
  mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO
  (cleared as faithful by the lighting audit) and loops THIS instance's point
  lights. pointContribution factored out (same calc_point_light wrap+norm shape).
- WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site
  (constant across the entity's parts), by the entity's AABB sphere; threaded
  into grp.LightSets parallel to grp.Matrices; global + per-instance buffers
  uploaded in Phase 5. Camera-independent ⇒ stable for static buildings.
- GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame.

Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green
(parallel-array lockstep preserved). Visually gated: the meeting hall now holds
steady as the camera approaches (was the popping symptom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:47:40 +02:00
Erik
c2170ab18f feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port)
Port of retail gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + HandleSelection
@0x4cd540 → SetTalkFocus. Button shows active channel label; click opens a
12-item popup that extends UPWARD (chat sits at screen bottom); selecting an
entry calls OnChannelChanged and updates Selected. BitmapFont? Font uses the
fully-qualified type name to match UiChatInput convention. Includes 6 xunit
tests covering channel table shape, default selection, and popup-pick routing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:42:22 +02:00
Erik
bcc45d668e feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port)
Ports retail UIElement_Text editable one-line mode (caret = glyph index;
caret pixel-X = sum of glyph advances via UiDatFont) and ChatInterface's
100-entry command history (up/down arrow; sentinel -1 = live line).
Submit (Enter/KeypadEnter) fires OnSubmit callback, clears, pushes history.
Draws via DrawStringDat (dat font) or DrawString (BitmapFont) fallback.
AcceptsFocus=true + IsEditControl=true so UiRoot routes Char/KeyDown to it
and suppresses global hotkeys while typing. 6 new tests, all green.

Decomp refs: UIElement_Text::MoveCursor @0x468d00,
             UIElement_Text::FindPixelsFromPos @0x472b40,
             ChatInterface::ProcessCommand @0x4f5100

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:36:44 +02:00
Erik
2940b4e3b2 feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable
Implements the right-side chat scrollbar widget. Ports retail
UIElement_Scrollbar::UpdateLayout @0x4710d0 (thumb sizing + placement)
and HandleButtonClick @0x470e90 (step ±1 line, page on track click).

Dat element ids sourced from chat LayoutDesc 0x21000006 (base layout
0x2100003E): up-button sprite 0x06004C69, down-button 0x06004C6C, track
0x06004C5F, thumb middle 0x06004C63. Up/down buttons occupy the top and
bottom ButtonH (16px) regions of the widget height, matching element
positions Y=0 and Y=32 in the base scrollbar template.

Adds 6 pure ThumbRect tests (no GL): sizing, clamping to MinThumb,
position at start/mid/end, no-overflow full-fill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:31:12 +02:00
Erik
aa94cedc38 fix(render): A7 point-light shape — per-vertex Gouraud + faithful calc_point_light (wrap + norm)
The torch/point-light look was wrong two ways, both now fixed against the
named retail decomp (calc_point_light 0x0059c8b0) via our verified
LightBake.PointContribution port:

1. Per-PIXEL → per-VERTEX. accumulateLights moved from mesh_modern.frag to
   mesh_modern.vert so point lights Gouraud-interpolate across each triangle
   the way retail's fixed-function T&L does. The per-pixel eval made a tight,
   hard-edged "spotlight" pool on flat walls; per-vertex is a soft, broad
   gradient. frag now just consumes the interpolated vLit (+ fog + flash).

2. Simplified ramp → faithful calc_point_light shape. The live point/spot
   branch was max(0,N·L) × linear(1−d/range) × cap — missing two terms our
   LightBake.cs port already has:
     • half-Lambert WRAP (1/1.5)·(N·D + 0.5·d), D un-normalised — a face
       angled away from a torch still catches light (retail's soft terminator)
       instead of snapping to black.
     • distance-cube NORM branch norm = distsq>1 ? distsq·d : d — inverse-
       square-ish soft far halo + punchy near field, vs the flat linear ramp.
   Per-channel no-blowout cap (min(scale·color, color)) retained.

The per-channel cap was also added to the legacy mesh.frag for consistency.

A read-only retail-vs-acdream lighting audit (11-agent workflow) confirmed
these two as the cause of the "better but a bit off" look and cleared the
ambient/sun/terrain/color-space chain as already faithful. Remaining
confirmed divergences (per-object light selection; dungeon static vertex
bake) are filed as the next fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:27:27 +02:00
Erik
0eaef67b9d feat(D.2b): UiChatView drives the shared UiScrollable model
Replace the ad-hoc _scroll float with a public UiScrollable instance.
OnDraw feeds ContentHeight/ViewHeight/LineHeight into the model each
frame and reads baseY = bottom - contentH + (MaxScroll - ScrollY) —
the (MaxScroll-ScrollY) inversion reconciles UiScrollable's top-origin
convention (0=oldest, MaxScroll=newest) with the visual layout (newest
at bottom). The wheel handler routes through ScrollByLines with a sign
flip so wheel-up still reveals older lines. _pinBottom tracks whether
the view is at the end and calls ScrollToEnd() each draw to auto-scroll
new messages. ClampScroll static method kept — referenced by existing
tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:23:17 +02:00
Erik
9f273c9343 feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:19:29 +02:00
Erik
7552dcba39 feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum
- Add `DatFont` property (UiDatFont?): when set, OnDraw uses
  ctx.DrawStringDat + datFont.MeasureWidth for all transcript lines;
  BitmapFont path unchanged as fallback when DatFont is null.
- Cache `_lastDatFont` alongside `_lastFont` so HitChar hit-tests the
  same advance source that drew the last frame.
- HitChar prefers `_lastDatFont` (via UiDatFont.GlyphAdvance) over
  `_lastFont` (via bf.Advance) for column resolution, keeping
  drag-select and Ctrl+C accurate with the dat font.
- Scroll event handler uses DatFont?.LineHeight first, so the wheel
  quantum stays correct when the dat font has a different line height.
- WheelLines 3f → 1f: retail UIElement_Text::HandleMouseWheel
  (@0x471450) advances one line per notch; our 3-line quantum was
  wrong.
- Add UiChatViewDatFontTests: pins GlyphAdvance formula
  (Before+Width+After = 10) and CharIndexAt dat-advance integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:14:56 +02:00
Erik
50883e445b feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline
Both the ImGui devtools ChatPanel and the upcoming retail chat window
now route through ChatCommandRouter.Submit so command handling lives in
one place. The ChatPanel inline block (TryHandleClientCommand / EqAny /
BuildHelpText) is deleted; ChatCommandRouter carries the same logic
verbatim. ChatPanel.Render becomes a 2-line delegate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:09:27 +02:00
Erik
3d25e8760f @
docs(D.2b): chat-window re-drive implementation plan (8 tasks A-H)

TDD task breakdown for the data-driven chat window: ChatCommandRouter extraction
(A), UiChatView dat-font (B), UiScrollable + wire-in (C/C2), UiChatScrollbar (D),
UiChatInput (E), UiChannelMenu (F), ChatWindowController bind/route (G), GameWindow
cutover + divergence rows (H). Each ported widget cites its retail class::method.

Plan: docs/superpowers/plans/2026-06-15-chat-window-redrive.md
Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-15 22:04:35 +02:00
Erik
26cb34f126 @
docs(D.2b): chat-window re-drive design spec + list-ui-layouts research tool

Plan-2 chat piece of the LayoutDesc importer. Identifies the chat window as
LayoutDesc 0x21000006 (gmMainChatUI, element class 0x10000041) and grounds a
faithful, data-driven re-drive in the named retail decomp (ChatInterface +
gmMainChatUI + UIElement_Text/_Scrollable/_Scrollbar/_Menu) plus a user-provided
retail screenshot.

Design (full-faithful scope, user-approved):
- transcript = UIElement_Text 0x10000011 (dat font, bottom-pinned, 10k behead cap,
  pixel scroll, 1 line/wheel-notch)
- scrollbar = right-side track 0x10000012 + thumb 0x1000048c + up/down
- input = editable UIElement_Text 0x10000016 (caret, 100-entry history, Enter/Send)
- channel menu = UIElement_Menu 0x10000014 ("Chat" selector -> active channel)
- shared ChatCommandRouter extracted from ChatPanel
- screenshot correction: the four 0x10000522-525 left-edge elements are the
  numbered CHAT TABS (1-4), not scroll buttons (a research-agent inference the
  retail screenshot refutes)
- deferred (need non-UI plumbing, each gets a divergence row): tab switching/
  filtering, squelch, clickable name-tags, in-element word-wrap, styled runs,
  font config, opacity transition

Tooling: AcDream.Cli `list-ui-layouts <datdir> [0xRootType]` — read-only index of
every UI LayoutDesc by root element class + size + element-Type histogram; how the
chat layout was located (root type 0x10000041). Reusable for future panel re-drives.

Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-15 19:38:27 +02:00
Erik
50758d4795 docs(D.2b): chat-window re-drive session handoff (Plan 2 chat piece)
Captures: current hand-authored chat window (UiNineSlicePanel + UiChatView,
read-only, debug font), the importer toolkit to reuse, the retail gmMainChatUI
oracles, the open design questions (scope / behavioral widgets / dat font), and
the first research step (find the chat LayoutDesc id). Resume via brainstorming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:07:05 +02:00
Erik
0474feb6ca docs(D.2b): correct roadmap/plan — vitals window IS resizable (resize shipped 8aa643f)
The earlier 'not resizable / fixed-size' note was wrong (inverted edge-flag
reading). Resize shipped: dat edge-anchors reflow per UIElement::UpdateForParentSizeChange.
Noted the two number-render fixes (submission-order + glyph pixel-snap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:35:29 +02:00
Erik
34243f2c26 fix(D.2b): pixel-snap dat-font glyphs so vitals numbers stay sharp on resize
DrawStringDat placed each glyph quad at the raw (often fractional) pen/origin.
When a bar resizes to a fractional width, the centered cur/max number lands on a
sub-pixel x and the glyph atlas (linear-filtered) smears — the 'unsharp at certain
sizes' artifact. Round each glyph's destination to whole pixels (the pen keeps its
true fractional advance, so spacing is unaffected) — matches retail blitting glyphs
to integer dest. User-confirmed sharp across resize widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:31:58 +02:00
Erik
43064bab09 fix(D.2b): draw UI sprites in submission order so stamina/mana numbers render
TextRenderer batched sprites per-texture and drew each texture's whole buffer at
its FIRST-insertion point. The dat-font glyph atlas is one shared texture used by
all three vital numbers; it first appeared at the health bar, so all three numbers
were emitted right after the health bars — then the stamina + mana bar sprites
painted over their own numbers (only health survived). Replaced the per-texture
dictionary with submission-ordered segments (consecutive same-texture quads still
batch); each meter's number now draws after its own bars. The renderer's own
comment had predicted this break once bars became sprites (importer did that).
Removed the temporary UiMeter label diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:27:13 +02:00
Erik
8aa643f3e0 fix(D.2b): correct edge-anchor mapping (RightEdge==1=stretch) + enable vitals horizontal resize
ToAnchors was inverted vs retail UIElement::UpdateForParentSizeChange @0x00462640:
stretch is RightEdge==1 (not ==2/==4), LeftEdge==2 = track-right. Verified against
all 19 vitals fixture pieces. Enables Resizable/ResizeX on the importer vitals root
(the prior 'dat is fixed-size' conclusion was wrong). At-rest render unchanged
(anchors only fire on resize). Added a 160->200 resize conformance test.
Also fixed DatWidgetFactoryTests.RectAndAnchors_SetFromElementInfo which encoded
the old inverted model (Right=2 expecting Right anchor; corrected to Right=1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:05:04 +02:00
Erik
825536a2bd docs(D.2b): re-retire TS-30 in register (restore branch state lost in --theirs merge)
The earlier 'git checkout --theirs' resolution of the register merge conflict
took main's whole file, which reverted two branch-only changes: IA-15 (re-added
in c100484) and the TS-30 retirement. TS-30 (flat-rect UI panels) was retired by
D.2b Spec 1 when UiNineSlicePanel shipped the 8-piece chrome and is doubly moot
now that vitals draw the dat chrome via the importer. Removed the TS-30 row +
its phase-gated reference; TS count 30->29. All section counts now match actual
rows (IA 15 / AD 27 / AP 37 / TS 29 / UN 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:41:41 +02:00
Erik
c1004847a2 docs(D.2b): record vitals default-flip shipped (importer is now the default vitals)
Roadmap: update D.2b LayoutDesc importer entry to record that the default
flip shipped 2026-06-15 (bf77a23) — importer is the default at
ACDREAM_RETAIL_UI=1; vitals.xml + ACDREAM_RETAIL_UI_IMPORTER flag retired;
window movable, resize deferred to Plan 2 (WindowManager).

Plan: update "After Plan 1" to mark the flip DONE, clean up the Plan 2
description now that vitals.xml is gone.

Register:
- AP-37 "Why" cell: replace "Gated opt-in (ACDREAM_RETAIL_UI_IMPORTER)"
  with "Now the default vitals path (the hand-authored markup vitals was
  retired)" — the flag is gone.
- IA-15: add row (was missing from this branch) — D.2b retail UI design
  stance, updated to note that the vitals window is now rendered by the
  LayoutDesc importer (dat chrome elements), not UiNineSlicePanel;
  UiNineSlicePanel/RetailChromeSprites now back only chat window + plugin
  panels. IA count header 14 → 15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:36:47 +02:00
Erik
bf77a23ad3 feat(D.2b): flip vitals to the LayoutDesc importer; retire hand-authored vitals.xml
The importer (proven pixel-identical at the 2026-06-15 A/B gate) is now the
default vitals window when ACDREAM_RETAIL_UI=1 — data-driven from LayoutDesc
0x2100006C. Removed: the hand-authored vitals.xml build path, the asset file
(recoverable from git history), and the now-obsolete ACDREAM_RETAIL_UI_IMPORTER
flag (RuntimeOptions param + parse + 2 tests). The window is user-positioned at
(10,30) and movable; resize stays off — the dat stacked-vitals layout is fixed-
size (chrome edges near-pinned), faithful grip/dragbar resize is Plan 2.
MarkupDocument/UiNineSlicePanel remain for the chat window + plugin panels.

AcDream.App builds 0/0; AcDream.App.Tests 352 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:30:24 +02:00
Erik
5ac9d8c19c merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)
main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:19:15 +02:00
Erik
07cf120939 docs(D.2b): mark LayoutDesc importer Plan 1 shipped; defer default-flip to Plan 2 (drag/resize)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:03:31 +02:00
Erik
4dcc90cb51 docs(D.2b): register AP-32 + IA-15 amend for importer; doc/test review fixes (N1/N4)
Process/quality items from the LayoutDesc-importer final review — no runtime
behavior change.

I1a — amend IA-15: the 8-piece chrome edge/corner→position mapping is no longer
a guess.  The LayoutImporter (ACDREAM_RETAIL_UI_IMPORTER) reads real LayoutDesc
dat data and resolves positions + sprite ids directly; locked by the conformance
fixture vitals_2100006C.json.  Residual risk trimmed to anchor resolution at
non-800×600 + controls.ini cascade.  Pointers added to LayoutImporter.cs and the
format-doc.

I1b — add AP-32: the importer collapses the dat's nested meter structure
(Type-7 → two Type-3 containers → three image-slice grandchildren each) into
UiMeter's programmatic 3-slice fields instead of building those nodes generically
and porting UIElement_Meter::DrawChildren.  Standalone Type-0 text elements are
also skipped (Plan 2).  Retail oracles: UIElement_Meter::DrawChildren @0x46fbd0,
UIElement_Text::DrawSelf @0x467aa0.

I1c — AP section header 31 → 32.

N1 — ElementReader.cs: comment at the Type-merge line explaining that a derived
Type 0 (text element) inherits the base's Type 12 (style prototype), which
DatWidgetFactory skips; safe for Plan 1 because vitals numbers render via
UiMeter.Label.  Format-doc §10: correct the "render as UiDatElement" sentence to
"skipped entirely" (Type-0 → inherits Type-12 via Merge → factory returns null).

N4 — new conformance test VitalsTree_TextLabel_InheritsFontDidFromBaseLayout:
walks the raw ElementInfo tree from the fixture and asserts at least one element
carries FontDid==0x40000000, proving Resolve()'s inheritance merge fired against
real dat data.  FixtureLoader gains LoadVitalsInfos() that returns the raw tree
without calling Build.

Tests: 36 pass (was 35), 0 errors, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:55:01 +02:00
Erik
2b653b8fc0 test(D.2b): conformance polish — table-driven slice asserts + BOM-safe loader
Fix 1: replace 3 copy-paste meter blocks in VitalsTree_MetersHaveExpectedSliceIds
with a single table-driven loop — a 4th meter is now a one-liner and failures
name the failing meter id directly.

Fix 2: FixtureLoader now reads the fixture as bytes and strips the UTF-8 BOM
(EF BB BF) before passing the span to JsonSerializer, so a BOM-bearing fixture
file never causes a spurious JsonReaderException.

Fix 3: add [Trait("Category", "Conformance")] at the class level so conformance
tests are selectable by category filter.

Fix 4: add missing <param name="layoutId"> doc tag to LayoutImporter.ImportInfos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:38:55 +02:00
Erik
3567135a04 test(D.2b): vitals importer conformance — golden fixture + tree/slice/chrome checks
Job 1: extract LayoutImporter.ImportInfos() (public dat-shell half that returns the
resolved ElementInfo tree without building widgets) so fixture generation and
conformance tests can call it directly. Import() now delegates to ImportInfos() +
Build() — existing 32 Layout tests stay green.

Job 2: generate tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
from the real portal.dat via a throwaway [Fact] generator (deleted, not committed).
System.Text.Json with IncludeFields=true — ValueTuple serializes as Item1/Item2.
Pre-write validation confirmed health meter BackLeft=0x0600747E FrontRight=0x06007483
rect (5,5,150,16). Round-trip deserialization re-validated before writing.

Job 3: FixtureLoader.LoadVitals() deserializes the fixture from the test output
directory (CopyToOutputDirectory item in csproj) and returns ImportedLayout via
LayoutImporter.Build(root, _ => (0,0,0), null) — no dats, no GL.

Job 4: LayoutConformanceTests — 3 golden tests (35 asserts total):
  - VitalsTree_HasThreeMetersAtExpectedRects: 3 meters at x=5, w=150, h=16, y=5/21/37
  - VitalsTree_MetersHaveExpectedSliceIds: all 18 back+front slice ids health/stamina/mana
  - VitalsTree_ChromeCornerHasExpectedSprite: TL corner 0x10000633 → sprite 0x060074C3

Full App suite: 326 pass / 1 skip (pre-existing) / 0 fail. Build: 0 errors, 0 warnings.
Throwaway generator not committed (confirmed via git status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:29:30 +02:00
Erik
25be30b1a7 style(D.2b): split two-statement line in importer wiring (review nit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:23:01 +02:00
Erik
ab3ab79380 feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)
Adds RuntimeOptions.RetailUiImporter (ACDREAM_RETAIL_UI_IMPORTER=1) — a new
opt-in flag that runs the LayoutImporter-built vitals window ALONGSIDE the
hand-authored vitals panel for pixel-for-pixel A/B comparison. The importer
window is placed at x=200, y=30 so both render simultaneously within the same
ACDREAM_RETAIL_UI=1 session. The hand-authored path is entirely untouched and
remains the default; the importer path is the eventual switch-over target.

Also adds two RuntimeOptionsRetailUiTests covering the new flag: value "1" →
true, unset/other → false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:18:16 +02:00
Erik
e8ddb68801 feat(D.2b): factory propagates ReadOrder→ZOrder for faithful draw layering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:14:28 +02:00
Erik
7e56eff884 refactor(D.2b): VitalsController review fixes — cite format doc + use consts in test
Fix 1: Added a <para> to the VitalsController class summary citing
docs/research/2026-06-15-layoutdesc-format.md §11 as the source of the
three dat element ids, giving a paper trail back to the evidence per
the project's cite-in-comments rule.

Fix 2: Changed FakeLayout in VitalsBindingTests to accept (uint id,
UiElement e) tuples instead of (string idHex, UiElement e), and updated
all three call sites to pass VitalsController.Health/.Stamina/.Mana.
Tests now follow the constants automatically if they ever change rather
than silently passing with stale hex literals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:10:17 +02:00
Erik
9d2527d9c8 feat(D.2b): VitalsController — bind live vitals data by element id
Mirrors retail gmVitalsUI::PostInit: grab Health/Stamina/Mana meters from
the imported layout by their dat element ids (0x100000E6 / EC / EE) and
wire Func<float> fill + Func<string> label providers. Missing ids are
silently skipped (no throw). Slice sprites + dat font already set by the
factory — this is pure data wiring, not graphics.

3 TDD tests: single-meter fill+label, all-three distinct providers, missing-id no-throw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:06:14 +02:00
Erik
9a55a688ca refactor(D.2b): LayoutImporter review fixes — root-fallback trace + cursor-discard note
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:03:24 +02:00
Erik
bd01a29eb2 feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree
Implements Task 5 of the LayoutDesc Importer (Plan 1 — vitals conformance).

Pure layer (BuildFromInfos / Build):
- ImportedLayout result type: UiElement root + O(1) FindElement(uint id) lookup
- BuildWidget dispatches via DatWidgetFactory.Create; skips Type-12 prototypes (null)
- Meters consume their children (DatWidgetFactory already extracted slice ids —
  adding the dat children as UiElement nodes would duplicate geometry)
- All other element types recurse children generically via AddChild

Dat shell (Import):
- Loads LayoutDesc from dats; null-safe if layout is absent
- Resolves each top-level ElementDesc to ElementInfo via Resolve():
  BaseElement/BaseLayoutId chain with (layoutId,elementId) cycle guard
- ToInfo(): reads ElementDesc scalar fields (uint → float cast) + DirectState +
  named States (UIStateId.ToString() as key)
- ReadState(): extracts first MediaDescImage (File + DrawMode) per state +
  font DID from Properties[0x1A] → ArrayBaseProperty → DataIdBaseProperty.Value
- Each sibling element gets a fresh base-chain set (siblings don't share guards)

DRW API: all members confirmed from VitalsLayoutDump.cs usings — no
adjustments needed: LayoutDesc in DBObjs; ElementDesc/StateDesc/MediaDescImage/
ArrayBaseProperty/DataIdBaseProperty in Types; DrawModeType/UIStateId in Enums.

Tests (3/3 green):
- BuildFromInfos_HealthMeter_IsUiMeterAtRect — Type-7 child → UiMeter, Left=5, Width=150
- BuildFromInfos_Type12Child_IsSkipped_Type3Present — prototype absent, container present
- BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree — meter findable,
  both dat-children absent, UiMeter.Children empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:52:50 +02:00
Erik
fc79fd519d refactor(D.2b): DatWidgetFactory review fixes — single lookup + malformed-meter trace
Fix 1: SliceIds now projects the File id during Select rather than calling
TryGetValue twice (once in Where, once in the local File() helper). Added a
comment noting that OrderBy is stable so X-tie order follows insertion order.

Fix 2: BuildMeter emits a [D.2b] Console.WriteLine when the Type-3 container
count is not exactly 2, surfacing malformed or non-vitals meter elements during
Task 8 conformance testing without disturbing the existing solid-color fallback.

Fix 3: Test 5 adds two explicit NotEqual assertions confirming the
ShowDetail-only overlay sprite (OverlayFile = 0x06007490) did not leak into
FrontRight or FrontTile.

5/5 tests pass, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:45:38 +02:00
Erik
38855e7a7b feat(D.2b): DatWidgetFactory — Type→widget hybrid + meter slice extraction
Hybrid factory mapping ElementInfo.Type to a behavioral widget or the
UiDatElement generic fallback.  Type 7 (UIElement_Meter) → UiMeter with
back/front 3-slice ids populated from grandchild image elements; Type 12
(style prototypes / BaseElement stores) → null so the importer skips
them; all other types → UiDatElement.  Rect + anchors are set on every
returned widget via ElementReader.ToAnchors.

BuildMeter walks two levels of the element tree: the two Type-3 slice
containers ordered by ReadOrder (back behind, front on top), then within
each container the image children that carry a DirectState ("" key)
ordered by X for left-cap/center-tile/right-cap.  The expand-detail
overlay (present in the front container with only named ShowDetail/
HideDetail states and no "" entry) is excluded by the TryGetValue("")
filter automatically — no name-matching needed.

Fill/Label providers are intentionally NOT set here; Task 6
(VitalsController) binds them to live stat data.

5 TDD tests: Type7→UiMeter, UnknownType→UiDatElement, Type12→null,
rect+anchors propagation, and meter slice extraction with overlay exclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:39:31 +02:00
Erik
70dc391c41 test(D.2b): UiDatElement — cover DrawMode passthrough + media fallbacks
- Assert DrawMode values (not just File) in the existing named-vs-direct test
- Add ActiveMedia_NoMedia_ReturnsZero: empty StateMedia → (0,0)
- Add ActiveMedia_MissingNamedState_FallsBackToDirect: absent named key → DirectState
- OnDraw: replace `var (file, drawMode) = ...; _ = drawMode;` with idiomatic `var (file, _) = ...`
- Add `// exposed for unit testing` comment above ActiveMedia()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:34:50 +02:00
Erik
cc4de3ef77 feat(D.2b): UiDatElement — generic per-drawmode element renderer
Generic fallback widget for every LayoutDesc element type without a
dedicated behavioral widget (chrome corners/edges, drag bars, resize grips).
Holds an ElementInfo + active-state name; draws that state's media by tiling
(UV-repeat on both S+T axes, matching ImgTex::TileCSI). DrawMode constants
documented per format spec §6 (Undefined=0, Normal=1, Overlay=2,
Alphablend=3 — no Stretch mode). Plan 1: all modes render as the same
alpha-blended tiled quad; per-mode branches deferred to Plan 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:29:16 +02:00
Erik
55239575e6 refactor(D.2b): ElementReader review fixes — defensive Children copy + sentinel doc
- Merge: defensive copy `new List<ElementInfo>(derived.Children)` so a
  later mutation of the merged result or the input can't corrupt the other
- Merge: add comment on Width/Height 0-sentinel (Plan-1 safe; Plan-2
  limitation and float?-upgrade path documented inline)
- Test: replace mid-sentence "Wait —" authoring trace in
  EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft with a clean
  conclusion-first summary of the value-3 mapping rule

9/9 ElementReaderTests pass; 0 build errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:25:44 +02:00
Erik
f73422a79a feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors
Implements Task 2 of the LayoutDesc Importer (Plan 1 — vitals conformance).

- ElementInfo POCO: GL-free/dat-free snapshot of a resolved layout element.
  Shape matches the plan spec exactly (Id, Type as uint, X/Y/Width/Height as
  float, raw Left/Top/Right/Bottom uint edge flags, ReadOrder, FontDid, StateMedia
  dict, Children list). Tasks 3–6 depend on this shape.

- ElementReader.ToAnchors(uint,uint,uint,uint): maps dat edge-flag values
  (0=none, 1=near-pin, 2=far-pin, 3=floating-center, 4=stretch) to AnchorEdges
  bit flags. Corrects the plan's stale assumption that value 4 was the only anchor
  trigger; the verified format doc §4 shows 1→Left/Top, 2→Right/Bottom, 4→both.
  All-zero falls back to Left|Top (default pin top-left).

- ElementReader.Merge(base_, derived): inheritance merge mirroring BaseElement/
  BaseLayoutId. Derived scalars win when non-zero; position/edge-flags/ReadOrder
  always from derived; StateMedia merged (base defaults, derived overrides);
  Children from derived only.

TDD: tests written first (9 tests covering ToAnchors near-pin/far-pin/stretch/
zero/value-3, Merge scalar override/font inheritance/StateMedia merge/children).
All 9 pass; dotnet build 0 errors 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:20:23 +02:00
Erik
67819f35a4 docs(D.2b): LayoutDesc format enumeration (importer groundwork)
Resolves all 6 open unknowns for Tasks 2–6 of the LayoutDesc importer plan:

1. Edge-anchor flags: 1=near-pin, 2=far-pin, 3=float-center, 4=stretch.
   The plan's assumption of 4="pinned to that side" is corrected — 1 is
   the near-pin, 4 is stretch (both sides). Revised ToAnchors signature given.

2. ElementDesc members: all are public FIELDS (not properties). X/Y/Width/
   Height/LeftEdge/etc. are uint. Type is uint (not enum). States is
   Dictionary<UIStateId, StateDesc>. Children is Dictionary<uint, ElementDesc>.

3. StateDesc shape: Properties is Dictionary<uint, BaseProperty> with concrete
   subclasses (ArrayBaseProperty, DataIdBaseProperty, IntegerBaseProperty, etc.).
   Font DID (0x1A) is ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ].
   Font color (0x1B) is ArrayBaseProperty[ ColorBaseProperty ]. Fill (0x69) is
   NOT in the dat — pushed at runtime by gmVitalsUI::Update.

4. DrawModeType enum: Undefined=0, Normal=1, Overlay=2, Alphablend=3.
   No "Stretch" value exists. Vitals uses Normal(1) and Alphablend(3) only.

5. Type values confirmed from RegisterElementClass: 3=Field/container,
   7=Meter→UiMeter, 9=Resizebar, 0xC=Text, 2=Dragbar, 12=style prototype (skip).

6. Inheritance chain: vitals text labels (Type=0) inherit from base element
   0x10000376 in layout 0x2100003F (Type=12), which carries font DID 0x40000000.
   The full per-vital sprite id tables for 0x2100006C are confirmed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:05:53 +02:00
Erik
a7875cde22 docs(D.2b): LayoutDesc importer implementation plan (Plan 1 — vitals conformance) 2026-06-15 12:46:55 +02:00
Erik
64146bfc2a docs(D.2b): LayoutDesc importer design spec (data-driven retail windows) 2026-06-15 12:38:34 +02:00
Erik
0f55599ba5 feat(D.2b): draw the window resize-grip overlay (gold ridges + corner studs)
The retail vitals window border is TWO layers, not one: the bevel chrome
(0x060074BF-C6) PLUS a resize-grip overlay on top — gold ridged edge strips
and a square corner stud at each corner. acdream only drew the bevel, so the
border looked plainer than retail and the corners lacked the little square
sprite the user spotted.

The overlay ids come from the vitals LayoutDesc 0x2100006C (elements
0x1000063B-0x10000642): corner stud 0x06006129 (same 5x5 at all four corners),
edge strips 0x0600612A/2C (top/bottom) and 0x0600612B/2D (left/right). They
have transparent gaps so the bevel shows through — both layers are drawn.
UiNineSlicePanel now draws the grip overlay (edges tiled via the existing
UV-repeat, corner studs 1:1) after the bevel, so every retail-chrome window
(vitals + chat) gets it.

Verified the grip sprites + the composited result headlessly: dump-sprite-sheet
(new CLI: composite arbitrary sprite ids magnified) showed 0x06006129 is a gold
stud and 0x0600612A-D are gold ridged strips; render-vitals-mockup now renders
the faithful default window with the overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:05:18 +02:00
Erik
73468be02a fix(D.2b): tile the vital-bar middle instead of stretching it
Retail repeats the bar's "fill-tile" graphic at native width (verified:
the dat element 0x100000E9 is literally the fill-tile; the engine fills via
ImgTex::TileCSI; and a widened side-by-side shows retail tiling, not
stretching). acdream was stretching one copy of the middle slice across the
whole span, so the bevel/bead pattern smeared as the window widened.

UiMeter.DrawHBar now UV-repeats each slice at its NATIVE width: caps span one
native width (a single 1:1 copy), the wide middle spans many (it tiles, last
copy UV-cropped). This works because the UI textures are already GL_REPEAT-
wrapped (TextureCache.UploadRgba8) — the exact mechanism UiNineSlicePanel's
chrome border already uses, so the border edges were ALREADY tiling and need
no change. One draw call per slice; composes with the existing fill-fraction
clip (the partial last tile shows a partial bead).

render-vitals-mockup now renders a widened window twice (stretch vs tile) so
the difference is verifiable headless. Confirmed the tile repeats seamlessly
(no seams).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:39:56 +02:00
Erik
4e60c03a74 feat(D.2b): chat text selection + Ctrl-C copy
Windows-like selection in the retail chat window: left-click-drag selects
characters, Ctrl-C copies, Ctrl-A selects all. The selected span paints a
translucent highlight behind the text.

- UiElement.CapturesPointerDrag: a per-element opt-out so an interior drag is
  delivered to the widget (text selection) instead of moving/resizing the host
  window. UiRoot.OnMouseDown honours it AFTER edge-resize (a resizable window
  is still resizable from its frame) and BEFORE window-move.
- UiChatView: AcceptsFocus + IsEditControl + CapturesPointerDrag; caches the
  OnDraw layout so OnEvent hit-tests the same geometry; HitChar maps a local
  point to (line,col) with glyph-midpoint caret snapping; SelectedText joins a
  multi-line span with \n; Ctrl-C writes to IKeyboard.ClipboardText (only when
  non-empty, so an empty copy never clobbers the clipboard).
- UiHost exposes the wired IKeyboard (clipboard + Ctrl modifier state).

Adversarial-review fix (the 99 tests would have stayed green without it): a
coordinate-frame mismatch between MouseDown and MouseMove. UiRoot.OnMouseDown
dispatched HitTestTopDown's coords, which are relative to the TOP-LEVEL child,
while MouseMove/MouseUp use target.ScreenPosition. For the chat view inset at
(8,8) inside its window the anchor landed ~8px off the click. OnMouseDown now
delivers target-LOCAL coords like the other mouse events. Added a UiRoot
regression test asserting MouseDown and MouseMove share the target-local frame
for a nested child.

Decomp ref: SurfaceWindow text/selection model; clipboard via Silk.NET
IKeyboard.ClipboardText. Built with the chat-select-copy implement->review
workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:21:28 +02:00
Erik
36bd3522f4 feat(D.2b): retail dat-font (Font 0x40000000) for vitals numbers
The vitals cur/max overlay rendered with the consola TTF debug font,
which is wrong for the retail look. Port the retail dat-font render
path so the numbers use Font 0x40000000 (Latin-1, 16px, with outline
atlas) — the same font retail draws on the vitals window.

UiDatFont (new): loads the Font DBObj from the DatCollection and
uploads its two RenderSurface atlases (foreground glyph pixels
0x06005EE5 + background outline 0x06005EE6) through
TextureCache.GetOrUploadRenderSurface — the same direct-RenderSurface
path the D.2b chrome sprites use. Builds a char->FontCharDesc lookup
and exposes MeasureWidth + LineHeight. The per-glyph advance
(HorizontalOffsetBefore + Width + HorizontalOffsetAfter) is a pure
static so the pen math is unit-testable without GL or the dat.

UiRenderContext.DrawStringDat (new): two-pass per-glyph blit mirroring
SurfaceWindow::DrawCharacter (acclient 0x00442bd0) — the BACKGROUND
atlas sub-rect tinted black (outline) first, then the FOREGROUND
sub-rect tinted the text color (fill), with the pen accumulating the
retail advance the way the string loop does at 0x00467ed4. Respects
the UI transform stack. Skips the outline pass for fonts with no
background atlas.

No shader change was needed: the foreground atlas decodes A8 ->
(255,255,255,a), and ui_text.frag's RGBA-sprite path already
MULTIPLIES the texel by the per-vertex tint (texture(uTex,vUv)*vColor),
so tinting white+alpha by a color gives color+alpha (black outline,
text-color fill).

UiMeter: new DatFont property; the label renders via DrawStringDat
(centered with DatFont.MeasureWidth) when set, falling back to the
debug BitmapFont when null.

GameWindow: loads one UiDatFont for the vitals panel (under _datLock)
and assigns it to each UiMeter child; logs + falls back to the debug
font if the Font fails to load (never crashes).

Tests: 6 pure-logic UiDatFontTests for GlyphAdvance + MeasureWidth
(synthetic glyphs, negative bearings, missing chars, empty/null). Full
App UI suite green (84 passed).

DatReaderWriter member names verified via reflection on the 2.1.7
package: Font.{MaxCharHeight,BaselineOffset,ForegroundSurfaceDataId,
BackgroundSurfaceDataId,CharDescs} and FontCharDesc.{Unicode,OffsetX,
OffsetY,Width,Height,HorizontalOffsetBefore,HorizontalOffsetAfter,
VerticalOffsetBefore}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:02:35 +02:00
Erik
ff29787f12 fix(D.2b): vitals from the real stacked-window LayoutDesc (0x2100006C)
The vitals bars were rendered from the WRONG layout. The ids in vitals.xml
(0x0600113x) belong to LayoutDesc 0x21000014 -- the 800x28 floaty side-vitals
ROW. The stacked vitals window the user sees is LayoutDesc 0x2100006C
(160x58), which uses a different sprite set and geometry. Dumped the real
tree (new dump-vitals-layout CLI, reflective) and ported it:

- Sprites (#2): the stacked-window set 0x0600747E-0x0600748F (health/stamina/
  mana, each back+front 3-slice; caps 10px, mid 130px).
- Right cap (#1) + fill model: retail UIElement_Meter::DrawChildren draws the
  back 3-slice full then the front 3-slice CLIPPED to the fill fraction (its
  own right-cap shows at 100%, the back's shows through when partial). UiMeter
  now clips the front per-slice (UV-crop) instead of growing a capless slice.
- Spacing (#5): three flush 150x16 bars at y=5/21/37 in a 160x58 window
  (16px pitch, zero gap), per the dat rects -- not the old 20px-apart guess.
- Border (#3): the window is the 8-piece chrome frame (corners 0x060074C3-C6,
  edges 0x060074BF-C2, 5px) -- dat-confirmed identical to RetailChromeSprites.

The headless render-vitals-mockup now composites this exact window
(0x2100006C) from the real sprites with the same clipped-fill model, so the
look was verified before launch. Font (#4, dat Font 0x40000000) is the next
commit.

Decomp refs: gmVitalsUI::PostInit @0x4bfce0; UIElement_Meter::DrawChildren
@0x46fbd0 (scissor-fill); geometry from LayoutDesc 0x2100006C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:50:17 +02:00
Erik
ada863980c feat(D.2b): scrollable retail chat window (read-only foundation)
Add UiChatView, a transcript widget for the retail-look UI: renders the
ChatVM tail bottom-pinned (newest at the bottom, like retail) with
mouse-wheel scrollback and whole-line vertical clipping so text stays
inside the frame. Hosted in a draggable/resizable UiNineSlicePanel and
wired into the UiHost next to the vitals window, fed by a dedicated
ChatVM (200-line tail) over the same live ChatLog. Per-ChatKind colour
palette (speech white, tells magenta, channels blue, system yellow,
emotes grey, combat orange).

This is the read-only foundation. The next sub-step adds glScissor
clipping + word-wrap, drag-to-select, and Ctrl+C copy -- the last needs
a CapturesPointerDrag opt-out on UiElement so an interior drag selects
text instead of moving the window (today an interior drag still moves
the window, same as the vitals panel).

Tests: UiChatView.ClampScroll (pin-to-bottom, cap-at-overflow,
never-negative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:12:12 +02:00
Erik
1453ff7da2 feat(D.2b): retail 3-slice vital bars + headless mockup verifier
Render each vital bar as a horizontal 3-slice from the real retail
RenderSurface sprites (authoritative ids from the vitals LayoutDesc
0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a
stretched glassy-gradient middle, and a fixed-width right-cap. The
empty back track draws full width; the coloured front fill grows from
the left to the value (the track owns the right end, so the fill omits
its own right-cap). Replaces the flat single-sprite Alphablend overlay
that read as the old UI - this is the bordered gradient look from the
retail screenshot (red HP / gold stamina / blue mana).

UiMeter gains the six 9-slice ids (BackLeft/Tile/Right +
FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the
backleft/backtile/backright/frontleft/fronttile/frontright attrs;
vitals.xml carries the 18 per-vital ids. The temporary
ACDREAM_BAR_PROVEOUT component grid is removed.

Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite
that assembles the bars with the SAME DrawHBar logic, so the sprite
assembly can be verified by eye (Read the PNG) without launching the
client + server - the fast UI-iteration loop the user asked for.
export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:11 +02:00
Erik
d2b8a51426 docs: wrap-up — file #137 (dungeon collision) + #138 (teleport-out world loading); close #135/#136
- #137: dungeon collision wrong at doors / wall openings (EnvCell collision; needs repro).
- #138: teleport OUT of a dungeon loads the outdoor world incompletely (missing trees/
  scenery, broken collision) + a position desync (avatar moves but player position doesn't)
  — hypothesised as the dungeon-streaming collapse→EXPAND gap (same machinery as #135).
- #135 marked DONE (user-verified FPS-steady dungeon login); #136 closed (editor-marker hide).
- CLAUDE.md current-state refreshed: #135/#136 shipped, A7 lighting + #137/#138 remaining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:08:40 +02:00
Erik
84630517e3 feat(D.2b): vital bars use retail dat sprites (back track + fill-cropped front)
UiMeter gains SpriteResolve/BackSpriteId/FrontSpriteId; when both are
set, OnDraw draws the empty-track sprite full-width then the colored-fill
sprite UV-cropped to the live fill fraction (left-to-right drain). Falls
back to solid rects when sprite ids are absent, keeping existing behavior
and tests intact.

MarkupDocument.Build() parses `back`/`front` hex attrs on <meter> and
passes `resolve` into every UiMeter.  vitals.xml wires the authoritative
LayoutDesc 0x21000014 sprites (Health 0x06005F3C/3D, Stamina 3E/3F,
Mana 40/41).  The bar prove-out block in GameWindow.cs was already gone.

If the sprites decode as 1x1 magenta at runtime they are paletted
(INDEX16/P8) — the solid-color fallback will display instead and can be
investigated separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:45:54 +02:00
Erik
56ee5eff60 chore(D.2b): CLI dump-vitals-bars — read vitals LayoutDesc meter sprites
Adds `AcDream.Cli dump-vitals-bars <datDir>` subcommand that:
- Scans all 101 LayoutDesc objects in client_local_English.dat
- Finds the vitals window layout (0x21000014) by locating the Health
  meter element id 0x100000E6 (from gmVitalsUI::PostInit decomp)
- Walks each meter's sub-element tree (typed access via ElementDesc.Children,
  ElementDesc.States, ElementDesc.StateDesc, StateDesc.Media, MediaDescImage.File)
- Prints every RenderSurface DataId (0x06xxxxxx) per vital

Authoritative output:
  HEALTH  (0x100000E6): front-bar fill 0x06005F3D / track fill 0x06005F3C
                        E8/E9/EA pieces: 0x06001131/32/33, 0x06001141/40/3F
  STAMINA (0x100000EC): front-bar fill 0x06005F3F / track fill 0x06005F3E
                        E8/E9/EA pieces: 0x06001137/38/39, 0x06001147/46/45
  MANA    (0x100000EE): front-bar fill 0x06005F41 / track fill 0x06005F40
                        E8/E9/EA pieces: 0x06001134/35/36, 0x06001144/43/42

LayoutDesc shape discovered: Fields Width, Height, Elements (HashTable<uint,ElementDesc>).
ElementDesc shape: ElementId, Type, BaseElement, BaseLayoutId, DefaultState,
  X/Y/Width/Height/ZLevel, LeftEdge/TopEdge/RightEdge/BottomEdge,
  States (Dictionary<UIStateId,StateDesc>), Children (Dictionary<uint,ElementDesc>),
  StateDesc (direct single state).
StateDesc shape: StateId, PassToChildren, IncorporationFlags,
  Properties (Dictionary<uint,BaseProperty>), Media (List<MediaDesc>).
MediaDescImage shape: File (uint DataId), DrawMode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:39:33 +02:00
Erik
b303baf4a1 fix(D.2b): windows not anchor-managed (regression: move/resize was reset each frame)
The anchor pass added in f911b5f runs on every element's children — including
UiRoot's children, which are the top-level WINDOWS. With the default Left|Top
anchor, ApplyAnchor reset each window's Left/Top/Width/Height back to its
captured design rect EVERY frame, so user move/resize was undone instantly ("I
can't resize or move it"). A window is user-positioned, so it must not be
anchor-managed by its parent: set UiNineSlicePanel.Anchors = None. Children
INSIDE the window still anchor to it (the bars keep stretching with width).

Regression tests: UiNineSlicePanel.Anchors == None; ApplyAnchor(None) is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:06:58 +02:00
Erik
fd0ecfcf2e docs: close #136 — red cone was an editor-only placement marker (fixed 6f81e2c)
Rewrite the #136 entry with the definitive root cause (editor-only dat placement
marker hidden by retail's distance degrade, inherited as visible from the WB-derived
render path) replacing the earlier refuted texture-pipeline hypothesis; mark FIXED.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:05:03 +02:00
Erik
6f81e2c91d fix(render): hide editor-only placement markers in dungeons — port retail's degrade-to-nothing (#136)
The "red cone" (+ green floor petals) in the 0x0007 Town Network dungeon is a dat
EnvCell static object (Setup 0x02000C39 / GfxObj 0x010028CA) using pure red/green
MARKER textures (0x08000109 / 0x0800010A). It is an EDITOR-ONLY placement marker:
its DIDDegrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX},
i.e. 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) therefore never
draws it in the live client.

acdream's render pipeline 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 references in references/WorldBuilder) — so acdream inherited the
"show the marker" behavior and drew it forever. It only became visible now because
the #135 login-into-dungeon fix drops the player at the exact saved spawn next to it.

Fix: GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker pattern
(HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). The EnvCell
static-object hydration (GameWindow ~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). This is the 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.

Diagnosis was extensive (geometry-not-VFX via particle-off; texture-not-lighting via
flat-ambient frame dumps; per-surface runtime decode pinned the red/green marker
surfaces; a draw-time probe pinned the dat-static entity id; a dat dump of the Setup +
degrade table confirmed the editor-marker pattern). Verified live via a frame dump:
the red cone + green petals are gone, all real dungeon decorations still render.
4 new GfxObjDegradeResolver unit tests cover the marker / normal-LOD / no-table /
degrades-to-real-mesh cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:03:08 +02:00
Erik
f911b5f0af feat(D.2b): anchor layout — vital bars stretch with window; drop Vitals heading
Add AnchorEdges [Flags] enum and Anchors property (default Left|Top, so
all existing elements are unchanged) to UiElement. ApplyAnchor() captures
the design-time margins on first call then recomputes Left/Top/Width/Height
each frame; DrawSelfAndChildren drives it for every child before painting.
ComputeAnchoredRect is public + static so it can be unit-tested without a
running frame loop.

MarkupDocument.Build gains a private Anchor() CSV parser and threads it
into the <meter> initializer via the anchor= attribute.

vitals.xml: remove title="Vitals" (retail vitals has no heading) and add
anchor="left,top,right" to all three meter bars so they stretch when the
panel is dragged wider.

Two new xUnit tests in UiRootInputTests: Left+Right stretches width;
Left+Top only keeps fixed size. All 19 App.Tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:58:58 +02:00
Erik
af91b8432a feat(D.2b): per-window resize-axis lock; vitals window is X-only (retail)
Add ResizeX/ResizeY bool properties to UiElement (both true by default).
HitEdges() in UiRoot masks out locked axes after edge detection, so a
locked edge falls through to window-move behaviour — matching retail,
where the vitals bar height is fixed and only widens.

MarkupDocument.Build() parses an optional resize="x|y|both|none"
attribute on <panel>; vitals.xml gets resize="x" to enforce the
horizontal-only constraint in all instances of the panel.

Two new tests: HitEdges_RespectsResizeAxisLock (UiRootInputTests) and
Build_ResizeAttrX_SetsHorizontalOnly (MarkupDocumentTests). 11/11 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:51:56 +02:00
Erik
0500646f08 fix(D.2b): draw UI chrome behind content (TextRenderer Flush layer order)
TextRenderer.Flush batched by primitive type and flushed rects -> text ->
sprites LAST, so the 8-piece chrome (incl. the center fill) painted OVER the
vital bars + numbers ("the window is drawn in front of the bars"). Reorder to
sprites -> rects -> text so chrome composites behind widget fills + text.

Correct while bars are solid rects; when bars become gradient SPRITES this must
move to true submission/painter order (sprite-on-sprite z) — noted inline as the
D.2b follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:49:52 +02:00
Erik
de4f0167ef feat(D.2b): window resize (UiRoot edge-grip resize-drag mode)
Add parallel resize mode to the UiRoot retained-mode input state machine.
A left-drag starting within ResizeGrip=5px of a Resizable window's edge or
corner resizes it (min-size clamped); interior drags on a Draggable window
still reposition it.

Changes:
- UiElement: Resizable, MinWidth, MinHeight properties
- UiRoot: ResizeEdges flags enum; _resizeTarget state fields; FindWindow
  (replaces FindDraggable, matches Draggable||Resizable); HitEdges (static,
  internal, testable); ResizeRect (static, public, testable); OnMouseDown
  checks edge-grip before move; OnMouseMove resize branch precedes move;
  OnMouseUp clears _resizeTarget
- UiNineSlicePanel: Resizable = true (retail windows are resizable)
- UiRootInputTests: 4 new tests — ResizeRect_RightBottom, ResizeRect_LeftTop
  (min-clamp + origin shift), HitEdges_DetectsCornerAndInteriorNone,
  EdgeDrag_ResizesPanel_InteriorDragMoves (full integration path)

Note on test coordinate: right-edge grab uses x=298 (2px inside the panel's
hit-test boundary) rather than x=300 (exactly at edge, misses OnHitTest's
strict `<` check). This is intentional — the grip zone extends inward from
the edge boundary, so a click 2px inside correctly lands in both the
hit-test rect AND the resize-grip zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:27:57 +02:00
Erik
b4ed8e7908 docs: file #136 — red-cone dungeon decoration renders red (frozen-phase render divergence)
Investigated the user-reported divergence (a solid-red cone in the 0x0007 dungeon
that retail doesn't draw). Narrowed by elimination:
- geometry, not VFX (survives particles-off)
- object 0x70007055 / Setup 0x020019F0, physState=0x1C — NOT NoDraw/Hidden
- its distinguishing texture 0x06006D65 (DXT1 256x128) DECODES tan/opaque offline,
  identical to a neighbour decoration (0x020019EE / tex 0x06006D63) that renders fine
- not a per-instance tint (hook dropped)
=> the red is introduced at runtime in the WB bindless texture-array upload/sampling
path (a #105-class "samples undefined until flushed" / layer-handle misassignment),
possibly lighting. Both WB-render-migration and sky/lighting are FROZEN phases, so the
fix awaits explicit sign-off. Full diagnosis + reusable diagnostic approach in the issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:11:15 +02:00
Erik
4acecffcd6 feat(D.2b): wire UiHost input + moveable windows (UiRoot window-drag + WantCapture gate)
- UiElement: add Draggable flag; left-drag on a draggable element repositions
  it as a floating window instead of starting a drag-drop sequence.
- UiRoot: add WantsMouse/WantsKeyboard properties (mirrors ImGui's WantCaptureMouse
  pattern); add FindDraggable helper; inject _windowDragTarget state machine into
  OnMouseDown/OnMouseMove/OnMouseUp so draggable windows track the pointer offset.
- UiNineSlicePanel: set Draggable=true so retail window frames are movable by default.
- GameWindow: OR _uiHost?.Root.WantsMouse|WantsKeyboard into the SilkMouseSource
  wantCaptureMouse/wantCaptureKeyboard delegates and the direct MouseMove gate so
  game actions (movement, world-pick) are suppressed while the pointer is over a
  retail window — no double-handling with the InputDispatcher.
- GameWindow: wire all Silk Mice/Keyboards to UiHost after construction so the
  UiRoot tree receives live input.
- Tests: 3 new UiRootInputTests covering WantsMouse hit-test, window-drag
  reposition, and non-draggable panel immobility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:02:27 +02:00
Erik
2f4520ee12 docs(D.2b): mark D.2b + D.4 shipped (Spec 1 — markup engine + retail vitals)
Roadmap: D.2b (custom retail-look backend) and D.4 (dat sprites + 9-slice +
DrawSprite) both shipped this session via the Spec-1 work — the UiHost-based
markup engine (MarkupDocument + ControlsIni + IUiRegistry) rendering a
markup-driven retail Vitals panel (8-piece dat chrome + red/gold/blue bars).
Records the direct-RenderSurface decode finding + the confirmed chrome sprite
ids. Remaining D.2b polish (gradient bar sprite, AcFont/D.3, input integration,
LayoutDesc importer, D.5 panels) noted inline.

Full suite green (2413 passed / 0 failed / 3 pre-existing skips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:50:42 +02:00
Erik
019350fa31 feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost
Adds the plugin-facing UI registration surface (Task 9, final D.2b task).
Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are
buffered in BufferedUiRegistry before the GL window opens, then drained into
UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first-
party vitals panel. Faulty plugin markup is isolated (try/catch per panel,
logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in
Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:46:37 +02:00
Erik
07bf6cbf60 feat(D.2b): MarkupDocument (XML -> UiElement tree); vitals panel from vitals.xml
Implements Task 8 of the D.2b retail-UI plan. MarkupDocument.Build() parses
KSML-style panel markup into a live UiNineSlicePanel subtree, resolving
{Binding} attribute expressions against a supplied object via reflection.
Color format is #AARRGGBB (alpha-first, matching controls.ini). Handles
<panel> root (geometry + optional title label) and <meter> children (fill,
label, bar color). Future element kinds (label, button, image) extend the
switch without touching existing code.

vitals.xml encodes the just-approved vitals panel layout (health red #FFC70D0D,
stamina gold #FFD49E1F, mana blue #FF1F33D9); ships next to the binary via
PreserveNewest csproj rule. GameWindow.cs drops the 35-line hand-built panel
block in favour of a 4-line File.ReadAllText + MarkupDocument.Build call —
identical tree, identical render, now data-driven.

2 new tests (Build_CreatesPanelWithMeterFillLabelAndGeometry,
Build_NullBindingValuesYieldNullFillAndLabel) + 11 total targeted green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:38:07 +02:00
Erik
97bd1d2f09 feat(D.2b): controls.ini stylesheet loader + apply title color
Adds ControlsIni — a minimal flat-INI reader for retail's controls.ini
(#AARRGGBB alpha-first color tokens; case-insensitive section/key lookup;
missing file returns an empty sheet with no throw). Wires the [title]
color token into the vitals panel's UiLabel in GameWindow.OnLoad, with
hardcoded white as the fallback. Visually a no-op (retail's [title] color
is white), but proves the stylesheet plumbing end-to-end (D.2b §7).
Three unit tests cover section parsing, #AARRGGBB decode, and graceful
missing-file handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:31:55 +02:00
Erik
2c923755c4 fix(G.3): place the player on the cell floor for an indoor dungeon login (#135 follow-up)
Two regressions from the pre-collapse (712f17f), found by live gate + a runtime
probe:

1) Login-into-dungeon stopped loading the dungeon. The login-hold streaming
   observer fell through to the OFFLINE fly-camera branch once
   _lastLivePlayerLandblockId was filtered to the player guid (a dungeon-local
   NPC used to keep it pinned). A camera-derived observer far from the
   pre-collapsed dungeon tripped ExitDungeonExpand and unloaded it. Fix: a LIVE
   in-world session never uses the fly camera for the observer — it follows the
   player's server landblock, falling back to the recentered spawn center
   (_liveCenterX/Y). The fly camera is the OFFLINE observer only.

2) Even with the dungeon resident, auto-entry hung: the #106 "ground ready" gate
   required SampleTerrainZ under the spawn, but a dungeon's negative-offset cells
   place the spawn's WORLD position in a NEIGHBOUR terrain landblock the #135
   collapse deliberately doesn't load (probe: cellReady=True, terrReady=False
   forever). The terrain gate is wrong for an indoor spawn — the player lands on
   the EnvCell FLOOR. Fix: gate an indoor (hydratable) spawn/teleport on
   IsSpawnCellReady, not the terrain heightmap; outdoor (and unhydratable→demote)
   spawns still hold on terrain. Applied to both isSpawnGroundReady (login auto-
   entry) and TeleportArrivalReadiness (teleport). This is the faithful equivalent
   of retail's synchronous cell load + place-on-floor; the pre-#135 terrain hold
   only passed because the 25x25 window streamed the neighbour terrain.

Verified live: login into 0x0007 → auto-entered player mode, snapped to
0x00070145, dungeon renders, FPS steady. Register AD-2 amended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:13:12 +02:00
Erik
b18403da02 feat(D.2b): wire UiHost + live retail Vitals panel (render-only); retire TS-30
Wires the dormant AcDream.App/UI retained-mode tree into GameWindow under
ACDREAM_RETAIL_UI=1: an 8-piece dat-sprite UiNineSlicePanel framing three
UiMeter vital bars bound to the existing VitalsVM. Render-only (UiHost input not
yet bridged to the InputDispatcher — next sub-phase). Coexists with the ImGui
devtools path; no regression there.

Visually verified against a live retail client: the bars match retail's vitals
structure (three stacked horizontal bars, current/max numbers centered) — so the
earlier "orbs" assumption was wrong (retail vitals ARE bars), and stamina is GOLD
not cyan (the #10F0F0 research note was wrong). UiMeter gains a centered numeric
Label (stub debug font for now). Spec §8 + the markup example corrected to match.

Bookkeeping: retired divergence row TS-30 (flat-rect panels -> real dat chrome)
and added IA-15 (our UiHost/markup engine vs keystone.dll's LayoutDesc tree).

Remaining polish (filed, §15): glassy gradient bar fill sprite + the retail dat
font for the numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:56:57 +02:00
Erik
712f17f0f2 fix(G.3): pre-collapse dungeon streaming at login/teleport — kill the login FPS ramp (#135)
On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s.
Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to
the player's single dungeon landblock — AC dungeons have no neighbours) only
fires once the per-frame `insideDungeon` gate reads true, and that gate keys on
the physics CurrCell, which isn't set until the player is PLACED, which waits for
the dungeon landblock to hydrate. So during the whole hydration window NormalTick
bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their
~19k entities each — and the collapse only mops them up afterward. That mop-up is
the ramp.

Fix: trigger the SAME collapse early, the instant we recenter the streaming center
onto a sealed dungeon cell, before the first NormalTick.

- StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse
  early (idempotent). The expensive neighbour window is never enqueued.
- GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag
  (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the
  per-frame gate use, so the early decision matches the eventual one. Distinguishes
  a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor
  surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id
  can't type-confuse a LandBlock record as an EnvCell.
- Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport).
- Observer robustness: during a teleport PortalSpace hold the streaming observer
  follows the recentered destination, not the frozen pre-teleport position (which
  could drift >=2 landblocks off and trip ExitDungeonExpand). And
  _lastLivePlayerLandblockId is now filtered to the player guid (resolves the
  Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer
  off the dungeon.

Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new
workaround — AP-36 amended in the same commit. Adversarially reviewed across
timing / threading / faithfulness lenses; 5 new tests including the real runtime
ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:46:56 +02:00
Erik
064ef41ce4 feat(D.2b): UiMeter vital bar + fill-geometry tests
Adds UiMeter, the horizontal vital-bar widget for the D.2b retail-look
UI toolkit. Solid-color fill for Spec 1; the retail orb sprite + scissor
crop path is reserved for a later sub-phase. Five unit tests (1 Fact +
4 Theory) cover half-fill geometry and clamping at -1/0/1/2 fractions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:38:07 +02:00
Erik
0bf790c8bf feat(D.2b): UiNineSlicePanel — 8-piece retail window frame + geometry test
Implements the retail floating-window bevel as a UiPanel subclass using
RetailChromeSprites: 4 tiled edges + 4 stretched corners + tiled center fill,
matching the 8-piece border layout confirmed by the D.2b Step-0 prove-out.
Resolver delegate keeps GL out of unit tests. Geometry verified by
ComputeFrameRects_PlacesCornersEdgesAndCenter (1/1 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:36:11 +02:00
Erik
8e91805206 feat(D.2b): Step-0 chrome sprites confirmed + direct-RenderSurface upload path
Step-0 prove-out result: retail UI chrome sprites are RenderSurface objects
(0x06xxxxxx) that must be decoded DIRECTLY, not via the Surface->SurfaceTexture
chain GetOrUpload uses for world materials (which produced 1x1 magenta/garbage).
Added TextureCache.GetOrUploadRenderSurface(id, out w, out h) — Portal/HighRes
TryGet<RenderSurface> -> DecodeRenderSurface(palette:null) -> upload, separately
cached. This is the path UI chrome + (later) dat fonts use.

Confirmed the universal floating-window bevel is an 8-piece border + center fill:
  center  0x06004CC2 (48x48)
  edges   0x060074BF/C1 (10x5 horiz)  0x060074C0/C2 (5x10 vert)
  corners 0x060074C3..C6 (5x5)
Recorded in RetailChromeSprites.cs (edge/corner->position mapping is a best
guess pending the LayoutDesc 0x21000040 parse; visually confirmed at panel
render). The memory-note ids were right; only the decode path was wrong.

Temporary prove-out harness (added to GameWindow.OnRender) removed. proveout*.log
gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:32:27 +02:00
Erik
66888d2c8e fix(textures): DecodeSolidColor null-safe against null ColorValue
A Base1Solid (or OrigTextureId==0) Surface can carry a null ColorValue;
DecodeSolidColor dereferenced it (color.Alpha) and threw NullReferenceException.
It is called directly from TextureCache.DecodeFromDats, OUTSIDE
DecodeRenderSurface's try/catch, so the NRE crashed the whole client. Surfaced
by the D.2b chrome prove-out feeding UI surface ids. Guard null -> Magenta
(the decoder's existing "undecodable" sentinel). Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:36:07 +02:00
Erik
a100bc37a7 docs(G.3): file #134 (ramp slide) + #135 (login FPS); record #133 grey+FPS fixes
Wrap-up bookkeeping for the dungeon work this session:

- #135 — login FPS ramp (~10 fps -> high over ~30 s): the streaming
  collapse only fires once CurrCell resolves to a sealed cell, so the
  first-frame bootstrap loads ~24 neighbour ocean-grid dungeons (+ ~19k
  entities each) then unloads them. Residual of the dungeon collapse;
  clean fix = pre-collapse at login when the spawn cell is a sealed
  dungeon cell.
- #134 — ramp slide-response feel ("lags downward" instead of gliding
  along the slope). SURFACED (not caused) by 3e006d3 caching the ramp
  connector cell in the physics graph; the slope-walk/edge-slide is now
  exercised. Port the retail slide-response; no band-aid.
- #133 — progress note: dungeon FPS FIXED (streaming collapse to the
  single dungeon landblock, 14-30 -> ~1000+ fps) + grey barrier FIXED
  (register portals-only connector cells for BOTH visibility and the
  physics graph even when they build 0 sub-meshes; d90c538 + 3e006d3).
  A7 per-vertex lighting bake (LightBake Core 3b93f91) is the remaining
  "lighting off" work; revised diagnosis (intensity=100 is the real dat
  value; the divergence is no-static-light-burnin, not a mis-read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:33:07 +02:00
Erik
c9eef1d7cd feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite
Add uUseTexture==2 (RGBA modulate) branch to ui_text.frag so dat sprites
can be drawn through the existing 2D batcher without touching the font path.

TextRenderer gains _spriteBufs (per-GL-handle List<float>), DrawSprite(), and
a Flush block that issues one draw call per distinct texture with uUseTexture=2.
Also adds DepthMask(false) in the state-save block (restored to true after) to
prevent the transparent-quad pass from writing depth and corrupting the 3D scene
if the UI is flushed mid-frame.

TextureCache gains GetOrUpload(surfaceId, out width, out height) — caches pixel
dimensions alongside the GL handle so UI 9-slice geometry can compute slice UVs
from the source image size without a second decode.

UiRenderContext gains a DrawSprite forwarder that applies the current 2D
translate stack, matching the DrawRect / DrawRectOutline pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:28:29 +02:00
Erik
3b93f91ebe feat(A7): LightBake Core — verified per-vertex static-light burn-in (foundation, not wired)
The faithful fix for the spotty dungeon/house/outdoor lighting is retail's per-vertex
static-light bake (D3DPolyRender::SetStaticLightingVertexColors 0x0059cfe0), NOT a
per-pixel ramp. This lands the GL-free Core: LightBake.PointContribution /
ComputeVertexColor port calc_point_light (0x0059c8b0) VERBATIM — verified against a
clean Ghidra decompile (the BN pseudo-C is x87-mangled): half-Lambert wrap with
LIGHT_POINT_RANGE=0.75 (0x007e5430), the distsq>1 norm branch, the per-channel
min-to-color clamp, and the final [0,1] clamp. static_light_factor=1.3 (0x00820e24)
is already folded into LightSource.Range by LightInfoLoader.

7 conformance tests (hand-derived golden values) green. NOT wired yet — the
integration (a per-vertex colour attribute on the cell mesh + the bake driver keyed
on envCellId + the shader consumption) is the remaining A7 work; see ISSUES.md A7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:27:45 +02:00
Erik
3e641339e9 chore(G.3): strip the #133 temp diagnostics
Remove the throwaway probes added to diagnose the dungeon FPS/grey issues now that
they're fixed: the ACDREAM_LOG_FPS headless line + [cellreg] registration line
(GameWindow), and the [pv-trace] 0x0007 gate-widen + raw-NDC bbox addition to the
flap probe (PortalVisibilityBuilder, reverted to the pre-#133 form). The permanent
Phase-U.4c [flap]/[pv-trace] probes (ACDREAM_PROBE_FLAP) are kept as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:27:45 +02:00
Erik
626d06ebc1 feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles
Adds two startup-time env toggles that Phase D.2b's retail-UI panel
frame will read:
- ACDREAM_RETAIL_UI=1  → opts.RetailUi (bool, default false)
- ACDREAM_AC_DIR=<path> → opts.AcDir   (string?, default null)

Both follow the existing helper conventions (IsExactlyOne / NullIfEmpty).
No call sites broke because the only construction site in RuntimeOptions.cs
already uses named arguments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:25:21 +02:00
Erik
35152248f1 docs(D.2b): implementation plan — retail panel frame + live Vitals
9-task TDD plan against the re-grounded spec, building on the existing
AcDream.App/UI scaffold: RuntimeOptions toggles, textured-sprite path in
TextRenderer (+ frag uUseTexture=2, + TextureCache size overload), Step-0 chrome
prove-out, UiNineSlicePanel + UiMeter widgets, wire UiHost + live Vitals
(render-only) retiring TS-30, controls.ini loader, MarkupDocument (XML ->
UiElement tree), and the IUiRegistry plugin surface. Exact code per step; pure
parsers TDD'd in AcDream.App.Tests, GL/visual bits user-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:56 +02:00
Erik
3e006d372a fix(G.3): register connector cells in the PHYSICS graph too — viewer-cell transit (#133)
After registering portals-only connector cells for VISIBILITY (d90c538), an
angle-dependent residual grey remained when the camera crossed a ramp: the
camera-collision sweep (SmartBox::update_viewer -> sphere_path.curr_cell, pc:92870)
could not transit INTO the connector cell because it had no physics cell to sweep
into — CacheCellStruct was still gated on drawable sub-meshes. So the viewer cell
stalled one cell behind the eye (confirmed live: [flap-sweep] transited every cached
neighbour but NEVER the un-cached connector 0x014D, viewerCell stuck at 0x00070103
while the eye sat 1.32 m past the connector's portal plane), and the side test
correctly culled the on-screen connector portal -> grey.

Fix: move CacheCellStruct out of the `cellSubMeshes.Count > 0` gate, next to
BuildLoadedCell — cache EVERY cell with a valid cellStruct for physics too. Retail
keeps the whole landblock cell array resident for the sweep; a portals-only
connector has an empty collision BSP but its portals drive the transit. User-gated:
"I see no grey background any longer."

Build green; 12 flood-gate tests + 677 physics/cell/transit tests green (no collision
or membership regression). TEMP render probes still retained (strip after).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:41 +02:00
Erik
d50023f6d9 docs(D.2b): re-ground spec onto existing AcDream.App/UI scaffold
A direct read of src/AcDream.App/UI/ found a complete (dormant) retained-mode
toolkit the grounding workflow missed: UiRoot (input routing, focus, capture,
drag-drop, tooltip, click detection, world fall-through), UiElement,
UiPanel/UiLabel/UiButton, UiHost (Tick/Draw + WireMouse/WireKeyboard),
UiRenderContext, retail-faithful UiEvent codes. It's never wired into GameWindow,
and UiPanel.cs is the exact file divergence row TS-30 cites.

So the retail UI is this existing UiRoot tree — NOT an IPanelHost/IPanelRenderer
backend. Rewrote the architecture sections: Spec 1 now WIRES the dormant UiHost
and adds only the gaps (DrawSprite + frag uUseTexture=2, UiNineSlicePanel,
UiMeter, MarkupDocument that builds a UiElement subtree, ControlsIni). Input
machinery already exists in UiRoot; deferring it is now about integrating two
input consumers, not a missing contract. Plugin contract becomes a UiElement/
markup subtree added to UiRoot (IUiRegistry on IPluginHost), not IPanel.

Net: strictly less new code, more faithful, retires TS-30 by subclassing the
file it cites. Added §0 documenting the correction + the process lesson
(subsystem-discovery must glob by directory, not by the parent's framing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:13:30 +02:00
Erik
de9229eed5 docs(D.2b): design spec — retail panel frame + live Vitals (Approach C)
Brainstormed design for the D.2b retail-look UI backend: our own KSML-style
markup + controls.ini stylesheet + retained-mode toolkit on Silk.NET (no
embedded browser, zero external deps — Approach C, chosen over Ultralight/CEF
and RmlUi for memory/dep-weight/faithfulness).

Spec 1 scope: an 8-piece dat-sprite window frame + live Vitals bars bound to
the existing VitalsVM, gated behind ACDREAM_RETAIL_UI=1, rendered via a reused
TextRenderer batch. Render-only (input/hit-test, AcFont glyphs, anchor solver,
LayoutDesc importer all deferred).

Grounded by a read-only research workflow (7 readers + gap-critic). The critic
corrected several stale memory/plan-doc facts now baked into the spec's
do-not-trust list: VitalsVM is a sealed class (not the old record); chrome
sprite IDs are unverified (Step-0 dat prove-out resolves them empirically);
controls.ini exists and #FFDBD6A8 is editbox text not a bg; DatCollection reads
are thread-safe; KSML is rich-text not the layout language (we mirror
ElementDesc).

Phase D.2b / Milestone M5 (parallelizable with M3/M4 — opened as a parallel
track while M1.5 stays the active critical-path milestone). Retires divergence
row TS-30 + adds one IA row when the chrome ships.

Also gitignores the /.superpowers/ visual-companion scratch dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:00:14 +02:00
Erik
d90c5385d2 fix(G.3): register portals-only connector cells for visibility (#133 ramp grey)
The grey "barrier" at a dungeon ramp was a one-cell registration gap. The ramp's
connector cell (0x0007014D) is a portals-only pass-through — CellMesh.Build yields
0 drawable sub-meshes for it (you walk through it on adjacent floors). But the whole
registration block — including the portal-VISIBILITY registration (BuildLoadedCell ->
_cellVisibility) — was gated behind `if (cellSubMeshes.Count > 0)`. So that cell was
never added to the visibility graph; the flood lookup-missed it (PortalVisibilityBuilder
:369), couldn't traverse it to the room below, and the grey clear color showed through.

Confirmed live via two added probes: [cellreg] registered=204/205 (only 0x014D missing)
+ [pv-trace] p4->0x0007014D skip=lookup-miss. After the fix: registered=205,
hasRamp=True, skip=lookup-miss gone, the room below renders.

Fix: compute the cell transforms and call BuildLoadedCell (visibility) for EVERY cell
with a valid cellStruct, regardless of drawable sub-meshes — matching retail, which
keeps the whole landblock cell array resident before the flood runs. Drawing
(RegisterCell, _pendingCellMeshes) and the physics BSP (CacheCellStruct) stay gated on
drawable geometry (a portals-only connector has nothing to draw and no collision
surface). Not a regression from the FPS-collapse work — a pre-existing gate the
now-navigable dungeon exposed (every ramp/stair/cellar mouth would show it).

TEMP diagnostics retained for the residual angle-grey investigation (strip after):
[cellreg] (GameWindow), the 0x0007 [pv-trace] gate widen + raw-NDC bbox (PortalVisibility-
Builder). Three earlier render-math theories (portal_side, on-screen clip, near-eye
projection) were each refuted by apparatus/probe before shipping — this is the verified one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:49:02 +02:00
Erik
7d8da99f79 fix(G.3): collapse dungeon streaming at the snap, not after landblock finalize (#133)
The dungeon-streaming gate read SeenOutside from the render registry
(_cellVisibility.TryGetCell), which only succeeds AFTER the landblock FINALIZES —
~tens of seconds for a 205-cell dungeon. So the collapse fired late and the full
25x25 neighbor window churned in first ("~30s to stabilize at high FPS").

EnvCell extends ObjCell, which already carries SeenOutside (set from the EnvCell
dat flags at construction), so CurrCell.SeenOutside is available the moment the
player is placed (the snap). Read it directly instead of the registry. Collapse now
engages ~3s in (snap) instead of ~30s (finalize); residual is the ~24 neighbors the
bootstrap loads before the snap, which then unload. Also simplifies the predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:06:17 +02:00
Erik
53e22a350d fix(G.3): relocate the player entity to its CELL landblock indoors, not position-derived (#133)
After the dungeon-collapse fix the local player avatar stopped rendering: the
per-frame RelocateEntity moved the player entity to its position-derived landblock
floor(pp/192), which for a dungeon's negative-local-Y cell is the off-by-one (0,6)
— the very landblock the collapse unloads. So the player entity sat in an unloaded
landblock and was never drawn (the dungeon itself, in 0x0007, rendered fine).

Fix: when the player is in an indoor cell (CellId low word >= 0x0100), relocate to
the cell's OWN landblock (CellId >> 16), matching the streaming-collapse pin. The
cell id is authoritative for ocean-placed dungeon geometry. Outdoor entities keep
the position-derived path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:52:01 +02:00
Erik
2561918a70 fix(G.3): pin dungeon collapse to the cell's landblock, not the position-derived one (#133)
"The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's
EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap
showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock
_liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row
off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which
nulled CurrCell (the cell no longer existed) and left the player floating in
outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the
Bug-A negative-local-coordinate class.

Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock
(CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is
the authoritative landblock for ocean-placed dungeon geometry.

Also hardened the hysteresis so a transient CurrCell flicker can't thrash:
- Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon).
- Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the
  ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so
  it now HOLDS the collapse instead of expanding.
- SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock),
  never the per-frame observer landblock.

Build green; 59 streaming tests green (flicker regression test updated to the
realistic adjacent off-by-one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:51:50 +02:00
Erik
d9e7dd65e9 fix(G.3): hysteresis on the dungeon streaming gate — stop collapse↔expand thrash (#133)
The first cut of the dungeon gate keyed expand on the per-frame insideDungeon
signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to
null mid-frame while the player stays put in the dungeon landblock, so the gate
flipped collapse→expand→collapse every few frames. Each expand re-streamed the
full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so
registered lights leaked to 212k and FPS spiked to single digits between the
~199 fps collapsed frames.

Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell.
Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter);
expand only when the observer actually moves to a different landblock (portal/
teleport out). CurrCell flicker no longer thrashes.

Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand).
Build green; 60 streaming tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:43:18 +02:00
Erik
56860501b6 fix(G.3): collapse streaming to the single dungeon landblock indoors (#133 FPS)
Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around
the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of
torch/particle emitters, all drawn though never visible. In AC all dungeons are
packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated
dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) →
17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count,
not entities.

Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a
dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) —
every dungeon is a self-contained landblock you never see out of.

Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv &&
!SeenOutside — the same predicate that kills the sun/sky), collapse streaming to
just the player's dungeon landblock and unload the neighbors. Building interiors
(cottage/inn) have SeenOutside cells, so they are NOT gated and keep their
surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the
neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking
LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7
lighting bake landing next.

Mechanics (StreamingController):
- Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer
  ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every
  resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if
  needed.
- Stay collapsed: sweep any straggler that finished loading after the edge (a Load
  the worker had already dequeued before ClearLoads).
- Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the
  new center, unload anything stale.

AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell
predicate as an approximation of ACE's full landblock IsDungeon classification).
GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after
the A7 FPS+lighting verification).

Build green; 58 streaming tests green (6 new dungeon-gate tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:32:56 +02:00
Erik
007e287309 fix(A7): port retail calc_point_light (1-dist/falloff) ramp — kill the "spotlight" hard edge (#133)
The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.

Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.

Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
  Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
  false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
  allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
  (per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.

Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:48:46 +02:00
Erik
5872bcf075 perf(lighting): allocation-free nearest-N light selection (#133 FPS)
Tick built a new List<>(N) and ran an O(N log N) Sort every frame; in a dungeon
N is thousands of torches, so it allocated a large list per frame (GC pressure ->
FPS). Replace with an insertion partial-select that keeps the nearest maxPoint
directly in the _active window — O(N * maxPoint), maxPoint<=8, zero allocation.
Same selection result (nearest 8); lighting suite 20/20 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:26:17 +02:00
Erik
0fe479ba06 docs(A7): pin the GENERAL light over-saturation cause (intensity=100 mis-read) + FPS note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:47 +02:00
Erik
1e70a5a484 fix(G.3 A7): torch range = Falloff x 1.5 (retail rangeAdjust) — wider pools (#133)
Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the hardware light
Range = Falloff * rangeAdjust (1.5, global 0x00820cc4). We used Range = Falloff, so
torches reached only 2/3 of retail -> tight 'candle/spotlight' bubbles in dungeons.
Match retail's reach. Ambient 0.20 confirmed retail-faithful (the 0.30 was CreatureMode,
not world cells). Lighting suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:58:03 +02:00
Erik
9e809bc661 diag: ACDREAM_PROBE_LIGHT [light-detail] — per-light range/intensity/cone (#133 A7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:55:14 +02:00
Erik
167f05c4fa docs(G.3 A7): record dungeon light-selection fix (activeLights 2->8) + the 0.30 ambient follow-up
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:45:29 +02:00
Erik
a80061b0c2 fix(G.3 A7): dungeon lighting — select 8 NEAREST lights, not viewer-in-range (#133)
The active-light selection dropped any point light whose range didn't reach the
VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks
the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader
(mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed
a torch whenever the player stood outside its range, so a dungeon room with 2227
registered torches lit only the ~1 the player was standing in (activeLights ~= 1,
rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter;
take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack
const; updated the two tests that codified the old filter. Core lighting suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:35:01 +02:00
Erik
d6fb788c96 diag: ACDREAM_PROBE_LIGHT — log dungeon ambient/sun/active-light state (#133 A7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:43:27 +02:00
Erik
a40c38e8bd milestone(G.3): dungeons RENDER — #95 was a Bug-A symptom, not an unbounded flood
Autonomous /loop verification: a live launch into the 0x0007 dungeon renders with a
sane budget (WB-DIAG instances ~39,000, meshMissing=0; was 9.1M pre-Bug-A), correct
membership (no ACE failed-transition spam), navigable. The chain: G.3a teleport
hold+place + Bug A (2ce5e5c, validated-claim landblock prefix) + login-into-dungeon
recenter (47ae237). A headless diagnostic (Issue95DungeonFloodDiagnosticTests, 95d9dab)
proved the portal flood is already bounded (1-17 cells vs the stab_list's 120-204), so
#95's "port grab_visible_cells stab_list bounding" was the WRONG fix and is NOT pursued.
ISSUES #95 -> RESOLVED, #133 -> renders + login-into-dungeon fixed; CLAUDE.md current
state + render digest updated. Remaining for M1.5: A7 dungeon torch/point-lighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:36:04 +02:00
Erik
47ae237e7b fix(G.3): recenter streaming onto the spawn landblock at login (#133)
A character saved inside a far dungeon hung at the #107 auto-entry hold because
the streaming center was fixed at the startup default and the login spawn never
recentered it, so the dungeon never streamed. Mirror the teleport-arrival
recenter on the login player-spawn path: when the player's spawn landblock
differs from the current center, recenter before translating the spawn position
(landblock-local -> new-center frame). No-op for a same-landblock (normal
Holtburg) login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:00:14 +02:00
Erik
95d9dab4bb test(#95): headless dungeon-flood diagnostic — measure visible-cell count on 0x0007
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:52:00 +02:00
Erik
dd7b73a837 docs(G.3): file login-INTO-a-dungeon gap (streaming not recentered at login)
Re-gate of Bug A revealed: logging in with the character saved inside a far
dungeon hangs at the #107 auto-entry hold (frozen, no [snap]). The streaming
center is set once at startup to the default and the login spawn never recenters
it, so the dungeon never streams and IsSpawnCellReady never goes true. The
teleport-arrival path recenters (G.3a); the login path doesn't. Filed under #133
with the fix shape (recenter onto the spawn landblock at login) + the ACE-reset
workaround.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:39:45 +02:00
Erik
c8188e0ed6 docs: correct stale UCG CellGraph comments — the graph is active, not inert
The "consumed by nobody (zero behavior change)" / "INERT in Stage 1 (no writer)"
comments predate the UCG becoming load-bearing. Verified against the call sites:
CellGraph is populated unconditionally in CacheCellStruct (before the idempotency
+ null-BSP guards, so BSP-less cells are included) and consumed for the player
render/lighting root (CurrCell, written at the PhysicsEngine.UpdatePlayerCurrCell
player chokepoint; read by GameWindow:7502/7717), the universal id->cell resolver
(GetVisible), the 3rd-person camera cell (FindVisibleChildCell), and the
block-local terrain origin (TryGetTerrainOrigin, read by CellTransit:484/736).
Comments only — no behavior change. Core suite 1445 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:58 +02:00
Erik
70c559c1ba docs(G.3): gate correction — G.3a core landed; #95 CONFIRMED LIVE (not superseded)
The G.3a visual gate ran a real PlayerTeleport into the 0x0007 dungeon. The core
hold+place worked (grounded on the dungeon floor, no ocean) and Bug A (landblock-
prefix mis-stamp) is fixed (2ce5e5c). But the gate proved #95 (portal-graph
visibility blowup, ~9.1M instances/frame) is LIVE under the current pipeline — my
plan's "likely superseded / conditional G.3b" premise was wrong. Spec §2.5/§3.2 +
ISSUES #133/#95 updated: G.3b (grab_visible_cells stab_list bounding) is REQUIRED,
needs its own grounding/brainstorm. Also noted: the render-only hydration decouple
was reverted (e7058ca) for making the player invisible at Holtburg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:30:43 +02:00
Erik
2ce5e5c862 fix(G.3a): validated-claim placement keeps the claim's landblock prefix (#133)
The #111 validated-claim branch returned lbPrefix | (cellId & 0xFFFF), where
lbPrefix is found by searching resident landblocks for one containing the
candidate position. A dungeon EnvCell's local Y can be negative, so the dungeon
landblock fails the [0,192) bounds test and the loop matches a neighbouring
(e.g. Holtburg) resident block -> the validated claim 0x00070143 got re-stamped
0xA9B30143, making the client mis-resolve the player to the wrong landblock and
spam ACE with rejected moves. The validated claim's full id is authoritative;
return it directly. Byte-identical for the login case (position in the claim's
own landblock); fixes the far-teleport dungeon case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:27:45 +02:00
Erik
e7058caa79 Revert "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)"
This reverts commit ab050a015f.
2026-06-13 18:05:36 +02:00
Erik
3238f1fde4 docs(G.3a): note CacheCellStruct's unconditional UCG CellGraph add is inert (#133)
Code-review follow-up: the hydration decouple's safety rests not only on
CacheCellStruct self-gating its BSP cache, but on the fact that a geometry-less
cell — though now added to the UCG CellGraph unconditionally — never enters the
_cellStruct BSP dictionary membership/placement resolve through, so the player
can never be rooted in one. Document that load-bearing invariant at the hoist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:33:52 +02:00
Erik
ab050a015f 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>
2026-06-13 17:26:34 +02:00
Erik
f22121bd7d 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>
2026-06-13 17:16:12 +02:00
Erik
aca4b4645a refactor(G.3a): Place flips Idle before delegate; test mid-hold reset (#133)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:11:40 +02:00
Erik
7947d7ad0a feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:06:33 +02:00
Erik
c9650bd3bd plan(G.3a): core teleport-into-dungeon implementation plan (#133)
TDD plan for the gated G.3a core: a pure TeleportArrivalController state machine
(hold-until-hydration + force-snap on impossible/timeout) + its GameWindow wiring
(replace the unconditional arrival snap with recenter + deferred BeginArrival;
per-frame Tick; readiness predicate reusing the #107 login triplet) + the EnvCell
physics/visibility hydration decouple + the visual acceptance gate. G.3b/c/d get
their own plans after the gate.

Also syncs the spec: the readiness predicate reuses SampleTerrainZ + IsSpawnCellReady
+ IsSpawnClaimUnhydratable (the validated #107 login gate) rather than a new
IsLandblockApplied query — strictly more faithful, less new surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:02:03 +02:00
Erik
6680fd42b2 spec: G.3 dungeon support design (M1.5 exit-gate) — phased, retail-faithful
Brainstorm outcome for #133/G.3. Grounds the corrected root cause (dungeon
landblock = flat terrain + EnvCells, streams via the existing pipeline; the
blocker is the teleport-arrival snap firing BEFORE the dest landblock hydrates)
against the current code (5 verified seams) and lays out Approach C:

  G.3a  core teleport-into-dungeon: hold-until-hydration on the arrival path
        (reuse #107 IsSpawnCellReady + IsSpawnClaimUnhydratable) + #111
        validated-claim EnvCell placement + dest-ready streaming query +
        dest-coord validation + timeout safety + decouple EnvCell
        physics/visibility hydration from the render-mesh guard.  -> VISUAL GATE
  G.3b  #95 stab_list bounding — CONDITIONAL on the gate showing the blowup
        (its repro is stale, from the T4-deleted WB path; the current flood is
        landblock-confined + enqueue-once, so #95 is likely superseded).
  G.3c  faithful TeleportAnimState portal-tunnel FSM (decomp 004d6300 /
        219405-219774); the TAS_TUNNEL hold-exit gates on G.3a's same readiness
        predicate (the tunnel IS the hold's visual form).
  G.3d  recall game-actions (/ls etc.) — same arrival flow; doubles as the test
        lever.

Supersedes the §12 port-plan of r09 (most of it already shipped); r09 stays the
wire/format/recall contract reference. Resolves the handoff's 4 open questions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:43:39 +02:00
182 changed files with 34265 additions and 929 deletions

3
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,633 @@
# G.3a — Core Teleport-Into-Dungeon Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path.
**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard.
**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5).
**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes.
---
## File Structure
| File | Responsibility | Action |
|---|---|---|
| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** |
| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** |
| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** |
`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1).
---
## Task 1: `TeleportArrivalController` (pure state machine, TDD)
**Files:**
- Create: `src/AcDream.App/World/TeleportArrivalController.cs`
- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
public class TeleportArrivalControllerTests
{
// Records each Place(destPos, destCell, forced) call.
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
private static TeleportArrivalController Make(
ArrivalReadiness verdict,
List<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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,6 +50,11 @@
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv"> <None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </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>
<ItemGroup> <ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ --> <!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -69,6 +69,33 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[]; 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 // 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 // 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 // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
@ -95,10 +122,107 @@ uniform mat4 uViewProjection;
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's // _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset; 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 vec3 vNormal;
out vec2 vTexCoord; out vec2 vTexCoord;
out vec3 vWorldPos; out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle; out flat uvec2 vTextureHandle;
out flat uint vTextureLayer; out flat uint vTextureLayer;
@ -123,6 +247,7 @@ void main() {
vWorldPos = worldPos.xyz; vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal); vNormal = normalize(mat3(model) * aNormal);
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
vTexCoord = aTexCoord; vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];

View file

@ -7,10 +7,13 @@ uniform sampler2D uTex;
uniform int uUseTexture; uniform int uUseTexture;
void main() { void main() {
if (uUseTexture != 0) { if (uUseTexture == 1) {
// Font atlas is a single-channel R8 texture; red = coverage alpha. // Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r; float coverage = texture(uTex, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage); 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 { } else {
FragColor = vColor; FragColor = vColor;
} }

View file

@ -25,14 +25,39 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly Shader _shader; private readonly Shader _shader;
private readonly uint _vao; private readonly uint _vao;
private readonly uint _vbo; private readonly uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes; private int _vboCapacityBytes;
private readonly List<float> _textBuf = new(8192); private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024); 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 _textVerts;
private int _rectVerts; private int _rectVerts;
private Vector2 _screenSize; 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) public TextRenderer(GL gl, string shaderDir)
{ {
_gl = gl; _gl = gl;
@ -56,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(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> /// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -64,17 +103,32 @@ public sealed unsafe class TextRenderer : IDisposable
_screenSize = screenSize; _screenSize = screenSize;
_textBuf.Clear(); _textBuf.Clear();
_rectBuf.Clear(); _rectBuf.Clear();
_segUsed = 0; // pool the SpriteSeg objects across frames
_textVerts = 0; _textVerts = 0;
_rectVerts = 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> /// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color) public void DrawRect(float x, float y, float w, float h, Vector4 color)
{ {
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
_rectVerts += 6; else { 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> /// <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) public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{ {
@ -119,16 +173,47 @@ public sealed unsafe class TextRenderer : IDisposable
if (gw > 0 && gh > 0) if (gw > 0 && gh > 0)
{ {
AppendQuad(_textBuf, if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
gx, gy, gw, gh, else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
color);
_textVerts += 6;
} }
cursorX += g.Advance; 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, private static void AppendQuad(List<float> buf,
float x, float y, float w, float h, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color) float u0, float v0, float u1, float v1, Vector4 color)
@ -159,7 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary> /// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font) public void Flush(BitmapFont? font)
{ {
if (_textVerts == 0 && _rectVerts == 0) return; bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
_shader.Use(); _shader.Use();
_shader.SetVec2("uScreenSize", _screenSize); _shader.SetVec2("uScreenSize", _screenSize);
@ -171,36 +258,85 @@ public sealed unsafe class TextRenderer : IDisposable
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace); 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.DepthTest);
_gl.Disable(EnableCap.CullFace); _gl.Disable(EnableCap.CullFace);
_gl.DepthMask(false);
_gl.Enable(EnableCap.Blend); _gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Untextured rects first — they form panel backgrounds. // LAYERED compositing for the UI (background → fill → text):
if (_rectVerts > 0) // 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", 0); _shader.SetInt("uUseTexture", 2);
UploadBuffer(_rectBuf); _gl.ActiveTexture(TextureUnit.Texture0);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); _shader.SetInt("uTex", 0);
for (int i = 0; i < segUsed; i++)
{
var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex));
}
} }
// Textured text glyphs. // 2. Untextured rects — widget fills on top of the chrome.
if (_textVerts > 0 && font is not null) if (rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
}
// 3. Textured debug-font text glyphs on top.
if (textVerts > 0 && font is not null)
{ {
_shader.SetInt("uUseTexture", 1); _shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0); _gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0); _shader.SetInt("uTex", 0);
UploadBuffer(_textBuf); UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); _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) private void UploadBuffer(List<float> buf)
@ -223,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable
public void Dispose() public void Dispose()
{ {
_gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo); _gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao); _gl.DeleteVertexArray(_vao);
_shader.Dispose(); _shader.Dispose();

View file

@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly GL _gl; private readonly GL _gl;
private readonly DatCollection _dats; private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new(); private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
/// <summary> /// <summary>
/// Composite cache for surface-with-override-origtex entries (Phase 5 /// Composite cache for surface-with-override-origtex entries (Phase 5
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
@ -30,6 +31,18 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new(); private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle; 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; private readonly Wb.BindlessSupport? _bindless;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three // Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
@ -80,6 +93,74 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h; 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> /// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose /// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with /// "why are clouds not transparent" — if cloud textures come out with
@ -476,7 +557,19 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return composed; return composed;
} }
private uint UploadRgba8(DecodedTexture decoded) /// <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)
{ {
uint tex = _gl.GenTexture(); uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, tex); _gl.BindTexture(TextureTarget.Texture2D, tex);
@ -493,8 +586,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
PixelType.UnsignedByte, PixelType.UnsignedByte,
p); p);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); // font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
@ -582,5 +678,17 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
_gl.DeleteTexture(_magentaHandle); _gl.DeleteTexture(_magentaHandle);
_magentaHandle = 0; _magentaHandle = 0;
} }
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
// by GetOrUploadRenderSurface but was not swept here before this fix.
foreach (var h in _handlesByRenderSurfaceId.Values)
_gl.DeleteTexture(h);
_handlesByRenderSurfaceId.Clear();
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
// (IconComposer composited icons). Not stored in any keyed cache.
foreach (var h in _adhocHandles)
_gl.DeleteTexture(h);
_adhocHandles.Clear();
} }
} }

View file

@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private uint _clipSlotBuffer; private uint _clipSlotBuffer;
private uint[] _clipSlotData = Array.Empty<uint>(); 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 // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind // 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. // our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
@ -231,6 +242,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BufferData(GLEnum.ShaderStorageBuffer, _gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); (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.ShaderStorageBuffer, 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
} }
@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot) public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
=> _cellIdToSlot = 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 // GetEnvCellGeomId
// Verbatim copy of WB EnvCellRenderManager.cs:94-103. // Verbatim copy of WB EnvCellRenderManager.cs:94-103.
@ -843,6 +877,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// WB EnvCellRenderManager.cs:406-409: uniform state setup. // WB EnvCellRenderManager.cs:406-409: uniform state setup.
_shader.SetInt("uRenderPass", (int)renderPass); _shader.SetInt("uRenderPass", (int)renderPass);
_shader.SetInt("uFilterByCell", 0); _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 // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from // moving"): upload uViewProjection HERE rather than inheriting it from
@ -997,6 +1032,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
} }
} }
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
private int[] GetCellLightSet(uint cellId)
{
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RenderModernMDIInternal // RenderModernMDIInternal
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant). // Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
@ -1016,6 +1080,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
int passIdx = (int)renderPass; int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return; 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. // §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 // 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 — // was established leaked it (same early-out shape as the totalDraws==0 leak —
@ -1213,6 +1286,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr); (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. // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
// (globalVao validated at the top of the method — a return here would leak the // (globalVao validated at the top of the method — a return here would leak the
// pass state established above.) // pass state established above.)
@ -1228,6 +1330,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback). // (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
BindClipRegionBinding2(); 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.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
@ -1443,5 +1547,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; } if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3 if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3 if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
} }
} }

View file

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

View file

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

View file

@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId)
{ {
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : 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> /// <summary>

View file

@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); _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> /// <summary>
/// Drain up to <paramref name="maxBatchSize"/> completed results. /// Drain up to <paramref name="maxBatchSize"/> completed results.
/// Non-blocking. Call from the render thread once per OnUpdate. /// Non-blocking. Call from the render thread once per OnUpdate.
@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable
} }
while (_inbox.Reader.TryRead(out var job)) 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); EnqueuePrioritized(job, highPriority, lowPriority);
}
if (highPriority.Count == 0 && lowPriority.Count == 0) if (highPriority.Count == 0 && lowPriority.Count == 0)
continue; continue;
@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable
lowPriority.Enqueue(job); 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( private static void RemoveLowPriorityJobsForLandblock(
Queue<LandblockStreamJob> queue, Queue<LandblockStreamJob> queue,
uint landblockId, uint landblockId,

View file

@ -22,9 +22,24 @@ public sealed class StreamingController
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions; private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain; private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain; private readonly Action<uint>? _removeTerrain;
private readonly Action? _clearPendingLoads;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; 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> /// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain + /// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter. /// scenery + entities). Set at construction; readable thereafter.
@ -71,13 +86,15 @@ public sealed class StreamingController
GpuWorldState state, GpuWorldState state,
int nearRadius, int nearRadius,
int farRadius, int farRadius,
Action<uint>? removeTerrain = null) Action<uint>? removeTerrain = null,
Action? clearPendingLoads = null)
{ {
_enqueueLoad = enqueueLoad; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions; _drainCompletions = drainCompletions;
_applyTerrain = applyTerrain; _applyTerrain = applyTerrain;
_removeTerrain = removeTerrain; _removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_state = state; _state = state;
NearRadius = nearRadius; NearRadius = nearRadius;
FarRadius = farRadius; FarRadius = farRadius;
@ -97,7 +114,76 @@ public sealed class StreamingController
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item> /// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list> /// </list>
/// </summary> /// </summary>
public void Tick(int observerCx, int observerCy) public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
{
// Hysteresis. Cases:
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// → re-collapse onto it.
// - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold — never expand on an adjacent flicker.
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
// from the ocean-grid dungeon block) → expand.
if (insideDungeon && centerId != _collapsedCenter)
EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
ExitDungeonExpand(observerCx, observerCy);
else
SweepCollapsed();
}
else if (insideDungeon)
{
EnterDungeonCollapse(observerCx, observerCy, centerId);
}
else
{
NormalTick(observerCx, observerCy);
}
DrainAndApply();
}
/// <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)
{ {
if (_region is null) if (_region is null)
{ {
@ -116,9 +202,88 @@ public sealed class StreamingController
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id); foreach (var id in diff.ToUnload) _enqueueUnload(id);
} }
}
// Drain up to N completions per frame so a big diff doesn't spike /// <summary>
// GPU upload time. Remaining completions wait for the next frame. /// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
/// (ACE <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()
{
var drained = _drainCompletions(MaxCompletionsPerFrame); var drained = _drainCompletions(MaxCompletionsPerFrame);
foreach (var result in drained) foreach (var result in drained)
{ {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,11 @@ using System.Numerics;
namespace AcDream.App.UI; 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> /// <summary>
/// Base class for every UI widget in the retained-mode tree. /// Base class for every UI widget in the retained-mode tree.
/// ///
@ -88,6 +93,39 @@ public abstract class UiElement
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary> /// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
public int ZOrder { get; set; } 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 ────────────────────────────────────────────────── // ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; } public UiElement? Parent { get; private set; }
@ -108,6 +146,19 @@ public abstract class UiElement
return true; 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 ─────────────────────────────────────────────── // ── Virtual overrides ───────────────────────────────────────────────
/// <summary> /// <summary>
@ -116,6 +167,25 @@ public abstract class UiElement
/// </summary> /// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { } 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> /// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { } protected virtual void OnTick(double deltaSeconds) { }
@ -146,12 +216,18 @@ public abstract class UiElement
{ {
if (!Visible) return; if (!Visible) return;
// Translate into our local space. // Translate into our local space + push this window's opacity (multiplies into
// descendants' sprite/rect draws; text bypasses the alpha so it stays sharp).
ctx.PushTransform(Left, Top); ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try try
{ {
OnDraw(ctx); 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). // Children painted back-to-front (lowest ZOrder first).
if (_children.Count > 0) if (_children.Count > 0)
{ {
@ -161,9 +237,42 @@ public abstract class UiElement
for (int i = 0; i < ordered.Length; i++) for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx); 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 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(); ctx.PopTransform();
} }
} }
@ -183,9 +292,14 @@ public abstract class UiElement
/// </summary> /// </summary>
internal UiElement? HitTest(float localX, float localY) internal UiElement? HitTest(float localX, float localY)
{ {
if (!Visible || !Enabled || ClickThrough) return null; if (!Visible || !Enabled) return null;
// Children first, in reverse Z-order (topmost first). // Children first, in reverse Z-order (topmost first). ClickThrough means
// THIS element is transparent to the pointer — but its children are NOT.
// A ClickThrough container (e.g. a UiDatElement panel that hosts the chat
// input / transcript) must still let the pointer reach its behavioral
// children, so the ClickThrough check happens AFTER the child walk, gating
// only whether THIS element claims the hit.
if (_children.Count > 0) if (_children.Count > 0)
{ {
var ordered = _children.ToArray(); var ordered = _children.ToArray();
@ -198,6 +312,70 @@ public abstract class UiElement
} }
} }
if (ClickThrough) return null;
return OnHitTest(localX, localY) ? this : null; return OnHitTest(localX, localY) ? this : null;
} }
// ── Anchor layout ────────────────────────────────────────────────────
private bool _anchorCaptured;
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
/// the margins captured (at first layout / design size) to each anchored edge.
/// Called by the parent each frame before drawing children.</summary>
internal void ApplyAnchor(float parentW, float parentH)
{
if (Anchors == AnchorEdges.None) return;
if (!_anchorCaptured)
{
_amL = Left; _amT = Top;
_amR = parentW - (Left + Width);
_amB = parentH - (Top + Height);
_aw0 = Width; _ah0 = Height;
_anchorCaptured = true;
}
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
Left = x; Top = y; Width = w; Height = h;
}
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
/// button width changes) so the new rect becomes the anchor baseline.</summary>
internal void ResetAnchorCapture() => _anchorCaptured = false;
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
/// a chat input blurring itself (exiting write mode) after submit.</summary>
internal UiRoot? FindRoot()
{
UiElement e = this;
while (e.Parent is not null) e = e.Parent;
return e as UiRoot;
}
/// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.</summary>
public static (float x, float y, float w, float h) ComputeAnchoredRect(
AnchorEdges a, float mL, float mT, float mR, float mB,
float w0, float h0, float parentW, float parentH)
{
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
float x, w;
if (l && r) { x = mL; w = parentW - mR - mL; }
else if (r) { w = w0; x = parentW - mR - w0; }
else { x = mL; w = w0; }
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
float y, h;
if (t && b) { y = mT; h = parentH - mB - mT; }
else if (b) { h = h0; y = parentH - mB - h0; }
else { y = mT; h = h0; }
if (w < 0) w = 0;
if (h < 0) h = 0;
return (x, y, w, h);
}
} }

View file

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

View file

@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable
public UiRoot Root { get; } = new(); public UiRoot Root { get; } = new();
public TextRenderer TextRenderer { get; } public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; } 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; private long _startTicks = System.Environment.TickCount64;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
@ -82,6 +89,7 @@ public sealed class UiHost : System.IDisposable
public void WireKeyboard(IKeyboard kb) public void WireKeyboard(IKeyboard kb)
{ {
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k); kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k); kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
kb.KeyChar += (_, c) => Root.OnChar(c); kb.KeyChar += (_, c) => Root.OnChar(c);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -57,14 +57,17 @@ public class UiLabel : UiElement
/// callback. Retail equivalent is Keystone's button widget, driven by /// callback. Retail equivalent is Keystone's button widget, driven by
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed / /// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
/// disabled) from the panel layout. /// 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> /// </summary>
public class UiButton : UiPanel public class UiSimpleButton : UiPanel
{ {
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public event System.Action? Click; public event System.Action? Click;
public UiButton() public UiSimpleButton()
{ {
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f); BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);

View file

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

View file

@ -4,6 +4,10 @@ using System.Numerics;
namespace AcDream.App.UI; 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> /// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities /// Top-level UI container. Implements the retail "Device" responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
@ -40,6 +44,10 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary> /// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; } 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> /// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect /// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48. /// are ignored. Retail sets this via Device vtable +0x48.
@ -49,12 +57,30 @@ public sealed class UiRoot : UiElement
/// <summary>Widget with mouse capture (during click-drag).</summary> /// <summary>Widget with mouse capture (during click-drag).</summary>
public UiElement? Captured { get; private set; } 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> /// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; } public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; } public object? DragPayload { get; private set; }
private UiElement? _lastDragHoverTarget; private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY; private int _pressX, _pressY;
private bool _dragCandidate; 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 private const int DragDistanceThreshold = 3; // pixels, retail-observed
// Hover / tooltip tracking. // Hover / tooltip tracking.
@ -109,6 +135,13 @@ public sealed class UiRoot : UiElement
// Render children (panels) sorted by z-order — modal last so it // Render children (panels) sorted by z-order — modal last so it
// sits on top. // sits on top.
DrawSelfAndChildren(ctx); 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) ── // ── Input entry points (called from GameWindow's Silk.NET handlers) ──
@ -120,6 +153,26 @@ public sealed class UiRoot : UiElement
MouseX = x; MouseX = x;
MouseY = y; 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 // If we have capture, deliver MouseMove to the captured widget
// AND drive drag state machine; do NOT fall through. // AND drive drag state machine; do NOT fall through.
if (Captured is not null) if (Captured is not null)
@ -155,19 +208,68 @@ public sealed class UiRoot : UiElement
if (Modal is not null && !ContainsAbsolute(Modal, x, y)) if (Modal is not null && !ContainsAbsolute(Modal, x, y))
return; return;
var (target, lx, ly) = HitTestTopDown(x, y); var (target, _, _) = HitTestTopDown(x, y);
if (target is null) 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); WorldMouseFallThrough?.Invoke(btn, x, y, flags);
return; return;
} }
// Set keyboard focus if target accepts it. // Keyboard focus follows a left click: the input bar (an edit control) takes
if (target.AcceptsFocus) SetKeyboardFocus(target); // focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
if (btn == UiMouseButton.Left)
SetKeyboardFocus(target.AcceptsFocus ? target : null);
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
SetCapture(target); SetCapture(target);
_dragCandidate = true;
// Window resize / move: find the window (Draggable or Resizable ancestor).
// A left-drag starting near an edge resizes; interior drag repositions;
// otherwise it's a normal drag-drop candidate.
var window = FindWindow(target);
if (btn == UiMouseButton.Left && window is not null)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None)
{
// Edge resize still wins, even over a CapturesPointerDrag child:
// a resizable chat window can be resized from its frame.
_resizeTarget = window;
_resizeEdges = edges;
_resizeStartX = window.Left; _resizeStartY = window.Top;
_resizeStartW = window.Width; _resizeStartH = window.Height;
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
else if (target.CapturesPointerDrag)
{
// The pressed widget owns interior drags (e.g. text selection):
// do NOT move the ancestor window. The already-dispatched MouseDown
// event + SetCapture(target) let the target drive its own drag via
// the MouseMove events it receives while captured.
_dragCandidate = false;
}
else if (window.Draggable)
{
_windowDragTarget = window;
_windowDragOffX = x - (int)window.Left;
_windowDragOffY = y - (int)window.Top;
_dragCandidate = false;
}
else { _dragCandidate = true; }
}
else if (target.CapturesPointerDrag)
{
// No window ancestor, but the target still owns its interior drag.
_dragCandidate = false;
}
else
{
_dragCandidate = true;
}
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201). // Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
int rawType = btn switch int rawType = btn switch
@ -177,8 +279,13 @@ public sealed class UiRoot : UiElement
UiMouseButton.Middle => UiEventType.MiddleDown, UiMouseButton.Middle => UiEventType.MiddleDown,
_ => UiEventType.MouseDown, _ => 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, var e = new UiEvent(target.EventId, target, rawType,
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly); Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(target, in e); BubbleEvent(target, in e);
} }
@ -187,6 +294,20 @@ public sealed class UiRoot : UiElement
MouseX = x; MouseY = y; MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false); 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) if (DragSource is not null)
{ {
FinishDrag(x, y); FinishDrag(x, y);
@ -251,6 +372,18 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0) 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. // Focus widget first.
if (KeyboardFocus is not null) if (KeyboardFocus is not null)
{ {
@ -436,6 +569,48 @@ public sealed class UiRoot : UiElement
return (null, 0, 0); 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) private static bool ContainsAbsolute(UiElement e, int x, int y)
{ {
var sp = e.ScreenPosition; var sp = e.ScreenPosition;

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" /> <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> </ItemGroup>
</Project> </Project>

View file

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

View file

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

View file

@ -1,10 +1,139 @@
using System.Diagnostics; using System.Diagnostics;
using AcDream.Cli;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Env = System.Environment; 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. // 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 // This proves DatReaderWriter works on our retail dats and gives us a baseline inventory
// to compare against what a future renderer needs. // to compare against what a future renderer needs.
@ -160,3 +289,146 @@ static (string Name, Func<int> Count)[] CountCellByLow16(DatCollection dats)
("Region", () => dats.GetAllIdsOfType<Region>().Count()), ("Region", () => dats.GetAllIdsOfType<Region>().Count()),
}; };
} }
/// <summary>
/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the
/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter
/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual
/// (front-bar and back-bar/track), each containing:
/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite)
/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite)
/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite)
/// - elem 0x100000EA (DirectStateDesc = right-edge sprite)
///
/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014.
/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt.
/// </summary>
static int DumpVitalsBars(string dvbDatDir)
{
const uint HEALTH_ELEM_ID = 0x100000E6u;
const uint STAMINA_ELEM_ID = 0x100000ECu;
const uint MANA_ELEM_ID = 0x100000EEu;
if (!Directory.Exists(dvbDatDir))
{
Console.Error.WriteLine($"error: directory not found: {dvbDatDir}");
return 2;
}
using var dats = new DatCollection(dvbDatDir, DatAccessType.Read);
// Find the vitals layout: scan all LayoutDescs for one containing the health meter element.
Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)...");
uint? vitalsId = null;
LayoutDesc? vitalsLayout = null;
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>())
{
var ld = dats.Get<LayoutDesc>(id);
if (ld is null) continue;
if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; }
}
if (vitalsLayout is null)
{
Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter).");
return 1;
}
Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}");
Console.WriteLine();
// For each vital meter, collect all MediaDescImage DataIds from its sub-tree.
var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") };
foreach (var (eid, vitalName) in meters)
{
Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:");
var meterElem = VbFindElement(vitalsLayout!, eid);
if (meterElem is null) { Console.WriteLine(" <element not found>"); continue; }
var sprites = new List<(string Role, uint DataId, string DrawMode)>();
VbCollectSprites(meterElem, sprites, 0);
if (sprites.Count == 0)
{
Console.WriteLine(" <no sprites found in sub-tree>");
}
else
{
foreach (var (role, dataId, drawMode) in sprites)
Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})");
}
Console.WriteLine();
}
return 0;
}
// ─── dump-vitals-bars helpers ───────────────────────────────────────────────
static bool VbContainsElementId(LayoutDesc ld, uint targetId)
{
var elems = ld.Elements;
foreach (var kvp in elems)
{
if (kvp.Key == targetId) return true;
if (VbChildContains(kvp.Value, targetId)) return true;
}
return false;
}
static bool VbChildContains(ElementDesc elem, uint targetId)
{
foreach (var kvp in elem.Children)
{
if (kvp.Key == targetId) return true;
if (VbChildContains(kvp.Value, targetId)) return true;
}
return false;
}
static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId)
{
foreach (var kvp in ld.Elements)
{
if (kvp.Key == targetId) return kvp.Value;
var found = VbFindChild(kvp.Value, targetId);
if (found is not null) return found;
}
return null;
}
static ElementDesc? VbFindChild(ElementDesc elem, uint targetId)
{
foreach (var kvp in elem.Children)
{
if (kvp.Key == targetId) return kvp.Value;
var found = VbFindChild(kvp.Value, targetId);
if (found is not null) return found;
}
return null;
}
static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth)
{
string indent = new string(' ', depth * 2);
// Check the element's direct StateDesc
if (elem.StateDesc is not null)
VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_);
// Check each named state
foreach (var kvp in elem.States)
VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_);
// Recurse into children
foreach (var kvp in elem.Children)
VbCollectSprites(kvp.Value, out_, depth + 1);
}
static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_)
{
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
out_.Add((role, img.File, img.DrawMode.ToString()));
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -80,7 +80,35 @@ public sealed class WorldSession : IDisposable
// sizing hint for tall-scenery selection indicators when the // sizing hint for tall-scenery selection indicators when the
// server publishes it for non-useable display entities. // server publishes it for non-useable display entities.
uint? Useability = null, uint? Useability = null,
float? UseRadius = null); float? UseRadius = null,
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
uint IconId = 0,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
// WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay
// (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively.
// Zero when the server did not send the field (common for most entities).
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor. CreateObject-only; 0 = no effect.
uint UiEffects = 0,
// D.5.4 (2026-06-18): full item field set, forwarded to the object table.
// Quantity fields int? (ACE PropertyInt convention); id/mask fields uint?.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;
@ -153,6 +181,20 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public event Action<SetState.Parsed>? StateUpdated; 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> /// <summary>
/// Fires when the server sends a PlayerTeleport (0xF751) game message, /// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload /// signalling that the player is entering portal space. The uint payload
@ -716,7 +758,26 @@ public sealed class WorldSession : IDisposable
parsed.Value.Friction, parsed.Value.Friction,
parsed.Value.Elasticity, parsed.Value.Elasticity,
parsed.Value.Useability, parsed.Value.Useability,
parsed.Value.UseRadius)); parsed.Value.UseRadius,
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
parsed.Value.UiEffects,
parsed.Value.WeenieClassId,
parsed.Value.Value,
parsed.Value.StackSize,
parsed.Value.StackSizeMax,
parsed.Value.Burden,
parsed.Value.ItemsCapacity,
parsed.Value.ContainersCapacity,
parsed.Value.ContainerId,
parsed.Value.WielderId,
parsed.Value.ValidLocations,
parsed.Value.CurrentWieldedLocation,
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship));
} }
} }
else if (op == DeleteObject.Opcode) else if (op == DeleteObject.Opcode)
@ -890,6 +951,13 @@ public sealed class WorldSession : IDisposable
if (parsed is not null) if (parsed is not null)
VitalCurrentUpdated?.Invoke(parsed.Value); 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) else if (op == GameEventEnvelope.Opcode)
{ {
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the // Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
@ -1072,6 +1140,18 @@ public sealed class WorldSession : IDisposable
SendGameAction(body); 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> /// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{ {

View file

@ -92,6 +92,16 @@ public sealed class CombatState
public float GetHealthPercent(uint guid) => public float GetHealthPercent(uint guid) =>
_healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f; _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; public int TrackedTargetCount => _healthByGuid.Count;
// ── Inbound handlers (wired from WorldSession.GameEvents) ──────────────── // ── Inbound handlers (wired from WorldSession.GameEvents) ────────────────

View file

@ -121,14 +121,13 @@ public sealed class PropertyBundle
} }
/// <summary> /// <summary>
/// Per-item live state. The server owns item identity (ObjectId); /// Per-object live state (the data side of every server object — items and creatures alike).
/// acdream mirrors properties here on <c>CreateObject</c> and updates /// Retail <c>ACCWeenieObject</c>.
/// via <c>UpdateProperty*</c> messages.
/// </summary> /// </summary>
public sealed class ItemInstance public sealed class ClientObject
{ {
public uint ObjectId { get; init; } public uint ObjectId { get; init; }
public uint WeenieClassId { get; init; } // "blueprint" public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public ItemType Type { get; set; } public ItemType Type { get; set; }
public EquipMask ValidLocations { get; set; } public EquipMask ValidLocations { get; set; }
@ -136,6 +135,13 @@ public sealed class ItemInstance
public uint IconId { get; set; } // 0x06xxxxxx public uint IconId { get; set; } // 0x06xxxxxx
public uint IconUnderlayId{ get; set; } // "magic" underlay public uint IconUnderlayId{ get; set; } // "magic" underlay
public uint IconOverlayId { get; set; } // "enchanted" overlay 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 StackSize { get; set; } = 1;
public int StackSizeMax { get; set; } = 1; public int StackSizeMax { get; set; } = 1;
public int Burden { get; set; } // per-stack total public int Burden { get; set; } // per-stack total
@ -144,9 +150,49 @@ public sealed class ItemInstance
public int ContainerSlot { get; set; } = -1; public int ContainerSlot { get; set; } = -1;
public bool Attuned { get; set; } public bool Attuned { get; set; }
public bool Bonded { 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(); 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> /// <summary>
/// Container = inventory pack. Hierarchy is strictly 2-deep: character /// Container = inventory pack. Hierarchy is strictly 2-deep: character
/// → side packs; a side pack cannot hold another side pack (r06 §7). /// → side packs; a side pack cannot hold another side pack (r06 §7).
@ -157,7 +203,7 @@ public sealed class Container
public int Capacity { get; set; } = 102; // main inv default public int Capacity { get; set; } = 102; // main inv default
public int SideCapacity { get; set; } = 0; // 0 for side-pack public int SideCapacity { get; set; } = 0; // 0 for side-pack
public int BurdenLimit { get; set; } public int BurdenLimit { get; set; }
public List<ItemInstance> Items { get; } = new(); public List<ClientObject> Items { get; } = new();
public List<Container> SidePacks { get; } = new(); // empty for side-pack public List<Container> SidePacks { get; } = new(); // empty for side-pack
public bool IsSidePack => SideCapacity == 0; public bool IsSidePack => SideCapacity == 0;
} }

View file

@ -0,0 +1,287 @@
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();
}
}

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