Commit graph

731 commits

Author SHA1 Message Date
Erik
9d26ecc623 feat(ui): Campaign OP slice OP3 — Options panel shell, open paths, Gameplay tab
Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host
0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import
pattern CharacterController already validates, registered through
RetailPanelUiController so it shares retail's "one active gmPanelUI child"
mutual exclusion with every other sibling panel for free. F11 and the
toolbar's options button (0x1000019B, already authoring panel id 10) both
now open it; the close button fires the same ToggleOptionsPanel action.

OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact
Apply/Reset/Defaults/visibility semantics from
UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately,
Apply commits every row unconditionally + flushes the batched blob, Reset
reverts only Changed rows, Defaults restores without committing, and
tab-switch/window-hide revert uncommitted edits. Wired for all four tabs;
this slice registers real rows on none of them (Gameplay authentically has
none — a pure button list). UiTabPanel gains an ActivePageChanged event so
the page model can hook every tab transition, including the initial
default-tab activation.

The seven Gameplay-tab buttons: Exit Game reuses the existing graceful
window-close path; Exit to Character Selection gets retail's confirmation
dialog and byte-verified mid-air refusal but still behaves as Exit Game
(AD-74 — no pre-world character-select flow exists); Configure Keyboard
and In-Game Help Files are inert this slice (AD-76 for Help — the
plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse
short-circuit to their own byte-verified failure text through the
interface-text seam instead of ShellExecute against a dead URL (AD-75);
Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port,
persisting five new CameraTurningSettings preferences and sending
PlayerOption.UseMouseTurning — TS-74 records that acdream has no
persistent mouse-turning camera mode for the bit to drive yet.

Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline
12,871/4/0 — only new tests added).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 02:14:40 +02:00
Erik
5242de9f15 fix(ui): OP2 re-review closure (coordinator pass) — AP-195, tooltip port, zero-children pin
Closes the mechanism lens's REOPEN (one MUST-FIX) and both lenses' small
residuals on the OP2 rework (b236a442); the blast lens re-review was
CLOSED outright. Fable-direct per the two-failure escalation rule.

- AP-195 filed: UIOption_CheckboxBitfield64 ports HALF of Refresh
  @0x004859C0 — the ANY-set checkbox predicate is exact, but the ALL-set
  LED media swap (P0x10000082=0x06004D17 / P0x10000083=0x06004D19) and
  the ListBox self-sizing tail (ResizeTo/CalculatePaperSize — the block
  IS a UIElement_ListBox in retail) are unported, and the block's row
  stacking is a second divergent implementation beside UiTemplateListBox.
  All due at OP5 before the Chat tab's connected gate; the IsSet doc
  comment now names both halves instead of quoting only the ported one.
- Row tooltips: UiButton gains settable TooltipText surfaced through the
  shared GetTooltipText hover pipeline (UiCatalogSlot's pattern);
  UiCheckboxBitfield64.AddChild applies the row tooltip retail stamps in
  CreateChildren @0x00485DF0, and documents that the 0x10000084 row-index
  attribute stamp is deliberately replaced by the typed mask closure.
- AD-73 addendum: UiTemplateListBox.ConsumesDatChildren=true is inert
  only while no authored Type-5 element carries children — that premise
  is now conformance-PINNED across all 32 fixtures (a future DAT
  regeneration surfacing an authored child fails the build instead of
  silently dropping it).
- Plan doc: OP2's contract names UiTabPanel.cs (retail UIElement_Panel),
  not the fictional-class-named UiTabControl.cs; ledger records OP1 and
  OP2 both CLOSED.

Full Release suite: 12,871 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:26:29 +02:00
Erik
b236a44279 fix(ui): OP2 rework — dormant UiDatElement subclasses, fixed Panel/CheckboxBitfield64 mechanism
OP2 (df9c7a35) was double-REJECTed: an unconditional Type-8/Type-5 factory
mapping silently re-classed 15 elements across 7 shipped panels (vendor
backdrop lost its fill, character/spellbook roots stopped passing clicks
through, combat gained a phantom import-time tab takeover, ten ListBoxes
gained a spurious hit-testable viewport) because the stale 27 pre-existing
fixtures never exercised the new fields — and the mechanism itself cited a
nonexistent "UIElement_TabControl" class, inverted UiCheckboxBitfield64's
checked-state predicate, and synthesized fake per-row geometry instead of
using the widget's own authored template.

Shape change: UiTabPanel (renamed from UiTabControl) and UiTemplateListBox
now derive from UiDatElement (unsealed) and stay DORMANT by default — an
imported Type-8/Type-5 element gets authored-media drawing, ClickThrough
generic-decoration default, and IUiDatStateful propagation identical to the
pre-OP2 UiDatElement fallback, with zero import-time side effects. The
factory's Type-8/Type-5 arms are unconditional again (no more guard whose
premise the blast-radius sweep proved false), because dormancy makes an
unactivated instance behaviorally indistinguishable from the old fallback.
UiTabPanel.ActivateTabBehavior() and UiTemplateListBox's lazy viewport
creation are the explicit, controller-driven opt-ins Campaign OP slice OP3+
will call; today nothing does, so the four pre-existing shipped Type-8
hosts (character/spellbook/vendor/combat) and ten pre-existing Type-5
ListBoxes keep their pre-OP2 behavior exactly. Filed AD-73 for this
dormant-vs-retail's-unconditional-activation adaptation.

Mechanism fixes (docs/research/2026-08-11-op2-review-mechanism.md):
- UiTabPanel cites UIElement_Panel (Type 8 is UIElement_Panel; no
  UIElement_TabControl exists in the PDB), resolves buttons/pages via a
  GetChildRecursive-equivalent descendant search (not direct-children-only),
  performs no switch when no entry authors 0x32 (deleted the _tabs[0]
  fallback), and surfaces unresolved tab-table entries via UnresolvedEntries
  + a diagnostic line instead of a silent no-op.
- ElementReader.ReadTabTable skips entries missing 0x30/0x31, matching
  retail's SetupTabPageHash @0x0046C2E0 entry filter.
- UiCheckboxBitfield64 now builds every row from its OWN authored template
  (property 0x64 -> {0x2100002B, 0x10000521}) via AddItemFromTemplateList,
  deleting the synthesized ElementInfo + invented RowHeight=14 — matching
  retail's CreateChildren @0x00485DF0, which is itself a UIElement_ListBox
  call. IsSet is now retail's ANY-bit-set predicate (Refresh @0x004859C0),
  not all-bits-set. TS-72 retired: the click-toggle bit math is now fully
  decomp-confirmed (SetBitsOnOrOff via ListenToElementMessage @0x00485AE0).

Regenerated all 32 UI fixtures against real DAT (ACDREAM_REGENERATE_UI_FIXTURES=1)
and committed them — 27 pre-existing fixtures now carry Outline/OutlineColor/
TabTable/TemplateList/ScrollbarElementId; the 5 Options fixtures were already
current. Updated EffectsUiControllerTests' now-correct UiTemplateListBox
class-identity assertion. Added: 6 built-widget behavior pins for all five
pre-existing Type-8 elements + a representative Type-5 element the dormancy
model protects (OP2ReworkBlastRadiusConformanceTests.cs); 5 reader-level
tests driving ReadTabTable/ReadTemplateList/the 0x72 reader from raw
property bags (ElementReaderTests.cs); a multi-bit-mask UiCheckboxBitfield64
test proving the any-bit predicate (the prior single-bit test couldn't
distinguish it from all-bits); an activation-idempotency test and a
before-activation click-is-inert test for UiTabPanel.

Full Release suite: 12,868 passed / 4 skipped / 0 failed (baseline 12,853/4/0
post-OP1-fixes; +15 net new tests, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:08:08 +02:00
Erik
09029f9f4b fix(runtime,net): OP1 review fixes — server-seed gate, tick-wired auto-save/logout flush, fellowship mutual exclusion
Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.

MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.

MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.

SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).

SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.

SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.

SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.

SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.

SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).

SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.

SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.

Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.

Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
  from inside a flush callback can still be erased by the trailing
  "_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
  (same outcome whether the callback runs inside or outside the lock),
  not reachable from any current caller, not a one-liner to close
  correctly (needs a per-dirty-period generation token). Left as documented
  in the review; worth closing before the Options panel ever flushes from
  inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
  touching files this round doesn't otherwise edit (SocialActions.cs,
  CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
  the "one-liner in a file already being edited" instruction.

Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).

Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:39:51 +02:00
Erik
df9c7a35eb feat(ui): Campaign OP slice OP2 — tab control, template ListBox, UIOption widget mappings
Ships the two new widget primitives the retail Options panel needs plus the
four remaining UIOption_* factory mappings, so every tab page (OP3-OP6) has
somewhere to mount.

- ElementReader/ElementInfo gain three new dat-property readers, following
  the existing effective-state-resolution pattern (never a per-state
  first-wins scan, per the round-5 N1 lesson): the Type-8 tab table
  (property 0x2E -> TabTable), a ListBox's row-template list (property
  0x64 -> TemplateList), and scrollbar linkage (property 0x72 ->
  ScrollbarElementId). LayoutImporter gains one hook
  (IUiChildrenAttachedListener) so a widget can resolve cross-references
  its own dat properties name by id once its subtree actually exists.

- UiTabControl (Type 8): switches exactly one page-slot child visible,
  syncs each tab button's Open/Closed state via the existing
  RetailTabBinding helper, and honors the authored default tab on mount.

- UiTemplateListBox (Type 5 with an authored template list): wraps a
  UiScrollablePanel viewport (sealed, so composition not inheritance) and
  ports AddItemFromTemplateList(index) — the resolver seam a page
  controller wires with real DAT access via the SAME
  LayoutImporter.ImportInfos(dats, layoutId, elementId) overload
  RetailDialogFactory already uses for its catalog LayoutDesc.

