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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
@
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>
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>
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>
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>
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>
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>
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>
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>
- #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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Adds the 2026-06-13 dungeon-G.3 handoff doc + a dat-probe test that
RESOLVED the pivotal design ambiguity. A research agent assumed dungeon
landblocks are terrain-less (LandblockLoader.Load returns null ->
"rewrite the pipeline for terrain-less landblocks", 13 seams). The dat
probe refutes it: dungeon landblock 0x0125 has a flat (all-zero-height)
LandBlock record PLUS 71 EnvCells and no buildings/objects -> it streams
fine via the existing pipeline as a flat-terrain landblock.
The real blocker (#133) is narrow: the teleport-arrival handler
(GameWindow.cs:4928) snaps the player via physics.Resolve BEFORE the
dungeon landblock streams in -> Resolve falls back to the resident
Holtburg landblocks -> places the player at A9B3 ocean. Fix shape:
hold-until-hydration (reuse the #107 IsSpawnCellReady gate for the
teleport-arrival path) + place into the EnvCell + the retail
TeleportAnimState portal-space FSM for the full-G.3 loading screen.
ACE confirms dungeons are single-landblock, so "multi-landblock LOD"
is moot.
The handoff captures: this session's closes (#108-residual/#127/#125
gated, #116 partial), the M1.5 re-open decision, the corrected root
cause, the 5-way reference grounding (holtburger/ACE/retail decomp +
the dat probe), the design direction, and the open brainstorm questions.
Next session: resume the brainstorm at "propose approaches" -> spec ->
writing-plans -> implement.
Suites green: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correction to 1bf037a. The user reverted the M1.5 landing: the indoor
world isn't done while dungeons are completely broken. Attempting the
dungeon demo revealed it's not a single bug (#133) but a whole-feature
gap — terrain-less indoor-only dungeon landblocks aren't supported
anywhere in the streaming/load/render/physics pipeline:
- LandblockLoader.Load returns null when there's no LandBlock terrain
record (dungeons have none) -> the dungeon never loads.
- LandblockStreamer fails when the terrain mesh build returns null
(dungeons have no terrain mesh).
- The teleport-arrival snap Resolves before the dungeon hydrates ->
places the player in the old Holtburg frame over ocean.
The user chose the FULL Phase G.3 scope (dungeon streaming + portal-space
loading screen + multi-landblock LOD + PlayerTeleport handling) and
pulled it into M1.5. M1.5 lands only when BOTH the building/cellar demo
(done) and the dungeon demo (enter via portal, navigate 3-5 rooms, walls
block, smooth transitions) pass. M2 (CombatMath) re-deferred.
Currently brainstorming the dungeon-support design (spec ->
docs/superpowers/specs/). Docs corrected: milestones (M1.5 ACTIVE +
extended, M2 DEFERRED, currently-working-toward -> M1.5), CLAUDE.md
current-state, ISSUES.md #133 (G.3 pulled into M1.5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M1.5 "Indoor world feels right" lands on its primary building/cellar
demo, user-gated across the 2026-06 sessions: multi-floor inn navigation
without sling-out/wall-clip, cottage cellar descend+ascend without
falling through, walls block everywhere, smooth cell transitions. The
holistic Option-A render port (one DrawInside(viewer_cell), BR-2..BR-7 /
T1..T6) and the A6.P4 per-cell shadow physics shipped and were gated; the
doorway-flap family is closed (#119/#128, #112, #113, #124,
#129/#130/#131/#132, #108-residual, #127) and the #90/synthesis
workarounds removed.
The dungeon half is the one piece NOT landed: attempting the dungeon demo
(meeting-hall portal) surfaced issue #133 - teleport-into-a-dungeon snaps
the player BEFORE the dungeon landblock streams in (GameWindow.cs:4928
Resolve falls back to the resident Holtburg landblocks -> snaps to an
outdoor cell over ocean). That is Phase G.3 (dungeon streaming +
PlayerTeleport handling, M4), not a render bug (#95 died with the Option
A rewrite). Per the milestones doc's pre-flagged choice, the dungeon demo
is promoted to G.3 and M1.5 lands on the building/cellar demo (user
decision 2026-06-13).
Start M2 "Kill a drudge" - first port target CombatMath.ComputeDamage
(port-ready per the combat-math research memo; ACE oracle). Drudges spawn
outdoors for the demo, so M2 does not depend on #133/G.3.
Files: milestones doc (M1.5 -> LANDED, M2 -> ACTIVE, currently-working-
toward flipped), CLAUDE.md current-state -> M2, ISSUES.md #133 filed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GL root cause was fixed in fcade06 (the gpu_us query-ring stale
errors). This closes the remaining design debt: a genuinely-failed
UploadMeshData was dropped permanently.
Exact mechanism (traced this session): UploadMeshData's catch returns
null, the staged item is already consumed, and _renderData stays empty -
but the prepared data lingers in _cpuMeshCache, so the #128 EnsureLoaded
re-arm hits PrepareMeshDataAsync's CPU-cache short-circuit
(ObjectMeshManager.cs:448-453) which returns the cached data WITHOUT
re-staging it for upload. The mesh stays invisible until CPU-cache
eviction - session-sticky under low cache pressure (the in-tower
scenario).
Fix: the per-frame Tick drain (WbMeshAdapter) now re-stages a failed
upload for the NEXT frame via ObjectMeshManager.UploadOrRequeue, bounded
by MaxUploadRetries (3). The attempt counter lives on the ObjectMeshData
object so it resets to 0 naturally on re-prepare. Re-stages are
collected and re-enqueued AFTER the drain loop, never inside it, so a
deterministic failure cannot spin the queue within a single frame; past
the cap it gives up with a loud [up-retry] ... giving up line - a
genuine GL defect now surfaces instead of the old silent permanent drop
or an unbounded retry storm. Retail loads content synchronously and has
no such failure mode; this converges the async pipeline toward that
guarantee.
The uncaught GenerateMipmaps path (open-question c) is INTENTIONALLY
left to surface errors - a blanket catch there would mask future real
defects (no-workarounds rule), and its trigger (fcade06) is retired.
No visual gate (robustness). Build green; App.Tests 264 + WbMeshAdapter
tests green. No GL-context test seam exists for the upload path, so the
bounded retry is verified by construction + the regression suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user brought up Ghidra; its decompiler (patchmem.gpr, full PDB)
resolved the Binary-Ninja `test ah,5` x87 branch-sign ambiguity that
blocked the desk read. CSphere::slide_sphere (0x00537440) decompiles
cleanly to:
fVar3 = |cross(collisionNormal, contactPlane.N)|²;
if (::F_EPSILON <= fVar3) { // crease exists
... offset = cross * dot(cross,gDelta)/fVar3;
if (|offset|² < ::F_EPSILON) return COLLIDED_TS; // degenerate guard
... add_offset_to_check_pos -> SLID_TS
}
Retail compares the SQUARED magnitudes against F_EPSILON
(0.000199999995 ~= 0.0002 = PhysicsGlobals.EPSILON). Our port compared
against EpsilonSq (0.0002^2 = 4e-8) - a ~5000x too-tight threshold (the
BN pseudo-C rendered the comparison as `test ah,5` after an x87 FCMP,
which is sign-ambiguous; agent reads disagreed). Fixed both comparisons
at TransitionTypes.cs:3098,3105 to EPSILON.
Effect: crease-exists now needs >=0.81 deg between the wall and contact
normals (was 0.011 deg - which routed near-parallel pairs through the
numerically unstable projection); the degenerate guard now hard-stops
slides under ~1.41 cm like retail (was 0.2 mm). Branch POLARITY was
already correct - no change there.
No regression: full physics suite (612) + full Core (1443) green. Not a
register deviation (no row existed; this is an undocumented porting
error corrected to match retail).
This does NOT close#116 - it fixes a tangential constant, not either
reported shape. Ghidra also settled the two shapes' diagnosis (recorded
in ISSUES.md #116 + physics digest):
- Shape-1: our cn=UnitZ default IS retail-faithful (validate_transition
0x0050aa70 has the identical `if (collision_normal_valid==0)
set_collision_normal(UnitZ)`). The real divergence is upstream -
tick-22760 our collision_normal_valid was false where retail's was
true (it recorded the door-face normal). Needs the instrumented
tick-22760 replay.
- Shape-2 (D4 stays skipped, note sharpened): slide_sphere slides
in-frame (SLID_TS) so Z=1.92 is faithful and the D4 Z=2.0 hard-stop
pin is the suspect half; the threshold fix didn't move D4 (real slide,
not degenerate). Needs a cdb trace of an airborne wall hit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>