Retail's m_pLevelText (0x1000023B) element is a plain UIElement_Text, not a
sprite-digit widget — the "large gold" appearance comes from the dat font
size on that specific element. Since 0x40000001 (18px) is the largest font
confirmed in client_portal.dat and there is no per-element font override in
our LayoutImporter, use rowDatFont (18px) for the level value so it fills
more of the 65×50 element and approaches the retail appearance.
Investigation: namedretail grep gmStatManagementUI + UpdateCharacterInfo
(0x004f0770) confirmed Type-12 UIElement_Text + SetText(L"%d"). No NumberSprite
or DigitSprite widget type exists in retail — the diff is purely font size.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the translucent-gold BackgroundColor tint with sprite 0x06001397
(Button state 6 — the retail dark horizontal bars) for the selected-row
background. When spriteResolve is provided to Bind(), clicking a row now
applies BackgroundSprite=0x06001397 + SpriteResolve on that row and clears
it on all others. Falls back to HighlightBg tint when no resolver is passed
(tests, or contexts without GL).
UiPanel.BackgroundSprite / SpriteResolve added so any panel can host a
sprite background in place of the solid BackgroundColor rect. HandleRowClick
updated to accept spriteResolve and apply the sprite or tint branch
depending on availability. Two new tests verify the sprite / deselect paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RowHeight reduced 44→30px to pack the 9 rows tighter, matching retail's denser list
- Attribute row name/value text now uses Font 0x40000001 (MaxCharHeight=18px) instead of
the default 0x40000000 (16px); both fonts are in client_portal.dat (confirmed 2026-06-26)
- RenderStack gains LargeDatFont field; RenderBootstrap.Create loads both fonts
- FixtureProvider passes LargeDatFont as rowDatFont to CharacterStatController.Bind
- CharacterStatController.Bind gains rowDatFont? parameter (falls back to datFont);
passed to BuildAttributeRows so row UiText elements use the larger font
- Full solution tests green (681/683 App + 1579/1581 Core + 343 Net + 425 UI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Name label color changed from gold to white (retail "Horan" is white; 2026-06-26 ref)
- Level caption now provides 2 lines ("Character" / "Level") so neither truncates in the 65px element
- "Total Experience (XP):" caption (TotalXpLabelId 0x10000234) now visible; fixed Padding=0 on
LabelLeft so the dat-font line is not clipped by the bottom-pin scroll math in small-height elements
- "XP for next level:" caption + value injected as UiText children ON the UiMeter (0x10000236);
the meter's ConsumesDatChildren=true only gates the importer — AddChild at runtime works fine;
framework draws children after OnDraw so the text overlays the red bar faithfully
- Selected footer title (State B) renders WHITE per retail spec; State A title stays body/parchment
- Maxed attribute (raise cost = 0) shows "Infinity!" in Experience To Raise line per retail spec
- 5 new tests; 1 existing LevelCaption test updated to expect 2 lines; 681/683 pass (full suite green)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A position dump (the apparatus, after 5 speculative passes) showed the footer title element is H=55
(the whole footer box) in the dat; centering "Select an Attribute to Improve" in it landed the text
dead-center (~y572), painting over "Skill Credits Available:" (y565) and "Unassigned Experience:"
(y582). Constrain the title to a thin line (H=18) at the footer top + drop its anchors so the
per-frame stretch doesn't re-grow it. All three State-A lines now read cleanly. Diagnosed from data,
not guessing — exactly the apparatus-over-cdb call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUG 1 (tab bar): Tab group elements (0x10000228/229/538) are UiText with
ConsumesDatChildren=true so their 3 button children are consumed at import.
Fix: inject 3 sprite UiTexts per tab as CHILDREN OF LAYOUT ROOT at absolute
tab rects, ZOrder=8/9 so they draw over dat-imported UiTexts (ZOrder=1-3).
Original tab groups hidden. Active tab (Attributes) gold; inactive parchment.
BUG 2 (footer): Three root causes, all fixed.
(a) _byId stores LAST registered copy per id: stateA (0x10000240) was
the Titles-page copy, hidden by the page-visibility pass. Fixed by
walking root.Children to find the Attributes page (contains NameId)
then FindInSubtree for stateA within that subtree.
(b) Attributes-page stateB/stateC siblings (stacked at y=545) were still
Visible=True, drawing over stateA line-1/line-2. Fixed with
HideAllById walking the Attributes page subtree for ids 241/247.
(c) Footer label elements (H=17-18px, Padding=4f) were routed through
UiTexts scroll path: bottom-pinned baseY ended above the top clip
boundary, silently blanking all text. Fixed: LabelProvider sets
Padding=0f for directly-bound footer single-line labels.
UiDatElement.ElementId exposes _info.Id for subtree id-based walks.
676 tests pass, vitals panel unaffected (regression screenshot clean).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Gap 1 — Header captions:
- 0x1000023A ("Character Level") above the level value: bound via LabelLeft().
- 0x10000234 ("Total Experience (XP):") left of total XP: bound via LabelLeft().
- 0x10000237/0x10000238 (XP-to-level label + value) are children of the UiMeter
(0x10000236, ConsumesDatChildren=true) and cannot be bound — documented in
code comments; XP meter fill still bound via Fill=XpFraction.
Gap 2 — Tab bar sprites:
- Tab group elements 0x10000228/229/538 are Type-12 UIElement_Text in the dat
(ConsumesDatChildren=true), so the three button children (left-cap, center,
right-cap) are consumed at import and absent from the widget tree. Old
SetTabState/SetButtonStateRecursive found no UiButton children to set.
- Fix: AddTabSprites() injects three UiText sprite-children per group using
the known RenderSurface ids confirmed from the retail UI layout dump:
Open (active) 0x06005D92/0x06005D94/0x06005D96
Closed (inactive) 0x06005D93/0x06005D95/0x06005D97
Source: dump nodes 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E,
state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool).
Gap 3 — Footer legibility:
- The shared footer child ids (0x1000024E etc.) appear in THREE footer-state
groups (A/B/C). ImportedLayout._byId stores the LAST duplicate = narrower
State B/C copies (145px labels). Fix: hide State B/C groups (footerB/footerC
Visible=false), walk State A container (0x10000240) positionally to bind the
wider State A labels (195px). FooterLine1Label now reads "Skill Credits
Available:" and FooterLine2Label reads "Unassigned Experience:" at full width.
Tests: 3 old tab-state tests (SetButtonStateRecursive expectation) replaced by
4 new sprite-injection tests + 2 caption-binding tests. Full suite: 676 pass,
0 fail (was 673 pass after 3 failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- UiClickablePanel: new UiPanel subclass with OnClick action + HandlesClick=true
so row clicks survive whole-window-Draggable ancestor frames.
- CharacterStatController overhaul (Pass 2):
- Tab bar button states: SetButtonStateRecursive walks each tab group container
(0x10000228/229/538) setting UiButton.ActiveState="Open" (Attributes) or
"Closed" (Skills/Titles) via UIStateId enum string names from DatReaderWriter.
- Row click: 9 rows become UiClickablePanel; sel[] mutable box drives footer/highlight.
- Toggle: clicking the same row deselects (→ footer State A); click a new row
updates title="Attrib: value", line-1 label="Experience To Raise:", line-1
value=cost, line-2="Unassigned Experience:" in both states.
- Row highlight: BackgroundColor=HighlightBg (semi-translucent gold) on selected row.
- Raise buttons (0x10000246 ×1 + 0x100005EB ×10): hidden initially; shown on
selection with ActiveState="Normal" (affordable) or "Ghosted" (cost=0 or unaffordable).
CollectButtonsById tree-walk finds ALL copies of the button across tab-page mounts
(not just the last-registered _byId copy) so all instances are controlled.
- CharacterSheet: AttributeRaiseCosts long[] (Strength…Mana raise costs in retail
display order; cost=0 → max/disabled row demos the Ghosted button state).
- SampleData.SampleCharacter: fills AttributeRaiseCosts[9] — Strength/Quickness=0
(maxed), Focus@10→110 matching the retail screenshot (spec §4).
- 35 new tests (total 673 pass) covering: row click→footer B title/line1/line2,
toggle deselect→footer A, switch row, highlight set/clear, raise button
hidden/Normal/Ghosted/deselect, tab Open/Closed states, GetRaiseCost helper,
GetRowName helper, SampleData fixture sanity.
Console.WriteLine("[CharacterStat] Row click: index=N → selected=N (Name)") fires
on every click for the user's live verification in the studio.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Canvas clicks now reach the panel UiHost so buttons, tabs, and slots
respond to user interaction. Previously only UiRoot.Pick (inspector
selection) received click events; the panel itself was inert.
Key changes:
- StudioInspector.DrawCanvas now returns a CanvasInputEvent struct
(was nullable click tuple) — carries isHovered, move position,
leftDown/Up, and scroll delta, all in panel-local pixels.
- Coordinate mapping: panel_local = mouse_screen - GetItemRectMin()
(1:1, no scale factor). V-flip (uv0.Y=1, uv1.Y=0) makes screen
top = panel Y=0, so NO extra Y inversion. Documented in comments.
- StudioWindow.OnLoad: removed WireMouse — raw Silk window coords are
offset by the canvas sub-window position and land in the wrong place.
WireKeyboard kept (keyboard input needs no spatial remapping).
- StudioWindow.OnRender: forwards OnMouseMove always (hover states),
plus OnMouseDown/Up/OnScroll in Interact mode. Console.WriteLine
on each forwarded left-click for live verification.
- Interact/Inspect toggle: checkbox in the Studio toolbar (default
Interact). Inspect mode restores old click-to-select-element behavior
while still forwarding OnMouseMove for hover states.
- CanvasCoordMappingTests: 7 pure-math unit tests covering the
origin, interior points, corner, chrome OOB, and the no-extra-flip
invariant (no GL required).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: the sub-layout 0x2100002C (design H=337px) mounted into tab slot
0x1000022B (H=575px) via ShouldMountBaseChildren. ElementReader.Merge takes the
derived (sub-layout) H=337 as canonical, so the background element's first
ApplyAnchor call captured _amB=238 and the list box captured _amB=65 — both
stayed at their 337px-parent sizes even though the slot is 575px.
Fix: CascadeHeight in LayoutImporter.Resolve mirrors retail's
UIElement::UpdateForParentSizeChange. When ShouldMountBaseChildren fires and the
slot is taller than the base design, every full-stretch background child has its
height cascaded: Top+Bottom anchors → stretch, Bottom-only → pin-to-bottom,
Top-only/None → unchanged. This propagates the correct bottom margins through the
entire subtree before the first render frame.
Layout result (confirmed via headless screenshot):
- List box grows from 160px → 398px (9 rows × 44px ≈ 396px)
- Footer elements move from abs Y≈307 → abs Y≈545 (matching retail dump)
- Separator moves to abs Y≈535
Row constants updated: RowHeight 44px, IconSize 24px, RowPadX 4px, IconGap 6px.
Footer State-A text corrected per spec: title="Select an Attribute to Improve",
line-1 label="Skill Credits Available:", line-1 value=SkillCredits (96),
line-2 label="Unassigned Experience:", line-2 value=UnassignedXp (formatted N0).
CharacterSheet.UnassignedXp (long, retail InqInt64(2)) added.
SampleData.SampleCharacter().UnassignedXp = 87_757_321_741L.
5 footer tests renamed + assertions updated; SampleCharacter_UnassignedXp_IsSet added.
639 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verified root cause (oracle-first) of the user's "stretched + less-detailed"
outdoor terrain: acdream tiles the base ground texture once per 24m landcell
(terrain_modern.frag TILE=1.0) and never applies retail's landscape DETAIL
texture overlay (dat TerrainTex.DetailTextureId / DetailTexTiling).
Decomp-cited mechanism (landPolyDraw detail UV = curr_detail_tiling*baseUV
pc:702299; 10->50 depth fade pc:702263; TEXOP_MODULATE pc:425099; single
landscape scalar via TexMerge::GetDetailTiling pc:263852). Filtering + 512^2
resolution ruled out.
Fix attempt (parallel detail texture array + MODULATE2X + distance fade) built
and ran but rendered the ground black (detail array sampled ~0); reverted.
Handles verified NOT swapped -- the bug is detail-texture DATA (gray fill /
DetailTextureId decode). Filed with full debugging steps for a fresh pass.
Also recorded the DO-NOT-RETRY: the first investigation agent FABRICATED a base
`TexTiling` dat field that does not exist (only DetailTexTiling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LayoutDesc 0x2100002E is a tabbed window; all three pages (Attributes/Skills/Titles) mount
their content as overlapping root children. acdream drew all three, so the inactive pages'
backdrops painted over the active content — the dim "almost opaque" cover the user reported,
and it displaced the XP meter. The shared gmStatManagement ids are duplicated across pages and
_byId keeps the LAST-mounted copy, so every bound widget lives in exactly one page; the
controller now keeps that page visible (ContainsWidget reference-walk from the bound name
element) and hides the rest. Header/attrs/XP-bar now render clean + correctly placed.
Tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Element map for LayoutDesc 0x2100002E (the tabbed Attributes/Skills/Titles window) +
the importer-mount reality + the StringTable verdict + the follow-up list. Grounds the
CharacterStatController work in 0e644b5 and the remaining refinements.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redo of the character pilot per faithful-port rules. The previous pilot GUESSED a text
report and put it on the wrong window. The decomp (verified by dumping acdream's
importer-resolved tree) shows LayoutDesc 0x2100002E is the tabbed Attributes/Skills/Titles
window: its tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI) which
chains into the gmStatManagementUI header. The importer ALREADY mounts every content element
(FindElement resolves name 0x10000231, heritage 0x10000232, PK 0x10000233, level 0x1000023B,
total-XP 0x10000235, XP meter 0x10000236 → UiMeter, list box 0x1000023D) — EventId was a red
herring (the dat id lives in _byId, not the widget's EventId field).
CharacterStatController (port of gmStatManagementUI::UpdateCharacterInfo 0x004f0770 +
UpdateExperience 0x004f0a70 + UpdatePKStatus 0x004f00a0) binds those real elements: name,
heritage, PK status, level, total XP, the XP-to-level meter fill, and the six innate
attributes in the list box. Studio (--layout 0x2100002E) now renders the actual panel
content, not an overlay. 16 controller tests green; full App suite stays green.
Refinements with known decomp sources (follow-ups): tab-button active/inactive state so the
3 tabs draw their sprites; exact label wording from the StringTable (table 0x10000001 →
dat 0x31000001); the full AttributeInfoRegion row template (column-aligned values + raise
buttons). CharacterController (text-report 0x2100001A) retained for that separate sub-panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The character report element m_pMainText (0x1000011d) is created at RUNTIME by
gmCharacterInfoUI — it is NOT in any static LayoutDesc (confirmed: acdream's
dat-import of both 0x2100002E and 0x2100006E lacks it; only a runtime UI-tree
capture has it). So CharacterController.Bind now CREATES the UiText report
element + attaches it to the panel body (Left 12 / Top 44 / fills, ZOrder above
chrome) and fills it via LinesProvider — replicating what the retail gm*UI does.
Verified: the studio (--layout 0x2100002E) renders the full report — identity,
birth/age/deaths, vitals, the 6 attributes, skills, augmentations, encumbrance.
Tests rewritten to assert the CREATED element (10 pass); full App suite 619 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task A — CharacterController (LayoutDesc 0x2100002E)
- CharacterSheet.cs: data record for all fields used by the report sections,
with retail property-id citations per field (DateOfBirth 0x62, TotalPlayTime
0x7d, NumDeaths 0x2b, SkillCredits 0xb5/0xc0, AugmentationStat 0x162,
EncumbranceVal 0x5/0xe6).
- CharacterController.cs: static Bind(layout, data, datFont) wires element
0x1000011d (m_pMainText, confirmed gmCharacterInfoUI::PostInit 0x004b86f0) as
a UiText; LinesProvider builds the report. Report section order mirrors
gmCharacterInfoUI::Update (0x004ba790):
1. UpdatePlayerBirthAgeDeaths 0x004b8cb0 — birth/age/deaths
2. UpdateEnduranceInfo 0x004b8eb0 — vitals (H/S/M cur/max)
3. UpdateInnateAttributeInfo 0x004b87e0 — 6 attrs InqAttribute 1,2,4,3,5,6
= Strength, Endurance, Quickness, Coordination, Focus, Self
4. UpdateFakeSkills 0x004b8930 — skill credits (InqInt 0xb5 / 0xc0)
5. UpdateAugmentations 0x004b9000 — aug name (InqInt 0x162 switch 1..0xb)
6. UpdateLoad 0x004b8a20 — burden cur/max/pct
Element 0x1000011d imports as UiText (Type 12); multi-line text set via
LinesProvider — same pattern as ChatWindowController transcript.
- SampleData.SampleCharacter(): plausible retail-scale CharacterSheet fixture.
- FixtureProvider.Populate case 0x2100002Eu: CharacterController.Bind with
SampleData.SampleCharacter + VitalsDatFont.
- CharacterControllerTests.cs: 10 pure data-wiring + content tests (no GL/dats).
Task B — Studio panel picker → main menu bar
- StudioWindow.OnRender: replaced the floating "Studio" toolbar window (kToolbarH
40px) with ImGui.BeginMainMenuBar / EndMainMenuBar (kMenuBarH 22px). Panel
picker combo now lives in the always-on-top menu bar and cannot be occluded by
Tree/Canvas/Props panes. paneY reduced from 40→22, freeing ~18px for the canvas.
Build: dotnet build green (0 CS errors; DLL-lock warnings are AcDream.App being
live — not compile errors). Full suite: 619 passed, 2 skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix 1 (Selection highlight): StudioInspector.DrawCanvas draws a bright-green
2px outline over Selected using the window draw list. ScreenPosition maps
directly to FBO pixel space; rectMin offsets into screen space.
Fix 2 (In-studio panel picker): DrawToolbar (BeginCombo over ListSlugs output).
StudioWindow resolves _dumpFile once in OnLoad, tracks _currentSlug, and calls
LoadDumpPanel on combo change. LoadDumpPanel removes old root via RemoveChild,
loads new slug, resets Selected. No relaunch needed.
Fix 3 (Fixed layout): All four panes use SetNextWindowPos + SetNextWindowSize
with ImGuiCond.FirstUseEver. Toolbar (0,0 x windowW x 40), tree (0,40 x 280),
canvas (280,40 x centre), props (right-340,40 x 340). User can drag from these.
Build + dotnet test green (609 passed, 0 failed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rendered all 26 dump windows through the studio's --screenshot mode + a
System.Drawing contact-sheet montage. The generic dump LayoutSource renders
every panel with no crash; fidelity tracks static-chrome (in the dump) vs
runtime content (slots/stats/appraise, not in the dump). Static-rich panels
(vitals/toolbar/character/map/radar/effects/chat…) render great; runtime-only
windows (examine) are correctly blank; inventory shows backdrop+frame but its
nested sub-window chrome is sparse (next investigation). screenshots gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `--screenshot <path>` to the UI Studio: when set the window is
created hidden (WindowOptions.IsVisible = false), the panel is loaded
and rendered into PanelFbo on the first OnRender tick, pixels are read
back with the new PanelFbo.ReadColorRgba(), rows are flipped (GL
bottom-left → PNG top-left), and the result is saved via ImageSharp
SaveAsPng before calling _window.Close().
Render size is derived from the loaded root's Width/Height (clamped
256–2048 px); falls back to 1280×720 when the root has no explicit
size. In headless mode ImGui, the inspector, and input wiring are
all skipped — only PanelFbo is created. Interactive path is
unchanged. SixLabors.ImageSharp 3.1.12 was already a direct dep of
AcDream.App (Phase O-T7); no new PackageReference needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Task 4b: a second load path for the UI Studio that reads the committed
retail UI layout dump (docs/research/2026-06-25-retail-ui-layout-dump.json)
and renders any of the 26 retail windows as a static sprite hierarchy.
New files:
- src/AcDream.App/Studio/UiDumpModel.cs — POCOs + System.Text.Json parse of
the dump (UiDump, DumpPanel, DumpNode, DumpRect, DumpStateSet, DumpImage,
DumpState + UiDumpModel static helpers: Parse, ListSlugs, PickImageId).
- src/AcDream.App/Studio/DumpLayout.cs — DumpLayout.Load(path, slug, resolve,
out err): parses the dump, finds the panel by slug, builds a UiElement tree.
Internal DumpSpriteElement draws its sprite via DrawSprite (not reusing
UiDatElement — avoids the ElementInfo/StateMedia dat-import dependency for
this static mockup). DumpGroupElement is a transparent container for Group
nodes. Rect basis is ABSOLUTE in the dump (verified: inventory root at
absolute x=500 and its children also start at x≈500 — child offset from
parent is 0–50px, not 500px); DumpLayout subtracts parent rect to produce
parent-relative Left/Top for each child.
- tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs — 5 tests covering:
inventory load with 0x100001D5 check + >= 40 nodes, unknown slug → null+err,
root at origin, children are parent-relative, all 26 slugs smoke-load.
Modified files:
- StudioOptions: adds DumpSlug + DumpFile fields; --dump <slug> and
--dump-file <path> args; ResolveDumpFile() walks up to the solution root
to find the default dump JSON (mirrors ConformanceDats.SolutionRoot()).
--dump suppresses the default vitals layout so the two modes are exclusive.
- StudioWindow.OnLoad: when DumpSlug is set, loads via DumpLayout (no
FixtureProvider, no controllers — static structure only); else falls
through to the existing LayoutSource + FixtureProvider path.
Results: DumpLayoutTests 5/5 passed; full AcDream.App.Tests 609 passed, 2
skipped (same as before); dotnet build green, 0 warnings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FixtureProvider.Populate for 0x21000023 was binding only
InventoryController and omitting PaperdollController, so the
~21 equip slots rendered empty even though sample equipped items
existed in the table. Also, the three per-list empty-slot
sprites were not being resolved from the dat (contentsEmpty /
sideBagEmpty / mainPackEmpty all defaulted to 0), so the slot cells
had no background art.
Fixes:
- FixtureProvider.Populate gains a DatCollection dats param
(mirrors the GameWindow.OnLoad lookup at line 2233-2235).
The 0x21000023 case now resolves all three empty sprites via
ItemListCellTemplate.ResolveEmptySprite and passes them to
InventoryController.Bind.
- PaperdollController.Bind is now called after InventoryController
in the same 0x21000023 case, matching GameWindow:2257-2265
(same layout subtree, contentsEmpty as the equip-slot placeholder,
sendWield: null since there is no live session in the studio).
- StudioWindow.OnLoad passes _dats! to the updated Populate signature.
SampleData.AddEquipped was already correct: MoveItem(guid, PlayerGuid,
-1, equipMask) at ClientObjectTable.cs:134 sets
CurrentlyEquippedLocation = newEquipLocation and calls Reindex which
places the item in _containerIndex[PlayerGuid]. No SampleData change
needed.
Tests: 2 new assertions in FixtureProviderTests —
sideBags_matchInventoryControllerFilter (Type.HasFlag(Container)
|| ItemsCapacity > 0 filter must match both bags) and
equippedItems_retainLocationAndAreInContents (equipped items have
CurrentlyEquippedLocation != None AND appear in GetContents so the
paperdoll controller can find them). 604 pass / 2 skip / 0 fail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task 4 of the UI Studio plan: adds SampleData (static ClientObjectTable
builder with a synthetic player, 6 loose items, 2 side bags, and 3 equipped
pieces) and FixtureProvider (switches on layoutId and calls the production
controller Bind methods — VitalsController, ToolbarController,
InventoryController — so the studio previews panels with plausible data
instead of empty widgets).
Icon ids approach: raw-resolve stub — resolves the base iconId via
RenderStack.ResolveChrome and returns the GL handle directly. This is v1
(single-layer icon); the full 5-layer IconComposer composite is a live-game
concern, not a layout-preview concern.
StudioWindow wires FixtureProvider.Populate after _source.Load; the
ClientObjectTable is stored on _objects so controller event subscriptions
(ObjectAdded/ObjectMoved) remain live for the window's lifetime.
4 new FixtureProviderTests (SampleTable_hasPackContents,
SampleTable_hasWeaponAndArmor, SampleTable_hasEquippedItems,
SampleTable_hasSideBags) — all pass. Full App suite: 602 passed / 2
skipped (pre-existing) / 0 failed. Build green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code-review follow-ups to the ImGui inspector:
- Add public UiRoot.Pick(x,y) over the private HitTestTopDown (honors
Z-order + modal exclusivity); StudioWindow uses it instead of a manual
UiElement.HitTest with subtracted ScreenPosition.
- RenderStack : IDisposable — disposes the GL pieces it owns in one place;
StudioWindow OnClosing + Dispose both call _stack?.Dispose(), closing the
error-path leak (only UiHost was disposed on the Dispose-without-OnClosing
path).
- Drop the stale _dt field; OnRender passes its own dt to Tick + BeginFrame.
- Fix a stale PanelFbo comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 3 of the acdream UI Studio plan. The studio previously drew the
panel straight to the window; it now renders the panel into an off-screen
FBO (PanelFbo) and displays it in an ImGui Canvas pane alongside a Tree
pane (recursive element hierarchy, click-to-select) and a Properties pane
(EventId/type/rect/anchors/ZOrder/flags of the selected element).
Click-to-inspect: a left-click inside the Canvas calls UiElement.HitTest
on the panel root (same-assembly internal access) and selects the topmost
element at the cursor, wiring the canvas directly to the tree selection.
PanelFbo lifecycle mirrors PaperdollViewportRenderer (RGBA8 color +
Depth24Stencil8 renderbuffer, GLStateScope-sealed, lazy resize on size
change). V-flip in DrawCanvas (uv0=(0,1)/uv1=(1,0)) corrects GL's
bottom-left FBO origin to ImGui's top-left convention so click Y maps
directly to panel-local Y without inversion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task 2 of the acdream UI Studio plan. Adds three new files under
src/AcDream.App/Studio/ and one new test file:
- StudioOptions.cs: record + Parse() for the ui-studio CLI args (positional
dat dir or ACDREAM_DAT_DIR, --layout 0xNNNN, --markup <path>; defaults to
vitals 0x2100006C when neither layout nor markup given).
- LayoutSource.cs: wraps LayoutImporter.Import for dat-backed layouts; markup
path sets LastError "markup unsupported (Task 6)" and returns null for now.
Exposes Kind / LayoutId / MarkupPath / LastError / CurrentLayout / Reload().
- StudioWindow.cs: Silk.NET 1280×720 GL 4.3 window; boots RenderBootstrap,
wires input to UiHost, loads the panel via LayoutSource, adds the root to
UiHost.Root.AddChild. QualitySettings resolved the same way GameWindow.Run()
does (SettingsStore → QualitySettings.From → WithEnvOverrides).
- Program.cs: ui-studio dispatch at the top of top-level statements (before
the dat-dir parse) so `dotnet run -- ui-studio` routes to StudioWindow.
- LayoutSourceTests.cs: dat-gated test (skips when dats absent); verifies that
the vitals LayoutDesc (0x2100006C) loads, root is non-null, byId contains the
vitals root element (0x100005F9), and Kind == DatLayout. Passes (1/1 with dats
present; silently skips on CI).
Note: the spec asserts FindElement(0x2100006Cu) — that id is the LayoutDesc
dat id, not a widget element id. The actual vitals root element id is 0x100005F9
(confirmed from the vitals_2100006C.json fixture). The test uses the correct
element id and documents the discrepancy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extracts the subset of GameWindow.OnLoad the UI Studio needs into a
standalone RenderBootstrap.Create factory: bindless detection, shaderDir,
SceneLightingUboBinding, mesh shader, TextureCache, animLoader,
WbMeshAdapter, SequencerFactory, EntitySpawnAdapter,
EntityClassificationCache, WbDrawDispatcher (+ A2C gate from
QualitySettings), UiDatFont load, and UiHost. No terrain / sky /
physics / streaming — only the pieces listed in the RenderStack record.
GameWindow is untouched; this is additive new code only.
Note: task spec listed VitalsDatFont as AcDream.App.UI.Layout.UiDatFont
but the type lives in AcDream.App.UI — corrected here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A standalone dev tool that renders any acdream UI panel through the
PRODUCTION renderer (UiHost/LayoutImporter/dat-sprite path + the WB mesh
pipeline for the 3-D doll), with an ImGui click-to-inspect inspector,
sample-data fixtures, markup hot-reload + write-back, and render-config
sliders. Collapses the edit→build→login→F12→eyeball loop into edit→glance.
Full v1 scope (user pre-approved): both sources (dat LayoutDesc id +
markup file), full fixtures, editable inspector + doll-camera sliders,
live doll. Seven isolated units; the highest-risk step (extracting a
shared RenderBootstrap from GameWindow.OnLoad) is sequenced first behind
a "game still renders" gate, with a studio-local-duplicate fallback. The
drag-drop designer is deferred to phase 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The paperdoll doll now matches retail: correct held pose, framing, and
facing. Three decomp-sourced fixes closed the visual gate.
Pose: cdb-confirmed m_didAnimation = 0x030003C0 (gmPaperDollUI), played
once + HELD (set_sequence_animation framerate=0, RedressCreature
0x004a3c22). Dumping the dat showed the 29-frame anim has only two
distinct keyframes — frame 0 (transitional, bent arm) and frames 1..28
(byte-identical: the settled stance, arms down + leg back) — so
ApplyPaperdollPose applies the LAST frame statically (no looping).
Camera: ported verbatim from UIElement_Viewport::SetCamera (decomp
0x004a5a39). position (0.12,-2.4,0.88); direction (0,0,0) => IDENTITY
view frame => look straight down +Y, ZERO yaw; FOV pi/4 (CreatureMode
ctor default 0x004543cf); ambient 0.3. The prior hand-tune aimed the
camera at mid-body, adding a ~2deg yaw that turned the doll's face away
— full-body framing comes from eye-height + FOV, not aiming.
Heading: retail Frame::set_heading(h) (0x00535e40) builds facing
(sin h, cos h); System.Numerics CreateFromAxisAngle(+Z, +h) rotates the
body's default +Y forward to (-sin h, cos h) — the X-lean was MIRRORED
(~22deg), the real cause of the turned-away face. Negate the angle to
land on retail's facing.
Wrap-up: stripped the temporary O/P pose-frame stepper + Slice2
diagnostics; divergence register AP-66 reworded, AP-67 (RTT doll render
vs in-cell CreatureMode::Render) + AP-68 (per-race UpdateForRace
unimpl) added; DollCameraTests pinned to the retail values + a zero-yaw
guard. tools/cdb/paperdoll-pose.cdb = the pose-DID capture script.
Build + full suite green (Core 1579 / Core.Net 343 / App 597 / UI 425).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the 134 D.2b UI commits onto the physics/collision development line so
main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open
doors fully passable) + the full collision/streaming/dense-town-FPS arc meet
the paperdoll/inventory work.
# Conflicts:
# docs/ISSUES.md
# docs/architecture/retail-divergence-register.md
An OPEN door (ETHEREAL_PS 0x4 set on Use) still stopped the player with a
residual threshold block after the collision sweep, despite swinging open
visually.
The resolver runs two collision passes per step: the main sweep and a
step-down (foot-sphere "is there floor?") sub-pass. acdream tested the
ethereal door in BOTH; the main pass cleared it (BSPQuery Path 1 + the
Layer-2 override) but the step-down pass had no escape, leaving a Collided
result at the sill -- the "can-sized cylinder on the ground threshold".
Retail's CPhysicsObj::FindObjCollisions (pc:276795-276806) SKIPS an ethereal
target when sphere_path.step_down != 0 -- it only tests it in the main pass.
So an open door is fully passable everywhere; the swung panel's position is
irrelevant (ethereal = no collision; the swing animation is purely visual).
Port that branch verbatim: an ethereal-for-this-test target (target state &
0x4, OR mover-ethereal vs a non-static target) is `continue`d when
sp.StepDown is set.
Live-verified (user confirmed): the door now blocks 0x while open (0x1000C),
still blocks while closed (0x10008); pre-fix it blocked 217x while open.
Core suite green (1595/0).
(An earlier "animate the door collision" theory was wrong and dropped -- if
collision tracked the swung panel you'd bump the panel in its open position,
which retail does not do. The user caught it.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Town props placed as landblock stabs whose ONLY collision is a Setup
CylSphere/Sphere (no physics BSP) registered ZERO collision shapes and were
walk-through -- torches, braziers, lamp-posts, candle-stands, posts.
Root: the ISSUES #83 / A1.6 gate `!_isLandblockStab` skipped Setup
cyl/sphere registration for ALL landblock stabs, on the false assumption
"landblock stabs collide via BSP only (retail CBuildingObj)." That
over-broadened -- it also killed collision for BSP-less stabs.
Retail's CPhysicsObj::FindObjCollisions (@0x0050f050) uses binary dispatch:
the object's physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE its
CSetup CylSpheres/Spheres -- never both. Confirmed via live retail cdb on the
Holtburg torch (Setup 0x020005D8 at world (105.99,17.17)): FindObjCollisions
target num_cylsphere=1, cyl h=2.2 -- a cylsphere, exact-matching the dat
(cylSphere r=0.2 h=2.2); the StabList confirms stab[95]=0x020005D8 there.
Fix: gate the Setup cyl/sphere registration on `entityBsp == 0` instead of
stab-ness. Preserves #83's anti-doubling (stab WITH a BSP -> BSP-only) while
restoring collision for BSP-less stabs. Other landblock entities on this path
(scenery -- tree-trunk cylspheres) are unaffected.
Live-verified: torch + candle/brazier family block now; ~115 cyl/sphere
Setups register across streamed landblocks. Core suite green (1595/0).
The earlier selection-sphere hypothesis was WRONG and is reverted -- the cdb's
r=0.48 sphere was the player/NPC body (every body sphere is ~0.48), not the
torch. The correct cdb method: capture the TARGET at FindObjCollisions (not
`this` in CSphere::intersects_sphere) and confirm by position + Setup-id.
(Issue numbered #149 to stay clear of main's #148; this worktree branched
before main's #145-148 were added.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User gate: the "Slots" toggle caption was gold (should be white) and centered
(should sit at the left, before the slots). UiButton gains a LabelAlignment
(Center default / Left); the paperdoll caption is set white + Left-aligned.
The retail checkbox box (green-checked / circle-unchecked) + the exact pose
are the two remaining pieces, done next.
Build + full App suite green (596).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RegisterLiveEntityCollision had a premature gate at the top of the method:
if (!hasCyl && !hasSphere && !hasRadius) return;
This fired BEFORE ShadowShapeBuilder.FromSetup ran. The builder emits a BSP shape
for every Part whose GfxObj has a PhysicsBSP, regardless of CylSpheres/Spheres/Radius.
A furniture weenie with only a physics-BSP mesh (candle holder, candelabra, etc.)
has no CylSpheres, no Spheres, and Radius=0 -- so it was always dropped, making it
fully passable (invisible wall that lets the player walk through it).
Fix: remove the premature gate. The three `bool` locals (hasCyl, hasSphere, hasRadius)
are retained -- `hasRadius` is still used by the Radius fallback lower in the method
for entities with no CylSphere/Sphere/BSP but a non-zero setup.Radius. The correct
final gate at shapes.Count==0 (after builder + Radius fallback) handles all cases:
- BSP-only entity: builder emits BSP shape -> shapes.Count>0 -> registered.
- Truly shapeless (no BSP, no cyl, no sphere, no radius): builder empty, no Radius
fallback fires -> shapes.Count==0 -> return (not registered, passable). Correct.
Retail anchor: CPhysicsObj::FindObjCollisions (acclient_2013_pseudo_c.txt:276917) --
the gate at pc:276917 is on the MOVER's CPartArray, not a target-side shape filter.
CPartArray::FindObjCollisions (pc:286236) iterates ALL parts; each part's
find_obj_collisions tests physics_bsp when present. There is no retail equivalent of
our premature gate that skips BSP-only targets.
Tests (ShadowShapeBuilderShapeSourceTests): two new cases.
Setup_WithBspPart_NoCylSpheres_EmitsBspShape -- proves the builder emits the shape
the premature gate was discarding.
Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList -- regression guard proving
truly shapeless entities are still not registered (the shapes.Count==0 gate holds).
Full Core suite: 1595 pass / 0 fail / 2 skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CPhysicsObj::FindObjCollisions at pc:276961-276989 has a two-layer mechanism for
ethereal doors. Layer 1 (BSPQuery Path-1 sphere_intersects_solid, pc:323742) already
ported in Task 3 handles the open-gap case. Layer 2 (pc:276963-276977) is a
force-reset that catches the residual case: when the player's sphere CENTER crosses
a thin ethereal slab, sphere_intersects_solid can still return Collided via
HitsSphere (polygon contact on the slab face). Without Layer 2, the opened door
remains an invisible wall until the player's center passes ~0.48m beyond the slab.
Port: in FindObjCollisionsInCell, immediately after the shape dispatch (BSP / Sphere /
Cylinder branches), insert the Layer-2 gate:
if (result != OK && sp.ObstructionEthereal && !sp.StepDown && (obj.State & 0x1u) == 0)
{ result = OK; ci.CollisionNormalValid = false; }
The STATIC_PS (0x1) guard (pc:276969) ensures static-ethereal env geometry still
blocks. Cylinder/Sphere shapes already return OK from their own ObstructionEthereal
early-outs so Layer 2 is effectively BSP-only in practice, but is written
unconditionally matching retail.
Tests (ObstructionEtherealTests): three new BSP Layer-2 cases using a synthetic
wall polygon in local-space coordinates. (A) ethereal non-static: passable. (B)
ethereal+static: still blocks. (C) non-ethereal: still blocks. All 11 tests pass;
DoorCollision/CellarUp/CornerFlood/HouseExitWalk regression gate: 25/25 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part A (D4): CObjCell::check_entry_restrictions (pc:309576) gate is omitted from
FindEnvCollisions. Investigation confirmed the gate requires restriction_obj (per-cell
access-lock entity id) + weenie-object-table dispatch (CanMoveInto / CanBypassMoveRestrictions)
— neither exist in acdream. CellPhysics has no restriction_obj field; DatReaderWriter
models no per-cell access locks. Gap is inert in all dev content (ACE starter area has
no access-locked env cells). Documented as AP-50 in the retail divergence register.
Part B (W1): Replace the hardcoded-false smell in BspOnlyDispatch with named internal
helpers PvpExempt() and MissileIgnore() that return false with retail oracle citations
(pc:276808-276841 and pc:274385 respectively). BspOnlyDispatch now folds all three
terms in retail's exact predicate structure. Behavior is byte-identical in M1.5 scope
(both stubs false ⇒ reduces to HAS_PHYSICS_BSP_PS check alone, same as before).
A6.P7 door dispatch unchanged.
Tests: 3 new guard tests in A6P7DispatchRulesTests — W1_PvpExempt_ReturnsFalseInM15Scope,
W1_MissileIgnore_ReturnsFalseInM15Scope, W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly.
Suite: 1590 pass / 0 fail / 2 skip (was 1587 + 3 = 1590).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors RemoveBuildingsForLandblock (#146) for indoor CellPhysics entries.
_cellStruct is first-wins (CacheCellStruct's ContainsKey guard); without
eviction a dungeon's BSP WorldTransform is permanently locked to the
_liveCenter value at first streaming — a teleport recenter leaves cells at a
stale offset (~source↔dest distance), and foot-sphere collision queries miss
the geometry that visually renders correctly.
PhysicsDataCache.RemoveCellsForLandblock iterates _cellStruct.Keys and
TryRemove-s every entry whose high-word matches the evicted landblock prefix.
PhysicsEngine.RemoveLandblock now calls it alongside ShadowObjects.RemoveLandblock
so cell BSPs rebase on the next CacheCellStruct pass, same as buildings.
No divergence register row needed: this closes a gap introduced when the #146
building-eviction pattern was created without the symmetric cell eviction.
Tests: RemoveCellsForLandblockTests (3 cases): evicts-matching-prefix,
empty-cache no-throw, no-matching-cells leaves-others. Core suite: 1587 pass / 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail oracle greps confirmed:
- CSphere::intersects_sphere @ 0x00537ae4 (pc:321692): the ethereal branch
is `void __thiscall` — all paths return void (no COLLIDED). The function
performs a proximity check only; no blocking result is produced.
- CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573): same void-return
pattern — ethereal branch calls collides_with_sphere (check only, no slide),
all returns are void = passable.
Change: added `if (sp.ObstructionEthereal) return TransitionState.OK` at the
top of SphereCollision and CylinderCollision in TransitionTypes.cs, mirroring
the void-return semantics of both retail functions. The existing per-object
clear at pc:276989 (line 2837) still fires after the early OK return.
Before this fix: an ethereal-alone NPC/ghost with a Cylinder or Sphere shadow
shape would BLOCK the player (regression introduced when Task 3 made ETHEREAL-
alone fall through ShouldSkip instead of instant-skipping). After: all three
shape types — BSP (via BSPQuery Path 1), Sphere, and Cylinder — correctly pass
through when obstruction_ethereal is set.
Tests: added 4 tests to ObstructionEtherealTests.cs verifying:
- Ethereal Cylinder → passable (sweep passes through, no CollisionNormalValid)
- Ethereal Sphere → passable (same)
- Non-ethereal Cylinder → still blocks (regression guard)
- Non-ethereal Sphere → still blocks (regression guard)
Full Core suite: 1584 pass, 0 fail, 2 skip (pre-existing dat skips).
Pseudocode doc updated with confirmed cyl/sphere ethereal contracts and the
complete set/clear/consume flow summary.
Retail refs:
- CSphere::intersects_sphere @ 0x00537ae4 / acclient_2013_pseudo_c.txt:321692
- CCylSphere::intersects_sphere @ 0x0053b4a0 / acclient_2013_pseudo_c.txt:324573
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail CPhysicsObj::FindObjCollisions (0x0050f050) only instant-skips when
BOTH ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) are set (pc:276782).
ETHEREAL-alone sets sphere_path.obstruction_ethereal=1 (pc:276806) and
continues to the shape dispatch. BSPTREE::find_collisions (0x0053a496) routes
Path 1 (sphere_intersects_solid) when the flag is set (pc:323742): the open
door has no solid leaf at the doorway, so the test returns OK → player passes
through. CEnvCell::find_env_collisions (0x0052c144) clears the flag first so
ENV walls are never weakened (pc:309580, "D5 clear").
Changes:
- CollisionExemption.ShouldSkip: require BOTH bits for Gate-1 early-out
(previously ETHEREAL alone returned true — the AD-7 shim). Divergence
register row AD-7 deleted.
- SpherePath: add ObstructionEthereal field (mirrors retail
SPHEREPATH.obstruction_ethereal).
- FindObjCollisionsInternal loop: set sp.ObstructionEthereal=(target&0x4)!=0
before shape dispatch; clear it after (per-object clear pc:276989).
Also clear at the null-BSP continue site to keep flag clean.
- FindEnvCollisions: clear sp.ObstructionEthereal=false at top (D5 clear
pc:309580) — ENV cell walls are always solid.
- BSPQuery.FindCollisions Path 1: change `obj.Ethereal` (ObjectInfo.Ethereal,
always false — dead code) to `path.ObstructionEthereal`. Gate now correctly
mirrors retail pc:323742: PLACEMENT_INSERT || obstruction_ethereal.
Consume site change (BSPQuery.cs before/after):
BEFORE: if (path.InsertType == InsertType.Placement || obj.Ethereal)
AFTER: if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
Mirrors retail pc:323742 exactly. obj.Ethereal was dead code (ObjectInfo.Ethereal
is never set true anywhere); the correct flag is SpherePath.ObstructionEthereal.
Tests: 1580 pass / 0 fail / 2 skip (was 1576/0/2 + 4 new in ObstructionEtherealTests.
CellarUp, CornerFlood, DoorCollision, HouseExitWalk all green — no wall regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r),
which is geometrically wrong: a cylinder has flat caps; a sphere does not.
This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow
entries are tested as spheres — 3-D distance, no height clamping.
Changes:
- ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2).
The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder
sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path
(anyCyl=false → included), which is correct.
- ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere
(CylHeight=0) for Setup.Spheres instead of a short Cylinder.
- CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept
solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port
of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention
confirmed against the decomp: retail negates the root to produce a
forward t ∈ (0,1].
- TransitionTypes.cs: added Sphere narrow-phase branch between BSP and
Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap
(not XY-only). Added SphereCollision() method implementing the 3-D
wall-slide response. Updated diagnostic logging at :2734 to cover Sphere.
- Updated ShadowShapeBuilderTests for new Sphere type assertion.
- New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored
cases (head-on, tangent, perpendicular-miss, lateral-near-miss,
sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping,
vertical-sweep).
Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail);
ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed).
Build: 0 errors, 10 warnings (pre-existing).
Tests: 1576 pass / 0 fail / 2 skip (1578 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail oracle: CPartArray::InitParts@0x00517F40, CGfxObj::Serialize@0x00534970 (physics_bsp
gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 (returns OK /
passable when physics_bsp==null). Render-mesh bounds never enter collision.
Changes:
- GameWindow.cs: delete the ~200-line VISUAL mesh-bounds collision block (the
isPhantomSetup / isPhantomGfxObj locals + the if-block computing worldMin/worldMax
AABB + the ShadowObjects.Register call that capped and registered the synthetic
cylinder). Also removes dead counter variables scHaveBounds/scRegistered/scNoBounds/
scTooThin; trims the ProbeBuildingEnabled summary line accordingly.
- PhysicsDataCache.cs: delete IsPhantomGfxObjSource (the predicate that only existed
to fence the mesh-AABB synthesis; the "phantom" concept is now the default — no DAT
shape means no registration, verbatim with retail).
- PhysicsDataCachePhantomSourceTests.cs: deleted (tested the removed method).
- ShadowShapeBuilderShapeSourceTests.cs: new guard test — a Setup with parts but
hasPhysicsBsp=false and no CylSpheres/Spheres yields an empty shape list, locking
the DAT-only rule in the builder.
- retail-divergence-register.md: AP-2 row deleted (divergence retired).
Objects with no DAT physics shape (no CylSpheres, no Spheres, no part with a
PhysicsBSP) now register no collision shape and are passable, verbatim with retail.
Objects with real DAT shapes (BSP parts, CylSpheres) are unaffected.
dotnet build green, 22/22 tests passing (ShadowShapeBuilderShapeSourceTests +
CellarUpTrajectoryReplay + CornerFlood + Issue147ArwicBuildings replay harnesses).
Visual gate pending: walk Holtburg + open world; objects that become passable must
match retail (DAT has no physics shape — trees with real CylSpheres still solid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19-agent verified audit of how retail decides which objects collide vs
acdream's per-channel filters. Confirms the user's "1 fix for all collision"
intuition: acdream already has retail's two-layer shape (per-cell shadow
registration + query-time exemption); the divergences are now narrow and
enumerable, not a scattered filter mess.
Audit (docs/research/2026-06-24-collision-inclusion-audit.md): 6 confirmed
deviations (D1 mesh-AABB phantom HIGH, D2 ETHEREAL-alone, D3 no sphere
primitive, D4 entry-restrictions, D5 obstruction_ethereal absent, D8 cell-
transform stale cache) + 2 refuted by the adversarial pass (D6 placement-
insert present in BSPQuery; D7 terrain pass-through is the #135/#138
streaming-gap, retail does it too). Loader verified faithful: CacheGfxObj
reads PhysicsBSP gated on HasPhysics; the mesh-AABB is a pure additive
non-faithful layer.
Design (docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md):
"1 fix" = one DAT-only shape authority (delete mesh-AABB), one query
predicate, four faithful channels kept distinct (retail keeps find_env/
find_building/find_obj separate), one per-apply rebase invariant. 5
independently-gated slices. Retires register rows AP-2 + AD-7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Next-session brief: build retail's collision-inclusion model (PhysicsState/
ETHEREAL/HAS_PHYSICS_BSP predicate + per-cell shadow-list registration + the
building-shell/terrain/EnvCell channels), map acdream's per-channel ad-hoc
filters against it, enumerate deviations, and design ONE unified retail-faithful
mechanism to replace them. Motivated by this session's #146/#147 — both were the
same shape (a per-channel filter diverging from retail). Brainstorm-gated,
report-first, no guess-patches. Includes the apparatus inventory + a paste-ready
prompt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "#145 far-town frame" premise was wrong: #146's bldOrigin probe proved
Arwic buildings are correctly framed. The walls were portal-less buildings
skipped by the collision cache; the terrain-3%-grounded sub-question is
re-scoped LOW (no fall-through observed; likely a contactPlaneValid nuance).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A town's outer/perimeter walls have NO collision even on a fresh login: the
player walks straight through them while houses block fine. Confirmed NOT a
frame issue — #146's bldOrigin probe showed Arwic buildings are correctly
framed (~12 m from the player, not km off), so the "#145 far-town frame"
premise was wrong here.
Root cause (dat-confirmed, Issue147ArwicBuildingsDumpTests): a perimeter wall
is stored in LandBlockInfo.Buildings as a doorless shell — 16 of Arwic's 30
buildings are PORTAL-LESS, ringing the town at 24 m intervals (x=12/132,
y=12/108). The building-collision cache loop skipped them via
`if (building.Portals.Count == 0) continue;` — a filter meant only for the
transit/entry feature (CellTransit.CheckBuildingTransit) that also dropped the
collision shell. Retail's find_building_collisions (0x006b5300) tests the shell
BSP independent of the portal list, so a doorless wall still collides.
Fix: don't skip portal-less buildings — cache them with an empty portal list
(no transit) but their collision shell (ModelId) intact. User-verified: Arwic
perimeter walls now block (Collided/Slid). Adds the dat-dump fixture test.
Suites green: Core 1569(+2 skip), App 468(+2 skip), UI 425, Net 317.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Static building/house-wall collision worked on a fresh login to a town but was
lost after logging in elsewhere and PORTALING in: the foot-sphere clipped
straight through walls while server-spawned doors (own collision) still blocked.
Root (capture-confirmed, ACDREAM_PROBE_BUILDING bldOrigin): the building shell
BSP's WorldTransform is computed from the streaming-relative origin
(origin = (lb − _liveCenter)·192) AT CACHE TIME, and CacheBuilding is idempotent
(first-wins per cell). A teleport recenters _liveCenter, but the idempotent guard
never re-bases the cached transform — so the shell sat at a stale world offset
(login Arwic → portal Holtburg: bldOrigin=(-5488,2149), ~5.5 km from the player
at (83,24)), and FindBuildingCollisions never penetrated → result=OK everywhere →
no block. Terrain doesn't suffer this because AddLandblock overwrites its
WorldOffset on every apply.
Fix: clear a landblock's cached buildings at the start of each ApplyLoadedTerrain
(PhysicsDataCache.RemoveBuildingsForLandblock), then let the existing loop
re-populate fresh with the CURRENT origin — the per-apply re-base terrain already
gets, while keeping CacheBuilding's per-cell first-wins within each fresh pass
(retail CSortCell::add_building). Also adds bldOrigin to the [bldg-channel] probe.
Verified on the exact repro (Arwic login → Holtburg portal → walk into wall):
bldOrigin now (107.5,36.0) at the wall (was -5488,2149); channel returns
Collided → Slid (block + wall-slide). Suites green: Core 1568(+2 skip),
App 468(+2 skip), UI 425, Net 317.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>