- DatWidgetFactory maps the four remaining UIOption_* widgets, each
  verified against the regenerated options_2100002B.json fixture before
  writing any code: 0x10000037 (Slider) is structurally an ordinary
  horizontal UIElement_Scrollbar, so it reuses BuildScrollbar directly;
  0x10000038 (Menu) is structurally identical to the vendor category
  dropdown UiMenu already models, so it reuses `new UiMenu()` like the
  Type-6 case; 0x10000036 (CheckboxSlider) composes an existing
  UIOption_Checkbox child + UIOption_Slider child via the new
  UiOptionToggleSlider wrapper; 0x10000044 (CheckboxBitfield64) authors
  zero children in the dat (every row is added at runtime via retail's own
  AddChild(lowMask, highMask, label, tooltip) call shape), so it's a new
  UiCheckboxBitfield64 composing UiButton per row. No new drawing code
  anywhere in this set.

- Five new committed fixtures (options_2100002B/2100002A/21000028/
  2100005C/21000029) plus 25 new conformance tests pinning the tab table
  (4 entries, Gameplay default), all three template arrays, scrollbar
  linkage, every new widget-type mapping, and a UiTabControl behavioral
  test (switch -> exactly one page visible, click-through the tab
  button). The Character ListBox's authored 6-header/49-toggle shape
  (lane B section counts) is proven reachable end-to-end through
  AddItemFromTemplateList against the committed fixture.

- Regenerating fixtures also touched 27 PRE-EXISTING, unrelated fixtures
  (an Outline/OutlineColor field pair added by an earlier commit,
  bcc34ee3, that predates when those fixtures were last regenerated).
  Per the slice contract, that drift was NOT committed — reverted back to
  HEAD, only the five new Options-panel fixtures are new files here.

- Filed TS-72: UiCheckboxBitfield64's click-toggle bit math (AND/OR
  set/clear semantics) is a documented approximation — the decompiled
  excerpt this campaign pulled covers UIOption_CheckboxBitfield64::Apply's
  WRITE side, not its own click-handler's bit math. Flagged for OP5 (the
  Chat tab controller, the first consumer that reaches the wire) to
  verify against the real decomp before any live transaction depends on
  it; nothing user-reachable can observe this yet.

Full Release suite: 12,770 passed / 4 skipped / 0 failed (was 12,745/4/0
post-OP1 — 25 net new tests, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:06:43 +02:00
Erik
86c0a7e0ee feat(runtime,net): Campaign OP slice OP1 — full character-option table, dirty model, real 0x01A1 blob builder
The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).

- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
  -> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
  from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
  enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
  auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
  button table). Reconstructing CharacterOptions1/2 defaults from the
  ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
  cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
  widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
  table (was a 6-case switch). New TrySetOption is the ONE shared local-
  write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
  write the bit locally first, then either send 0x0005 immediately (auto-
  save ids) or MarkDirty for the batched blob, no-op on an unchanged value
  (retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
  FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
  TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
  now route through TrySetOption instead of duplicating the write; this
  fixes the headless local-write gap the OP1 research flagged (the direct
  adapter previously sent the wire message without writing the bit first,
  same class of bug CH4 fixed for the graphical host). Both also reject an
  id outside the table instead of silently accepting it. LiveSessionRuntime
  Factory's SendSingleCharacterOption closure now delegates to the same
  seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
  blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
  both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
  graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
  the real PlayerModule::Pack body per the wire research's field-by-field
  layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
  comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
  0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
  filters (via new CharacterOptionsBlobSource) instead of zeroing them.
  Conformance: a hand-computed golden byte vector (not generated by the
  builder under test — the CH3 builder died of tests that pinned a wrong
  shape and looked green) plus a round-trip through PlayerDescriptionParser.

Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).

Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 23:31:34 +02:00
Erik
bcc34ee301 feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles
Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a
second (background) glyph atlas per font, dilated 2px on every side,
plus two border-pixel scalars (Font.NumHorizontalBorderPixels/
NumVerticalBorderPixels) that acdream's font reader never read — so
even the pre-existing outline parameter drew almost nothing once
enabled. Landed together (either half alone is a no-op or a
regression):

- UiDatFont carries BorderX/BorderY from the DAT font resource.
- UiRenderContext.DrawStringDat inflates the background blit's source
  and destination rect by that margin and restructures into retail's
  exact two-pass whole-string outline-then-fill model
  (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for
  fonts with no background atlas. Corrects the stale "property 0xd"
  comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor).
- LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/
  OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/
  ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every
  authored-outline element across the DAT set is correct at once.
- SpewBox: RetailFontId corrected from a round-3 heuristic
  (0x40000025) to the actually-authored 0x40000001 (18px bold serif),
  Outline=true set on the controller's UiText. Fill colour stays the
  user-gate-round-1-pinned yellow — font atlases are alpha-only
  (PFID_A8), so there is no baked shading that could explain the
  screenshot's gold as anything other than the outline itself.
- Chat transcript: default fill now seeds from its authored
  ARGB(255,204,204,204) instead of an unrelated color-table slot
  (ChatTranscriptRenderer.BuildLines takes the transcript's own
  DefaultColor as a parameter); the 34-entry LogTextType table is
  untouched, and every existing CH1 conformance test stays green
  unmodified.

Regenerated the committed chat_2100006f.json fixture from the real
installed DAT, confirming end to end (not by missing-field default)
that the transcript carries no outline.

Tests: font-reader border fields + inflation math pinned against the
real DAT font, two-pass draw ordering/tint/inflation via a new
TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22
import at both the ElementReader.Merge and StateDesc-property layers,
SpewBox font/outline, and the chat default-shade seed with the color
table proven untouched.

