Root cause (measured live via ACDREAM_PROBE_PARK=1): HeadlessSessionWorldProjection
drove the first-entry conductor unconditionally, including while
HeadlessCollisionNeighborhood's own 3x3 publication plan held a genuinely open
RuntimeCollisionAdmission for the local player's landblock. Every
TrySealCollisionEvaluationAuthority attempt during that window failed
(IsCollisionEvaluationPrefixAdmissible false) and retried forever without
recovering — measured verdict: "seal-refused" repeating with no preceding
[rearm] verdict= line (the operation never even reached the AwaitingCell park).
This is the diagnosis doc's "structural half" mechanism; no evidence of the
"circular HasOldPrefixPlacementDebt" hypothesis was observed, so that shape
was not needed.
Step 1 (enabler): HeadlessStaticStateAudit.ValidateProcessIsolation now takes
sessionCount and only refuses process-global physics probes for
sessionCount > 1 — its own multi-root-attribution rationale never applied to
a single session, and it was blocking the exact probe built to diagnose this
class of stall.
Step 3a (root cause): new IHeadlessCollisionNeighborhood.IsQuiescent gates
ProjectSpawn/ProjectPosition/PumpFirstEntry's conductor-drive calls — the
conductor is never driven while the neighborhood's own publication owns
collision authority for that tick.
Step 4 (defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer now
requires Controller.CanExecuteLiveMovement instead of just a non-null
controller — the headless-only gap that turned the (now-fixed) hydration
stall into a hard crash reaching SuspendObjectUpdate on a dormant controller.
RuntimeLocalPlayerFrameController's three shared entry points gained the same
guard, contract-preserving for the graphical host.
Verified end-to-end against live ACE (jump-probe policy, three runs):
hydration succeeds cleanly (136 entities load vs. 0 before), no seal-refused
spam, no crash from the original bug, graceful logout every time. Full
airborne-transition confirmation is blocked by a separate, newly-discovered,
pre-existing defect filed as #368 (the headless scheduler's
Task.Delay(...).ConfigureAwait(false) tick loop can resume on a different
ThreadPool thread mid collision-generation, tripping
EnsureCollisionMutationThread) — explicitly out of scope here, not mentioned
anywhere in the #365 diagnosis, and unsafe to fix without graphical-host
verification this session was constrained not to perform.
New tests: the real-admission hydration test (fails on the pre-Step-3a tree,
verified by temporarily reverting the three gates and confirming failure,
then restoring), the PumpFirstEntry quiescence-gate test, the
CanAdvancePlayer publication-lifecycle test, the dormant-controller
sabotage tests for RuntimeLocalPlayerFrameController, and the audit
single/multi-session tests. RuntimeLocalPlayerPhysicsPublicationStateTests
is untouched.
Full Release suite: 12,343 passed / 4 skipped / 0 failed (baseline ~12,330/4
plus 11 new tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Round-3 probe evidence pinpointed it: '[jump] ReportJumpRefusal
result=NotGrounded hasCallback=False' — the edge detection, OnWalkable
clearing, and refusal dispatch all worked; the callback was null because
CommitRuntimeOwnedController (the C3c production publication path) writes
_controller directly and never applied _onInterfaceText the way the
internal setter does. Two install paths, one wired. Regression test pins
the commit path.
Runtime tests 1,323/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The Campaign CH jump-probe headless reproduction quarantined on its first
advance tick: AdvanceBeforeNetwork re-asserts the resolved local entity id
every tick, which the C3c configuration seal treats as a mutation on a
dormant controller. A same-value write is now a no-op; a DIFFERENT id
while sealed still throws. One layer deeper the probe exposed #365: the
headless world never hydrates (entities stay 0, the movement controller
never publishes), so headless bots cannot move at head — filed with the
full evidence chain. HeadlessDiagnosticWriter.Failure now emits the full
exception detail (type-only cost a whole diagnosis round-trip).
Runtime tests 1,322/0, Headless tests 89/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Same staleness class as the Campaign P paragraph: the tracker (register
AP-1/AP-145/AP-22 retirements, addb5657 closure commit, the closed-campaign
memory crib) had it closed since 2026-08-07.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrong-LayoutDesc root cause (0x21000006 vs retail 0x2100006F), resident
floating windows toggled by keybind, 8 authored resize grips, global
opacity options 0x10000080/81.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The composition scope's completion contract threw 'unpublished
resources: spew box' on every graphical launch with the retail UI:
scope.Acquire's returned lease was discarded, so it could never be
Transfer()ed alongside its siblings. Suite missed it because the
composition tests run with RetainedUi absent, so the acquire block
never executed. Ownership after transfer matches the wait-notice
no-tunnel arm: the retained-UI root's teardown reclaims the element
tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CH5: flip the campaign plan's status header from ACTIVE to CODE-COMPLETE,
correct the CH4 ledger row's suite count to its final 12,221 (was showing
the pre-review-fix 12,190) and fill in the CH5 row, and add a closeout
paragraph for the previously-undocumented 5d247d55 re-review round.
Register sweep found one drift: the TS section header claimed 42 active
rows against an actual recount of 40 (TS-66 is retired/struck-through and
was miscounted as active) — corrected. AP/AD/IA/UN section counts,
AP-175..183, AP-176 retirement, and UN-9's deletion all verified
consistent, no other changes. Cross-referenced issues #359-#363 to the
campaign doc. Extended CLAUDE.md's Current-state Campaign-status sentence
to record Campaign CH's CODE-COMPLETE status and carried tail. Added a
Campaign CH entry (plus a missing Campaign A entry) to the roadmap's
shipped-campaign summary block, matching the Campaign V/N/P paragraph
format.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
Follow-up to 614a1e05 — the ledger row's commit reference couldn't be
known until after that commit landed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Slice CH2 (retail SpewBox interface text) landed at 77c8296e; records
its commit + suite counts in the campaign ledger. Review and user gate
still pending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Commits the command-registry, interface-text (SpewBox), and
side-channels-vs-ACE research docs (R3 color-table landed with CH1).
Corrects the CH1 ledger SHA the implementer recorded pre-amend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail colors chat lines by the 34-value wire LogTextType (ACE's
ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old
ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail
colors onto one bucket per ChatKind — e.g. every Channel line rendered
colorLightBlue (Magic's slot) when retail's actual palette spans five
different colors across the Turbine rooms and legacy allegiance family.
Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim
(RetailChatColorTable, all 34 RGBA floats read from the PDB-paired
binary's .data section) and threads a new ChatEntry.LogTextType field
through every ingestion site to the correct retail wire value:
HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote
hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel
broadcasts derive their type from the channel bit via the new
LegacyChannelChatType helper (ported from the decompiled
Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send);
TurbineChat rooms map through TurbineChatDisplayNames.LogTextType;
CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/
CombatEnemy per Player_Combat.cs; kill/death lines use retail's
decompiled 0x00 Default (not a combat color). ChatWindowController's
transcript now folds LogTextType through RetailChatColorTable with
retail's exact "out-of-range keeps the previous line's color" carry
rule; ChatPanel's combat highlighting sources the same table.
Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries
were wrong). Adds register row AP-175 for the pre-existing (unchanged)
Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind
description. Narrows ISSUES #139 — its chat-colors half is done.
Retail renders no chat timestamp prefix path exists in acdream today,
so the "timestamp is always colorGrey 0x0C" rule has nothing to attach
to; noted here per the research doc rather than left silent.
Research: docs/research/2026-08-09-chat-retail-color-table.md
Full Release suite: 11,833 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First track of the alpha-release program. Four deliverables: exact
BuildChatColorLookupTable colors, working side channels vs ACE, retail's
on-screen interface text, complete / and @ command registry. Model split
per user direction: Opus research, Fable planning, Sonnet implementation,
dual-lens Opus review per slice. Slices finalize when the four research
lanes land.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan doc and ISSUES.md already carried the closures; CLAUDE.md's
Current-state paragraph still listed Campaign P as active with #268,
#269, TS-8, and matrix rows open. Also records Campaign A's
code-complete state and its open tail (#358).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deferred by user direction. The mute mechanism (2cf94dbc) is fine; the
chord never fires: 101 [input] actions logged in the session, zero for
AcdreamToggleAudioMute. Hypotheses ranked in the issue — merged-binding
absence (the suspicious stable '152 bindings' count), dispatcher modifier
matching, retained-UI Ctrl-chord consumption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two items from listening-gate round 2.
Mute: AcdreamToggleAudioMute (default Ctrl+M; bare M is selection) flips
OpenAlAudioEngine.Muted, implemented as the AL LISTENER gain - unused
since A2 moved all mixing to the CPU, so it is a free master switch that
silences already-playing voices instantly and restores them exactly,
without touching the retail mixing math, the -50 dB allocation cutoff, or
any persisted volume setting. Rebindable like every other action; console
line confirms each flip.
Inn chatter: the user hears talk-and-laughter ambience in retail inns and
not in acdream. Three installed-dat scans (pinned as conformance tests in
EnvCellSoundEmitterInventoryTests) prove the mechanism is NOT client
data: no interior static in the town landblock carries an ambient-slot
sound table, no Setup among all 5,935 in the portal dat references one,
and yet 23 sound tables carrying ONLY Ambient1..8 slots exist - pure
soundscape banks with nothing client-side pointing at them. They are
wire-bound: the server attaches one to an emitter object via
CreateObject's sound-table field and fires the slots over 0xF750 - ACE
implements exactly this (EmoteType.Sound heartbeat emotes ->
GameMessageSound broadcast). Our 0xF750 receiver (slice A3) is live and
now instrumented (ACDREAM_PROBE_SOUND_WIRE=1, via the new
AudioDiagnostics owner per Code Structure Rule 5, with per-event drop
reasons in AudioHookSink.PlayServerSound). A probed session against the
local ACE received ZERO 0xF750 events across a town walkabout: the
silence is server world-content (no emitters configured/firing), not a
client drop. The first scan's assertion originally encoded the
emitter-object hypothesis; the data refuted it, and the test now pins the
negative so the conclusion cannot silently rot.
Full Release suite green (the one failure during development was the
hypothesis-pinning assertion, corrected to pin the finding).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two user findings from the Campaign A listening session.
1. The portal tunnel's in-flight sound was silent while its enter/exit
cues played. The tunnel's authored SoundTweakedHook drained into the
world 3-D path at its synthetic owner's origin (0,0,0) — after A2 that
dies twice: the listener is usually beyond the -50 dB no-allocate radius,
and the world pool is suspended for the whole transit hold. The cues the
user COULD hear were on the interface bus, which has neither problem, and
retail's tunnel is gmSmartBoxUI — UI-owned — so that bus is also the
faithful route. UiPresentationHookSink now wraps the shared router for
the tunnel: sound-bearing hooks go from-centre through the interface bus
(AudioHookSink.OnUiHook); every other hook kind still reaches the
particle/lighting/translucency sinks unchanged.
2. Ambience cut dead inside houses; retail keeps the outdoor soundscape
in sky-lit interiors. This is TS-66, now retired: the ambient listener
source resolves the per-cell CEnvCell.seen_outside bit through the
physics cache (the same #107 field AdjustPosition reads) and converts the
envcell-local origin through the cell's WorldTransform into landblock
coordinates before the 3x3 walk centres on it — an outdoor Position's
origin is already landblock-local, an envcell's is cell-local, and
skipping that conversion would centre the walk wrongly by up to a
landblock. A not-yet-resident cell record resolves to silence for that
rebuild rather than a wrong walk. Sealed dungeons stay silent, which is
retail-correct.
The user also reports interiors carrying their own local sound in retail
(hearth-type emitters). Statics already register their sound tables and
route animation hooks, so the expectation is that the seen_outside fix
plus existing emitters covers it; re-listen decides, and anything still
missing becomes a precise follow-up.
Full Release suite: 11,740 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Login could hang forever at reveal ready=True with the world never
revealed: UI and sky drawn, geometry absent, client healthy. The player's
first-entry conductor was being terminally dropped by a TRANSIENT
condition.
Mechanism, pinned by probes: the C3c-F2 rearm guard validates the exact
destination cell's prefix admissibility before moving the dormant lease
out of AwaitingCell, but the placement transaction's ring search touches
NEIGHBOUR landblocks and TrySealCollisionEvaluationAuthority covers every
touched prefix. A hard login recenter admits nine landblocks at once, so
a rearm taken while a neighbour's admission was still registered passed
the guard and failed the seal. The operation was left in
AwaitingPreparation, IsDormantLocalActivationAwaitingCell went false, and
EvaluateActivation had no way to say 'retry' - it fell through to
RejectedAuthority, which RuntimeFirstEntryDriveController treats as
terminal. The local player left the pump (pending=0), the movement
controller never published, auto-entry never fired, the reveal never
completed. Timing-flipped: the same binary worked when the rearm landed
outside a neighbour's admission window, then lost that race consistently.
Fix is classification, not state: EvaluateActivation reports DeferredCell
when the abort happens while the dormant lease is still current
(IsDormantLocalActivationLeaseCurrent), so the conductor keeps retrying.
The operation deliberately stays in AwaitingPreparation - the retry
re-runs the full evaluation against fresh state, which is the recovery
contract the publication-state tests already pin (the SAME token
evaluates Evaluated once the authority settles). Genuine discards still
report RejectedAuthority. A first attempt that re-parked the lease to
AwaitingCell was rejected by the test matrix: recovery would then need
the rearm gate, which is stricter than the seal, and the
reentrant-restriction-mutation recoveries hung in DeferredCell.
Seven publication-state tests move their transient-abort assertion from
RejectedAuthority to DeferredCell; the two genuinely-terminal tests
(lease retired) are unchanged. The [wake]/[rearm]/[pump] probes that
pinned the mechanism stay behind ACDREAM_PROBE_PARK=1 with the rest of
the C4 family.
Exonerated by experiment before the fix: ACE (wire capture shows
PlayerCreate sent; retail logs in fine) and the portal-cue commit
2914e43a (full revert stalled identically).
Verified: 2/2 live logins reach auto-entered player mode and reveal
event=complete, with the probe showing seal-refused -> retry -> recovery
in flight; full Release suite 11,740 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Losing window focus calls CameraPointerInputController.HandleFocusChanged
-> MouseLookController.EndForLifecycle -> PlayerMovementController
.EndMouseLook, and EnsurePublishedForRuntimeOperation throws when the
controller exists but is not yet published (mid-login) or already retired
(post-logout). A focus callback can land in either window, so a simple
alt-tab during the login stream took the whole process down with an
unhandled InvalidOperationException. Hit live during the Campaign A
listening-session launches.
EndAndRestoreCursor already guarded 'no controller'; publication state is
the finer-grained form of the same condition, so the guard is completed
with the new CanExecuteLiveMovement predicate (the exact lifecycle set
EnsurePublishedForRuntimeOperation accepts) rather than wrapping the call
in a catch. Cursor restore still runs unconditionally - presentation is
always safe. Published-controller behaviour is unchanged.
Also files issue #357: the login placement stall this session exposed
(reveal ready=True, player Place edge never executes, world never opens).
That one is NOT fixed here - full evidence chain, wire capture, and probe
output are in the issue. It is a placement-domain bug and blocks the
Campaign A listening gate.
Suite: green except the known load-dependent measurement flake
(RuntimeCollisionReportingStateTests allocation pin), which passes in
isolation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
E.2 described the audio engine as a retail-faithful 3D pool with
quieter-slot eviction and probability-weighted variant picking. Campaign
A disproved all three: retail creates every gameplay buffer 2D, evicts on
DAT priority rather than gain, and treats probability as a Bernoulli
silence gate — the 'weighted picking' was the bug that made creature
chatter fire ~20x too often.
Also fixes the A6 ledger SHA, which was stamped pre-amend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail EoR has no music system: the linked winmm MIDI player has zero
callers, 'music' appears zero times in the 65 MB decomp, SoundType has no
music member, InitPrefs registers no music key, and the install ships no
music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings
Music knob are deleted rather than left as an API modelling dead code —
the string-keyed signature was the tell, since every other entry point is
DID-keyed. Old settings.json files carrying a 'music' key still load; the
reader ignores unknown keys and the next save drops it.
The Ambient slider is now surfaced, because slice A5 gave it something to
drive, and its default returns to retail's 1.0 from an invented 0.8 —
InitPrefs defaults every sound preference to unity. The panel rule is
unchanged: no slider that does nothing.
r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong
sections (falloff, pan, voice pool, selection, music, ambient) so a
future reader reaches the lane notes instead of the Ghidra-era reads that
this campaign spent its first two slices undoing.
TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1
MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing
assumed a music system that does not exist. The ADPCM count remains
unmeasured and is named as the open question.
Deferred deliberately: #321's sound-cache decode-dedup race. It is a
pre-existing concurrency flake rather than audio-parity behaviour, and
shipping a speculative fix to a race I have not reproduced is exactly the
shortcut this project's no-workarounds rule exists to prevent.
Campaign A is code-complete; the plan carries the closeout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
The SoundTable probability field is a Bernoulli play/skip gate applied at
the play site (SoundManager::PlayProbability @0x005500E0), not a selection
weight — and variant selection (SoundManager::GetSound @0x00550680) is a
uniform index over (n-1) that ignores probability entirely. SoundCookbook
did the opposite: a cumulative-distribution walk weighted BY probability,
short-circuiting single-entry lists before rolling at all.
A dat census says 4,183 of 4,184 entries are single-entry and 686 of those
carry probability < 1.0, so the gate was categorically absent: Speak1 idle
chatter authored at 0.05 fired every trigger (~20x too often), wound/attack/
swoosh variants never dropped, and six 0.0001 entries always played.
Split into retail's two steps (PickVariant + PlayProbability, composed by
Select) over a new ISoundRandom modelling both retail roll ranges: the
variant roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid,
which is why 0.0001 resolves to ~1.2e-4. PickVariant reproduces retail's
(n-1) off-by-one verbatim per the port-faithfully rule — the last variant
of a multi-entry sound is unreachable, costing exactly one wave
(0x0A00051E) in the shipped dats.
Also removes invented mechanism this review disproved: the dead Core
SoundEntry/ISoundCache scaffold (PitchMin/PitchMax, Loop, Is3D — retail
never calls SetFrequency, never sets the loop flag, and creates every
gameplay buffer 2D), the engine's pitch plumbing, the int 0..7 priority
cast (the dat field is a float in [0,1]; 4,100 entries collapsed to 0),
and the clamp-at-the-field on volume (an unbounded gain retail clamps only
after the distance divide).
Tests rewritten as conformance against the disassembled values, replacing
a self-referential suite that pinned the wrong model.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.
Plan proposes slices A1-A6; awaiting user go.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>