diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4c19a3af..913e52fc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -197,7 +197,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 146 active rows (AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 147 active rows (AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -383,10 +383,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40). AMENDED 2026-08-10 at the CH6c review-fix round: reworded (2), added (3)/(4).** Four divergences from retail's focus-driven window opacity, all decomp-verified (`docs/research/2026-08-09-chat-retail-window-shell.md` §3). (1) SCOPE: retail's `ChatInterface::SetOpacity`/`SetDefaultOpacity`/`SetActiveOpacity` only ever run on `ChatInterface`-derived windows (the main chat window + the four floaties) — every other retail window (vitals, toolbar, inventory, ...) has no opacity fade at all. acdream's `RetailWindowOpacityController` subscribes to `RetailWindowManager.WindowRegistered` and applies the SAME focus-driven fade to every window the manager ever registers, so the one Settings → Chat tab transparency slider pair affects the whole retained UI. (2) DEFAULT VALUE — REWORDED at the review-fix round: retail's shipped defaults are PER WINDOW CLASS — the base `ChatInterface` ctor (`0x004F4550`) sets DefaultOpacity=0.5/ActiveOpacity=1.0, but `gmMainChatUI`'s own ctor (`0x004CD0F0`, called after the base ctor) overrides DefaultOpacity to 1.0 (the main window is ALWAYS fully opaque in both states); `gmFloatyChatUI::Create` (`0x004CE2C0`) calls the base ctor directly with no override, so only the four floating windows keep 0.5/1.0. acdream originally shipped the base ChatInterface value (0.5/1.0) as ONE shared global default applied to EVERY registered window — combined with (1)'s scope extension this faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box, including several windows that can never take keyboard focus at all and so were PERMANENTLY stuck at 0.5. Fixed at the review round to `gmMainChatUI`'s 1.0/1.0 override as the shared default instead: this reduces the remaining divergence to acdream's four floating chat windows shipping OPAQUE where retail's floaties ship 0.5-while-idle — user-settable via the same Settings → Chat opacity slider pair, so it is now a default-VALUE divergence only, not a missing mechanism. (3) EASING (new, filed at the review-fix round): retail's `ChatInterface::ListenToGlobalMessage @0x004F3840` — armed on the focus element-messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` at `0x004F5275` via `UIListener::RegisterForGlobalMessage(this, 3)` — eases the live opacity toward its target by 5% of the target-delta per tick, unregistering from the global tick once within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` snaps to the target opacity immediately on every focus-change event; porting the per-tick lerp needs a UI frame-tick hook the controller does not have today, so it is deferred rather than implemented this round. (4) FOCUS PREDICATE (new, filed at the review-fix round): retail's `ChatInterface::IsTextEntryFocused @0x004F30A0` tests specifically whether `GetFocusDescendant(rootElement) == this->m_chatEntry` — the chat ENTRY FIELD, not the window generally. acdream's `RetailWindowHandle.DescendantFocusChanged` fires whenever ANY focusable descendant of the window gains focus, a strictly broader predicate for any window with more than one focusable child. The linked active>=default invariant itself (`SetDefaultOpacity`/`SetActiveOpacity`'s mutual-correction bodies) IS ported exactly — `ChatOpacityLink` in `AcDream.UI.Abstractions`. | `src/AcDream.App/UI/RetailWindowOpacityController.cs`; `src/AcDream.App/UI/RetailWindowManager.cs` (`WindowRegistered`); `src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` (`DefaultOpacity`/`ActiveOpacity`) | Extending the fade to every window is the shape the user's requested "transparency setting" actually wants (a general UI preference, not a chat-only one); shipping the shared default at 1.0 keeps the out-of-box render retail-identical for the 11 non-chat windows AND the main chat window (the windows retail keeps opaque, several of which can never take focus at all), while the Settings → Chat transparency slider remains fully user-settable for anyone who wants the four floaties' retail translucence back. (3) and (4) are both presentation-only refinements — the fade direction and the linked-invariant math stay retail-exact, only the transition curve (snap vs. 5%-per-tick ease) and the focus predicate's granularity (any descendant vs. the text-entry specifically) diverge — so recording them without implementing the frame-tick hook (3) or narrowing the focus event (4) is the correct scope for a review-fix round rather than opening new implementation work | A user who compares acdream's default install against retail side-by-side now sees the 11 non-chat windows AND the main chat window matching (opaque); only the four floating chat windows still diverge (opaque vs. retail's 50%-while-idle) until the slider is dragged. (3) is visible as the opacity change happening in a single frame instead of retail's ~20-tick fade — low severity, since the START and END states are both retail-exact, only the transition is instant instead of eased. (4) is visible on any window with more than one distinct focusable descendant (e.g. a settings panel with several controls): acdream stays at ActiveOpacity while ANY of them holds focus, where retail would already have faded back to DefaultOpacity once focus left the specific text-entry element — for single-focusable-child windows (most of the retained UI today) the two predicates coincide and there is no observable difference | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40`; `ChatInterface::ListenToGlobalMessage @0x004F3840`; `ChatInterface::IsTextEntryFocused @0x004F30A0`; global-message arming switch @0x004F5275 (`UIListener::RegisterForGlobalMessage(this, 3)` on element messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E`) | | AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 | | AP-192 | **Filed 2026-08-10 (Campaign CH round-5 polish, review item S2 — non-UiText outline paths).** Authored glyph outline `0x21`/outline color `0x22` now reach every text-bearing retained widget (`UiText`, `UiButton`, `UiDatElement`, `UiField`, `UiMeter`, `UiMenu`, `UiCatalogSlot` — the last two settable-only, having no authored build path), seeded ONCE from the element's effective-default state via `ElementReader.ApplyCanonicalLegacyProjection`'s `TryGetEffectiveProperty` (DirectState-then-effective-default rule). Retail instead re-resolves text properties on every UI STATE CHANGE — a button entering state `0x3` whose StateDesc authors `0x21=true` gains the outline for the duration of that state. The authored data hits this today: the dialog panel's two buttons (`0x2100003C` elements `0x17`/`0x19`), the character panel button `0x10000535`, and the combat panel button `0x100000B2` each author `0x21=true` in state `0x3` ONLY (DefaultStateId=1 → no outline at effective-default; `0x100000B2` also authors DirectState `0x21=true`, which the canonical rule DOES honor). The same seed-once shape already governs `UiText` (its `ApplyDatState` re-resolves `0x1B` FontColor per state but not `0x21`/`0x22`). `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (BuildButton/BuildCheckbox/BuildMeter/BuildText + the editable-field branch); `src/AcDream.App/UI/UiText.cs` (`ApplyDatState`) | Seed-once from the canonical effective state is strictly closer to retail than the pre-round-5 any-state first-wins scan (which lit those state-`0x3` outlines PERMANENTLY); the widening this row rides in on makes every ALWAYS-outlined authored element (DirectState/default-state authors) render retail-correct, and per-state re-resolution needs a property-application pass on the existing `TrySetRetailState` path — a reviewed slice of its own, not a polish-commit fold-in | A button that retail outlines only in a specific UI state (the four state-`0x3` authors above — state 3 is a hover/highlight-class state) never shows that transient outline in acdream; conversely nothing over-renders, since the effective-default resolution correctly yields outline-off for those elements | `UIElement_Text::SetOutline @0x0046a81c` (`m_bitField & 0x10`); `UIElement_Text::DrawSelf @0x00467aa0` (two-pass outline+fill); LayoutDesc fixtures `dialogs_2100003C.json` (`0x17`/`0x19`), `character_2100002E.json` (`0x10000535`), `combat_21000073.json` (`0x100000B2`) | -| AP-207 | **Filed 2026-08-15 at Campaign CC slice CC3 (character-creation state machine).** Retail re-detects the closest-matching Profession template on every attribute-slider edit (`CharGenState::FitTemplateToCharacter @ 0x005C6130`, called from `gmCGProfessionPage::SetAttribValue @ 0x00482890` after every raise/lower), auto-flipping `template_` to whichever preset the current attribute+skill spread scores closest to (or to `0xFFFFFFFF`/"no match" when nothing fits within tolerance) via an FPU-heavy weighted-distance heuristic (`TEMPLATE_WEIGHT_ATTRIBUTES`/`_TRAINED_SKILLS`/`_SPECIALIZED_SKILLS`). Several of the function's float operations are literally unrecoverable in the named decomp (`/* unimplemented {fild/fidiv/fmul/fadd ...} */` markers Binary Ninja could not translate), consistent with this project's existing x87-blocked precedent. `RuntimeCharacterCreationState` never re-derives `Template` from attribute/skill edits — it only changes via an explicit `SelectTemplate` command, matching `SetTemplate @ 0x005C5A60`'s own commit path. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TrySetAttribute`, `TrySetSkillLevel` — neither calls a `FitTemplateToCharacter` port) | ACE's `PlayerFactory.CreatePlayer` only reads `TemplateOption` for the character's display title/name text (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138`) — it never re-validates attributes/skills against the named template, so a stale `Template` value has no server-side consequence; porting an FPU-unrecoverable heuristic for a value ACE ignores is not a good trade. | A free-editing user who drifts away from their chosen template's exact spread keeps seeing that template's name/button highlighted instead of retail's live re-detection (which might silently flip to a different preset name, or to "Custom"); this is presentation-only until CC4/CC5 build the Profession page's button highlight. | `CharGenState::FitTemplateToCharacter @ 0x005C6130`; `gmCGProfessionPage::SetAttribValue @ 0x00482890`; `CharGenState::SetTemplate @ 0x005C5A60`; `PlayerFactory.cs:135-138` | +| AP-207 | **Filed 2026-08-15 at Campaign CC slice CC3 (character-creation state machine). ANCHOR CORRECTED at the CC3 review-fix round (F5) — the original citation (`gmCGProfessionPage::SetAttribValue @ 0x00482890`) does not call `FitTemplateToCharacter`; it only writes the raw attribute via `SetStrength`/`SetEndurance`/etc. then calls `gmCGProfessionPage::UpdateAttributeValues`, which is one of the real call sites below.** Retail re-detects the closest-matching Profession template on every attribute-slider edit (`CharGenState::FitTemplateToCharacter @ 0x005C6130`, called from FOUR real sites: `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450` (call at `0x004827F4`), `gmCGProfessionPage::Update @ 0x00482830` (call at `0x00482840`), `gmCGProfessionPage::UpdateToDefaultAttributes @ 0x00482860` (call at `0x00482875` — a fourth site the original filing also missed), and `gmCGSummaryPage::Update @ 0x0047BAA0` (call at `0x0047BB63`)), auto-flipping `template_` to whichever preset the current attribute+skill spread scores closest to (or to `0xFFFFFFFF`/"no match" when nothing fits within tolerance) via an FPU-heavy weighted-distance heuristic (`TEMPLATE_WEIGHT_ATTRIBUTES`/`_TRAINED_SKILLS`/`_SPECIALIZED_SKILLS`). Several of the function's float operations are literally unrecoverable in the named decomp (`/* unimplemented {fild/fidiv/fmul/fadd ...} */` markers Binary Ninja could not translate), consistent with this project's existing x87-blocked precedent. `RuntimeCharacterCreationState` never re-derives `Template` from attribute/skill edits — it only changes via an explicit `SelectTemplate` command, matching `SetTemplate @ 0x005C5A60`'s own commit path. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TrySetAttribute`, `TrySetSkillLevel` — neither calls a `FitTemplateToCharacter` port) | ACE's `PlayerFactory.CreatePlayer` only reads `TemplateOption` for the character's display title/name text (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138`) — it never re-validates attributes/skills against the named template, so a stale `Template` value has no server-side consequence; porting an FPU-unrecoverable heuristic for a value ACE ignores is not a good trade. | A free-editing user who drifts away from their chosen template's exact spread keeps seeing that template's name/button highlighted instead of retail's live re-detection (which might silently flip to a different preset name, or to "Custom"); this is presentation-only until CC4/CC5 build the Profession page's button highlight. | `CharGenState::FitTemplateToCharacter @ 0x005C6130`; `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450`; `gmCGProfessionPage::Update @ 0x00482830`; `gmCGProfessionPage::UpdateToDefaultAttributes @ 0x00482860`; `gmCGSummaryPage::Update @ 0x0047BAA0`; `CharGenState::SetTemplate @ 0x005C5A60`; `PlayerFactory.cs:135-138` | | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | -| AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(0x10000003, 0xc) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0`. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; this row is the marker to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030`; `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | +| AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | +| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | ## 4. Temporary stopgap (TS) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index da1db90a..f65b5179 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -52,11 +52,19 @@ verificationState. Writers per page in the recon (SetHeritageGroup recomputes budgets + ApplyTemplate + RandomizeStartArea; SetGender reapplies clothing and UpdateTrueFacePal). -**Finish** (`DoFinish@236864`): trim+set name → empty name → -`ID_CharGen_NoNameWarning`, abort; `remainingAtrbCredits > 0` → credit -warning, abort (retail FORCES full spend — ACE does not; we port the client -gate); verification state must be UNDEF (no double submit) → set PENDING → -`Proto_UI::SendCharGenResult@0x00546A70`. +**Finish** (`DoFinish(this, arg2)@236864`): trim+set name → empty name → +`ID_CharGen_NoNameWarning`, abort. **CORRECTED at the CC3 review-fix round +(F3) — the original line here (`remainingAtrbCredits > 0` → abort, "retail +FORCES full spend") was WRONG; retail does NOT force a full spend.** The +real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary +Finish-button click passes `arg2 = 1` (@0x004E9579), and on unspent +credits shows `MakeCreditWarningDialog` and returns WITHOUT sending +(@0x004E91F2-0x004E9210) — but that dialog's own confirm handler +re-invokes `DoFinish(this, 0)` (@0x004E98BB), which SKIPS the credit check +entirely (`arg2 == 0`) and sends with the credits still unspent. ACE +accepts this — `ValidateAttributeCredits` only rejects a total that +EXCEEDS the max, never an under-spend. Then: verification state must be +UNDEF (no double submit) → set PENDING → `Proto_UI::SendCharGenResult@0x00546A70`. **Wire 0xF656** (`ACCharGenResult::CG_Pack@0x005C7200`, byte-identical to ACE's `CharacterCreateInfo.Unpack`): account String16L FIRST (outside the @@ -230,7 +238,7 @@ the user gate. |---|---|---|---|---| | CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retail’s own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. | | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | -| CC3 | IMPLEMENTED 2026-08-15 (unreviewed — Opus dual-lens review owed per campaign process) | `(HEAD — see git log)` | — | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. On Ok: appends the identity to the roster by REUSING `RuntimeCharacterSelectionState.ApplyRoster` (read current entries via `View.Visit`, append, re-apply — no new roster-mutation primitive) and logs straight in by REUSING the private `EnterSelectedCore` (no second enter route) — `gmCharGenMainUI::Update @ 0x004E8460`'s per-frame name-scan is deliberately not re-implemented since the SAME `0xF643` Ok reply already carries the exact guid/name (an equivalent, not divergent, substitution). `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change). Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (30 cases — every Finish gate, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (4 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting roster append + auto-enter + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate). Runtime 1701/0 (was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it. | +| CC3 | FIX ROUND COMPLETE 2026-08-15 (Opus dual-lens review found blocking findings F1-F4 on the controller integration, retail fidelity PASS; fix round addressed F1-F16; narrow re-review owed) | `9a84230c` (implementation), this fix round (see git log — sha unknowable pre-commit) | Blocking findings fixed (see fix-round summary below); narrow re-review owed | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | — | | | | | CC5 | — | | | | | CC6a | — | | | | diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 0881b2cb..e135a6cf 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1149,12 +1149,54 @@ public sealed class WorldSession : IDisposable { if (Characters is null || Characters.Characters.Count == 0) throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList"); - var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); EnterWorldSelection selection = SelectCharacterForEnterWorld( Characters, characterIndex); - CharacterList.Character chosen = selection.Character; - _activeCharacterId = chosen.Id; + EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout); + } + + /// + /// Send CharacterEnterWorldRequest and CharacterEnterWorld for the exact + /// (guid, accountName) identity the caller supplies, bypassing the + /// cached roster entirely. Campaign CC slice + /// CC3 review-fix round (F1): the index-based overload above assumes + /// refers to a slot in + /// — true for ordinary character-select entry, + /// but FALSE immediately after a character create. ACE never resends + /// post-create (it only appends server-side + /// and replies with the 0xF643 Ok identity — + /// references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:170-172), + /// so entering the newly created character by a re-derived index can + /// throw (zero pre-existing characters) or silently enter the WRONG + /// character (N pre-existing characters, since the caller's display + /// order need not match the wire order). Retail's own + /// CPlayerSystem::LogOnCharacter(gid) is itself guid-based, so + /// this is a more direct port of the same entry point — not a + /// deviation from retail — for the one caller (enter-straight-in after + /// create) that has an exact identity in hand and no reliable index. + /// + /// + /// Retail's own fallback when the freshly created name never appears in + /// its per-frame roster poll (gmCharGenMainUI::Update @ + /// 0x004E8460) bounces the UI back to character management + /// (QueueUIMode(0x1000000a) @ 0x004E85D7). acdream has no + /// analogous fallback here because this entry point is driven directly + /// by the identity carried on the SAME reply that confirms the create + /// succeeded — there is no polling step that could fail to find the + /// name, so there is nothing for a fallback to catch. + /// + /// + public void EnterWorld(uint characterGuid, string accountName, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(accountName); + byte[] enterWorldBody = CharacterEnterWorld.BuildEnterWorldBody(characterGuid, accountName); + EnterWorldCore(characterGuid, enterWorldBody, timeout); + } + + private void EnterWorldCore(uint characterGuid, byte[] enterWorldBody, TimeSpan? timeout) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + _activeCharacterId = characterGuid; Transition(State.EnteringWorld); SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody()); @@ -1220,7 +1262,7 @@ public sealed class WorldSession : IDisposable // CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account // populated by CharacterSet::UnPack, not the spelling supplied to the // login form. ACE validates this canonical account value. - SendGameMessage(selection.EnterWorldBody); + SendGameMessage(enterWorldBody); // LoginComplete is emitted by the host only after the accepted local // Create has completed its canonical first placement. Sending it at diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index e1902b50..1b34e5b8 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -446,9 +446,17 @@ public interface IRuntimeCharacterCreationCommands /// Retail's Finish button (gmCharGenMainUI::DoFinish @ /// 0x004E9170). On acceptance the request is already on the wire; /// the Ok/rejection reply arrives asynchronously as a status delta — - /// see . + /// see . + /// is retail's arg2 == 0 + /// — the credit-warning dialog's own confirm click — skipping the + /// unspent-attribute-credits gate; the ordinary caller passes + /// false (retail's arg2 = 1 button click), which shows + /// that warning instead of sending when credits remain. See + /// 's + /// doc comment for the full citation. RuntimeCommandResult Finish( - RuntimeGenerationToken expectedGeneration); + RuntimeGenerationToken expectedGeneration, + bool confirmUnspentCredits = false); RuntimeCommandResult AcknowledgeRejection( RuntimeGenerationToken expectedGeneration); diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index e35e79da..c169fce8 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -202,6 +202,21 @@ public interface ILiveSessionOperations void StartCharacterSelectionReceive(WorldSession session) => session.StartCharacterSelectionReceive(); void EnterWorld(WorldSession session, int activeCharacterIndex); + + /// + /// Campaign CC slice CC3 review-fix round (F1): mirrors + /// but enters by the exact + /// guid the 0xF643 Ok reply carried, bypassing the (by-design + /// post-create-stale) cached roster — see + /// 's doc + /// comment for the full retail citation. + /// + void EnterWorldByGuid( + WorldSession session, + uint characterGuid, + string accountName) => + session.EnterWorld(characterGuid, accountName); + void DeleteCharacter( WorldSession session, string accountName, @@ -1118,7 +1133,33 @@ public sealed class LiveSessionController } } - private RuntimeCommandResult EnterSelectedCore() + private RuntimeCommandResult EnterSelectedCore() => + EnterHighlightedCore(static (operations, session, character, _) => + operations.EnterWorld(session, character.ActiveIndex)); + + /// + /// Campaign CC slice CC3 review-fix round (F1): the log-straight-in + /// half of — identical + /// transaction shape to , but sends the + /// EnterWorld wire request by the exact created guid rather than by a + /// roster index (see ). + /// + private RuntimeCommandResult EnterCreatedCharacterCore( + RuntimeCharacterCreationIdentity identity) => + EnterHighlightedCore((operations, session, _, accountName) => + operations.EnterWorldByGuid(session, identity.Guid, accountName)); + + /// + /// Shared transaction for "the highlighted character is about to enter + /// the world": select it, send the caller-supplied EnterWorld wire + /// request, activate commands, and publish the entered-world state. + /// is the only thing that differs + /// between the ordinary index-based selection flow + /// () and the post-create guid-based flow + /// (). + /// + private RuntimeCommandResult EnterHighlightedCore( + Action sendEnterWorld) { SessionScope scope = _scope!; ulong generation = _generation; @@ -1141,7 +1182,7 @@ public sealed class LiveSessionController if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); - _operations.EnterWorld(scope.Session, character.ActiveIndex); + sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); @@ -1231,11 +1272,36 @@ public sealed class LiveSessionController /// (we already have the exact created guid/name from the SAME reply /// that triggered the roster append, so there is no need to re-scan for /// it the way retail's per-frame poll does — an equivalent, not a - /// divergent, substitution). Reuses - /// for the append (there is no single-entry append primitive to - /// duplicate) and for the log-straight-in - /// (no second enter route). A non-Ok reply only needs the state-machine - /// update already performed by + /// divergent, substitution). + /// + /// + /// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no + /// longer round-trips through + /// — that re-derives EVERY entry's ActiveIndex from display + /// (name-sorted) order, and ActiveIndex is a wire contract + /// (SendDeleteCharacter sends it as the CharacterSet slot; ACE + /// indexes session.Characters[(int)characterSlot] — + /// references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297), + /// so a full re-sort would silently retarget every PRE-EXISTING + /// character's delete slot to its alphabetical rank. Instead + /// + /// preserves every existing entry's ActiveIndex and assigns the + /// new entry's from the wire count BEFORE this create (ACE appends to + /// session.Characters, so the new character's slot equals that + /// pre-create count, 0-based) — read from the cached wire list + /// (), the SAME source + /// the ordinary index-enter path reads, not the sorted display mirror. + /// The subsequent log-straight-in also no longer goes through + /// 's roster-index EnterWorld send — that + /// cached wire list is BY DESIGN stale for the just-created character + /// (ACE never resends CharacterList post-create), so it uses + /// 's guid-based send instead + /// (see that method's and 's + /// doc comments). + /// + /// + /// A non-Ok reply only needs the state-machine update already performed + /// by /// — no roster/enter side effects. /// private void HandleCharacterCreationResponse( @@ -1265,7 +1331,19 @@ public sealed class LiveSessionController before.AccountName, before.SlotCount, entries); - CharacterSelectionState.ApplyRoster(report); + + // F2: the new character's wire slot is the pre-create count of the + // cached wire roster (ACE appends; that cached list is stale for + // THIS character by design, but its COUNT is still exactly the + // 0-based slot ACE assigned). Falls back to the display roster + // count only if the cached wire list is unexpectedly unavailable. + int wireIndex = + _operations.GetCharacters(scope.Session)?.Characters.Count + ?? before.RosterCount; + CharacterSelectionState.AppendCreatedCharacter( + identity.Guid, + identity.Name, + wireIndex); scope.Host.ReportRoster(report); if (!IsCurrent(scope, generation)) return; @@ -1279,7 +1357,7 @@ public sealed class LiveSessionController // running inside Tick()'s top-level operation (this handler fires // synchronously from _operations.Tick's inbound processing), exactly // the same calling convention StartCore's own inline enter uses. - _ = EnterSelectedCore(); + _ = EnterCreatedCharacterCore(identity); } public RuntimeCommandResult SelectHeritage( @@ -1463,17 +1541,25 @@ public sealed class LiveSessionController } /// - /// Ports gmCharGenMainUI::DoFinish @ 0x004E9170's send half: the - /// local gates live in ; - /// this method supplies the roster/slot-cap inputs from + /// Ports gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170's + /// send half: the local gates live in + /// ; this + /// method supplies the roster/slot-cap inputs from /// and, on acceptance, sends the /// wire request via Proto_UI::SendCharGenResult's port - /// (). A transport - /// failure resets the verification latch the same way an unsolicited - /// Undef/Pending reply does () + /// (). + /// is retail's arg2 == 0 + /// case — see 's + /// doc comment for the full credit-warning-dialog citation; the ordinary + /// caller passes false (retail's arg2 = 1 button click). A + /// transport failure resets the verification latch the same way an + /// unsolicited Undef/Pending reply does + /// () /// rather than leaving it stuck Pending forever. /// - public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration) + public RuntimeCommandResult Finish( + RuntimeGenerationToken expectedGeneration, + bool confirmUnspentCredits = false) { lock (_gate) { @@ -1481,13 +1567,15 @@ public sealed class LiveSessionController if (gate != RuntimeCommandStatus.Accepted) return CharacterCreationResult(gate); + SessionScope scope = _scope!; RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot; if (!CharacterCreationState.TryBeginFinish( selection.RosterCount, selection.SlotCount, out CharacterCreate.Request request, out uint[] skillAdvancementClasses, - out _)) + out _, + confirmUnspentCredits)) { return CharacterCreationResult(RuntimeCommandStatus.Rejected); } @@ -1495,13 +1583,22 @@ public sealed class LiveSessionController try { _operations.CreateCharacter( - _scope!.Session, + scope.Session, selection.AccountName, request, skillAdvancementClasses); return CharacterCreationResult(RuntimeCommandStatus.Accepted); } - catch + // F13: narrowed to what SendCharacterCreation's send path + // actually throws — WorldSession.SendGameMessage's own + // InvalidOperationException (transport not yet negotiated) and + // whatever the underlying UDP send raises (SocketException). + // Anything else is a genuine bug, not a transport hiccup, and + // should propagate rather than being silently swallowed into a + // rejection. + catch (Exception error) when ( + error is InvalidOperationException + or System.Net.Sockets.SocketException) { CharacterCreationState.ApplyCreationResponse( new CharGenVerificationResponse.Parsed( diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 70212973..f38ee9ee 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -506,6 +506,20 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// row's six attributes verbatim and re-derives the skill array (baseline /// reset, then the row's Normal skills trained and Primary skills /// specialized). + /// + /// + /// Campaign CC slice CC3 review-fix round (F4): when the currently + /// selected index is out of range for THIS + /// heritage's template list (a heritage switch left a stale index from + /// a previous, richer heritage), this clears it to + /// instead + /// of merely returning — mirroring the clamp + /// CharGenState::ConstrainAllByHeritage @ 0x005C65CC performs + /// right after ApplyTemplate in SetHeritageGroup + /// (if (template_ >= count) template_ = 0xffffffff;). Without + /// this, the stale out-of-range index would survive unchanged and reach + /// the wire via . + /// /// private void ApplyTemplateLocked(ChargenHeritageOptions heritage) { @@ -519,7 +533,10 @@ public sealed class RuntimeCharacterCreationState : IDisposable if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset) return; if (_template >= (uint)heritage.Templates.Count) + { + _template = RuntimeCharacterCreationSnapshot.TemplateUnset; return; + } ChargenTemplate row = heritage.Templates[(int)_template]; _attributes = row.Attributes; @@ -580,13 +597,34 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// /// Ports CharGenState::ResetSkillLevels @ 0x005C43B0's baseline /// derivation: resets the credit counter to the full budget, then for - /// every skill id costable in EITHER tier picks the state that costs - /// nothing yet — TrainedCost>0 → Untrained (must be paid for); - /// TrainedCost==0 && SpecializedCost<=0 → Specialized (free and - /// pre-specialized, e.g. an innate skill); TrainedCost==0 && - /// SpecializedCost>0 → Trained (free to train, costs to specialize). A - /// skill uncostable in both tiers is left untouched (stays Inactive on a - /// fresh set). + /// every skill id classifies TrainedCost>0 → Untrained (must be paid + /// for); TrainedCost==0 && SpecializedCost<=0 → Specialized + /// (free and pre-specialized, e.g. an innate skill); TrainedCost==0 + /// && SpecializedCost>0 → Trained (free to train, costs to + /// specialize). A skill uncostable in both tiers is left untouched + /// (stays Inactive on a fresh set). + /// + /// + /// Campaign CC slice CC3 review-fix round (F8): retail's real gate at + /// 0x005C4487 is if (trainedCost >= 0 && + /// specializedCost >= 0) — BOTH tiers non-negative, not "either + /// tier costable" as this comment previously said. This port's + /// instead gates on DICTIONARY PRESENCE + /// (found in the heritage's own list or the global SkillTable), which + /// is equivalent ONLY because of a CC1-established, installed-DAT-gated + /// invariant: costs are stored VERBATIM (never filtered), and for every + /// entry the reader ever produces, both NormalCost and + /// PrimaryCost are non-negative + /// (ChargenTableReaderInstalledDatTests's + /// NormalCost >= 0/PrimaryCost >= 0 assertions); a + /// skill uncostable in either tier is simply ABSENT from both the + /// heritage and global dictionaries in the installed DAT (retail's -1 + /// case), not present with a negative value. If that installed-DAT + /// shape ever changed (a partially-negative entry, one tier <0 and + /// the other >=0), this dictionary-presence gate would diverge from + /// retail's real per-value check — the regression test above is what + /// would catch that drift. + /// /// private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage) { @@ -644,14 +682,17 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// picks a uniformly random entry from the heritage's /// PrimaryStartAreaIndices (never SecondaryStartAreaIndices) /// and adopts it as the default starting area, bounds-checked against - /// the shared starter-area list. + /// the shared starter-area list. Campaign CC slice CC3 review-fix round + /// (F15): retail (0x005C5A0A, the if (var_9c > 0) + /// guard) touches startArea ONLY inside that branch — an empty + /// PrimaryStartAreaIndices leaves the field COMPLETELY + /// UNTOUCHED, not reset to -1. Unreachable through the installed + /// DAT (every heritage ships a non-empty primary list), but aligned + /// here for exactness. private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage) { if (heritage.PrimaryStartAreaIndices.Count == 0) - { - _startArea = -1; return; - } int candidate = heritage.PrimaryStartAreaIndices[ _random.Next(heritage.PrimaryStartAreaIndices.Count)]; _startArea = candidate >= 0 && candidate < _options.StarterAreas.Count @@ -1097,13 +1138,22 @@ public sealed class RuntimeCharacterCreationState : IDisposable return true; } - /// The CharacterSet slot this create targets. Retail resets - /// this to 0xFFFFFFFF on every rejection - /// (Handle_CharGenVerificationResponse's case 3/4/5/6/7) and the - /// decomp does not show which caller assigns a real value before the - /// first Finish — ACE itself never reads the field - /// (PlayerFactory.cs:154, commented out), so 0 is a safe - /// placeholder until a slot-aware caller (CC4/CC7) sets one. + /// The CharacterSet slot this create targets. Retail DOES + /// assign it — but only as a side effect of char-select, not chargen: + /// gmCharacterManagementUI::SelectCharacter @ 0x004EC160 calls + /// CharGenState::SetSlot(CharacterSet::GetSlot(...)) @ + /// 0x004EC22A with the slot of whichever EXISTING character the + /// player last clicked in the character-select list, reset to + /// 0xFFFFFFFF on entering chargen (0x004EC074, + /// 0x0055EA9A) and again on every rejection + /// (Handle_CharGenVerificationResponse's case 3/4/5/6/7). The + /// value retail actually sends on Finish is therefore semantically + /// STALE — the last-selected PRE-EXISTING character's own slot, not + /// anything about the character being created — and ACE never reads + /// the field regardless (PlayerFactory.cs:154, commented out). + /// 0 is a safe placeholder for the same reason it is safe in + /// retail: it is exactly as meaningless to ACE as retail's own stale + /// value. internal bool TrySetSlot(uint slot) { lock (_gate) @@ -1120,10 +1170,25 @@ public sealed class RuntimeCharacterCreationState : IDisposable // ── Finish / response ─────────────────────────────────────────────── /// - /// Ports gmCharGenMainUI::DoFinish @ 0x004E9170's complete gate - /// sequence: trim+commit the name (empty → refuse), require - /// remainingAtrbCredits == 0 (retail forces a full attribute - /// spend), require + /// Ports gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170's + /// complete gate sequence: trim+commit the name (empty → refuse), then + /// — ONLY when is false, + /// mirroring retail's arg2 != 0 half of + /// arg2 != 0 && remainingAtrbCredits > 0 — require + /// remainingAtrbCredits == 0. Retail does NOT force a full + /// attribute spend: the ordinary Finish-button click passes + /// arg2 = 1 (0x004E9579) and, on unspent credits, shows a + /// WARNING dialog and returns WITHOUT sending + /// (MakeCreditWarningDialog @ 0x004E91F6); that dialog's own + /// confirm handler re-invokes DoFinish(this, 0) + /// (0x004E98BB), which skips the credit check entirely and + /// sends with the credits still unspent. ACE accepts this + /// (ValidateAttributeCredits only rejects a total that EXCEEDS + /// the max, never an under-spend). + /// is retail's arg2 == 0 case: pass true only from the + /// warning dialog's own confirm path (CC4) or an equivalent headless + /// caller that has already decided to proceed with unspent credits. + /// Then requires /// to be false (no double submit), then the campaign's client-side slot /// cap (risk item 3 — retail's char-select UI, not DoFinish /// itself, refuses when the roster is already full; ACE never checks @@ -1139,7 +1204,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable int slotCount, out CharacterCreate.Request request, out uint[] skillAdvancementClasses, - out RuntimeCharacterCreationLocalRefusal refusal) + out RuntimeCharacterCreationLocalRefusal refusal, + bool confirmedUnspentCredits = false) { request = default; skillAdvancementClasses = []; @@ -1158,7 +1224,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable refusal = trimmed.Length == 0 ? new RuntimeCharacterCreationLocalRefusal( NoName: true, false, false, false) - : _remainingAttributeCredits > 0 + : !confirmedUnspentCredits && _remainingAttributeCredits > 0 ? new RuntimeCharacterCreationLocalRefusal( false, AttributeCreditsUnspent: true, false, false) : _verificationPending @@ -1245,6 +1311,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// review F3) — a call that arrives while /// is /// already false is a no-op rather than a second event. + /// + /// + /// Campaign CC slice CC3 review-fix round (F6): every branch below only + /// SETS kind inside lock (_gate); the single + /// call happens once, after the lock releases — + /// matching every other public method in this class. The Pending/Undef + /// branch previously published from inside the lock (harmless on its + /// own — 's own lock (_gate) is reentrant on + /// the same thread — but inconsistent with the rest of the class and a + /// lock-ordering risk once an observer callback reaches back into + /// caller-held locks, e.g. LiveSessionController._gate, while + /// still inside this one). + /// /// internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response) { @@ -1268,9 +1347,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable { // Silent state reset — retail shows no dialog (ACE sends // Pending for a disabled-Olthoi rejection; port as-is). - _revision++; - Publish(RuntimeCharacterCreationDeltaKind.StateChanged); - return; + kind = RuntimeCharacterCreationDeltaKind.StateChanged; } else { diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index eb6a07a1..2c289825 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -334,6 +334,53 @@ public sealed class RuntimeCharacterSelectionState : IDisposable selected); } + /// + /// Campaign CC slice CC3 review-fix round (F2): appends ONE freshly + /// created character without re-deriving every entry's + /// from display + /// order the way does. ActiveIndex is a + /// WIRE CONTRACT — SendDeleteCharacter sends it as the + /// CharacterSet slot and ACE indexes + /// session.Characters[(int)characterSlot] + /// (references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297) + /// — so round-tripping the post-create roster through + /// 's name-sort would silently retarget every + /// PRE-EXISTING character's delete slot to its alphabetical rank. + /// is the caller-supplied wire slot for the + /// NEW entry only (ACE appends to session.Characters, so the new + /// character's slot equals the wire roster's count BEFORE this create, + /// 0-based — the caller must read that from the cached wire source, not + /// from the sorted display mirror this class exposes). Every existing + /// entry's is + /// copied through untouched; only the array's DISPLAY order (name sort, + /// greyed-to-tail) is recomputed, exactly like 's + /// own sort. Does not touch highlight — callers that want the new entry + /// selected still call afterward. + /// + internal void AppendCreatedCharacter(uint characterId, string name, int wireIndex) + { + ArgumentNullException.ThrowIfNull(name); + lock (_gate) + { + ThrowIfDisposed(); + var appended = new RuntimeCharacterSelectionEntry[_entries.Length + 1]; + Array.Copy(_entries, appended, _entries.Length); + appended[^1] = new RuntimeCharacterSelectionEntry( + wireIndex, + characterId, + name, + SecondsGreyedOut: 0u); + + Array.Sort( + appended, + static (left, right) => + string.CompareOrdinal(left.Name, right.Name)); + _entries = StablePartitionGreyedToTail(appended); + _revision++; + } + Publish(RuntimeCharacterSelectionDeltaKind.RosterChanged, characterId); + } + /// /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName /// (0x004ec120) / RecvNotice_WorldName (0x004ec360) diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index ae80851f..d3f93985 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -165,6 +165,33 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes); } + /// + /// F4 acceptance gate: CharGenState::ConstrainAllByHeritage @ + /// 0x005C65CC clamps a stale template index to 0xffffffff + /// when it no longer fits the newly selected heritage's template list. + /// Without this, a high template index chosen against a + /// many-templates heritage would survive a switch to a heritage with + /// fewer templates and reach the wire via BuildRequestLocked. + /// + [Fact] + public void TrySelectHeritage_TemplateOutOfRangeForNewHeritage_ClearsToUnset() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + // Index 1 — valid for Aluvian's two templates (Custom=0, Preset=1). + Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex)); + Assert.Equal(1u, state.Snapshot.Template); + + // Impoverished has only ONE template (index 0) — index 1 no longer + // fits; gender (Male) stays valid for Impoverished too, so the + // clamp branch (not the "no gender yet" no-op branch) is the one + // under test. + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId)); + + Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, state.Snapshot.Template); + } + // ── Attributes ────────────────────────────────────────────────────── [Fact] @@ -230,6 +257,95 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Equal(16, state.Snapshot.Attributes.Strength); } + /// + /// F7 acceptance gate: CharGenState::BalanceAttributes @ + /// 0x005C3DF0's persistent cursor (ported as the instance field + /// _attributeBalanceCursor) advances past whichever attribute + /// last absorbed an overspend, so a SECOND overspend in a LATER call + /// does not re-drain the SAME donor the first call already emptied. + /// + [Fact] + public void TrySetAttribute_SuccessiveOverspends_AbsorbFromDifferentAttributes() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + // Str=16, everyone else at the 10 floor, fully spent (66/66) — the + // fixture's only above-floor attribute at the start. + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + + // First overspend: raising Endurance by 1 forces a 1-point + // donation. The cursor starts at Strength (the only above-floor + // attribute), so Strength donates. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11)); + Assert.Equal(15, state.Snapshot.Attributes.Strength); + Assert.Equal(11, state.Snapshot.Attributes.Endurance); + + // Second overspend: raising Coordination by 1 forces another + // 1-point donation. If the cursor had reset to Strength, Strength + // (still above floor at 15) would donate again — it doesn't: the + // cursor advanced past Strength after the first call, so THIS + // donation comes from Endurance (the attribute the FIRST call just + // raised) instead. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Coordination, 11)); + Assert.Equal(15, state.Snapshot.Attributes.Strength); // untouched this time + Assert.Equal(10, state.Snapshot.Attributes.Endurance); // donated + Assert.Equal(11, state.Snapshot.Attributes.Coordination); + } + + /// + /// F7 acceptance gate, the wrap case: when the donor found in one pass + /// is the LAST entry in the fixed round-robin order (Self — see + /// BalanceOrder's own doc comment: Strength, Endurance, + /// Coordination, Quickness, Focus, Self), the cursor wraps back to the + /// FIRST entry (Strength) rather than falling off the end. + /// + [Fact] + public void TrySetAttribute_BalanceCursor_WrapsFromSelfBackToStrength() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + // Str=16, everyone else at the 10 floor, fully spent (66/66). + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + + // Lock every attribute except Strength and Self: they stay in the + // budget total but are excluded from donation, isolating the wrap + // behavior to exactly the two attributes under test. + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Endurance, true)); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Coordination, true)); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Quickness, true)); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Focus, true)); + + // Move all 6 spare points from Strength to Self — Strength is the + // sole eligible donor (everything else is locked or is the raise + // target), so it donates all 6. The cursor lands just past + // Strength (index 0 → Endurance). + Assert.True(state.TrySetAttribute(ChargenAttributeId.Self, 16)); + Assert.Equal(10, state.Snapshot.Attributes.Strength); + Assert.Equal(16, state.Snapshot.Attributes.Self); + + // Raise Strength by 1: every locked attribute is skipped, so the + // search reaches Self (the only remaining eligible donor). Self is + // the LAST entry in the round-robin order, so this absorption + // wraps the cursor back to Strength (the FIRST entry) afterward. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 11)); + Assert.Equal(11, state.Snapshot.Attributes.Strength); + Assert.Equal(15, state.Snapshot.Attributes.Self); + + // Raise the (locked) Endurance attribute by 1 — locking only + // excludes an attribute from AUTOMATIC donation, not from being set + // directly. If the cursor wrapped correctly, the donor search + // starts at Strength again and Strength (still above floor at 11) + // donates FIRST — not Self (also still above floor at 15), which is + // what an un-wrapped cursor stuck past Self would have picked + // instead. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11)); + Assert.Equal(10, state.Snapshot.Attributes.Strength); + Assert.Equal(15, state.Snapshot.Attributes.Self); // unchanged — proves the wrap + Assert.Equal(11, state.Snapshot.Attributes.Endurance); + } + // ── Skills ────────────────────────────────────────────────────────── [Fact] @@ -342,6 +458,40 @@ public sealed class RuntimeCharacterCreationStateTests Assert.True(refusal.AttributeCreditsUnspent); } + /// + /// F3 acceptance gate: gmCharGenMainUI::DoFinish(this, arg2) @ + /// 0x004E9170's credit gate is arg2 != 0 && + /// remainingAtrbCredits > 0 — retail does NOT force a full + /// spend. The ordinary click warns and refuses + /// (arg2 = 1 @ 0x004E9579, tested above); the credit-warning + /// dialog's own confirm handler re-invokes DoFinish(this, 0) + /// (@0x004E98BB), which skips the check entirely and sends with the + /// credits still unspent. confirmedUnspentCredits: true is that + /// arg2 == 0 case. + /// + [Fact] + public void TryBeginFinish_UnspentAttributeCreditsConfirmed_IsAccepted() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent + state.TrySetName("Adventurer"); + Assert.Equal(6, state.Snapshot.RemainingAttributeCredits); + + bool accepted = state.TryBeginFinish( + 0, 11, out CharacterCreate.Request request, out _, + out RuntimeCharacterCreationLocalRefusal refusal, + confirmedUnspentCredits: true); + + Assert.True(accepted); + Assert.False(refusal.Any); + Assert.True(state.Snapshot.VerificationPending); + // The wire request carries the credits AS UNSPENT — confirming does + // not force-spend them, it only skips the local refusal. + Assert.Equal(10u, request.Attributes.Strength); + } + [Fact] public void TryBeginFinish_SecondCallWhilePending_IsRefused() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs index 6b5eb3d5..d6048d67 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -49,6 +49,13 @@ public sealed class LiveSessionControllerCharacterCreationTests public List Sessions { get; } = []; public int EnterWorldCount { get; private set; } + /// F1/F11: captures every guid-based enter call so the + /// post-create log-straight-in can be asserted against the EXACT + /// identity sent, instead of a bare counter that cannot tell an + /// index-based call from a guid-based one, or a right character + /// from a wrong one. + public List<(uint Guid, string AccountName)> EnterWorldByGuidCalls { get; } = []; + public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); @@ -63,9 +70,19 @@ public sealed class LiveSessionControllerCharacterCreationTests public void StartCharacterSelectionReceive(WorldSession session) { } + /// Two pre-existing characters whose WIRE order (array + /// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from + /// their ALPHABETICAL display order ("Amy" < "Zed") — F2's + /// regression gate. A single-character roster cannot distinguish a + /// correct wire-index-preserving append from a buggy + /// re-sort-and-renumber, because with only one entry the two orders + /// coincide. public CharacterList.Parsed? GetCharacters(WorldSession session) => new( 0u, - [new CharacterList.Character(0x50000001u, "Existing", 0u)], + [ + new CharacterList.Character(0x50000002u, "Zed", 0u), + new CharacterList.Character(0x50000003u, "Amy", 0u), + ], [], SlotCount: 11, AccountName: "testaccount", @@ -75,6 +92,12 @@ public sealed class LiveSessionControllerCharacterCreationTests public void EnterWorld(WorldSession session, int activeCharacterIndex) => EnterWorldCount++; + public void EnterWorldByGuid( + WorldSession session, + uint characterGuid, + string accountName) => + EnterWorldByGuidCalls.Add((characterGuid, accountName)); + public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) { } @@ -195,17 +218,36 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Equal("NewChar", host.Created[0].Name); Assert.Empty(host.Failed); - // The roster report following the Ok reply has BOTH the pre-existing + // The roster report following the Ok reply has EVERY pre-existing // character and the newly created one. LiveSessionRosterReport lastReport = host.Rosters[^1]; Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar"); - Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u); + Assert.Contains(lastReport.Entries, e => e.Id == 0x50000002u && e.Name == "Zed"); + Assert.Contains(lastReport.Entries, e => e.Id == 0x50000003u && e.Name == "Amy"); - // gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via - // EnterSelectedCore — the controller is now in-world as the new - // character, no second selection/EnterWorld call needed. + // F2 acceptance gate: the append preserves every PRE-EXISTING + // character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot + // 1 — deliberately NOT alphabetical order) and assigns the NEW + // character the true wire count (2, 0-based, after Zed and Amy). A + // round-trip through ApplyRoster's name-sort-and-renumber would + // have swapped Zed/Amy to 1/0 instead. + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed)); + Assert.Equal(0, zed.ActiveIndex); + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy)); + Assert.Equal(1, amy.ActiveIndex); + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry newChar)); + Assert.Equal(2, newChar.ActiveIndex); + + // F1 acceptance gate: gmCharGenMainUI::Update @ 0x004E8460's + // log-straight-in, reused via EnterCreatedCharacterCore — the + // controller is now in-world as the new character, entered by the + // EXACT guid the Ok reply carried (NOT the roster-index path, which + // the cached wire roster is by-design stale for post-create). Assert.True(controller.IsInWorld); - Assert.Equal(1, operations.EnterWorldCount); + Assert.Equal(0, operations.EnterWorldCount); + Assert.Single(operations.EnterWorldByGuidCalls); + Assert.Equal(0x50001234u, operations.EnterWorldByGuidCalls[0].Guid); + Assert.Equal("testaccount", operations.EnterWorldByGuidCalls[0].AccountName); Assert.Single(host.EnteredWorld); Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); } @@ -230,6 +272,7 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Empty(host.Created); Assert.False(controller.IsInWorld); Assert.Equal(0, operations.EnterWorldCount); + Assert.Empty(operations.EnterWorldByGuidCalls); Assert.Empty(host.EnteredWorld); // No roster append on a rejection. Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); @@ -254,6 +297,43 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.False(sent); } + /// + /// F3 acceptance gate: retail does NOT force a full attribute spend — + /// gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170 only warns + /// (arg2 != 0 && remainingAtrbCredits > 0) and the + /// credit-warning dialog's own confirm handler re-invokes + /// DoFinish(this, 0) (@0x004E98BB), which skips the check + /// entirely and sends. 's + /// confirmUnspentCredits parameter is that arg2 == 0 case. + /// + [Fact] + public void Finish_WithUnspentCreditsAndConfirmed_SendsAnyway() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted); + Assert.True(controller.SetName(generation, "NewChar").Accepted); + WorldSession session = operations.Sessions[0]; + byte[]? captured = null; + session.GameMessageCapture = (body, _) => captured = body; + + // Unconfirmed: refused, matching the sibling test above — the + // warning-dialog gate. + Assert.False(controller.Finish(generation).Accepted); + Assert.Null(captured); + + // Confirmed: sends anyway with the credits still unspent. + RuntimeCommandResult confirmed = controller.Finish(generation, confirmUnspentCredits: true); + + Assert.True(confirmed.Accepted); + Assert.NotNull(captured); + CapturedCreateRequest decoded = DecodeCreateRequest(captured!); + Assert.Equal("NewChar", decoded.Name); + Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged. + } + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) { MethodInfo method = typeof(WorldSession).GetMethod(