Full Release suite: 12,610 passed / 4 skipped / 0 failed
(AcDream.slnx, complete solution).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:28:34 +02:00
Erik
5b54387b8e fix(chat): round 4 — no user-visible meta text, real /help groups, indicator buttons toggle
Item 3 (#364): every honesty marker is now gone from user-visible /help
text. AllegianceOverview/HouseOverview's "[IMPLEMENTED]" tags and trailing
"Subcommands NOT marked..." sentences, and Day/Log/Render/Motd's appended
"NOT YET IMPLEMENTED in acdream" tails, are removed; the underlying retail
text is corrected/completed against the pseudo-C's own pristine
consolidated data dumps (Log and Motd had been silently truncated; Render
was entirely acdream-authored and is replaced with the real retail usage
string). The three PARTIAL /help group topics (channels/chatting/commands)
are now COMPLETE verbatim listings: HelpStupidChannelHack's three
"vtable slot" operands, previously believed undecodable, are the same
pooled/mislabeled-data artifact this campaign has hit before (AP-113's
precedent) — reading the function's own disassembly for the push imm32
preceding each constructor call resolves all three directly. messagetypes
is now a real ported construction (IsLegalChannel's 14-id whitelist +
LogTextTypeToString's name table + the exact join/wrap format) instead of
an acdream summary. Register row AP-184 retired.

Item 5: the main window's 1/2/3/4 indicator buttons now toggle their
floating chat window on click, per the user's retail memory overruling
the earlier decomp-only reading. UIElement_Button::HandleButtonClick has
its own generic click-driven action dispatch (property 0x12) reaching the
same DoVisibilityToggleAction the Alt+1..4 keybinds use; the button
fixture confirms this half is genuinely armed, but the floating-window
fixture authors no matching listener-registration property, so the
generic mechanism has no proven target in the data on hand. Per
CLAUDE.md, the user's retail memory is the axiom regardless:
ChatWindowController.BindIndicatorClicks wires each indicator's click
through the same ToggleFloatingChatWindow chokepoint the keybinds use,
as explicit user-directed retail behavior. SetIndicatorOpen stays the
sole writer of the Selected mirror so the visual stays consistent
through the click round trip.

Full reconciliation in docs/research/2026-08-09-chat-retail-window-shell.md
§1.4. Campaign plan gets the round-4 findings section; items 1+2
(text-style) are under parallel research, item 4 passed, item 6 deferred
to the settings track.

Suite: 12,579 passed / 4 skipped / 0 failed (Release, complete solution),
up from baseline 12,553/4/0 — net +26 tests, zero regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:46:34 +02:00
Erik
f7a6f46ba0 fix(chat): consolidated-review fixes — retail /help Detail extraction, seam wiring test
SHOULD-FIX 1: RetailClientCommandCatalog's ~45 catalog leaf verbs were
showing acdream-authored Summary text for /help <verb> instead of
retail's own Detail_HelpType(2) text. Byte-swept every Help* handler
against the PDB-paired acclient.exe (verified MATCH), confirmed each
Detail/Summary branch by reading the actual decompiled if/else shape
(address order and string length both proved unreliable alone), and
fixed a sweep_weenie_strings.py 800-char truncation bug that silently
dropped several longer Detail branches. Resolved every ambiguous
CmdHashData-registered verb (hor/hr/hom/hoa/alh/ah/friends_add/
friends_remove/squelch/unsquelch) by reading for Binary Ninja's
nullptr-4th-arg decompiler artifact instead of trusting it. Coverage:
42 of 47 distinct catalog Definitions verbatim-extracted, 4
confirmed-null (index/clist/on/off register with a genuinely null help
pointer — DoHelp falls to UnknownCommand for these, now reproduced),
1 honest UNVERIFIED (messagetypes builds its text from a runtime enum
table, not a static string). ChatCommandRouter now prefers retail
Detail text over the catalog summary; RetailCommandHelpTable's class
doc no longer overclaims its own scope.

SHOULD-FIX 2: extracted the a5a7eb4f-class OnInterfaceText wiring into
a testable CreateChatViewModel method and added
ComposedChatViewModelWiresOnInterfaceTextToSpewBox, which the prior
FakeFactory-based test suite could never exercise.

SHOULD-FIX 3: retires register row AP-113. DoLifestone/DoMarketplace
print their own 0x1A refusal text (byte-recovered, UTF-16LE) instead
of falling through to the generic 0x26 fallback; ChatCommandRouter's
comment corrected to state the fallback's real scope.

SHOULD-FIX 4: corrected the divergence register's stale AP section
header sentence about AP-190's opacity default (refuted by cc582899).

NITs: (a) HeadlessStaticStateAudit routes through the injected
HeadlessDiagnosticWriter instead of Console.WriteLine; (b) a bounded
300-pump liveness diagnostic on the IsQuiescent conductor gate (no
retry, no behavior change); (c) fixed the #365 hydration test's doc
comment contradiction against diagnosis §8; (d) the 0x26 fallback
dispatches on WeenieErrorMessages' own Type instead of hardcoding
ClientLocal.

Full Release suite: 12,553 passed / 4 skipped / 0 failed (baseline
03404b71: 12,542/4/0; net +11 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:22:36 +02:00
Erik
cc58289967 fix(chat): CH6c review fixes — opaque default, opacity-transition register clauses
BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface
value (0.5) as ONE shared global default applied to every
RetailWindowManager-registered window, not just the four floating chat
windows retail itself fades. That faded the whole out-of-box registered
UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including
several windows that can never take keyboard focus and so were stuck at
0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override
(0x004CD0F0) instead — retail-identical opaque presentation for the 11
non-chat windows and the main chat window; only the four floating chat
windows now diverge from retail's 0.5-while-idle default, and the
Settings -> Chat transparency slider remains fully user-settable.

AP-190 reworded and gains two new decomp-verified clauses: (3) retail
eases opacity toward its target by 5% of the delta per tick
(ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus
element-messages at @0x004F5275) where acdream snaps -- deferred, needs
a UI frame-tick hook the opacity controller doesn't have; (4) retail's
focus predicate is the chat ENTRY FIELD specifically
(ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses
any-focusable-descendant. Both findings + the pre-existing UiMenu.cs
PushAlphaAbsolute(1f) popup bypass are folded into the window-shell
research doc's opacity section.

NITs: fixed the stale "text bypasses the alpha" comment in
UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/
DrawString through the same ApplyAlpha chokepoint as sprites/rects);
added RetailWindowManager.WindowUnregistered + wired
RetailWindowOpacityController to detach and forget a window unregistered
while it held focus (previously only Dispose detached, leaking any
window unregistered mid-focus for the rest of the session); added
post-Dispose no-op guards to the three Set* opacity mutators; added a
DrawString (BitmapFont path) alpha regression test and a DrawStringDat
outline/background-pass alpha test (the existing tests only ever
exercised the foreground/fill pass).

Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves
SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation
(caught by the full-suite run this fix requires) to match the new 1.0
default.

Campaign ledger CH6c row updated to APPROVE-WITH-FIXES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:48:39 +02:00
Erik
a819687cf0 feat(chat): Campaign CH slice CH6c — window opacity + transparency setting
Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.

RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.

Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.

Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.

Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.

Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:00:55 +02:00
Erik
1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00
Erik
22020ef2c4 feat(chat): Campaign CH slice CH6b — floating chat windows 1-4
Mounts retail's four floating chat windows as always-resident, born-hidden
children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc
0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New
FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own
widget tree — built fresh per instance from one shared imported ElementInfo
— reusing ChatWindowController's word-wrap + retail color-carry algorithm via
the extracted ChatTranscriptRenderer instead of duplicating it. A floaty
window has no talk-focus menu (research doc §2.2), so its entry field always
sends on Say; the mismatch against retail's possible shared-channel behavior
is UNVERIFIED and filed as #369/AP-188.

Runtime owns the per-window filter/open state: ChatWindowState (new,
AcDream.Core.Chat) seeds retail's exact PostInit defaults per window
(window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00
Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4
0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full
ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from
ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on
RuntimeCommunicationState.ChatWindows so every host borrows the same
instance. The main window's filter (0xFBFFFFFF, "no user filter") never
actually gates anything because its own explicit-address branch already
covers every broadcast line — that's why UpdateFromPlayerModule early-returns
for window 0 in retail, ported here by construction rather than a special
case.

Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their
KeyBindings.RetailDefaults() chords already existed since Phase K.1c
(unwired until now). The MetaKeys table confirms retail's default is Alt+1
through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same
file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through
GameplayInputCommandController -> RetainedGameplayWindowCommands ->
RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow,
whose visibility-change event is the single chokepoint that syncs
ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button
regardless of what changed a window's visibility (keybind, close button, or
a restored layout).

A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 —
the only function in the whole binary that branches on a click message —
settles what the research doc had left as a hedge: it handles exactly
0x1000046f (max/min) and the talk-focus menu's selection message, with NO
case for 0x10000522-0x10000525. The four indicator buttons are PURE
one-directional mirrors in retail; clicking them does nothing.
ChatWindowController.SetIndicatorOpen ports this with no OnClick at all.
Corrected research doc §1.4 accordingly.

Persistence is local-only (register row AP-187; the retail 0x1000008C
GameplayOptions wire remains deferred to CH6f): window geometry and
open/visible state ride the existing generic RetailWindowLayoutPersistence
path for free once each window registers under its own WindowNames entry;
the four filter masks get a dedicated ChatSettings round-trip
(ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit
constants) loaded at mount and saved alongside SaveLayout().

Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule
matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests
(bind smoke tests against a synthetic 0x2100005B tree, per-window filter
routing, filter-change cache invalidation, fixed-Say submit), new
ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state,
cross-window isolation, range validation), GameplayInputCommandController
routing for the four toggle actions, and a SettingsStore filter round-trip.
Full Release suite: 12,392 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:10:20 +02:00
Erik
98de4f5ab3 fix(chat): Campaign CH round 3 — SpewBox flush-top/font, /help exact print sequence
User-gate round 3 findings (a)-(c):

(a) SpewBox: TopOffset moves from the round-1 60px placeholder to 0 (flush
to the viewport top). SpewBoxController never wired DatFont/Font at all
before this round, so it silently rendered through the 15px debug
BitmapFont fallback; it now resolves retail dat Font 0x40000025
(MaxCharHeight=11px) through a new RetailUiRuntime.Assets accessor —
the smallest font id confirmed in use by any currently-imported retail
LayoutDesc fixture, cross-referenced against every
tests/AcDream.App.Tests/UI/Layout/fixtures/*.json dump and confirmed
against the installed DAT via AcDream.Cli dump-font-atlas. It is also the
chat window's own smallest font (the 0x2100006F floating-window 1/2/3/4
indicator badges), so both selection criteria the brief offered agree.
Both remain best-available approximations, not resolved retail values —
register row AP-178 updated accordingly.

(b)/(c) /help and /help death: round 2 extracted the individual retail
strings byte-exact but never traced ClientCommunicationSystem::DoHelp's
complete print sequence. Byte-swept DoHelp's own range plus the five
Summary-branch functions it calls into (HelpEmote/HelpSquelch/
HelpStatusGroup/HelpTextGroup/HelpAllGroup) against the PDB-paired
acclient.exe. Retail's real shape: bare /help prints exactly TWO scroll
entries (HelpPrefixNote, then the 13-item AvailableHelpListing built from
DoHelp's own literals and each group's Summary_HelpType branch, in exact
source order) — not the acdream-invented cheat sheet BuildHelpText()
built before. Any resolved /help <verb> gets the SAME two-entry shape:
HelpPrefixNote, then ForMoreInformationPrefix concatenated directly onto
the verb's own Detail text (retail's own unsubstituted "<command>"
literal, ported verbatim). ChatCommandRouter.EmitVerbHelp applies this
uniformly to every resolved verb, not just death. An unresolved verb now
shows retail's real "Unknown command" fallback text; that fallback types
0x1A (ClientLocal), which retail routes to the SpewBox exclusively — a
gap ChatVM's UI.Abstractions layer can't yet reach, filed as ISSUES #367
/ register AP-186 rather than left silently unregistered.

Jump-in-air (round 2's open item 1) was root-caused and fixed separately
at a5a7eb4f between rounds — recorded in the campaign ledger.

Debug suite (all projects): 12,329 passed / 4 skipped / 1 failed — the
one failure is issue #351, a pre-existing Debug-only streaming flake
confirmed reproducing identically on the pristine pre-round-3 commit via
git stash, not a regression. Release verification covers every project
reachable without rebuilding AcDream.App: a live client process (PID
15064) held its own Release binaries locked for the session and was not
killed per project policy — AcDream.UI.Abstractions.Tests (867/867, the
layer both /help fixes live in) plus every other non-App-dependent
project, all 0 failed. AcDream.App/AcDream.App.Tests/AcDream.Core.Tests
(the SpewBox fix's layer) are green in Debug only this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:40:19 +02:00
Erik
1fd515436c feat(chat): Campaign CH slice CH6a — retail chat-window layout + 8-grip resize
Swap ChatWindowController's imported main-chat LayoutDesc from the wrong
0x21000006 (an unrelated layout whose root and 800px resize bar appear
nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window,
0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a
direct DAT dump, found in dats.Local not dats.Portal). Every downstream
compensation that existed only to paper over the wrong import is deleted:
the hand-cropped 490px content width, the dropped 800px resize bar, the
9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar
overlap shift, and the scrollbar top-reclaim. The window now mounts with
RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are
its complete chrome) instead of the universal nine-slice wrapper.

LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case:
UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm
(0x2A=bottom/0x2B=left/0x2C=right/0x2D=top,
UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges
bitmask. A direct DAT dump established the true shape: only 7 of the 8
grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is
a Type-2 Dragbar (move handle), not a Resizebar, because the main window
has no title bar. UiRoot now gives a directly-hit grip's own edges
priority over its generic proximity heuristic, and a directly-hit move
handle the same priority over ambient proximity — so the plain top strip
moves the window while its two corner grips resize it including the Y
axis, and all 4 edges + 4 corners work everywhere else. This also fixes
the reported "no diagonal cursor at corners" (CursorFeedbackController's
existing RetailCursorCatalog cursor ids already matched the DAT exactly;
they just never received a genuine diagonal edge combination) and "cannot
grow in Y from the bottom-right corner" (the old NineSlice+crop mount's
indirection is gone; the Imported mount uses the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000 directly).

The 8 cosmetic "_Locked" border-art twins default hidden (register row
AP-185 — retail's UiLocked-driven art swap between the two skins is not
ported; UiRoot.UiLocked continues to gate the underlying interaction
correctly either way). The 4 chat-window-1..4 indicator buttons import
generically (visible, inert) for CH6b to wire. The two hand-drawn
translucent-black tints on the transcript/input are removed now that
their parent panels draw their own authored background sprites.

Filed #366 (chat window's new-unseen-text indicator 0x1000048C is
swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope).
Corrected the research doc's "all eight grips" claim against the direct
DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:38:27 +02:00
Erik
c1f1582576 fix(chat): Campaign CH user-gate round 2 -- portal notice rerouted to SpewBox, verbatim /help extraction, jump-in-air evidence
Item 2: retail's portal-space "In Portal Space..." notice is the SpewBox
(ECM_UI::SendNotice_DisplayStringInfo(0x1A,...) -> AddTextToScroll(str,
0x1A, 1, 0), hardcoded to the SpewBox per the decomp), not a dedicated
centered overlay. PortalWaitNoticeController and its lease are deleted;
PortalTunnelPresentation's per-rotation-segment cadence now writes
straight into RuntimeCommunicationState.AddText(ClientLocal) -- the
SpewBox's own dedupe-at-index-0 handles the repetition exactly as
retail's does. Register row AP-184 records the surface fix and the AP-178
scope extension.

Items 4+5: /help text was partially fabricated -- the user caught the
"/help death" meta-message. Generalized
tools/pdb-extract/sweep_weenie_strings.py to decode narrow
PStringBase<char> literals (the ClientCommunicationSystem::Help* family's
shape) alongside its original UTF-16LE support, then swept every
HelpXxxGroup function's exact byte extent against the PDB-paired
acclient.exe. 4 of 7 group topics (death/status/text/allegiances) are now
complete verbatim listings; the other 3 (channels/chatting/commands) keep
an honest UNVERIFIED note citing HelpStupidChannelHack @0x0056f290 (a
genuinely undecodable BN-mislabeled-fragment mechanism) instead of the
old fabricated sentinel. 7 of ~35 channel one-liners are also now
verbatim. ISSUES.md #364 tracks the remainder;
RetailCommandHelpTableTests.cs pins every result byte-exact.

Item 1: jump-in-air refusal still silent live is NOT reproduced and NOT
speculatively fixed. Exhaustive static re-audit found the mechanism
correct by construction (single-writer OnWalkable, exactly-once-per-frame
Update()/Capture(), no interfering edge-history resets). A live headless
repro (new jump-probe bot policy, real ACE connect) was blocked --
probeaccount2 has no character, and the graphical client already owned
testaccount this session so the task's own fallback rule forbade using
it. Two temporary probes are left behind ACDREAM_PROBE_JUMP=1 (blocked
entirely in Headless by the existing multi-session static-state guard --
graphical-only for the next round).

Item 3 confirmed fixed, no regression. Item 6 (resize: no diagonal
cursors, cannot grow Y from bottom-right) folded into CH6a's existing
scope.

Full Release suite: 12,267 passed / 4 skipped / 0 failed (up from
12,221/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 08:40:24 +02:00
Erik
47e40900f3 fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes
The user tested Campaign CH's CODE-COMPLETE build live and reported ten
defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate —
round 1"). Items A-G are fixed here; the remaining three (extra chat
windows on 1/2/3/4, resize working in only one corner, transparency/
artifacts) are out of scope for a fix and filed as slice CH6.

A. Jump-in-air refusal never fired live: the jump block only ever
   evaluated input.Jump inside the grounded-charge or already-charging
   branches. PlayerMovementController now detects the press RISING EDGE
   while airborne and reports WeenieError.NotGrounded once per press,
   leaving the grounded charge/fire path untouched.
B. ChatVM's invented "[System] " prefix is dropped — retail prints
   system text bare. [Popup] is unchanged (AP-175).
C. SpewBoxController's color is now the user-pinned exact value
   (1, 1, 0.247, 1), the same bright yellow as an incoming Tell.
   Register row AP-178 updated: color CLOSES, size/position/font stay
   open per the user's live report that they still differ.
D. Closes #329: PortalTunnelPresentation now emits the portal wait cue
   unconditionally on every rotation-segment boundary, matching
   gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating
   on a 5-second hold local transits never reached. PortalWaitNotice
   Controller now renders it in the same pinned yellow as item C.
   Register row AP-150 retired.
E. Closes #362: new ClientCommandResponses.cs parses and renders the
   four previously-unhandled inbound GameEvents (ChannelIndex,
   ChannelList, AvailableHouses, AllegianceInfoResponse), each ported
   line-for-line from the named-retail decomp's inbound handlers.
   Register row TS-70 retired.
F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n'
   first, then word-wraps each segment independently — server text like
   /help's reply no longer collapses onto one line.
G. The chat input field's right edge no longer holds a fixed absolute
   pixel position across a window resize; Bind now upgrades it to
   retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch
   fallback so it tracks the window's client width instead of
   overflowing past a narrower resize.

Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline
12,221/4/0 + 26 new tests across items A, E, F, G).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:42:32 +02:00
Erik
5d247d5518 fix(chat): CH4 re-review fixes — dialog-queue reentrancy, settings option-bit chokepoint
Should-fix 1: RetailDialogFactory.CloseDialog's queued branch removed the
active DialogInfo, ran DialogDone (whose callback can synchronously open a
new dialog under the SAME queue key — the two-stage house-abandon
confirmation does exactly this), then called OpenNextDialog, which did an
unconditional Dictionary.Add on a key the reentrant dialog had already
re-occupied. Retail's HashTable::add tolerates the duplicate; Dictionary
throws. OpenNextDialog now returns early when the queue key is already
active — the reentrant dialog's own eventual close drains the queue.

Should-fix 2: @join/@leave wrote the local RuntimeCharacterOptionsState bit
before sending, but the Settings Chat toggles reached a second binding
(SendSingleCharacterOption) that only sent the wire message, leaving the
Turbine membership gate stale until the next PlayerDescription.
LiveSessionRuntimeFactory.CreateCommandBindings now has one shared local
function for both entrances.

Should-fix 3: corrected TS-68/#360 wording again — retail's DoAllegiance
dispatcher table EXECUTES boot/ban/officer/title/motd/name/lock/house/
chat/broadcast locally through their own handlers; acdream shows the
unrecognized-subcommand refusal for all nine pending the #360 port. What
matches retail is the ownership rule (the verb never reaches
DoChannelCommand/the server), not the subcommand behavior itself. Removed
the inaccurate "matching retail, not merely harmless" / "now matches
this" claims from both the register row and the issue.

Nits: corrected the HouseAbandonDialogCallback_First citation (0x00580E1A
is DoHouse's load site for the callback pointer, not the function entry —
the entry is 0x00580240, with the stage-2 confirmation string built at
0x005802D8) in both ClientCommandController.cs and the mirrored test
comment; added an InlineData case pinning "@clist allegiance" to
RequestChannelList(0x02000000); converted RetailClientCommandCatalog.
KnownVerbs from a plain array to a FrozenSet<string> with
StringComparer.OrdinalIgnoreCase, matching the file's other lookup tables.

Suite: 12,221 passed / 4 skipped / 0 failed (Release), up from CH4's
12,216/4/0 — net +5 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:24:43 +02:00
Erik
724ef2d389 fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation
Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.

Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.

Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.

Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.

Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
12,190/4/0 — net +26 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:59:35 +02:00
Erik
090825e703 feat(chat): Campaign CH slice CH4 — command registry completion
Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.

Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
  verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
  whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
  celestialhand, ...) now broadcast for real via a new
  RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
  BuildChatChannel wire builder.

Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
  Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
  (RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
  error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
  with a null function pointer).

New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.

Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.

RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.

Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:10:17 +02:00
Erik
e07fba5731 fix(chat): CH3 review fixes — phantom UN-9, allegiance-broadcast echo, /a legacy fallback
Applies the Opus review of Campaign CH slice CH3 (614a1e05):

- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
  OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
  to acdream's literal. The wrong 0x50C48D4A existed only in the research
  doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
  with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
  ACE's GameActionChatChannel handler includes the sender in its real-name
  Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
  AddTextToScroll), so the client must skip its local optimistic echo, not
  keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
  it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
  backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
  StartupTurbineChatSystem successfully starts Turbine chat — "never
  started" (TurbineChatState.Enabled == false) now falls back to legacy in
  both LiveSessionCommandRouter.RouteChat and
  DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
  allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
  OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
  GameWindowLiveSessionOwnershipTests tests proving the concrete
  ICommandBus.Publish wiring and the single LiveSessionCommandSurface
  construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
  (IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
  either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
  re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
  instead of a duplicate table; the gate-to-refusal-text mapping is now
  shared via TurbineChatMembershipGate.ResolveRefusalText instead of
  duplicated in both hosts; ChatSettings.Default now matches ACE's real
  CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
  clarifies only the five Hear toggles are server-backed; the register's
  §3 header recounted 129 -> 128.

Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:24:29 +02:00
Erik
614a1e055f feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity
Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.

Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.

Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.

11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:39:44 +02:00
Erik
233c30d13f fix(chat): CH2 re-review nits — resize centering, top-aligned flow, sweep wording
Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES,
following the REJECT->rework at e0e78883):

1. SpewBoxController's centered Left was captured once via
   AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's
   Left/Right-both-false branch pins a fixed margin). Anchors is now
   AnchorEdges.None and Tick recomputes Left every frame against the
   current root width.

2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow
   (VerticalJustify honored only via ConfigureDatState, which this
   synthesized element never calls). Added UiText.HonorVerticalJustification
   so a non-DAT controller can opt the scrollable path into
   VerticalJustify without a full LayoutDesc binding; SpewBoxController
   sets VerticalJustify=Top so lines flow from the top of the 450x72 box,
   matching newest-at-top insert semantics. Noted as invented-pending-
   measurement in AP-178's row (no new row).

3. Documented the deliberate inversion of UiText.LinesProvider's
   oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds
   newest-first, which is correct specifically because the box is now
   top-aligned) and added a test pinning the rendered order (newer message
   is the topmost line), driving root.Tick.

4. Fixed the stale "retail's code default, 1" comment in
   SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's
   AUTHORED value, 4.

5. Added the matching unmapped-id diagnostics line to
   LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern
   GameEventWiring's WeenieError/WeenieErrorWithString handlers already
   use.

6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in
   SpewBoxLayoutDumpDiagnostic: the loop's id source was DatCollection's
   top-level aggregate GetAllIdsOfType<LayoutDesc>(), not dats.Portal's
   own (which reports a count of ZERO for this type), so querying those
   ids against dats.Portal.TryGet established nothing about Portal either
   way. Corrected the same overclaim echoed in SpewBoxState's
   MaxConcurrentItems doc comment and in AP-178's register text (both the
   table row and the section-header history line). What's actually
   established: dats.Local hosts the SpewBox layout at 0x21000011; whether
   Portal also carries a copy remains unestablished.

7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox
   path for id 0x0561 (the 50-friends-cap refusal) in
   LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's
   ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory
   test in this tree is a source-text conformance grep, not an
   instantiation.

Ledger: CH2 ledger row's review column now reads REJECT -> reworked
e0e78883 -> re-review APPROVE-WITH-FIXES -> nits (this commit); Status
header flips CH2 to code-complete/closed pending the user gate, CH3 next.

Build green; touched-project tests green (19/19 new/changed,
4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed /
4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this
commit adds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:48:13 +02:00
Erik
e0e7888308 fix(chat): CH2 rework — SpewBox tick-driven visibility + binary-derived error table
Reworks Campaign CH slice CH2 per the REJECT-review findings doc
(docs/research/2026-08-09-ch2-review-findings.md).

BLOCKER 1 — SpewBoxController never rendered a line and leaked its
pending queue. LinesProvider only ran through UiText.OnDraw, which
gates on Visible — and the box started invisible, so the provider (the
sole caller of SpewBoxState.Tick) never ran. Gave the controller an
explicit per-frame Tick(now) driven by UiRoot's global-message-3
broadcast (a zero-size GlobalTimeSink child, the same pattern
VendorUiController.DragOverGlobalTimeSink already uses), matching
retail's gmSpewBoxUI::Update. LinesProvider now only returns the
cache. Tests rewritten to drive root.Tick(...) instead of calling the
provider directly, plus new coverage for visibility-without-a-draw,
queue-drain-without-a-draw, and bounded-queue-across-many-ticks.

BLOCKER 2 — re-derived the HandleFailureEvent routing table from the
PDB-paired binary instead of the pseudo-C's ~33-char string previews.
tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in
VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the
full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed
by case-label enumeration), resolved 0x4F8 (previously excluded),
fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and
0x518 — an automated diff between every swept literal and the landed
table found). Every changed row cross-checked against ACE's
WeenieError/WeenieErrorWithString enum doc comments; both oracles
agreed on every row, including a case where the review's own proposed
text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) —
corrected via the else-if block's own instruction address plus the ACE
cross-check. Pinned table count: 344 (338 + 5 + 0x4F8).

SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code;
folded into the ChatIdentity generation-reset stage (same lifetime
boundary), with a reset assertion added to the existing populated-reset
test.

SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an
empty-string early return; retail's AddTextToScroll trims both ends
(trim(&str, 1, 1, ws)) and has no empty guard. Both retired.

SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via
ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through
Communication.AddText(Resolve(code, param)) instead, and
ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router
fallback now resolves + calls OnSystemMessage directly.

SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case;
an unmapped id now resolves to a null Text (silence toward the
player) instead of the invented "WeenieError 0xNNNN" hex fallback,
with a diagnostics-only console log line for the id.

NITs — AP-TBD placeholders corrected to their real register rows
(AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the
windowId dual-destination gap and corrected three stale "lands with
CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal
to dats.Local and found the SpewBox element for real — LayoutDesc
0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems
(ListBox property 0x10000028) = 4, not retail's code default of 1.
AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and
SpewBoxController's extent/anchor/OneLine are now authored rather than
placeholder (absolute screen position and colour remain open); fixed
the "19 ids... lists 18" miscount by retiring the stale paragraph in
the class doc rewrite; aligned the UseDone handler's silent-status
check with the other two WeenieError handlers.

Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0
errors).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:14:26 +02:00
Erik
77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:02 +02:00
Erik
34d8a3c0e7 fix(chat): CH1 review fixes — sbb-idiom channel catch-all, command-output typing
Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:

BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).

BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).

Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.

dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 16:03:13 +02:00
Erik
2914e43aa9 fix(audio): portal cues fire on the sequencer's sound events, not the tunnel visuals
Slice A4 hung UI_EnterPortal on TeleportAnimEvent.EnterTunnel — the first
tunnel-family frame — so the cue landed a whole TunnelFadeIn after retail
plays it. Retail's site is gmSmartBoxUI::BeginTeleportAnimation
@0x004D638E, i.e. Begin(), which the sequencer already marks as
TeleportAnimEvent.PlayEnterSound.

That event has existed since the R6 portal-space work, complete with a
'Begin(): sound_ui_enter_portal' comment, and no consumer has ever
handled it — the switch in LocalPlayerTeleportController had cases for
Place, EnterTunnel, PlayExitSound and FireLoginComplete only, so the
sequencer emitted PlayEnterSound into nothing. A4 filled the gap in the
wrong place rather than filling it.

Both cues now go through named presentation methods driven by the
matching events: PlayEnterCue on PlayEnterSound, PlayExitCue on
PlayExitSound (the TunnelFadeOut -> WorldFadeIn edge, @0x004D7405, the
same tick the world viewport is revealed). EnterTunnel/ExitTunnel are
visuals again. The exit cue was already firing at the right moment, since
ExitTunnel was called from inside the PlayExitSound case — correct by
accident, explicit now.

Found by the user asking when the recall cues play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:22:48 +02:00
Erik
7c4dd1ade7 feat(audio): Campaign A slice A5 — retail's region ambient soundscape
acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.

A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.

The Opus review caught four bugs before this landed, one fatal:

- Cell offsets were built in ABSOLUTE world coordinates and differenced
  against the listener's STREAMED-frame position, so every one of 576
  offsets came out ~32 km, every contribution was culled, and the whole
  feature was silent with nothing logged. Offsets are now landblock-local
  the way Position::get_offset builds them, and the streamed-frame
  position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
  DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
  the table's entry count — enough to push a typical authored volume
  under the 0.03 audibility floor.
- The drain used  where retail's UseTime is strictly
  below, so a descriptor authored with a zero rate re-armed at the same
  instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
  so a newly audible ambient was silent for a full period after the
  crossing that made it audible.

Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.

Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.

The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:53:41 +02:00
Erik
6eaa490bb3 feat(audio): Campaign A slice A4 — the interface sound bus
Retail's UI sound bank was absent, so three families of cue were silent:
the portal enter/exit stingers, the AdminEnvirons dungeon atmosphere
(chanting, drums, whispers, thunder — what players remember as dungeon
'music'), and every other interface slot.

The bank's DID is not a literal anywhere in retail: GetUISoundTable
@0x00563FB0 asks GetByEnum for enum slot 7, and DBCache::GetDIDFromEnum
@0x00413940 resolves it through two EnumIDMap hops off the portal dat
header's master map. UiSoundTableResolver walks that chain the way
RetailCursorResolver already walks it for cursors. Against the shipped
dats it resolves to 0x2000004B, and that table holds exactly the 32 UI_*
slots (UI_EnterPortal 0x6A .. UI_Thunder6 0x8A) — content that confirms
the walk independently of the decode. UiSoundTableResolutionTests pins
the walk, the DID, and the content, and skips when dats are absent.

Two corrections to the research along the way. The lane-5 note recorded
GetByEnum's arguments transposed: the 0x22 it called a fileType is the
CACHE type (CLOCache(cache, CSoundTable::Allocator, 0x22)) and the real
second-hop key is 0x10000003; walking it the other way finds nothing. And
its claim that the interface volume pref applies is wrong — GetAttenuation
with ambient=0 multiplies by the EFFECT knob, so retail's
interface_sound_volume stays the dead knob lane 1 byte-decoded it to be.

EnvironSoundCueMap is an explicit 21-case table read straight out of
Handle_Admin__Environs @0x0055DE20, not arithmetic: codes 0x65..0x72 sit
0x11 below their SoundType, but 0x73/0x74 have no case, so 0x75 lands on
UI_Squeal (0x84) where an offset gives 0x86, and the switch ends at 0x7B
with no 0x7C case. Verified case-by-case against the decomp rather than
from the lane note, whose tail table was ambiguous.

Cues are attached where retail plays them: the teleport-animation
boundary for the portal pair, and the AdminEnvirons handler for the
stingers. PlaySoundFromCenter's pan-0 / distance-0 shape is what
PlayUiWave already implements after A2.

Retires TS-54. Narrows AP-115 to its notice-presentation residual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:22:09 +02:00
Erik
8bc458fb88 feat(audio): Campaign A slice A3 — the server sound channel (0xF750)
acdream never parsed retail's Sound event, so every server-driven cue was
silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking,
lifestone bind, spell resist, trap triggers, item mana depletion.

SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose
layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent
@0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared
length 16, and holtburger's PlaySoundData.

Playback reuses EntityEffectController's existing per-guid queue rather
than adding a second one, because retail routes sounds through the SAME
CObjectMaint blob queue as F754/F755: an event for a guid the client does
not know yet is parked and drained by HandleCreateObject, so a creature
that spawns and immediately grunts still grunts. Dropping it — the
obvious alternative — would silently lose the cue. Sound joins Direct and
Typed as a third PendingEffect kind so one readiness edge releases the
whole mixed stream in order.

AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the
animation-hook path: the sound plays at the WIRE volume and the
SoundTable entry's volume is ignored (the hook path does the opposite),
while the entry's probability still gates it and its priority still
drives eviction. An object with no SoundTable plays nothing, matching
CPhysicsObj::play_sound @0x0050F460's early return.

The no-window host parses and discards, exactly as it does for F754/F755
— sound is presentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:07:23 +02:00
Erik
e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with
m_3D = 0 and the DirectSound 3D listener the client sets up is dead code;
spatialization is two CPU scalars per voice, frozen at emission. This
slice ports that math and demotes OpenAL to a voice bank.

RetailSoundMixer (new, Core) carries the byte-decoded curve from
SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2,
clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g),
with a hard -50 dB floor at which retail does not start the voice at all
(audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's
(int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero,
forced to dead centre when (int)distance < 5, with no front/back and no
elevation cue. Every AL source is now source-relative with rolloff 0 and
the global distance model is None: AL's InverseDistanceClamped was
first-power (2/d), quieter than retail up close and far louder at range
with no cutoff whatsoever. That was the largest audible divergence in the
subsystem (AP-28, retired here).

RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan
for a free or finished slot, then evict the first slot whose DAT priority
is strictly lower, else drop. Eviction compared GAIN before, so a loud
unimportant sound could silence a quiet important one. It lives in Core
because the engine's play path talks to native AL handles and could not
be tested; the pool now has 12 conformance tests.

The listener keeps using the camera position, which the decode shows is
retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided
camera Position to SoundManager) — only the heading extraction changes,
since retail reads one compass bearing and never a forward/up basis. An
earlier draft of the plan called this a defect; corrected in the plan so
it is not fixed backwards.

Opus review found and this commit fixes: a linear pan-to-azimuth mapping
that saturated to full separation at 30 degrees (OpenAL Soft's own
speaker angle) where retail gives 15 dB — now inverts the constant-power
pan law, so full deflection reaches 0.776 of the arc and both channels
stay live; the stale FUN_00550ad0 / gain-eviction class header, which
contradicted the register row this commit writes; missing discriminating
tests for clamp order and pan truncation; dead PlayingGain state whose
comment invented a retail symbol; and a third in-tree copy of
Position::heading, now delegating to MoveToMath.PositionHeading.

MasterVolume folds into the mixer's one multiply instead of AL listener
gain, so the cutoff, radius and dB quantisation move with the slider.

Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy),
TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk,
applied on the ambient path only) filed. Research note corrected twice
where its summary contradicted its own decode (30 m dB, floor vs trunc).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:58:50 +02:00
Erik
6bb4cfa795 feat(ui): the spell-bar drop ring — retail's authored drag-accept state, and the ring exposed a real drop off-by-one
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
The green ring is retail's own art: every UIItem cell carries an
authored DragAccept child (catalog 0x21000037, child 0x1000045A), and
the spell bar's drag-over handler (SpellCastSubMenu::OnItemListDragOver
@0x004C5990) flips it to the Accept state (0x10000040 -> surface
0x060011F9) for any spell payload. Ported through a per-slot
SetDragAcceptVisual seam + a catalog DragOverAcceptance hook; other
lists are untouched (null acceptance = neutral). A polarity error in
our older docs (Accept/Reject state ids swapped) was corrected against
three independent sources; the shipped art was always right, only the
labels lied.

The ring shares ONE landing computation with the drop
(FavoriteDropIndex) — and that requirement exposed a genuine #354
off-by-one: the empty-tail path double-applied the -1 adjustment
(retail gates it on the lift's removal @0x004C7157), landing a
reordered spell second-to-last instead of last. Fixed;
discriminator-verified both ways. AP-172 narrowed + its false
empty-tail claim corrected.

Clean-room complete solution: 11,545 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 20:18:51 +02:00
Erik
81a9d85a1d fix(ui): spell-bar drag-reorder works — the per-frame rebuild was destroying the dragged cell (#354)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Everything already existed — the drag payloads, the favorite wire pair
(0x1E3 add-at-position / 0x1E4 remove, byte-confirmed against retail's
Event_AddSpellFavorite @0x006A0F70 and ACE), the insert-shift state
ops. The bug: lifting a favorite fires SpellbookChanged, the next
per-frame Tick rebuilt the bar, the rebuild flushed and recreated
every cell, and UiRoot's subtree-removal safety net canceled the
in-flight drag whose source had just been destroyed — one frame after
every lift, before any drop could land.

The rebuild now defers for the duration of the drag gesture, and the
drop ports retail's own -1-if-lifted-before-target index adjustment
(SpellCastSubMenu::AddFavorite @0x004C7060) so final positions are
byte-identical: insert-shift, not swap; drag-out still deletes (the
lift's removal stands on a missed drop, retail's shape). The
real-pointer-pipeline test fails against the pre-fix code with the
exact cancellation and passes after; a discriminator pins that
physical-item drop handlers reject the spell payload.

AP-172 files the one presentation divergence (mid-drag reflow happens
on release, not continuously) — renumbered from the agent's AP-171
draft, which collided with the same-day double-click row. #354 filed
and closed.

Clean-room complete solution: 11,541 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 18:28:47 +02:00
Erik
d674b99f56 feat(ui): double-click-to-buy (AP-171, user-approved) + #353 toolbar text fixes — authored right-justify and two-line name wrap (Fable)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Double-clicking a vendor shop item now buys through the Buy button's
exact quantity/price path — retail has NO double-click-to-buy (the
named table sweep's negative evidence stands); the user chose the
addition explicitly and AP-171 records it.

#353 (pre-existing, user-reported): the stack-count entry is AUTHORED
HJustify=2 — right-justified flush against the slider on its own row —
and UiField already supported RightAligned; nobody had honored the
authored value. The name element is AUTHORED two lines tall (H=31,
W=140): long names now word-wrap at the authored pixel width onto a
second centered row via two stacked one-line labels reusing the
existing centered draw path (WrapNameTwoLines: greedy word break, no
hyphenation, second row clips like retail).

Ten SelectedObjectController structure tests updated from
single-label to first-label access. Lesson re-learned the hard way:
the first "green" run used a stale TEST assembly (only the App
project had been rebuilt) — the clean-room caught it, per
feedback_stale_build_artifacts. Full App 4,329/3 and Core 4,381/1
verified green on properly rebuilt assemblies; the one transient
Core Release failure did not reproduce and is noted on #351.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:42:02 +02:00
Erik
02b735ba4a fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 17:17:04 +02:00
Erik
d003449bb4 fix(vendor): re-gate residuals — MaxStackSize is the stack operand, wire-authored use radius, purse summaries
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
R1 the split bar's operand is the item's authored MaxStackSize —
three retail sites read pwd._maxStackSize directly (InqListSlotCount
pc:200052, buy-button cases pc:203996/204086) where ACE never fills
the desc stack and standard stock is unlimited. Threaded StackSizeMax
end to end with one shared resolver; the two literal _maxStackSize
sites are now byte-exact; AP-165 retired, AP-169 corrected.
R2 walk-to-vendor never opened because GetUseRadius used an UNCITED
3m Creature heuristic as the local stop distance while ACE's poll
demands the authored radius (default 0.6 m) — the walk stopped and
the Use fired far outside acceptance. Now reads the wire-authored
spawn UseRadius with ACE's exact fallback; heuristic constants
deleted. A first sabotage attempt was non-discriminating
(coincidental 0.6) and was corrected — the discriminating version is
what landed.
R3 the Buying/Selling purse summaries ("Buying %d %s worth %hsp" /
"You have %hsp") recovered from the binary data segment where BN
mis-attributes the Buy-side literal; wired to staging and money
changes on the four authored text elements; AP-166 narrowed to the
pending-sell highlight.

Clean-room complete solution: 11,528 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 15:23:10 +02:00
Erik
68568a3a59 fix(vendor): grand-gate findings — wire-truth container counts, the live split bar, arrival-gated use, prepend-order race
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Four live findings, each with the paper-verification failure named:

G1 the container-capacity guard counted containers by a local
type/capacity heuristic that over-classifies ordinary items;
retail buckets from the wire's ContainerProperties at insert. Now
reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed
to the shop-stock half; a pre-check must never false-block).
G2 the amount bar never showed live because ACE never sets StackSize
on browse listings — DescStackSize is null for every real vendor item
and the C4 paper test hand-set the field, bypassing the materializer.
The materializer now falls back to the packed supply count (AP-169,
ACE adaptation); the new test drives the REAL materializer.
G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape):
ACE's HandleActionUseItem only opens the vendor when the Use finds
the player in range — a click-time send is greeted and dropped
(AP-170, ACE adaptation; retail's server walks the player, ACE
does not).
G4 bought items appended because ACE's placement echo (UIQueue) can
beat the CreateObject (SmartboxQueue) — cross-queue, no ordering
guarantee — and the early echo was silently dropped. ClientObjectTable
now stashes unresolved placements and replays them at Ingest: buys
land at the retail list head. No register row — this RESTORES parity.

Clean-room complete solution: 11,521 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 14:03:57 +02:00
Erik
c68ad1e646 fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
All thirteen findings, each anchored in recovered bytes or pc reads:

Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.

AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.

Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 12:50:28 +02:00
Erik
92ea3977b6 feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
C1 an out-of-range Use now approaches first via the existing
client-predicted BeginApproach (Pickup's far-range shape mirrored;
retail's ItemHolder::UseObject @0x00588A80 has no range check and the
dispatch stays immediate). C2 Add-to-List stages into the Buying tab
via VendorStagingList (RemoveProfileFromList's two shapes,
pc:200497-200537), Buy All sends ONE batched 0x005F and flushes
staging on send exactly as retail does (SendShopEvent -> Flush,
pc:204075-204076 — not UseDone-gated), and X-close over a non-empty
staging list shows retail's confirm string recovered verbatim from the
binary data segment (0x007b5bd8) through the existing dialog factory.
C3 the Selling tab's list is the sole drop target (retail's single
IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports
InqAcceptability with all rejection strings recovered verbatim from
the raw data segment; the sell side prices with BuyPrice (retail's
inverted naming: what the vendor PAYS) and 0x0060 carries no trailing
currency field, unlike Buy. C4 the status-bar reproduction test PASSES
against the production toolbar mount — retail's toolbar shows count +
name with the split bar and NO price parenthetical (that figure is the
vendor row's own cost text); no code change, the live gate referees.
C5 pack order verified correct, untouched.

Register: AP-161 narrowed to its two pre-existing cosmetic gaps;
AP-162 extended over Buy All; AP-164 (non-sellable bitfield
unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal
test, bounded), AP-166 (purse text + pending-sell highlight cosmetic)
filed.

Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 11:43:11 +02:00
Erik
33b45ee581 fix(ui): vendor dropdown polish — authored arrow-cap with open/closed flip, downward popup, left-aligned rows
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Three gate findings, each settled by authored data rather than
invention: the button face is retail's two-piece assembly and the
17x19 arrow-cap 0x1000034E now renders with its authored
Normal(closed)/Highlight(open) states; the popup direction is an
AUTHORED attribute (UIElement_Menu::Open pc:120210-120252 — bool
attr 5, chat authors upward=true, the vendor menu authors nothing and
defaults downward), so both menus are now byte-faithful with no
special case; and the 19/20px text indents were chat-specific
checkbox/LED clearances the vendor rows don't author — measured
against the live retail font, "Spell Components" overflowed by 11px
and now fits with 8px to spare. Chat's menu defaults are bit-identical
and its tests untouched.

AP-161's arrow-cap note closes. #351 files the pre-existing FarLoad
Debug flake (three sightings today, never in clean-room Release).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 10:59:59 +02:00
Erik
5224e43890 fix(vendor): gate-findings pass — the X button HIDES like retail, clicks return, the dropdown scrolls, pyreal suffix, staged-tab slots
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The user's connected gate found five issues; each fixed at the root:

G4 (the discovery): retail's vendor X button calls only SetVisible(0)
(pc:204147-204182) — the SESSION stays open and re-using the vendor
lands on the same-session refresh; the range watcher remains the sole
real close. Our port invented a full teardown on X, which is exactly
why reopening died. The Runtime fixture proves the wire dispatch was
never the problem; ACE has no already-open short-circuit.

G3 (regression from the drag-suppression fix): denying IsDragSource
also dropped press capture, so clicks fell through to window-drag.
UiItemSlot.HandlesClick now claims presses for any occupied cell
independent of drag eligibility — clickable and draggable are separate
concerns.

G5: the authored popup 0x21000043 is ONE scrollable column with a real
scrollbar subtree (live-dat scan: ListBox 0x10000350 + scrollbar
0x10000351), not a 3x6 grid. UiMenu gains an authored-driven
Scrollable mode (wheel, thumb drag, track paging, up/down buttons);
chat's menu is untouched and its ten tests prove it.

G1: retail's cost format is "%s %hsp (you have %hsp)" — the p after
each %hs is a LITERAL pyreal suffix the port swallowed as part of the
specifier. Restored.

G2: the Buying/Selling pages' authored lists (same cell template as
Items) get the empty-slot fill, presentation-only until staging.

Clean-room complete solution: 11,390 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 10:29:39 +02:00
Erik
3c9fc57adb fix(vendor): Slice 6 review corrections — ownership-checked retire, live slider display, drag-proof shop rows, hardened buy reservation
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
All nine findings from the buy-arc review, at root:

F1 the materializer's retire pass re-checks ownership (guid->vendorId
map; remove only while the live object's ContainerId still equals the
recording vendor) — buying a player-sold UNIQUE no longer deletes the
item you just purchased; the discriminating reparent-then-refresh test
pins it. F2 the cost/name display subscribes to the live split state
and shares ONE quantity computation with Buy (retail re-renders per
slider tick: RecvNotice_StackSliderChanged 0x004C4500) — the sentence
and the charge can no longer disagree. F3 shop rows never mint drag
payloads (UiItemSlot.AllowDragSource gates both IsDragSource AND
GetDragPayload — the second gate was caught by this pass's own test).
F4 sendBuy reports whether anything was sent; a null-session buy
cancels the reservation instead of leaking BusyCount forever.
F5 the retire loop snapshots, isolates per-guid observer failures, and
clears its tracking in finally and Dispose — teardown convergence can
no longer wedge. F6 auto-select is retail's unconditional
first-filtered-item shape (pc:201180-201184; the survival-check was
our invention and the comment claiming otherwise is corrected).
F7 non-stack buys clamp to quantity 1 locally (BuySingleItem
pc:201669). F8 the Add button is hard-disabled until staging exists.
F9 AP-161/162/163 rewritten to the post-fix reality.

Clean-room complete solution: 11,378 passed / 4 skipped / 0 failed.
The #350 render-ledger overflow observed this session is under
separate investigation and is NOT addressed here.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 23:12:50 +02:00
Erik
97cf873870 feat(vendor): Slice 6 buy arc — shop items are real objects, vendor selection is THE selection, and Buy works (0x005F)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Three ordered pieces in one landing (the shared controller/composition
files carry all three; the internal order was 6.1 -> 6.2 -> 6.3):

6.1 VendorShopItemMaterializer diff-merges the shop list into the live
ClientObjectTable on VendorState transitions (so client-local close and
session teardown retire the entries too) and never claims a guid it did
not add — ACE's UniqueItemsForSale can re-list a guid a player once
held (AP-163 files the collision-skip; no retail counterpart traced).
Right-click examine on shop items now routes through the ordinary
appraisal path — the 5.4 F7c blocker dissolves with the table entries.

6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine
all flow through the canonical SelectionState; the status bar and the
existing byte-faithful StackSplitQuantityState slider light up
unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the
slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the
stack (the splitSize/maxSplitSize distinction, research §B.3).
Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop
(pc:202848), not a CloseVendor-level clear that does not exist.

6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs,
and the trailing alternateCurrencyId the REAL client sends
(CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it.
TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and
completes on UseDone; the Buy button disables while a request is in
flight. The reconciliation round-trip (money property update, inventory
CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a
synthetic-inbound test against existing machinery — no new owner.

Register: AP-161 narrowed (selection + examine residuals close;
staging/Sell remain; double-click-to-buy confirmed ABSENT from retail
with negative evidence cited — we match retail). AP-162 files the
conscious no-client-side-affordability-precheck deferral.

Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 20:28:26 +02:00
Erik
e602f84be2 fix(ui): Slice 5.4 review corrections — the dropdown renders from its authored popup, retail cost semantics, auto-select, icon overlays
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
All nine review findings closed at root (one sub-item consciously
deferred):

F1 the category dropdown now draws: sprites/fonts wired and the popup
geometry read from the vendor menu's own authored popup LayoutDesc
0x21000043 (root 0x1000034F — correcting the review's 0x1000014F
transcription) per UIElement_Menu::MakePopup (pc:120705); chat's menu
is untouched and its tests prove it. The new test drives selection
through the REAL open/hit path the review flagged as bypassed.
F2+F3 the selected-item cost display ports VendorItemsUI::UpdateItemsUI
verbatim: quantity via the 0xDC41CB0 split-size mask (whole-stack for
ammo, per-unit for groceries/components; mask lives at the toolbar
SEEDING site pc:198784), plural names with retail's
fall-back-to-singular (pc:409056 — correcting the review's "name+s"
guess), full cost sentences with comma grouping and the player's coin
total, and Buy/Add buttons that disable without a selection.
F4 category switches auto-select the first filtered item (pc:201180).
F5 icon underlay/overlay/effects + plural name forwarded from the
already-parsed wire fields through VendorShopItem to the icon
composer. F6 a DIFFERENT vendor opens on its own first category;
same-vendor refresh preserves per the clamp. F7 scroll resets on
rebuild and authored empty slots fill; the right-click examine route
is consciously DEFERRED (shop items are not in ClientObjectTable and
the appraisal panel hard-requires it — documented, not faked).
F8 VendorState.Apply's fanout gets the same per-listener isolation as
Close/Reset. F9 AP-110/AP-161 wording corrected ("quantity-correct
pricing") and AP-161 rewritten to exactly the remaining conscious
gaps.

Clean-room complete solution with the #348 cursor fix in the same
tree: 11,334 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 18:26:17 +02:00
Erik
c721830e71 feat(ui): Slice 5.4 — the authored vendor browse panel (LayoutDesc 0x21000012)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The vendor window is retail's own: LayoutDesc 0x21000012, root
0x100000B7, found by enumerating all 101 layouts for the one
containing both known tab controls and clinched by the root's Type
0x10000017 — the literal UIElement::RegisterElementClass id for
gmVendorUI (pc:202075). Discovery evidence and the D0 read live in
the research doc's new §B.4.

D0 corrected two assumptions: retail's category "tabs" are a UiMenu
DROPDOWN fed by a hardcoded 18-row ordered category table (ported
bit-for-bit against our ItemType enum; list always scoped to exactly
one category, first-present wins, selection preserved across refresh
per retail's clamp), and the layout authors THREE tabs — Items
(browse, this slice), Buying and Selling (staged-transaction review,
Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's
mode-2 OpenTab opens. The non-default tabs render and switch pages
but stay inert, fenced in comments.

VendorUiController mounts Items: category dropdown, icon-cell item
row with the retained scrollbar, per-unit retail pricing via
VendorPricing.SellPrice (the vendor-stock path VendorProfile::
VendorSellPrice feeds), name/cost on selection. The panel is a pure
projection of VendorState — opens on populate, closes on clear; the
close button's VendorState.Close() is its only permitted mutation.
Nothing on the wire.

AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files
the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add
buttons, InqAcceptability). Twelve controller tests on a real-dat
fixture. Clean-room complete solution: 11,323 passed / 4 skipped /
0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 17:00:21 +02:00
Erik
6c6664a685 fix(app): #343 — a wounded render loop defers the native release instead of throwing over the real failure
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Root cause pinned by IL-decompiling Silk.NET.Windowing.Common:
ViewImplementationBase._inRenderLoop is set at DoRender/DoUpdate entry
and cleared ONLY on normal return, so a throwing frame callback leaves
it armed forever and any later Dispose -> Reset throws "You cannot call
Reset inside of the render loop", exit 82, replacing the original
wounding exception in the report.

The fix mirrors Silk's own bracket exactly: GameWindow._renderLoopArmed
set at OnUpdate/OnRender entry, cleared only on their normal return —
deliberately NOT in a finally, so it tracks the wound the same way
Silk's private field does. ReleaseNativeWindow checks it before
disposing: armed -> best-effort Close() (swallowed so it can never
become the reported failure), no Dispose, and a new terminal status
CompleteWithDeferredNativeRelease with Error kept null — the original
exception stays the primary report. Healthy paths (OnClosing's
in-loop completion, Run()'s tail release) are byte-unchanged, and the
new PublishNativeWindow parameters default to null so every existing
caller and test behaves identically.

Sabotage: disabling the armed-check flipped the deferral test to
Expected CompleteWithDeferredNativeRelease / Actual Complete —
the guard is what the test exercises. Clean-room suite 11,262 / 6 / 1,
the 1 being #340's documented load flake (passed standalone; second
recorded firing noted in its entry).

Queue: #344 done, #343 done; next #345's instrumented mechanism
session, then #341's boundary hunt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:28:29 +02:00
Erik
52bdf4df71 fix(world): #344 — a mid-teleport world-frame disagreement defers the projection instead of crashing
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
During a portal transit the two world-frame owners legitimately rebase
on different edges (Runtime at TeleportAdvanced, streaming only after
old-window retirement), and a spawn projection landing in that window
hit the #283 invariant as an unhandled render-path throw — the crash
the user hit entering a dungeon.

The guard's check is unchanged; only the disagreement RESPONSE is
discriminated on the canonical transit authority
(RuntimeWorldTransitState.IsTeleportActive, the same field the App
layer already reads for portal-in-flight): in transit -> the
materializer's existing "not yet" return, parking the projection on
its established retry rides (OnLandblockLoaded's re-attempt loop,
whose ordering guarantees agreement on retry because the recenter
coordinator adopts the new origin BEFORE unblocking new landblock
loads — verified at source; plus OnPosition recovery and
OnAppearance). Outside transit -> still throws: genuine corruption
stays loud. The implementer explicitly ruled out riding the Runtime
placement pump, which would have acknowledged-and-discarded the
completion receipt and silently dropped the entity forever.

Sabotage: removing the discriminator reddened the pre-existing #283
throw tests as well as the new not-in-transit test — the sabotage
defeats the original contract, not merely the new coverage. Four new
tests cover defer, defer-then-agree-then-succeed (projected exactly
once), throw-outside-transit, and the agreeing pass-through.

Clean-room suite: 11,261 passed / 6 skipped / 0 failed. #346 filed for
a sixth, distinct load-sensitive allocation flake observed during the
runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:07:45 +02:00
Erik
9671af0273 fix(physics): S2 — static publication emits authored Spheres as Spheres (AP-155 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Both static sites (LandblockPhysicsPublisher, the headless-only
LandblockPhysicsContentBuilder) emitted an authored Setup Sphere as a
base-anchored Cylinder of radius r and height 2r. The live path emits a
Sphere for the same data, so the same object collided differently by
arrival route, and the narrow phase met a flat cap where retail meets a
curved surface. Both sites now mirror ShadowShapeBuilder.FromSetup's
Sphere block exactly.

Combined Opus review: PASS. Its numeric verification of the dispatch
test's geometry (head-sphere clearance 0.201 m for the true sphere; the
cylinder counterfactual inside by 0.10 m XY with the Z band overlapping)
is what makes the discrimination claim more than a sabotage anecdote,
and its F8 finding is applied: the test now carries a POSITIVE control —
aiming straight through the boulder's centre must block — so a
membership/seed regression can no longer masquerade as a curve-hit
pass. F4 applied: CylHeight is asserted, not inferred (the C4 lesson).
F3 applied: the deleted Quaternion.Inverse base composition is recorded
as internally coherent for the old cylinder's world-Z axis — the defect
was the shape TYPE, not that rotation math.

The review also verified the deleted-cylinder blast radius: the F2
overlay's drawn span is IDENTICAL for both shapes (old [c-r, c+r], new
[c-r, c+r]); the flood sphere's centre rises by exactly r, which cannot
change outdoor membership (XY rectangle) and lands the indoor half on
Session B's dungeon gate alongside S1B; and the sphere-branch flood is
now pinned uncapped by a genuine eleventh-shape A/B test.
PublishStaticCollision — the headless static path — gains its first
test ever.

AP-155 is NARROWED, not deleted (review F13): the has-BSP source split
(entity.MeshRefs vs setup.Parts + AnimPartChanged) survives and keeps
the row active. The shared-primitive-emitter refactor that would make
route independence a compile-time property is the filed follow-up
(review F20).

Population: 3,506 of 5,935 installed Setups, structurally equal to
AP-157's third-branch count (byte-identical classifier — three
independent routes agree: 3,605 - 99 = 3,506).

Clean-room suite at implementation: 11,253 passed / 6 skipped / 0
failed on landed S1B. Post-review-hardening: Publisher tests 25/25,
Content tests 2/2, both green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:07:02 +02:00
Erik
55b07f6a62 refactor(physics): hoist the live-entity collision builder to Runtime (#330 groundwork)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
LiveEntityCollisionBuilder and LiveEntityDefaultPoseResolver move from
AcDream.App.Physics to AcDream.Runtime.Physics with no behaviour change
— diff-verified byte-identical shape math by both review lenses. The
Build signature's App-record parameter is replaced by presentation-free
primitives with identical guard semantics, INCLUDING the
FinalPhysicsState read the contract had missed and the implementer
surfaced rather than dropped. Visibility stays internal: Runtime's
existing InternalsVisibleTo grants already cover every consumer, so the
implementation's public widening is reverted per the architecture
review's finding 11.

The registration WIRING is deliberately WITHHELD. Both Opus lenses
failed it, converging: a shadow registered at spawn freezes there
(RuntimeRemotePhysicsUpdater is Runtime-homed but App-driven — nothing
headless ticks it), so a walking NPC becomes a phantom obstacle at its
spawn point while the real NPC still passes through the bot; three of
five shadow-lifetime edges leaked (pickup leaves a permanent invisible
collider, supersession orphans a duplicate, generation reset never
unregisters and the K-ledger convergence oracle only checks retained
shadows AFTER disposal clears them); and headless cannot resolve BSP
collision assets at all, so doors and chests would still be
walk-through. The frozen-shadow root was the SESSION LEAD's contract
error (fact 3), not the implementer's.

#330 stays OPEN, rewritten as the seven-point scope map the reviews
produced — the honest overnight deliverable is that map, not a
half-mechanism carrying new divergences.

Suite 11,235 passed / 4 skipped / 0 failed (the withheld seam's two
tests account for the delta from the implementation run's 11,237).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:49:13 +02:00
Erik
13fcf38138 fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:07:06 +02:00