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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
@
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>
@
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>
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>
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 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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>