diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6504323a..4c19a3af 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) — 142 active rows (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) — 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) 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,6 +383,10 @@ 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-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-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) | ## 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 b5ef1fdb..da1db90a 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -230,7 +230,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 | — | | | | +| 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. | | CC4 | — | | | | | CC5 | — | | | | | CC6a | — | | | | diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index 017b24c4..e1902b50 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -378,6 +378,82 @@ public interface IRuntimeAllegianceCommands bool on); } +// ── Character creation (Campaign CC slice CC3, 2026-08-15) ───────────────── + +/// +/// Generation-gated character-creation (CharGenState) outbound actions — +/// lands beside , the +/// same command family shape. +/// +public interface IRuntimeCharacterCreationCommands +{ + RuntimeCommandResult SelectHeritage( + RuntimeGenerationToken expectedGeneration, + uint heritageId); + + RuntimeCommandResult SelectGender( + RuntimeGenerationToken expectedGeneration, + uint genderKey); + + RuntimeCommandResult SelectTemplate( + RuntimeGenerationToken expectedGeneration, + uint templateIndex); + + RuntimeCommandResult SetAttribute( + RuntimeGenerationToken expectedGeneration, + Session.ChargenAttributeId attributeId, + int value); + + RuntimeCommandResult SetAttributeLock( + RuntimeGenerationToken expectedGeneration, + Session.ChargenAttributeId attributeId, + bool locked); + + RuntimeCommandResult TrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId); + + RuntimeCommandResult SpecializeSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId); + + RuntimeCommandResult UntrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId); + + RuntimeCommandResult SetAppearanceIndex( + RuntimeGenerationToken expectedGeneration, + Session.ChargenAppearanceSlot slot, + uint index); + + RuntimeCommandResult SetShade( + RuntimeGenerationToken expectedGeneration, + Session.ChargenShadeSlot slot, + double value); + + RuntimeCommandResult SelectStartArea( + RuntimeGenerationToken expectedGeneration, + int startAreaIndex); + + RuntimeCommandResult SetName( + RuntimeGenerationToken expectedGeneration, + string name); + + RuntimeCommandResult SetSlot( + RuntimeGenerationToken expectedGeneration, + uint slot); + + /// 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 . + RuntimeCommandResult Finish( + RuntimeGenerationToken expectedGeneration); + + RuntimeCommandResult AcknowledgeRejection( + RuntimeGenerationToken expectedGeneration); +} + public interface IGameRuntimeCommands { IRuntimeSessionCommands Session { get; } @@ -386,6 +462,10 @@ public interface IGameRuntimeCommands throw new NotSupportedException( "This command adapter does not project character selection."); + IRuntimeCharacterCreationCommands CharacterCreation => + throw new NotSupportedException( + "This command adapter does not project character creation."); + IRuntimeSelectionCommands Selection { get; } IRuntimeCombatCommands Combat { get; } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 0c6eb5ce..3a9bcd10 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -74,6 +74,8 @@ public sealed class DirectGameRuntimeCommandAdapter public IRuntimeSessionCommands Session => this; public IRuntimeCharacterSelectionCommands CharacterSelection => _runtime.Session; + public IRuntimeCharacterCreationCommands CharacterCreation => + _runtime.Session; public IRuntimeSelectionCommands Selection => this; public IRuntimeCombatCommands Combat => this; public IRuntimeMagicCommands Magic => this; diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index c96c3c5a..e35e79da 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using AcDream.Core.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -107,6 +108,22 @@ public interface ILiveSessionLifecycleHost void ApplySelectedCharacter(LiveSessionCharacterSelection selection); void ApplyEnteredWorld(LiveSessionCharacterSelection selection); void DetachSession(WorldSession session); + + /// + /// Campaign CC slice CC3: reported once per successful character + /// creation (the 0xF643 Ok identity), right before the roster is + /// re-reported with the new entry appended and the reused enter-selected + /// path runs — the same spot and + /// already occupy. Default no-op: a host + /// that wants status-stream parity with + /// SessionStatusWriter.CharacterCreated overrides this. + /// + void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) { } + + /// Campaign CC slice CC3: reported once per non-Ok 0xF643 + /// creation response. Default no-op — see . + /// + void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) { } } /// @@ -192,6 +209,15 @@ public interface ILiveSessionOperations session.SendDeleteCharacter(accountName, activeCharacterIndex); void RestoreCharacter(WorldSession session, uint characterId) => session.SendRestoreCharacter(characterId); + /// Campaign CC slice CC3: retail's Proto_UI::SendCharGenResult + /// @ 0x00546A70 outbound send, reached from gmCharGenMainUI::DoFinish. + /// + void CreateCharacter( + WorldSession session, + string accountName, + CharacterCreate.Request request, + ReadOnlySpan skillAdvancementClasses) => + session.SendCharacterCreation(accountName, request, skillAdvancementClasses); void Tick(WorldSession session); void DisposeSession(WorldSession session); } @@ -247,7 +273,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations public sealed class LiveSessionController : IDisposable, IRuntimeLiveSessionFramePhase, - IRuntimeCharacterSelectionCommands + IRuntimeCharacterSelectionCommands, + IRuntimeCharacterCreationCommands { private sealed class CharacterSelectionWireBinding : IDisposable { @@ -257,6 +284,7 @@ public sealed class LiveSessionController private readonly Action _restore; private readonly Action _error; private readonly Action _worldName; + private readonly Action _created; public CharacterSelectionWireBinding( WorldSession session, @@ -264,7 +292,8 @@ public sealed class LiveSessionController Action delete, Action restore, Action error, - Action worldName) + Action worldName, + Action created) { _session = session; _roster = roster; @@ -272,11 +301,13 @@ public sealed class LiveSessionController _restore = restore; _error = error; _worldName = worldName; + _created = created; session.CharacterListReceived += roster; session.CharacterDeleteAcknowledged += delete; session.CharacterRestoreReceived += restore; session.CharacterErrorReceived += error; session.ServerNameReceived += worldName; + session.CharacterCreateResponseReceived += created; } public bool IsDisposed => _session is null; @@ -291,6 +322,7 @@ public sealed class LiveSessionController session.CharacterRestoreReceived -= _restore; session.CharacterErrorReceived -= _error; session.ServerNameReceived -= _worldName; + session.CharacterCreateResponseReceived -= _created; } } @@ -397,11 +429,19 @@ public sealed class LiveSessionController public LiveSessionController( ILiveSessionOperations operations, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + ChargenOptions? chargenOptions = null) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); CharacterSelectionState = new RuntimeCharacterSelectionState( timeProvider); + // Campaign CC slice CC3: defaults to ChargenOptions.Empty (no + // heritages configured) — loading the installed DAT's chargen table + // and threading it through is a future App/GameRuntime wiring slice, + // not this one. A caller that never supplies real options simply + // gets an inert chargen surface (every heritage lookup misses). + CharacterCreationState = new RuntimeCharacterCreationState( + chargenOptions ?? ChargenOptions.Empty); } public RuntimeCharacterSelectionState CharacterSelectionState { get; } @@ -409,6 +449,11 @@ public sealed class LiveSessionController public IRuntimeCharacterSelectionView CharacterSelection => CharacterSelectionState.View; + public RuntimeCharacterCreationState CharacterCreationState { get; } + + public IRuntimeCharacterCreationView CharacterCreation => + CharacterCreationState.View; + public WorldSession? CurrentSession { get { lock (_gate) return _scope?.Session; } @@ -684,6 +729,7 @@ public sealed class LiveSessionController ulong generation = ++_generation; RuntimeGenerationToken activeGeneration = new(generation); CharacterSelectionState.Reset(activeGeneration); + CharacterCreationState.Reset(activeGeneration); try { DrainRetiredScope(); @@ -710,6 +756,7 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials); CharacterSelectionState.Begin(activeGeneration); + CharacterCreationState.Begin(activeGeneration); SessionScope? scope = null; try @@ -921,6 +968,14 @@ public sealed class LiveSessionController if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyWorldName(worldName.WorldName); } + }, + created => + { + lock (_gate) + { + if (IsCurrent(scope, generation)) + HandleCharacterCreationResponse(scope, generation, created); + } }); public RuntimeCommandResult Highlight( @@ -1149,6 +1204,350 @@ public sealed class LiveSessionController new RuntimeGenerationToken(_generation), characterId); + // ── Character creation (Campaign CC slice CC3) ───────────────────── + + /// Collects rows + /// into form for + /// 's roster-append + /// composition. + private sealed class RosterCollector(List entries) + : IRuntimeCharacterSelectionVisitor + { + public void Visit(in RuntimeCharacterSelectionEntry character) => + entries.Add(new LiveSessionRosterEntry( + character.CharacterId, + character.Name, + character.SecondsGreyedOut)); + } + + /// + /// Ports the Ok half of Handle_CharGenVerificationResponse @ + /// 0x0055E8B0 case 1 (the PENDING/create branch — + /// CharacterSet::AddIdentity) composed with + /// gmCharGenMainUI::Update @ 0x004E8460's per-frame roster scan + /// (which finds the freshly appended identity by name and calls + /// CPlayerSystem::LogOnCharacter directly): a fresh + /// is disambiguated locally + /// (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 + /// — no roster/enter side effects. + /// + private void HandleCharacterCreationResponse( + SessionScope scope, + ulong generation, + CharGenVerificationResponse.Parsed response) + { + CharacterCreationState.ApplyCreationResponse(response); + RuntimeCharacterCreationSnapshot creation = CharacterCreationState.Snapshot; + + if (creation.LastCreated is { } created) + scope.Host.ApplyCharacterCreated(created); + else if (creation.LastRejection is { } rejection) + scope.Host.ApplyCreationFailed(rejection); + + if (creation.LastCreated is not { } identity) + return; + + RuntimeCharacterSelectionSnapshot before = CharacterSelectionState.Snapshot; + var entries = new List(before.RosterCount + 1); + CharacterSelectionState.View.Visit(new RosterCollector(entries)); + entries.Add(new LiveSessionRosterEntry( + identity.Guid, + identity.Name, + SecondsGreyedOut: 0u)); + var report = new LiveSessionRosterReport( + before.AccountName, + before.SlotCount, + entries); + CharacterSelectionState.ApplyRoster(report); + scope.Host.ReportRoster(report); + if (!IsCurrent(scope, generation)) + return; + + if (!CharacterSelectionState.TryHighlight(identity.Guid)) + return; + if (!IsCurrent(scope, generation)) + return; + + // Inline, not through the public Enter() command: we are already + // 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(); + } + + public RuntimeCommandResult SelectHeritage( + RuntimeGenerationToken expectedGeneration, + uint heritageId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectHeritage(heritageId)); + } + } + + public RuntimeCommandResult SelectGender( + RuntimeGenerationToken expectedGeneration, + uint genderKey) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectGender(genderKey)); + } + } + + public RuntimeCommandResult SelectTemplate( + RuntimeGenerationToken expectedGeneration, + uint templateIndex) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectTemplate(templateIndex)); + } + } + + public RuntimeCommandResult SetAttribute( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + int value) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySetAttribute(attributeId, value)); + } + } + + public RuntimeCommandResult SetAttributeLock( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + bool locked) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySetAttributeLock(attributeId, locked)); + } + } + + public RuntimeCommandResult TrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TryTrainSkill(skillId)); + } + } + + public RuntimeCommandResult SpecializeSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySpecializeSkill(skillId)); + } + } + + public RuntimeCommandResult UntrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TryUntrainSkill(skillId)); + } + } + + public RuntimeCommandResult SetAppearanceIndex( + RuntimeGenerationToken expectedGeneration, + ChargenAppearanceSlot slot, + uint index) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySetAppearanceIndex(slot, index)); + } + } + + public RuntimeCommandResult SetShade( + RuntimeGenerationToken expectedGeneration, + ChargenShadeSlot slot, + double value) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySetShade(slot, value)); + } + } + + public RuntimeCommandResult SelectStartArea( + RuntimeGenerationToken expectedGeneration, + int startAreaIndex) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectStartArea(startAreaIndex)); + } + } + + public RuntimeCommandResult SetName( + RuntimeGenerationToken expectedGeneration, + string name) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySetName(name ?? string.Empty)); + } + } + + public RuntimeCommandResult SetSlot( + RuntimeGenerationToken expectedGeneration, + uint slot) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySetSlot(slot)); + } + } + + /// + /// Ports gmCharGenMainUI::DoFinish @ 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 () + /// rather than leaving it stuck Pending forever. + /// + public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + + RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot; + if (!CharacterCreationState.TryBeginFinish( + selection.RosterCount, + selection.SlotCount, + out CharacterCreate.Request request, + out uint[] skillAdvancementClasses, + out _)) + { + return CharacterCreationResult(RuntimeCommandStatus.Rejected); + } + + try + { + _operations.CreateCharacter( + _scope!.Session, + selection.AccountName, + request, + skillAdvancementClasses); + return CharacterCreationResult(RuntimeCommandStatus.Accepted); + } + catch + { + CharacterCreationState.ApplyCreationResponse( + new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.Undef, + null, + null, + null)); + return CharacterCreationResult(RuntimeCommandStatus.Rejected); + } + } + } + + public RuntimeCommandResult AcknowledgeRejection( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryAcknowledgeRejection()); + } + } + + private RuntimeCommandStatus ValidateCharacterCreationCommand( + RuntimeGenerationToken expectedGeneration) + { + RuntimeGenerationToken current = new(_generation); + if (expectedGeneration != current) + return RuntimeCommandStatus.StaleGeneration; + if (_disposed || _disposeRequested || _scope is null || _inWorld) + return RuntimeCommandStatus.Inactive; + return CharacterSelectionState.Snapshot.Lifecycle + == RuntimeCharacterSelectionLifecycle.AwaitingSelection + ? RuntimeCommandStatus.Accepted + : RuntimeCommandStatus.Inactive; + } + + private RuntimeCommandResult CharacterCreationResult(bool accepted) => + CharacterCreationResult( + accepted ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected); + + private RuntimeCommandResult CharacterCreationResult(RuntimeCommandStatus status) => + new(status, new RuntimeGenerationToken(_generation)); + private void StopCore() { // MUST-FIX 1: TS-71's logout-flush half — retail's @@ -1165,6 +1564,7 @@ public sealed class LiveSessionController _inWorld = false; _activeSelection = null; CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation)); + CharacterCreationState.Reset(new RuntimeGenerationToken(_generation)); if (_scope is { } scope) { _scope = null; @@ -1306,6 +1706,7 @@ public sealed class LiveSessionController { StopCore(); CharacterSelectionState.Dispose(); + CharacterCreationState.Dispose(); _disposed = true; } diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs new file mode 100644 index 00000000..70212973 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -0,0 +1,1469 @@ +using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Session; + +/// +/// The six retail attribute ids in CharGenState::GetAbsRemainingCredits @ +/// 0x005C3B20's switch order — 1=Strength, 2=Endurance, 3=Quickness, +/// 4=Coordination, 5=Focus, 6=Self. Note the 3/4 swap versus the "natural" +/// str/end/coord/quick reading order; this is retail's own internal id +/// numbering, distinct from the WIRE attribute order (which IS +/// str/end/coord/quick/focus/self — see ). +/// +public enum ChargenAttributeId +{ + Strength = 1, + Endurance = 2, + Quickness = 3, + Coordination = 4, + Focus = 5, + Self = 6, +} + +/// +/// One appearance index slot. Color slots ( etc.) +/// are a documented approximation (register AP-208): retail derives a +/// PER-STYLE color count via CharGenState::StoreColorInformation @ +/// 0x005C44D0 (reading each clothing style's own ClothingTable +/// palette-template list); acdream has no DAT-backed per-style color count in +/// , so every color slot is bounds-checked +/// against the gender's single shared +/// list instead. +/// +public enum ChargenAppearanceSlot +{ + EyesStrip, + NoseStrip, + MouthStrip, + HairStyle, + HairColor, + EyeColor, + HeadgearStyle, + HeadgearColor, + ShirtStyle, + ShirtColor, + TrousersStyle, + TrousersColor, + FootwearStyle, + FootwearColor, +} + +/// One of the six f64 dye-shade fields (retail's skinShade/hairShade/ +/// headgearShade/shirtShade/trousersShade/footwearShade). +public enum ChargenShadeSlot +{ + Skin, + Hair, + Headgear, + Shirt, + Trousers, + Footwear, +} + +public enum RuntimeCharacterCreationDeltaKind +{ + Reset, + StateChanged, + FinishRefused, + FinishSent, + Created, + CreationFailed, + RejectionAcknowledged, +} + +/// +/// The fourteen style/color indices plus the six f64 shades — exactly +/// CharacterCreate.Appearance's field set (Core.Net, CC2), so a +/// snapshot converts to a wire request with a single field-by-field copy. +/// mirrors retail's 0xFFFFFFFF "nothing selected" +/// sentinel for every index field. +/// +public readonly record struct RuntimeCharacterCreationAppearance( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairStyle, + uint HairColor, + uint EyeColor, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade) +{ + public const uint Unset = 0xFFFFFFFFu; + + /// Retail's construction-time shade sentinel: low dword 0, + /// high dword 0xBFF00000 is the IEEE-754 bit pattern for exactly + /// -1.0 (CharGenState::Reset @ 0x005C68A0). + public const double UnsetShade = -1.0; + + public static RuntimeCharacterCreationAppearance Default { get; } = new( + Unset, Unset, Unset, + Unset, Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + UnsetShade, UnsetShade, UnsetShade, + UnsetShade, UnsetShade, UnsetShade); +} + +/// +/// Every retail client-side gate gmCharGenMainUI::DoFinish @ 0x004E9170 +/// evaluates before a Finish click is allowed to reach the wire, plus the +/// campaign's client-side slot cap (risk item 3 — retail's UI, not +/// DoFinish itself, refuses when the roster is already full versus +/// CharacterList.slotCount; ACE never checks this server-side). +/// +public readonly record struct RuntimeCharacterCreationLocalRefusal( + bool NoName, + bool AttributeCreditsUnspent, + bool AlreadyPending, + bool RosterFull) +{ + public bool Any => + NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull; + + public static RuntimeCharacterCreationLocalRefusal None { get; } = default; +} + +/// +/// A completed creation (the 0xF643 Ok identity payload). +/// +public readonly record struct RuntimeCharacterCreationIdentity( + uint Guid, + string Name); + +/// +/// A non-Ok 0xF643 response, mapped to retail's dialog family +/// (Handle_CharGenVerificationResponse @ 0x0055E8B0's per-case dialog +/// dispatch, restated in 's doc +/// comment): → +/// NameReserved, → +/// NameBanned, / +/// → NameDBDown, +/// → +/// NameAdminDenied. / +/// never produce this +/// record — retail treats them as a silent state reset with no dialog (ACE +/// sends Pending for a disabled-Olthoi rejection; this is a genuine +/// retail quirk, not a bug — port as-is). +/// +public readonly record struct RuntimeCharacterCreationRejection( + uint RawCode, + CharGenVerificationResponse.Code Code, + string Reason, + string AttemptedName); + +public readonly record struct RuntimeCharacterCreationSnapshot( + RuntimeGenerationToken Generation, + bool IsActive, + long Revision, + uint HeritageId, + uint GenderKey, + RuntimeCharacterCreationAppearance Appearance, + uint Template, + ChargenAttributeValues Attributes, + uint AttributeLockMask, + uint TotalAttributeCredits, + int RemainingAttributeCredits, + uint TotalSkillCredits, + int RemainingSkillCredits, + string Name, + int StartArea, + uint Slot, + bool VerificationPending, + RuntimeCharacterCreationLocalRefusal LastLocalRefusal, + RuntimeCharacterCreationRejection? LastRejection, + RuntimeCharacterCreationIdentity? LastCreated) +{ + public const uint TemplateUnset = 0xFFFFFFFFu; + + public bool IsAttributeLocked(ChargenAttributeId attributeId) => + (AttributeLockMask & (1u << ((int)attributeId - 1))) != 0u; +} + +public readonly record struct RuntimeCharacterCreationDelta( + RuntimeGenerationToken Generation, + ulong Sequence, + long Revision, + RuntimeCharacterCreationDeltaKind Kind); + +public interface IRuntimeCharacterCreationObserver +{ + void OnCharacterCreationChanged(in RuntimeCharacterCreationDelta delta); +} + +public interface IRuntimeCharacterCreationEventSource +{ + IDisposable Subscribe(IRuntimeCharacterCreationObserver observer); +} + +/// +/// Borrowed view of the one Runtime-owned CharGenState mirror. +/// +public interface IRuntimeCharacterCreationView : IRuntimeCharacterCreationEventSource +{ + RuntimeCharacterCreationSnapshot Snapshot { get; } + + ChargenSkillAdvancementClass GetSkillLevel(uint skillId); + + /// The typed chargen options this state machine was built from + /// (heritages/templates/skill costs/starter areas) — exposed so + /// presentation can enumerate choices without a second copy of CC1's + /// model. + ChargenOptions Options { get; } +} + +/// +/// Sole mutable owner of character-creation (CharGenState) data. Contains no +/// App/UI types and no wire access — the Finish gate produces a ready-to-send +/// plus the 55-slot skill array; sending +/// it and processing the reply is the caller's job (, +/// mirroring exactly how it owns 's +/// wire-touching pair TryBeginRestore/ApplyRestore). +/// +/// +/// Every AC-specific rule here is ported from the named retail decomp: heritage +/// selection (CharGenState::SetHeritageGroup @ 0x005C67A0), template +/// application (CharGenState::ApplyTemplate @ 0x005C5080, committed via +/// SetTemplate @ 0x005C5A60), gender selection (SetGender @ +/// 0x005C64A0), the six attribute setters + credit math (SetStrength.. +/// SetSelf @ 0x005C4660..0x005C48E0, GetAbsRemainingCredits @ +/// 0x005C3B20, BalanceAttributes @ 0x005C3DF0), skill leveling +/// (SetSkillLevel @ 0x005C3C20, the baseline derivation in +/// ResetSkillLevels @ 0x005C43B0), the random starting-area default +/// (RandomizeStartArea @ 0x005C59E0), and the Finish gate +/// (gmCharGenMainUI::DoFinish @ 0x004E9170). +/// +/// +public sealed class RuntimeCharacterCreationState : IDisposable +{ + /// Retail's literal BalanceAttributes iteration order — + /// 1=str, 2=end, 4=coord, 3=quick, 5=focus, 6=self (NOT numeric id + /// order). See CharGenState::BalanceAttributes @ 0x005C3DF0. + /// + private static readonly ChargenAttributeId[] BalanceOrder = + [ + ChargenAttributeId.Strength, + ChargenAttributeId.Endurance, + ChargenAttributeId.Coordination, + ChargenAttributeId.Quickness, + ChargenAttributeId.Focus, + ChargenAttributeId.Self, + ]; + + private sealed class ViewProjection(RuntimeCharacterCreationState owner) + : IRuntimeCharacterCreationView + { + public RuntimeCharacterCreationSnapshot Snapshot => owner.Snapshot; + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + owner.GetSkillLevel(skillId); + + public ChargenOptions Options => owner._options; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + owner._events.Subscribe(observer); + } + + private readonly object _gate = new(); + private readonly CharacterCreationEventStream _events = new(); + private readonly ViewProjection _view; + private readonly ChargenOptions _options; + private readonly Random _random; + private RuntimeGenerationToken _generation; + private bool _active; + private long _revision; + private uint _heritageId; + private uint _genderKey; + private RuntimeCharacterCreationAppearance _appearance = + RuntimeCharacterCreationAppearance.Default; + private uint _template = RuntimeCharacterCreationSnapshot.TemplateUnset; + private ChargenAttributeValues _attributes; + private uint _attributeLockMask; + private uint _totalAttributeCredits; + private int _remainingAttributeCredits; + private uint _totalSkillCredits; + private int _remainingSkillCredits; + private readonly ChargenSkillAdvancementSet _skills = new(); + private int _attributeBalanceCursor = 1; + private string _name = string.Empty; + private int _startArea = -1; + private uint _slot; + private bool _verificationPending; + private RuntimeCharacterCreationLocalRefusal _lastLocalRefusal; + private RuntimeCharacterCreationRejection? _lastRejection; + private RuntimeCharacterCreationIdentity? _lastCreated; + private bool _disposed; + + public RuntimeCharacterCreationState( + ChargenOptions options, + Random? random = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _random = random ?? Random.Shared; + _view = new ViewProjection(this); + } + + public IRuntimeCharacterCreationView View => _view; + + public ChargenOptions Options => _options; + + public RuntimeCharacterCreationSnapshot Snapshot + { + get + { + lock (_gate) + { + return new RuntimeCharacterCreationSnapshot( + _generation, + _active, + _revision, + _heritageId, + _genderKey, + _appearance, + _template, + _attributes, + _attributeLockMask, + _totalAttributeCredits, + _remainingAttributeCredits, + _totalSkillCredits, + _remainingSkillCredits, + _name, + _startArea, + _slot, + _verificationPending, + _lastLocalRefusal, + _lastRejection, + _lastCreated); + } + } + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + internal void Begin(RuntimeGenerationToken generation) + { + lock (_gate) + { + ThrowIfDisposed(); + _generation = generation; + _active = true; + ClearSessionState(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.Reset); + } + + internal void Reset(RuntimeGenerationToken generation) + { + lock (_gate) + { + if (_disposed) + return; + _generation = generation; + _active = false; + ClearSessionState(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.Reset); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _active = false; + ClearSessionState(); + _revision++; + } + _events.Dispose(); + } + + // ── Heritage / gender / template ─────────────────────────────────── + + /// + /// Ports CharGenState::SetHeritageGroup @ 0x005C67A0: recomputes + /// the attribute/skill credit budgets from the heritage row, re-applies + /// the currently selected template (a no-op the first time — retail's + /// ApplyTemplate only mutates attributes/skills when + /// template_ != 0xFFFFFFFF), and rolls a fresh default starting + /// area from the heritage's PrimaryStartAreaIndices + /// (RandomizeStartArea @ 0x005C59E0). Deliberately omits the + /// defensive DAT-corruption clamp in ConstrainAllByHeritage @ + /// 0x005C6590 (a sanity ceiling on totalAtrbCredits/ + /// totalSkillCredits that force-resets everything to zero on + /// malformed content) — CC1's installed-DAT gates already prove the + /// installed heritage table never trips it. + /// + internal bool TrySelectHeritage(uint heritageId) + { + if (!_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + return false; + + lock (_gate) + { + if (_disposed || !_active) + return false; + + _heritageId = heritageId; + _totalAttributeCredits = heritage.AttributeCredits; + _totalSkillCredits = heritage.SkillCredits; + _remainingSkillCredits = checked((int)heritage.SkillCredits); + + ApplyTemplateLocked(heritage); + RandomizeStartAreaLocked(heritage); + ConstrainAppearanceByGenderLocked(); + RecomputeRemainingAttributeCreditsLocked(); + // ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive + // re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through + // our own gated skill commands, but kept for parity with a + // heritage switch that leaves stale skill picks over-budget. + if (RecomputeSkillSpendLocked(heritage) < 0) + ResetSkillLevelsLocked(heritage); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Ports CharGenState::SetGender @ 0x005C64A0: clamps + /// every appearance index into the new gender's option-list bounds. The + /// four SetXStyle(this, this->XStyle) re-invocations retail + /// performs afterward only refresh per-style color-count bookkeeping we + /// don't model (register AP-208); they are a no-op once the shared + /// ClothingColors clamp above has already run, so they are + /// intentionally not re-executed here. + internal bool TrySelectGender(uint genderKey) + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage) + || !heritage.GendersByKey.ContainsKey((int)genderKey)) + { + return false; + } + + _genderKey = genderKey; + ConstrainAppearanceByGenderLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// + /// Ports the seven Profession-page buttons, each of which calls + /// CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60 — template + /// index 0 IS "Custom"/the Adventurer row (CC1 finding), not a UI-only + /// bypass. ApplyTemplate force-overrides to template 0 for the + /// two Olthoi heritages regardless of the requested index. + /// + internal bool TrySelectTemplate(uint templateIndex) + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage) + || templateIndex >= (uint)heritage.Templates.Count) + { + return false; + } + + _template = templateIndex; + ApplyTemplateLocked(heritage); + RecomputeRemainingAttributeCreditsLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// + /// Applies the current row's attributes and + /// skills, mirroring CharGenState::ApplyTemplate @ 0x005C5080 + /// exactly: clears every attribute lock, force-selects template 0 for + /// the Olthoi/OlthoiAcid heritages, and — only when heritage+gender are + /// both selected and a template has actually been chosen — copies the + /// row's six attributes verbatim and re-derives the skill array (baseline + /// reset, then the row's Normal skills trained and Primary skills + /// specialized). + /// + private void ApplyTemplateLocked(ChargenHeritageOptions heritage) + { + _attributeLockMask = 0u; + if (_heritageId == (uint)ChargenHeritageGroup.Olthoi + || _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid) + { + _template = 0u; + } + + if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset) + return; + if (_template >= (uint)heritage.Templates.Count) + return; + + ChargenTemplate row = heritage.Templates[(int)_template]; + _attributes = row.Attributes; + + ResetSkillLevelsLocked(heritage); + foreach (uint skillId in row.NormalSkills) + ApplyTemplateSkillEntryLocked(heritage, skillId, ChargenSkillAdvancementClass.Trained); + foreach (uint skillId in row.PrimarySkills) + ApplyTemplateSkillEntryLocked(heritage, skillId, ChargenSkillAdvancementClass.Specialized); + } + + /// + /// Ports the inline refund-then-charge shape both of ApplyTemplate's + /// skill loops use (the Normal loop calls SetSkillLevel directly; + /// the Primary loop, 0x005C5194-0x005C5229, hand-inlines the identical + /// math): refund whatever the skill's CURRENT class costs (if + /// Trained/Specialized), charge the target class's cost, commit only if + /// the recomputed remaining credits stay non-negative. + /// + private void ApplyTemplateSkillEntryLocked( + ChargenHeritageOptions heritage, + uint skillId, + ChargenSkillAdvancementClass targetClass) + { + if (skillId == 0 || skillId >= ChargenSkillAdvancementSet.SlotCount) + return; + + ChargenSkillAdvancementClass previous = _skills[skillId]; + int remaining = _remainingSkillCredits; + if (previous == ChargenSkillAdvancementClass.Trained + && TryGetSkillCost(heritage, skillId, out int prevTrained, out _)) + { + remaining += prevTrained; + } + else if (previous == ChargenSkillAdvancementClass.Specialized + && TryGetSkillCost(heritage, skillId, out _, out int prevSpecialized)) + { + remaining += prevSpecialized; + } + + if (!TryGetSkillCost(heritage, skillId, out int trainedCost, out int specializedCost)) + return; + + int charge = targetClass switch + { + ChargenSkillAdvancementClass.Specialized => specializedCost, + ChargenSkillAdvancementClass.Trained => trainedCost, + _ => 0, + }; + remaining -= charge; + if (remaining < 0) + return; + + _skills[skillId] = targetClass; + _remainingSkillCredits = remaining; + } + + /// + /// 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). + /// + private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage) + { + _remainingSkillCredits = checked((int)_totalSkillCredits); + if (_heritageId == 0 || _genderKey == 0) + return; + + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (!TryGetSkillCost(heritage, skillId, out int trainedCost, out int specializedCost)) + continue; + + _skills[skillId] = trainedCost > 0 + ? ChargenSkillAdvancementClass.Untrained + : specializedCost <= 0 + ? ChargenSkillAdvancementClass.Specialized + : ChargenSkillAdvancementClass.Trained; + } + } + + /// Two-tier skill cost lookup — the heritage's own list first, + /// the global SkillTable on a miss (ACCharGenData::GetSkillTrainedCost + /// / GetSkillSpecializedCost, CC1's + /// convention). Returns false when a skill id is uncostable in BOTH + /// tiers (retail's -1/-1 case). + private bool TryGetSkillCost( + ChargenHeritageOptions heritage, + uint skillId, + out int trainedCost, + out int specializedCost) + { + if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost) + || _options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out cost)) + { + trainedCost = cost.NormalCost; + specializedCost = cost.PrimaryCost; + return true; + } + trainedCost = 0; + specializedCost = 0; + return false; + } + + /// Full re-derivation of spend across all 55 slots — reuses + /// CC1's already-reviewed rather + /// than duplicating the two-tier walk. + private int RecomputeSkillSpendLocked(ChargenHeritageOptions heritage) => + checked((int)_totalSkillCredits) + - ChargenSkillCreditMath.ComputeSpent( + _skills, + heritage.SkillCostsBySkillId, + _options.GlobalSkillCostsBySkillId); + + /// Ports CharGenState::RandomizeStartArea @ 0x005C59E0: + /// 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. + 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 + ? candidate + : -1; + } + + // ── Attributes ────────────────────────────────────────────────────── + + /// + /// Ports gmCGProfessionPage::SetAttribValue @ 0x00482890 (the + /// slider-drag entry point) composed with the matching + /// CharGenState::SetXxx(this, value, balance: 1) setter: clamps + /// the requested value into [10,100], further clamps a RAISE to + /// what allows, then + /// redistributes the overspend from the other unlocked attributes via + /// — exactly retail's own + /// slider-drag behavior (always balance=1; template application uses + /// balance=0 and goes through + /// instead). + /// + internal bool TrySetAttribute(ChargenAttributeId attributeId, int requestedValue) + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0) + return false; + + int current = GetAttributeLocked(attributeId); + int clamped = Math.Clamp( + requestedValue, + ChargenAttributeMath.AttributeMin, + ChargenAttributeMath.AttributeMax); + if (clamped > current) + { + int absRemaining = GetAbsRemainingCreditsLocked(attributeId); + if (clamped - current > absRemaining) + clamped = current + absRemaining; + if (clamped < current) + clamped = current; + } + + SetAttributeRawLocked(attributeId, clamped); + BalanceAttributesLocked(attributeId); + RecomputeRemainingAttributeCreditsLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Ports CharGenState::LockAttribute @ 0x005C3BE0. + internal bool TrySetAttributeLock(ChargenAttributeId attributeId, bool locked) + { + lock (_gate) + { + if (_disposed || !_active) + return false; + uint bit = 1u << ((int)attributeId - 1); + _attributeLockMask = locked + ? _attributeLockMask | bit + : _attributeLockMask & ~bit; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Ports CharGenState::GetAbsRemainingCredits @ + /// 0x005C3B20: the budget remaining if every unlocked attribute + /// OTHER than were reset to the + /// floor — the amount a slider drag on that one attribute may still + /// claim. + private int GetAbsRemainingCreditsLocked(ChargenAttributeId queriedAttributeId) + { + int total = checked((int)_totalAttributeCredits); + foreach (ChargenAttributeId id in BalanceOrder) + { + bool useCurrent = IsAttributeLockedLocked(id) || id == queriedAttributeId; + total -= useCurrent + ? GetAttributeLocked(id) + : ChargenAttributeMath.AttributeMin; + } + return total; + } + + /// + /// Ports CharGenState::BalanceAttributes @ 0x005C3DF0: while the + /// six raw attributes sum above the budget, decrement one unlocked, + /// above-floor attribute at a time (never the just-raised one) in + /// retail's fixed round-robin order, starting from a persistent cursor + /// that advances past whichever attribute last absorbed the overspend. + /// The cursor is an instance field rather than retail's true process + /// global — acdream constructs a fresh Runtime per client process the + /// same way retail's own global resets on every client launch, so the + /// observable behavior is identical. + /// + private void BalanceAttributesLocked(ChargenAttributeId excluded) + { + int over = AttributeTotalLocked() - checked((int)_totalAttributeCredits); + if (over <= 0) + return; + + bool started = false; + // Defensive iteration cap — retail's own caller-side clamp + // (TrySetAttribute's pre-clamp against GetAbsRemainingCreditsLocked) + // guarantees termination the same way SetAttribValue's clamp does in + // retail, but a Runtime service must never spin forever on a future + // caller that skips that clamp. + int guard = 6 * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin) + 1; + while (guard-- > 0) + { + foreach (ChargenAttributeId id in BalanceOrder) + { + if (!started) + { + if ((int)_attributeBalanceCursor != (int)id) + continue; + started = true; + } + if (id == excluded) + continue; + + int value = GetAttributeLocked(id); + if (value > ChargenAttributeMath.AttributeMin && !IsAttributeLockedLocked(id)) + { + over -= 1; + SetAttributeRawLocked(id, value - 1); + if (over <= 0) + { + int index = Array.IndexOf(BalanceOrder, id); + _attributeBalanceCursor = (int)(index == BalanceOrder.Length - 1 + ? BalanceOrder[0] + : BalanceOrder[index + 1]); + return; + } + } + } + started = true; + } + } + + private int AttributeTotalLocked() => _attributes.Total; + + private int GetAttributeLocked(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => _attributes.Strength, + ChargenAttributeId.Endurance => _attributes.Endurance, + ChargenAttributeId.Quickness => _attributes.Quickness, + ChargenAttributeId.Coordination => _attributes.Coordination, + ChargenAttributeId.Focus => _attributes.Focus, + ChargenAttributeId.Self => _attributes.Self, + _ => 0, + }; + + private void SetAttributeRawLocked(ChargenAttributeId id, int value) + { + _attributes = id switch + { + ChargenAttributeId.Strength => _attributes with { Strength = value }, + ChargenAttributeId.Endurance => _attributes with { Endurance = value }, + ChargenAttributeId.Quickness => _attributes with { Quickness = value }, + ChargenAttributeId.Coordination => _attributes with { Coordination = value }, + ChargenAttributeId.Focus => _attributes with { Focus = value }, + ChargenAttributeId.Self => _attributes with { Self = value }, + _ => _attributes, + }; + } + + private bool IsAttributeLockedLocked(ChargenAttributeId id) => + (_attributeLockMask & (1u << ((int)id - 1))) != 0u; + + private void RecomputeRemainingAttributeCreditsLocked() => + _remainingAttributeCredits = + checked((int)_totalAttributeCredits) - AttributeTotalLocked(); + + // ── Skills ────────────────────────────────────────────────────────── + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) + { + lock (_gate) + return _skills[skillId]; + } + + internal bool TryTrainSkill(uint skillId) => + TrySetSkillLevel(skillId, ChargenSkillAdvancementClass.Trained); + + internal bool TrySpecializeSkill(uint skillId) => + TrySetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized); + + internal bool TryUntrainSkill(uint skillId) => + TrySetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained); + + /// + /// Ports CharGenState::SetSkillLevel @ 0x005C3C20: refund the + /// skill's current class cost (if Trained/Specialized), charge the + /// target class's cost, commit only if the recomputed remaining skill + /// credits stay non-negative. Skills uncostable in BOTH tiers are + /// refused outright — the binding fact from CC1's review (R1: retail's + /// own both-miss branch would refund +1 credit; that path is + /// intentionally kept unreachable by never exposing those 16 ids as + /// advanceable, matching retail's own skills listbox, which never lists + /// them). + /// + private bool TrySetSkillLevel(uint skillId, ChargenSkillAdvancementClass targetClass) + { + if (skillId == 0 || skillId >= ChargenSkillAdvancementSet.SlotCount) + return false; + + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage)) + return false; + if (!TryGetSkillCost(heritage, skillId, out int trainedCost, out int specializedCost)) + return false; + + ChargenSkillAdvancementClass previous = _skills[skillId]; + if (previous == targetClass) + return true; + + int remaining = _remainingSkillCredits; + remaining += previous switch + { + ChargenSkillAdvancementClass.Trained => trainedCost, + ChargenSkillAdvancementClass.Specialized => specializedCost, + _ => 0, + }; + remaining -= targetClass switch + { + ChargenSkillAdvancementClass.Trained => trainedCost, + ChargenSkillAdvancementClass.Specialized => specializedCost, + _ => 0, + }; + if (remaining < 0) + return false; + + _skills[skillId] = targetClass; + _remainingSkillCredits = remaining; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + // ── Appearance ────────────────────────────────────────────────────── + + /// + /// Rejects an out-of-range index rather than silently clamping (unlike + /// retail's own gender-switch reclamp, ) + /// — a caller requesting an index outside the CURRENT gender's option + /// list is a caller bug, not a resize this owner should paper over. + /// always + /// succeeds ("no selection" / "no headgear"). + /// + internal bool TrySetAppearanceIndex(ChargenAppearanceSlot slot, uint index) + { + lock (_gate) + { + if (_disposed || !_active + || !TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + { + return false; + } + + if (index != RuntimeCharacterCreationAppearance.Unset) + { + int count = AppearanceSlotCountLocked(slot, gender); + if (index >= (uint)count) + return false; + } + + _appearance = WithAppearanceIndex(_appearance, slot, index); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + internal bool TrySetShade(ChargenShadeSlot slot, double value) + { + double clamped = Math.Clamp(value, 0.0, 1.0); + lock (_gate) + { + if (_disposed || !_active) + return false; + _appearance = WithShade(_appearance, slot, clamped); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + private bool TryGetGenderOptionsLocked( + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender) + { + gender = null; + if (_heritageId == 0 || _genderKey == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage)) + return false; + return heritage.GendersByKey.TryGetValue((int)_genderKey, out gender); + } + + private static int AppearanceSlotCountLocked( + ChargenAppearanceSlot slot, + ChargenGenderOptions gender) => slot switch + { + ChargenAppearanceSlot.EyesStrip => gender.EyeStrips.Count, + ChargenAppearanceSlot.NoseStrip => gender.NoseStrips.Count, + ChargenAppearanceSlot.MouthStrip => gender.MouthStrips.Count, + ChargenAppearanceSlot.HairStyle => gender.HairStyles.Count, + ChargenAppearanceSlot.HairColor => gender.HairColors.Count, + ChargenAppearanceSlot.EyeColor => gender.EyeColors.Count, + ChargenAppearanceSlot.HeadgearStyle => gender.Headgears.Count, + ChargenAppearanceSlot.ShirtStyle => gender.Shirts.Count, + ChargenAppearanceSlot.TrousersStyle => gender.Pants.Count, + ChargenAppearanceSlot.FootwearStyle => gender.Footwear.Count, + // Color slots: shared-list approximation, register AP-208. + ChargenAppearanceSlot.HeadgearColor + or ChargenAppearanceSlot.ShirtColor + or ChargenAppearanceSlot.TrousersColor + or ChargenAppearanceSlot.FootwearColor => gender.ClothingColors.Count, + _ => 0, + }; + + private static RuntimeCharacterCreationAppearance WithAppearanceIndex( + RuntimeCharacterCreationAppearance appearance, + ChargenAppearanceSlot slot, + uint index) => slot switch + { + ChargenAppearanceSlot.EyesStrip => appearance with { EyesStrip = index }, + ChargenAppearanceSlot.NoseStrip => appearance with { NoseStrip = index }, + ChargenAppearanceSlot.MouthStrip => appearance with { MouthStrip = index }, + ChargenAppearanceSlot.HairStyle => appearance with { HairStyle = index }, + ChargenAppearanceSlot.HairColor => appearance with { HairColor = index }, + ChargenAppearanceSlot.EyeColor => appearance with { EyeColor = index }, + ChargenAppearanceSlot.HeadgearStyle => appearance with { HeadgearStyle = index }, + ChargenAppearanceSlot.HeadgearColor => appearance with { HeadgearColor = index }, + ChargenAppearanceSlot.ShirtStyle => appearance with { ShirtStyle = index }, + ChargenAppearanceSlot.ShirtColor => appearance with { ShirtColor = index }, + ChargenAppearanceSlot.TrousersStyle => appearance with { TrousersStyle = index }, + ChargenAppearanceSlot.TrousersColor => appearance with { TrousersColor = index }, + ChargenAppearanceSlot.FootwearStyle => appearance with { FootwearStyle = index }, + ChargenAppearanceSlot.FootwearColor => appearance with { FootwearColor = index }, + _ => appearance, + }; + + private static RuntimeCharacterCreationAppearance WithShade( + RuntimeCharacterCreationAppearance appearance, + ChargenShadeSlot slot, + double value) => slot switch + { + ChargenShadeSlot.Skin => appearance with { SkinShade = value }, + ChargenShadeSlot.Hair => appearance with { HairShade = value }, + ChargenShadeSlot.Headgear => appearance with { HeadgearShade = value }, + ChargenShadeSlot.Shirt => appearance with { ShirtShade = value }, + ChargenShadeSlot.Trousers => appearance with { TrousersShade = value }, + ChargenShadeSlot.Footwear => appearance with { FootwearShade = value }, + _ => appearance, + }; + + /// Ports the non-reset half of CharGenState::ConstrainAllByGender + /// @ 0x005C5B80: with heritage+gender both selected, clamp every + /// index into the new gender's option-list bounds (never below zero — + /// an empty list clamps to the + /// sentinel, matching a count-1 clamp on a zero count wrapping to + /// -1). Without both selected, every field resets to sentinel, matching + /// the function's other branch. + private void ConstrainAppearanceByGenderLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + { + _appearance = RuntimeCharacterCreationAppearance.Default; + return; + } + + RuntimeCharacterCreationAppearance a = _appearance; + a = WithAppearanceIndex(a, ChargenAppearanceSlot.EyesStrip, ClampIndex(a.EyesStrip, gender.EyeStrips.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.NoseStrip, ClampIndex(a.NoseStrip, gender.NoseStrips.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.MouthStrip, ClampIndex(a.MouthStrip, gender.MouthStrips.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HairStyle, ClampIndex(a.HairStyle, gender.HairStyles.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HairColor, ClampIndex(a.HairColor, gender.HairColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.EyeColor, ClampIndex(a.EyeColor, gender.EyeColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HeadgearStyle, ClampIndex(a.HeadgearStyle, gender.Headgears.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HeadgearColor, ClampIndex(a.HeadgearColor, gender.ClothingColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.ShirtStyle, ClampIndex(a.ShirtStyle, gender.Shirts.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.ShirtColor, ClampIndex(a.ShirtColor, gender.ClothingColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.TrousersStyle, ClampIndex(a.TrousersStyle, gender.Pants.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.TrousersColor, ClampIndex(a.TrousersColor, gender.ClothingColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.FootwearStyle, ClampIndex(a.FootwearStyle, gender.Footwear.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.FootwearColor, ClampIndex(a.FootwearColor, gender.ClothingColors.Count)); + _appearance = a; + } + + private static uint ClampIndex(uint value, int count) + { + if (value == RuntimeCharacterCreationAppearance.Unset) + return value; + return value >= (uint)count + ? unchecked((uint)(count - 1)) + : value; + } + + // ── Town / name / slot ───────────────────────────────────────────── + + /// Ports CharGenState::SetStartArea @ 0x005C4000 — bounds + /// against the shared starter-area list only, no heritage-list + /// restriction (retail's Town page offers whichever indices it wants; + /// this setter accepts any in-range index). + internal bool TrySelectStartArea(int index) + { + lock (_gate) + { + if (_disposed || !_active) + return false; + if (index < 0 || index >= _options.StarterAreas.Count) + return false; + _startArea = index; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Retail's name[33] buffer — 32 usable chars plus a + /// null terminator. Trimming happens at Finish time + /// (DoFinish's own PStringBase::trim), not here — this + /// setter only enforces the hard length cap. + internal bool TrySetName(string name) + { + ArgumentNullException.ThrowIfNull(name); + string bounded = name.Length > 32 ? name[..32] : name; + lock (_gate) + { + if (_disposed || !_active) + return false; + _name = bounded; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + 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. + internal bool TrySetSlot(uint slot) + { + lock (_gate) + { + if (_disposed || !_active) + return false; + _slot = slot; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + // ── 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 + /// 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 + /// this). On success the verification state flips to Pending and this + /// returns the exact wire request the caller must send via + /// — + /// is ALWAYS materialized from + /// , the seam that + /// makes the 55-slot invariant structurally impossible to violate. + /// + internal bool TryBeginFinish( + int rosterCount, + int slotCount, + out CharacterCreate.Request request, + out uint[] skillAdvancementClasses, + out RuntimeCharacterCreationLocalRefusal refusal) + { + request = default; + skillAdvancementClasses = []; + bool accepted; + lock (_gate) + { + if (_disposed || !_active) + { + refusal = RuntimeCharacterCreationLocalRefusal.None; + return false; + } + + string trimmed = _name.Trim(); + _name = trimmed; + + refusal = trimmed.Length == 0 + ? new RuntimeCharacterCreationLocalRefusal( + NoName: true, false, false, false) + : _remainingAttributeCredits > 0 + ? new RuntimeCharacterCreationLocalRefusal( + false, AttributeCreditsUnspent: true, false, false) + : _verificationPending + ? new RuntimeCharacterCreationLocalRefusal( + false, false, AlreadyPending: true, false) + : slotCount > 0 && rosterCount >= slotCount + ? new RuntimeCharacterCreationLocalRefusal( + false, false, false, RosterFull: true) + : RuntimeCharacterCreationLocalRefusal.None; + + _lastLocalRefusal = refusal; + accepted = !refusal.Any; + if (accepted) + { + _verificationPending = true; + request = BuildRequestLocked(); + skillAdvancementClasses = ToSkillArrayLocked(); + } + _revision++; + } + Publish(accepted + ? RuntimeCharacterCreationDeltaKind.FinishSent + : RuntimeCharacterCreationDeltaKind.FinishRefused); + return accepted; + } + + private CharacterCreate.Request BuildRequestLocked() => new( + _heritageId, + _genderKey, + new CharacterCreate.Appearance( + _appearance.EyesStrip, + _appearance.NoseStrip, + _appearance.MouthStrip, + _appearance.HairColor, + _appearance.EyeColor, + _appearance.HairStyle, + _appearance.HeadgearStyle, + _appearance.HeadgearColor, + _appearance.ShirtStyle, + _appearance.ShirtColor, + _appearance.TrousersStyle, + _appearance.TrousersColor, + _appearance.FootwearStyle, + _appearance.FootwearColor, + _appearance.SkinShade, + _appearance.HairShade, + _appearance.HeadgearShade, + _appearance.ShirtShade, + _appearance.TrousersShade, + _appearance.FootwearShade), + _template, + new CharacterCreate.Attributes( + checked((uint)_attributes.Strength), + checked((uint)_attributes.Endurance), + checked((uint)_attributes.Coordination), + checked((uint)_attributes.Quickness), + checked((uint)_attributes.Focus), + checked((uint)_attributes.Self)), + _slot, + // Retail derives classID via DBObj::GetDIDByEnum(0x10000003, 0xc) at + // GetCharGenResult @0x005C4030 — a DAT DID lookup Core has no access + // to. ACE ignores the field entirely (PlayerFactory.cs:154, + // commented out), so 0 is a documented placeholder — register AP-209. + 0u, + _name, + checked((uint)(_startArea < 0 ? 0 : _startArea)), + IsAdmin: false, + IsEnvoy: false); + + private uint[] ToSkillArrayLocked() + { + IReadOnlyList wire = _skills.ToWireClasses(); + var array = new uint[wire.Count]; + for (int i = 0; i < array.Length; i++) + array[i] = wire[i]; + return array; + } + + /// + /// Ports the four rejection dialog mappings + the silent + /// Pending/Undef reset from Handle_CharGenVerificationResponse @ + /// 0x0055E8B0. Idempotent-tolerant to a second, unrequested Ok/reject + /// while nothing is pending (ACE's own double-NameInUse quirk, CC2 + /// review F3) — a call that arrives while + /// is + /// already false is a no-op rather than a second event. + /// + internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response) + { + RuntimeCharacterCreationDeltaKind kind; + lock (_gate) + { + if (_disposed || !_active || !_verificationPending) + return; + + _verificationPending = false; + if (response.IsOk + && response.Guid is { } guid + && response.Name is { } name) + { + _lastCreated = new RuntimeCharacterCreationIdentity(guid, name); + _lastRejection = null; + kind = RuntimeCharacterCreationDeltaKind.Created; + } + else if (response.AsCode is CharGenVerificationResponse.Code.Pending + or CharGenVerificationResponse.Code.Undef) + { + // Silent state reset — retail shows no dialog (ACE sends + // Pending for a disabled-Olthoi rejection; port as-is). + _revision++; + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return; + } + else + { + string reason = response.AsCode.ToString(); + _lastRejection = new RuntimeCharacterCreationRejection( + response.RawCode, + response.AsCode, + reason, + _name); + kind = RuntimeCharacterCreationDeltaKind.CreationFailed; + } + _revision++; + } + Publish(kind); + } + + internal bool TryAcknowledgeRejection() + { + lock (_gate) + { + if (_disposed || _lastRejection is null) + return false; + _lastRejection = null; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.RejectionAcknowledged); + return true; + } + + // ── Plumbing ──────────────────────────────────────────────────────── + + private void ClearSessionState() + { + _heritageId = 0; + _genderKey = 0; + _appearance = RuntimeCharacterCreationAppearance.Default; + _template = RuntimeCharacterCreationSnapshot.TemplateUnset; + _attributes = default; + _attributeLockMask = 0u; + _totalAttributeCredits = 0u; + _remainingAttributeCredits = 0; + _totalSkillCredits = 0u; + _remainingSkillCredits = 0; + for (uint i = 1; i < ChargenSkillAdvancementSet.SlotCount; i++) + _skills[i] = ChargenSkillAdvancementClass.Inactive; + _name = string.Empty; + _startArea = -1; + _slot = 0u; + _verificationPending = false; + _lastLocalRefusal = RuntimeCharacterCreationLocalRefusal.None; + _lastRejection = null; + _lastCreated = null; + } + + private void Publish(RuntimeCharacterCreationDeltaKind kind) + { + RuntimeGenerationToken generation; + long revision; + lock (_gate) + { + if (_disposed) + return; + generation = _generation; + revision = _revision; + } + _events.Publish(generation, revision, kind); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); +} + +internal sealed class CharacterCreationEventStream : IDisposable +{ + private readonly object _gate = new(); + private readonly List _pending = []; + private IRuntimeCharacterCreationObserver[] _observers = []; + private ulong _sequence; + private bool _dispatching; + private bool _disposed; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Array.IndexOf(_observers, observer) >= 0) + { + throw new InvalidOperationException( + "The character-creation observer is already subscribed."); + } + var replacement = new IRuntimeCharacterCreationObserver[_observers.Length + 1]; + Array.Copy(_observers, replacement, _observers.Length); + replacement[^1] = observer; + Volatile.Write(ref _observers, replacement); + } + return new Subscription(this, observer); + } + + public void Publish( + RuntimeGenerationToken generation, + long revision, + RuntimeCharacterCreationDeltaKind kind) + { + lock (_gate) + { + if (_disposed) + return; + _pending.Add(new RuntimeCharacterCreationDelta( + generation, + unchecked(++_sequence), + revision, + kind)); + if (_dispatching) + return; + _dispatching = true; + } + + int index = 0; + while (true) + { + RuntimeCharacterCreationDelta delta; + lock (_gate) + { + if (index >= _pending.Count) + { + _pending.Clear(); + _dispatching = false; + return; + } + delta = _pending[index++]; + } + + foreach (IRuntimeCharacterCreationObserver observer in Volatile.Read(ref _observers)) + { + try + { + observer.OnCharacterCreationChanged(in delta); + } + catch (Exception error) + { + Console.Error.WriteLine( + $"runtime: character-creation observer failed: {error.Message}"); + } + } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _pending.Clear(); + _dispatching = false; + Volatile.Write(ref _observers, []); + } + } + + private void Unsubscribe(IRuntimeCharacterCreationObserver observer) + { + lock (_gate) + { + int index = Array.IndexOf(_observers, observer); + if (index < 0) + return; + var replacement = new IRuntimeCharacterCreationObserver[_observers.Length - 1]; + if (index > 0) + Array.Copy(_observers, 0, replacement, 0, index); + if (index < _observers.Length - 1) + { + Array.Copy( + _observers, + index + 1, + replacement, + index, + _observers.Length - index - 1); + } + Volatile.Write(ref _observers, replacement); + } + } + + private sealed class Subscription( + CharacterCreationEventStream owner, + IRuntimeCharacterCreationObserver observer) + : IDisposable + { + private CharacterCreationEventStream? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer); + } +} diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs new file mode 100644 index 00000000..cb06bb7a --- /dev/null +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs @@ -0,0 +1,206 @@ +using System.Numerics; +using AcDream.Core.CharGen; + +namespace AcDream.Runtime.Tests.CharGen; + +/// +/// A small, hand-authored fixture for +/// tests — NOT installed-DAT data +/// (that is CC1's ChargenTableReaderInstalledDatTests concern). Shapes +/// mirror retail's field semantics closely enough to exercise every gate: +/// two heritages (a normal one and an Olthoi variant that must force template +/// 0), a "Custom"/floor template plus a str-heavy preset, a mix of costed +/// (including two FREE skills exercising every ResetSkillLevels +/// baseline branch) and deliberately uncostable skill ids, and one gender +/// with small appearance-option lists to exercise index bounds. +/// +internal static class RuntimeCharacterCreationStateFixture +{ + public const uint AluvianId = 1u; + public const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi; + public const uint ImpoverishedId = 90u; + public const uint MaleGenderKey = 1u; + + /// str=10 end=10 coord=10 quick=10 focus=10 self=10 — the + /// budget-66 heritage leaves 6 credits unspent after this template, + /// matching retail's "Custom sits at the floor" finding. + public const uint CustomTemplateIndex = 0u; + + /// str=16, everything else at floor — sum 66 (fully spent). + public const uint PresetTemplateIndex = 1u; + + /// Costed only by the heritage's own list — Normal 4 / Primary 12. + public const uint SkillTrainSpecialize = 1u; + + /// The Preset template's Primary (specialized) skill. + public const uint SkillPresetPrimary = 2u; + + /// FREE and pre-specialized by ResetSkillLevels's baseline + /// (NormalCost=0, PrimaryCost<=0). + public const uint SkillFreeSpecialized = 3u; + + /// FREE to train, costs to specialize + /// (NormalCost=0, PrimaryCost>0). + public const uint SkillFreeTrained = 4u; + + /// Uncostable in BOTH tiers — must never be settable (the CC1 + /// R1 binding fact: keep the both-miss refund path unreachable). + public const uint SkillUncostable = 5u; + + /// The Custom template's authored Normal skill. + public const uint SkillCustomNormal = 24u; + + public static ChargenOptions Build() + { + var starterAreas = new List + { + new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]), + new(1, "Yaraq", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]), + }; + + var skillCosts = new Dictionary + { + [SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12), + [SkillPresetPrimary] = new(SkillPresetPrimary, NormalCost: 3, PrimaryCost: 9), + [SkillFreeSpecialized] = new(SkillFreeSpecialized, NormalCost: 0, PrimaryCost: 0), + [SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 5), + [SkillCustomNormal] = new(SkillCustomNormal, NormalCost: 2, PrimaryCost: 6), + }; + + var gender = new ChargenGenderOptions( + GenderKey: (int)MaleGenderKey, + Name: "Male", + Scale: 1u, + SetupId: 0x2000054u, + SoundTableId: 0u, + IconId: 0u, + BasePaletteId: 0u, + SkinPalSetId: 0u, + PhysicsTableId: 0u, + MotionTableId: 0u, + CombatTableId: 0u, + BaseObjDesc: ChargenObjDesc.Empty, + HairColors: [100u, 101u], + HairStyles: + [ + new ChargenHairStyle(1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + new ChargenHairStyle(2u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + ], + EyeColors: [200u, 201u], + EyeStrips: + [ + new ChargenEyeStrip(1u, 2u, ChargenObjDesc.Empty, ChargenObjDesc.Empty), + ], + NoseStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)], + MouthStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)], + Headgears: [new ChargenGearOption("Cap", 1u, 300u)], + Shirts: [new ChargenGearOption("Shirt", 2u, 301u)], + Pants: [new ChargenGearOption("Pants", 3u, 302u)], + Footwear: [new ChargenGearOption("Boots", 4u, 303u)], + ClothingColors: [400u, 401u, 402u]); + + var aluvianTemplates = new List + { + new( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [SkillCustomNormal], + PrimarySkills: []), + new( + "Preset", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10), + NormalSkills: [SkillTrainSpecialize], + PrimarySkills: [SkillPresetPrimary]), + }; + + var aluvian = new ChargenHeritageOptions( + AluvianId, + "Aluvian", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 66u, + SkillCredits: 50u, + PrimaryStartAreaIndices: [0, 1], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: skillCosts, + Templates: aluvianTemplates, + GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + + var olthoiTemplates = new List + { + new( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + new( + "NeverChosen", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(20, 20, 20, 20, 20, 20), + NormalSkills: [], + PrimarySkills: []), + }; + + var olthoi = new ChargenHeritageOptions( + OlthoiId, + "Olthoi", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 60u, + SkillCredits: 0u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary(), + Templates: olthoiTemplates, + GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + + // A deliberately impoverished heritage — just enough skill credits + // to train SkillTrainSpecialize but never specialize it — so a + // TrySpecializeSkill affordability refusal is directly testable + // without needing to hand-drain the richer Aluvian budget. + var impoverished = new ChargenHeritageOptions( + ImpoverishedId, + "Impoverished", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 60u, + SkillCredits: 5u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary + { + [SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12), + }, + Templates: + [ + new ChargenTemplate( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + ], + GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + + return new ChargenOptions( + starterAreas, + new Dictionary + { + [AluvianId] = aluvian, + [OlthoiId] = olthoi, + [ImpoverishedId] = impoverished, + }, + new Dictionary()); + } +} diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs new file mode 100644 index 00000000..ae80851f --- /dev/null +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -0,0 +1,505 @@ +using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.CharGen; + +/// +/// Campaign CC slice CC3: — +/// retail's CharGenState mirror. Every test cites the retail function +/// it is pinning; see the class's own doc comments for full addresses. +/// +public sealed class RuntimeCharacterCreationStateTests +{ + private static RuntimeCharacterCreationState CreateActive() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(1234)); + state.Begin(new RuntimeGenerationToken(1)); + return state; + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + [Fact] + public void Begin_StartsWithNoHeritageOrGenderSelected() + { + RuntimeCharacterCreationState state = CreateActive(); + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + + Assert.True(snapshot.IsActive); + Assert.Equal(0u, snapshot.HeritageId); + Assert.Equal(0u, snapshot.GenderKey); + Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); + Assert.Equal(-1, snapshot.StartArea); + Assert.False(snapshot.VerificationPending); + Assert.Equal(string.Empty, snapshot.Name); + } + + [Fact] + public void Reset_ClearsEveryFieldAndDeactivates() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + Assert.True(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey)); + Assert.True(state.TrySetName("Someone")); + + state.Reset(new RuntimeGenerationToken(2)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.False(snapshot.IsActive); + Assert.Equal(0u, snapshot.HeritageId); + Assert.Equal(0u, snapshot.GenderKey); + Assert.Equal(string.Empty, snapshot.Name); + Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); + // Commands are refused once inactive. + Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + } + + // ── Heritage / gender / template ──────────────────────────────────── + + [Fact] + public void TrySelectHeritage_UnknownId_IsRejected() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.False(state.TrySelectHeritage(0xDEADu)); + Assert.Equal(0u, state.Snapshot.HeritageId); + } + + [Fact] + public void TrySelectHeritage_RecomputesBudgetsAndRollsARandomPrimaryStartArea() + { + RuntimeCharacterCreationState state = CreateActive(); + + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, snapshot.HeritageId); + Assert.Equal(66u, snapshot.TotalAttributeCredits); + Assert.Equal(50u, snapshot.TotalSkillCredits); + // No template chosen yet — ApplyTemplate's own guard leaves + // attributes untouched (CharGenState::ApplyTemplate @ 0x005C5080). + Assert.Equal(0, snapshot.Attributes.Total); + Assert.Equal(66, snapshot.RemainingAttributeCredits); + // RandomizeStartArea @ 0x005C59E0 only ever picks from + // PrimaryStartAreaIndices, [0, 1] in the fixture. + Assert.True(snapshot.StartArea is 0 or 1); + } + + [Fact] + public void TrySelectGender_RequiresHeritageFirst() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.False(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey)); + } + + [Fact] + public void TrySelectGender_UnknownKeyForHeritage_IsRejected() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + Assert.False(state.TrySelectGender(99u)); + } + + [Fact] + public void TrySelectTemplate_Custom_AppliesFloorAttributesAndLeavesCreditsUnspent() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.Equal(0u, snapshot.Template); + Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), snapshot.Attributes); + // 66 budget - 60 floor spend = 6 unspent, matching CC1's "Custom + // sits at the floor" finding. + Assert.Equal(6, snapshot.RemainingAttributeCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillCustomNormal)); + } + + [Fact] + public void TrySelectTemplate_Preset_TrainsNormalAndSpecializesPrimarySkills() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.Equal(new ChargenAttributeValues(16, 10, 10, 10, 10, 10), snapshot.Attributes); + Assert.Equal(0, snapshot.RemainingAttributeCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal( + ChargenSkillAdvancementClass.Specialized, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillPresetPrimary)); + // ResetSkillLevels' baseline (0x005C43B0) still holds for skills the + // template row doesn't mention. + Assert.Equal( + ChargenSkillAdvancementClass.Specialized, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeSpecialized)); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeTrained)); + } + + [Fact] + public void TrySelectTemplate_OlthoiHeritage_AlwaysForcesTemplateZero() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.OlthoiId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + // CharGenState::ApplyTemplate @ 0x005C5080: mHeritageGroup == 0xc + // force-sets template_ = 0 regardless of the requested index. + Assert.True(state.TrySelectTemplate(1u)); + + Assert.Equal(0u, state.Snapshot.Template); + Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes); + } + + // ── Attributes ────────────────────────────────────────────────────── + + [Fact] + public void TrySetAttribute_ClampsToTheFloorAndCeiling() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 5)); + Assert.Equal(10, state.Snapshot.Attributes.Strength); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 999)); + // Clamped further by the abs-remaining-credits check below 100. + Assert.True(state.Snapshot.Attributes.Strength <= ChargenAttributeMath.AttributeMax); + } + + [Fact] + public void TrySetAttribute_RaisingOneAttributeRebalancesAnAboveFloorAttributeDownToTheFloor() + { + // CharGenState::BalanceAttributes @ 0x005C3DF0: starting from the + // Preset template (Strength=16, everyone else at the 10 floor, fully + // spent), raising Endurance consumes the "assumed floor" room + // GetAbsRemainingCredits grants by pretending Strength could drop to + // floor — BalanceAttributes then actually performs that drop. + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16)); + + ChargenAttributeValues attrs = state.Snapshot.Attributes; + Assert.Equal(16, attrs.Endurance); + Assert.Equal(10, attrs.Strength); + Assert.Equal(10, attrs.Coordination); + Assert.Equal(10, attrs.Quickness); + Assert.Equal(10, attrs.Focus); + Assert.Equal(10, attrs.Self); + Assert.Equal(66, attrs.Total); + Assert.Equal(0, state.Snapshot.RemainingAttributeCredits); + } + + [Fact] + public void TrySetAttributeLock_PreventsThatAttributeFromAbsorbingABalance() + { + // CharGenState::LockAttribute @ 0x005C3BE0 + GetAbsRemainingCredits + // @ 0x005C3B20's locked branch: a locked attribute contributes its + // CURRENT value (not the floor) to the abs-remaining computation, so + // no room is assumed available from it. + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Strength, true)); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16)); + + // No room was available (Strength locked at 16, everything else at + // floor, budget already fully spent) — Endurance cannot rise. + Assert.Equal(10, state.Snapshot.Attributes.Endurance); + Assert.Equal(16, state.Snapshot.Attributes.Strength); + } + + // ── Skills ────────────────────────────────────────────────────────── + + [Fact] + public void TrySpecializeSkill_UncostableSkill_IsAlwaysRejected() + { + // Binding fact from the CC1 review (R1): the 16 uncostable skill ids + // must never be settable — retail's own listbox never lists them. + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + + Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); + Assert.False(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); + Assert.False(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); + Assert.Equal( + ChargenSkillAdvancementClass.Inactive, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillUncostable)); + } + + [Fact] + public void TrainThenSpecializeSkill_ChargesExactlyPrimaryCostNotBoth() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + int before = state.Snapshot.RemainingSkillCredits; + + Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(before - 4, state.Snapshot.RemainingSkillCredits); + + Assert.True(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + // PrimaryCost (12) is the TOTAL, not an increment on NormalCost. + Assert.Equal(before - 12, state.Snapshot.RemainingSkillCredits); + + Assert.True(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(before, state.Snapshot.RemainingSkillCredits); + } + + [Fact] + public void TrySpecializeSkill_InsufficientCredits_IsRejectedAndLeavesStateUnchanged() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + Assert.Equal(5, state.Snapshot.RemainingSkillCredits); + + // PrimaryCost (12) exceeds the 5-credit budget outright. + Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(5, state.Snapshot.RemainingSkillCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Untrained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + + // NormalCost (4) fits; the SAME skill Specialized still does not. + Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(1, state.Snapshot.RemainingSkillCredits); + Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(1, state.Snapshot.RemainingSkillCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + } + + // ── Finish gates ──────────────────────────────────────────────────── + + private static RuntimeCharacterCreationState ReadyToFinishState() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); // fully spent attrs + state.TrySetName("Adventurer"); + return state; + } + + [Fact] + public void TryBeginFinish_EmptyName_IsRefused() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + state.TrySetName(" "); + + bool accepted = state.TryBeginFinish( + rosterCount: 0, + slotCount: 11, + out _, + out _, + out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.NoName); + Assert.False(state.Snapshot.VerificationPending); + } + + [Fact] + public void TryBeginFinish_UnspentAttributeCredits_IsRefused() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent + state.TrySetName("Adventurer"); + + bool accepted = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.AttributeCreditsUnspent); + } + + [Fact] + public void TryBeginFinish_SecondCallWhilePending_IsRefused() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + Assert.True(state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal first)); + Assert.False(first.Any); + + bool second = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(second); + Assert.True(refusal.AlreadyPending); + } + + [Fact] + public void TryBeginFinish_RosterAtSlotCap_IsRefused() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + + bool accepted = state.TryBeginFinish( + rosterCount: 11, + slotCount: 11, + out _, + out _, + out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.RosterFull); + } + + [Fact] + public void TryBeginFinish_Accepted_TrimsNameAndProducesExactly55SkillSlots() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + state.TrySetName(" Adventurer "); + + bool accepted = state.TryBeginFinish( + rosterCount: 2, + slotCount: 11, + out CharacterCreate.Request request, + out uint[] skillAdvancementClasses, + out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.True(accepted); + Assert.False(refusal.Any); + Assert.True(state.Snapshot.VerificationPending); + Assert.Equal("Adventurer", request.Name); + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, request.Heritage); + Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, request.Gender); + Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, request.Template); + Assert.Equal(16u, request.Attributes.Strength); + Assert.Equal(CharacterCreate.SkillAdvancementClassCount, skillAdvancementClasses.Length); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Specialized, + skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); + } + + // ── Response handling ─────────────────────────────────────────────── + + private static RuntimeCharacterCreationState PendingState(out uint[] skills) + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + Assert.True(state.TryBeginFinish(0, 11, out _, out skills, out _)); + return state; + } + + [Fact] + public void ApplyCreationResponse_Ok_RecordsCreatedIdentityAndClearsPending() + { + RuntimeCharacterCreationState state = PendingState(out _); + + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.Ok, 0x5000_1234u, "Adventurer", 0u)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.False(snapshot.VerificationPending); + Assert.Equal( + new RuntimeCharacterCreationIdentity(0x5000_1234u, "Adventurer"), + snapshot.LastCreated); + Assert.Null(snapshot.LastRejection); + } + + [Theory] + [InlineData(CharGenVerificationResponse.Code.NameInUse)] + [InlineData(CharGenVerificationResponse.Code.NameBanned)] + [InlineData(CharGenVerificationResponse.Code.Corrupt)] + [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] + [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName( + CharGenVerificationResponse.Code code) + { + RuntimeCharacterCreationState state = PendingState(out _); + + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)code, null, null, null)); + + Assert.NotNull(state.Snapshot.LastRejection); + RuntimeCharacterCreationRejection rejection = state.Snapshot.LastRejection!.Value; + Assert.Equal(code, rejection.Code); + Assert.Equal(code.ToString(), rejection.Reason); + Assert.Equal("Adventurer", rejection.AttemptedName); + Assert.False(state.Snapshot.VerificationPending); + Assert.Null(state.Snapshot.LastCreated); + } + + [Theory] + [InlineData(CharGenVerificationResponse.Code.Pending)] + [InlineData(CharGenVerificationResponse.Code.Undef)] + public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection( + CharGenVerificationResponse.Code code) + { + // ACE sends Pending for a disabled-Olthoi rejection — retail shows + // no dialog. Port as-is. + RuntimeCharacterCreationState state = PendingState(out _); + + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)code, null, null, null)); + + Assert.False(state.Snapshot.VerificationPending); + Assert.Null(state.Snapshot.LastRejection); + Assert.Null(state.Snapshot.LastCreated); + } + + [Fact] + public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored() + { + // ACE's own quirk (CharacterHandler.CharacterCreateEx calls + // IsCharacterNameAvailable TWICE, producing two NameInUse replies + // for one rejected create): the second reply must be a no-op, not a + // second rejection event/state change. + RuntimeCharacterCreationState state = PendingState(out _); + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.NameInUse, null, null, null)); + Assert.NotNull(state.Snapshot.LastRejection); + + // Acknowledge to clear, then feed a SECOND unsolicited reply — must + // stay cleared (idempotent-tolerant, no crash, no new rejection). + Assert.True(state.TryAcknowledgeRejection()); + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.NameInUse, null, null, null)); + + Assert.Null(state.Snapshot.LastRejection); + } + + [Fact] + public void TryAcknowledgeRejection_ClearsTheSurfacedRejection() + { + RuntimeCharacterCreationState state = PendingState(out _); + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.NameBanned, null, null, null)); + Assert.NotNull(state.Snapshot.LastRejection); + + Assert.True(state.TryAcknowledgeRejection()); + + Assert.Null(state.Snapshot.LastRejection); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs new file mode 100644 index 00000000..6b5eb3d5 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -0,0 +1,371 @@ +using System.Buffers.Binary; +using System.Net; +using System.Reflection; +using System.Text; +using AcDream.Core.CharGen; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Runtime; +using AcDream.Runtime.Session; +using AcDream.Runtime.Tests.CharGen; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// Campaign CC slice CC3: 's +/// character-creation integration — the wire send (via a REAL +/// + GameMessageCapture, the same seam +/// WorldSessionCharacterCreationTests uses in Core.Net) and the +/// Ok-response round trip (roster append reusing +/// , the reused +/// EnterSelectedCore log-straight-in, and the new +/// / +/// hooks) via a +/// real inbound 0xF643 packet through WorldSession.ProcessDatagram +/// (reflection — the same private test seam Core.Net's own creation tests +/// use). +/// +public sealed class LiveSessionControllerCharacterCreationTests +{ + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + public int Receive(Span destination, TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return -1; + } + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + throw new OperationCanceledException(cancellationToken); + public void Dispose() { } + } + + private sealed class TestOperations : ILiveSessionOperations + { + public List Sessions { get; } = []; + public int EnterWorldCount { get; private set; } + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint, new TestTransport()); + Sessions.Add(session); + return session; + } + + public void Connect(WorldSession session, string user, string password) { } + + public void StartCharacterSelectionReceive(WorldSession session) { } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => new( + 0u, + [new CharacterList.Character(0x50000001u, "Existing", 0u)], + [], + SlotCount: 11, + AccountName: "testaccount", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) => + EnterWorldCount++; + + public void Tick(WorldSession session) { } + + public void DisposeSession(WorldSession session) { } + } + + private sealed class TestHost : ILiveSessionLifecycleHost + { + public List Rosters { get; } = []; + public List EnteredWorld { get; } = []; + public List Created { get; } = []; + public List Failed { get; } = []; + + public LiveSessionBinding BindSession(WorldSession session) => + new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); + public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } + public void ReportConnecting(string host, int port, string user) { } + public void ReportConnected() { } + public void ReportRoster(LiveSessionRosterReport roster) => Rosters.Add(roster); + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } + public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => + EnteredWorld.Add(selection); + public void DetachSession(WorldSession session) { } + public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => + Created.Add(identity); + public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => + Failed.Add(rejection); + } + + private static LiveSessionConnectOptions LiveOptions() => new( + Enabled: true, + "127.0.0.1", + 9000, + "testaccount", + "password", + Character: null, + Probe: false, + AwaitCharacterSelection: true); + + private static (LiveSessionController Controller, TestOperations Operations, TestHost Host, RuntimeGenerationToken Generation) + StartAwaitingSelection() + { + var operations = new TestOperations(); + var host = new TestHost(); + var controller = new LiveSessionController( + operations, + timeProvider: null, + RuntimeCharacterCreationStateFixture.Build()); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + Assert.Equal(LiveSessionStartStatus.AwaitingCharacterSelection, result.Status); + + return (controller, operations, host, controller.Generation); + } + + private static void BuildReadyCharacter(LiveSessionController controller, RuntimeGenerationToken generation) + { + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); + Assert.True(controller.SetName(generation, "NewChar").Accepted); + } + + [Fact] + public void Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + + WorldSession session = operations.Sessions[0]; + byte[]? captured = null; + GameMessageGroup? capturedGroup = null; + session.GameMessageCapture = (body, group) => + { + captured = body; + capturedGroup = group; + }; + + RuntimeCommandResult result = controller.Finish(generation); + + Assert.True(result.Accepted); + Assert.NotNull(captured); + Assert.Equal(GameMessageGroup.LoginQueue, capturedGroup); + + CapturedCreateRequest decoded = DecodeCreateRequest(captured!); + Assert.Equal("testaccount", decoded.AccountName); + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, decoded.Heritage); + Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, decoded.Gender); + Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, decoded.Template); + Assert.Equal(16u, decoded.Strength); + Assert.Equal("NewChar", decoded.Name); + Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, decoded.NumSkills); + Assert.Equal(CharacterCreate.SkillAdvancementClassCount, decoded.SkillAdvancementClasses.Length); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Specialized, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); + } + + [Fact] + public void Finish_ThenOkResponse_AppendsToRosterAndLogsStraightIn() + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); + + Assert.Single(host.Created); + Assert.Equal(0x50001234u, host.Created[0].Guid); + Assert.Equal("NewChar", host.Created[0].Name); + Assert.Empty(host.Failed); + + // The roster report following the Ok reply has BOTH the 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); + + // 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. + Assert.True(controller.IsInWorld); + Assert.Equal(1, operations.EnterWorldCount); + Assert.Single(host.EnteredWorld); + Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); + } + + [Fact] + public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection() + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); + + Assert.Single(host.Failed); + Assert.Equal(CharGenVerificationResponse.Code.NameInUse, host.Failed[0].Code); + Assert.Equal("NewChar", host.Failed[0].AttemptedName); + Assert.Empty(host.Created); + Assert.False(controller.IsInWorld); + Assert.Equal(0, operations.EnterWorldCount); + Assert.Empty(host.EnteredWorld); + // No roster append on a rejection. + Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); + } + + [Fact] + public void Finish_RefusedLocallyWithUnspentAttributeCredits_NeverTouchesTheWire() + { + (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]; + bool sent = false; + session.GameMessageCapture = (_, _) => sent = true; + + RuntimeCommandResult result = controller.Finish(generation); + + Assert.False(result.Accepted); + Assert.False(sent); + } + + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) + { + MethodInfo method = typeof(WorldSession).GetMethod( + "ProcessDatagram", + BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(session, [new ReadOnlyMemory(datagram), null, true]); + } + + private static byte[] BuildResponseBody(uint code, uint guid, string name) + { + var w = new PacketWriter(); + w.WriteUInt32(CharGenVerificationResponse.ResponseOpcode); + w.WriteUInt32(code); + if (code == (uint)CharGenVerificationResponse.Code.Ok) + { + w.WriteUInt32(guid); + w.WriteString16L(name); + w.WriteUInt32(0u); + } + return w.ToArray(); + } + + private static byte[] BuildResponsePacket(uint code, uint guid, string name) + { + byte[] message = BuildResponseBody(code, guid, name); + var fragments = new byte[MessageFragmentHeader.Size + message.Length]; + GameMessageFragment.WriteSingleFragment(fragments.AsSpan(), fragmentSequence: 1u, GameMessageGroup.UIQueue, message); + return PacketCodec.Encode( + new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments }, + fragments, + outboundIsaac: null); + } + + private readonly record struct CapturedCreateRequest( + string AccountName, + uint Heritage, + uint Gender, + uint Template, + uint Strength, + string Name, + uint NumSkills, + uint[] SkillAdvancementClasses); + + /// Manual mirror of 's + /// exact field order — see that class's doc comment for the full + /// layout. + private static CapturedCreateRequest DecodeCreateRequest(ReadOnlySpan body) + { + int pos = 0; + uint opcode = ReadU32(body, ref pos); + Assert.Equal(CharacterCreate.Opcode, opcode); + string accountName = ReadString16L(body, ref pos); + uint constant = ReadU32(body, ref pos); + Assert.Equal(1u, constant); + uint heritage = ReadU32(body, ref pos); + uint gender = ReadU32(body, ref pos); + _ = ReadU32(body, ref pos); // eyesStrip + _ = ReadU32(body, ref pos); // noseStrip + _ = ReadU32(body, ref pos); // mouthStrip + _ = ReadU32(body, ref pos); // hairColor + _ = ReadU32(body, ref pos); // eyeColor + _ = ReadU32(body, ref pos); // hairStyle + _ = ReadU32(body, ref pos); // headgearStyle + _ = ReadU32(body, ref pos); // headgearColor + _ = ReadU32(body, ref pos); // shirtStyle + _ = ReadU32(body, ref pos); // shirtColor + _ = ReadU32(body, ref pos); // trousersStyle + _ = ReadU32(body, ref pos); // trousersColor + _ = ReadU32(body, ref pos); // footwearStyle + _ = ReadU32(body, ref pos); // footwearColor + for (int i = 0; i < 6; i++) + _ = ReadF64(body, ref pos); // six shades + uint template = ReadU32(body, ref pos); + uint strength = ReadU32(body, ref pos); + _ = ReadU32(body, ref pos); // endurance + _ = ReadU32(body, ref pos); // coordination + _ = ReadU32(body, ref pos); // quickness + _ = ReadU32(body, ref pos); // focus + _ = ReadU32(body, ref pos); // self + _ = ReadU32(body, ref pos); // slot + _ = ReadU32(body, ref pos); // classId + uint numSkills = ReadU32(body, ref pos); + var skills = new uint[numSkills]; + for (int i = 0; i < numSkills; i++) + skills[i] = ReadU32(body, ref pos); + string name = ReadString16L(body, ref pos); + // startArea, isAdmin, isEnvoy, checksum follow — not needed here. + return new CapturedCreateRequest( + accountName, heritage, gender, template, strength, name, numSkills, skills); + } + + private static uint ReadU32(ReadOnlySpan body, ref int pos) + { + uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + return value; + } + + private static double ReadF64(ReadOnlySpan body, ref int pos) + { + double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos)); + pos += 8; + return value; + } + + private static string ReadString16L(ReadOnlySpan body, ref int pos) + { + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + string value = Encoding.ASCII.GetString(body.Slice(pos + 2, len)); + int recordSize = 2 + len; + int padding = (4 - (recordSize & 3)) & 3; + pos += recordSize + padding; + return value; + } +}