From 43482c1380fa89d6125d9e6ffc6ddb84c4d51338 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 01:51:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(vt):=20round=203=20item=209=20=E2=80=94=20?= =?UTF-8?q?loot=20cutover=20to=20real=20.utl=20(consistency=20with=20the?= =?UTF-8?q?=20other=20three)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MossTankLootProfileStore now reads/writes real .utl files through VtankLootProfileSerializer.TryRead/Write in the VtankProfiles storage, matching the Settings/.usd, Route/.af, and Meta/.af cutovers already landed: directory-backed listing (new VtankProfileDirectory.ListLootProfiles, *.utl, "--" hidden rule), .cdf participation (LootFileName, already present in VtankCharacterBinding but never populated by this store), and a one-time JSON migration (SweepLegacyRosterIfNeeded) that converts BOTH this character's own "By char" document and every other named profile the pre-cutover roster still lists — unlike Meta/Route (whose rosters were already abandoned pre-round-2), loot's roster was still the LIVE mechanism right up to this commit, so there is no separate "selected vs the rest" split the way Settings/Meta/Route each have. WriteLegacyExport is deleted; exports/ has no remaining loot writer. Found and fixed a real representational-loss bug the cutover would otherwise have introduced: VtankLootProfileSerializer.ExportRequirements replaces an empty VtankRequirements list with a "safely disabled" VTClassic placeholder requirement — correct when .utl was only ever a courtesy export mirror alongside the authoritative JSON store, but silently destructive once .utl becomes the SOLE store, since every MossTank-authored rule's Expression text would be permanently discarded on its first save/reload cycle. Added a MossTank-owned length-delimited "MossTankRuleExpressions" block (using the serializer's own existing UnknownBlocks round-trip contract — a real VTClassic reader just ignores it as an unrecognized block, the same as any other extension) that restores each affected rule's exact Expression text on load. Filed AD-123: MossTank's own ByCharacter auto-.utl-file convention for loot (kept for consistency with the other three stores) versus retail's real loot picker, which seeds only [None] and has no per-character auto file at all (docs/research/vtank-kb/01-settings-and-profiles.md section 3). Updated four pre-existing tests for the new storage shape/behavior: ProfileGiveControllerTests' FakeHost now wires VtankProfiles (the loot store no longer uses Storage at all); LootProfilesAreIndependentNamedDocuments and LootCommandsImportAndExportExactVtclassicUtlFiles now assert against the real file/no-exports-mirror shape instead of the deleted hashed-JSON/ exports-mirror one. Added LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce pinning the migration + its idempotence. Mutation: reverted MossTankLootProfileStore.cs/VtankProfileDirectory.cs to HEAD (keeping only the new/changed tests) and ran the three cutover-dependent tests — all three failed (no real .utl file, no ListLootProfiles, exports/ mirror still expected) — confirming they exercise the bug/gap before the fix. LootingUsesVtankDefaultsAndPersistsTheOrderedRuleEditor (pre-existing, unchanged) independently caught the representational-loss bug during development before the MossTankRuleExpressions block was added. Co-Authored-By: Claude Fable 5.1 --- .../retail-divergence-register.md | 3 +- .../MossTankLootProfileStore.cs | 587 ++++++++++++------ .../VtankProfileDirectory.cs | 31 + .../MossTankPanelTests.cs | 89 ++- .../ProfileGiveControllerTests.cs | 4 + 5 files changed, 511 insertions(+), 203 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d1d87710..689908c9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -76,7 +76,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 93 active rows (AD-122 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7 — MossTank's .cdf writes real VTank-native Nav/Meta filenames as its own .af format instead of .nav/.met, unreadable by a real VTank instance sharing the same profile directory; AD-120 filed 2026-09-04 at the S4-c2 fix round 1 (M3) — a translucent building-shell instance under building detail draws immediately at its own walk-stream alpha-submission mark rather than "in place" mid-mesh-call, since acdream's opaque instances are stream-batched and retail's mesh call has no equivalent; AD-119 filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2) — the portal-depth color path substitutes a `ColorWrite=false` write mask for retail's zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend, a provably pixel-identical no-op either way; AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch) — its original item 1, the render-only destination-cell move rule, was VERIFIED the same night as retail's own zero-sphere `CObjCell::find_cell_list` 0x0052b4e0 mechanism and is not a deviation; AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 94 active rows (AD-123 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 9 — MossTank keeps a ByCharacter auto loot .utl file for internal consistency across all four stores, where retail's own loot picker seeds only [None] and has no per-character auto file at all; AD-122 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7 — MossTank's .cdf writes real VTank-native Nav/Meta filenames as its own .af format instead of .nav/.met, unreadable by a real VTank instance sharing the same profile directory; AD-120 filed 2026-09-04 at the S4-c2 fix round 1 (M3) — a translucent building-shell instance under building detail draws immediately at its own walk-stream alpha-submission mark rather than "in place" mid-mesh-call, since acdream's opaque instances are stream-batched and retail's mesh call has no equivalent; AD-119 filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2) — the portal-depth color path substitutes a `ColorWrite=false` write mask for retail's zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend, a provably pixel-identical no-op either way; AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch) — its original item 1, the render-only destination-cell move rule, was VERIFIED the same night as retail's own zero-sphere `CObjCell::find_cell_list` 0x0052b4e0 mechanism and is not a deviation; AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -120,6 +120,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-123 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 9.** Retail's real loot-profile picker (`aa()`, `uTank2/PluginCore.cs:7127-7154`) seeds ONLY `[None]` — there is no per-character auto loot file and no "mine only" filter for loot at all (`docs/research/vtank-kb/01-settings-and-profiles.md` section 3: "the loot default has no equivalent auto-name; loot profiles default to none"). MossTank keeps its own `ByCharacter`/"By char" auto-profile convention for loot anyway, for internal consistency with the Settings/Nav/Meta stores (all three of which DO have a real retail auto-file). | `src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs` (`ByCharacter`, `CurrentFileName`); `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`ListLootProfiles`) | The auto file is a normal `.utl`, named the same `--Name_Server.utl` shape the other three auto-files use; a real VTank install never creates or reads this convention itself, so it is additive, not a collision with anything retail writes. | A user comparing acdream's loot picker to real VTank's own `cmbLootSet` sees an extra "By char" entry retail never shows, and (harmlessly) an extra `--Name_Server.utl` file in a shared real-VTank profile directory. | `aa()` (`uTank2/PluginCore.cs:7127-7154`). | | AD-122 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7.** `VtankProfileDirectory.WriteCharacterBinding` writes a real `.cdf`'s Nav/Meta lines (4-5) as MossTank's own `.af` names (metaf's human-readable grammar), not VTank's native binary `.nav`/`.met`. When `ACDREAM_VTANK_PROFILE_DIR` points at a REAL installed VirindiTank profile folder for direct interop, the `.cdf` this store writes there names files a real VTank instance cannot load (it only reads `.nav`/`.met`). | `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`WriteCharacterBinding`); `src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs`/`MossTankRouteProfileStore.cs` (`.af` naming) | `.usd` settings and `.utl` loot stay real/binary-compatible; only Nav/Meta went `.af`-only for slice 1 (see `docs/research/vtank-kb/06-navigation-and-nav.md`/`07-meta-and-expressions.md`). acdream itself only ever reads its own `.cdf` writes back, so this is self-consistent as long as the two clients never share one profile directory. | A user pointing `ACDREAM_VTANK_PROFILE_DIR` at their real VTank install and then opening that character in real VTank gets a Nav/Meta load failure (wrong format for the recorded filename) even though Settings/Loot still work. | `da.q()`/`da.e()` (`refs/vtank/decompiled/da.cs:105-164`) — real VTank's own `.cdf` read/write. | | AD-119 | **Filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2; `docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md` §6 R3).** Retail's portal-depth draws (`D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90, the `BLEND_SRCALPHA`/`BLEND_INVSRCALPHA` `SetBlendFunction` call) keep color writes ENABLED with a zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend — every OTHER piece of R3's state (`DEPTHTEST_ALWAYS`, depth write on, `CULLMODE_NONE`, no stencil) is ported exactly. acdream instead disables the color-write mask outright on the SAME pipeline (`ColorWrite = false` alongside `Blend = GpuBlendMode.None`) and `portal_depth.frag`'s `main()` writes no color output at all — a write-mask substituting for a zero-alpha blend. | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs:92,100` (`CreatePortalPipeline`'s `Blend`/`ColorWrite` fields); `src/AcDream.App/Rendering/Shaders/portal_depth.frag` (empty `main()`, no color output) | Retail's blend equation is `dst' = src*srcAlpha + dst*(1-srcAlpha)`; with `srcAlpha` fixed at 0 this collapses to `dst' = dst` for every fragment regardless of its RGB — the destination color buffer is left byte-identical either way. A write mask reaches the SAME outcome (the destination is never touched) through a structurally simpler path — no blend-unit work per fragment, no fragment color output to author or keep in sync with a "must stay zero" alpha invariant — so the two are pixel-identical, not merely usually-equivalent. | None expected: the equivalence is provable from the blend algebra above, not measured, so no capture, transcript, or visual gate can distinguish the two. The write mask is in fact the SAFER of the two going forward — a future edit that gives `portal_depth.frag` a real color output (e.g. an authored debug tint) still writes nothing under today's mask, where a ported zero-alpha blend would depend on that same edit remembering to keep alpha at exactly 0. | `D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90 (`SetBlendFunction(BLEND_SRCALPHA, BLEND_INVSRCALPHA, BLENDOP_ADD)`, `SetDepthBufferMode(DEPTHTEST_ALWAYS, ...)`, `SetCullMode(CULLMODE_NONE)`); `PortalDepthMaskRenderer.Rhi.cs` | | AD-120 | **Filed 2026-09-04 at the S4-c2 fix round 1 (M3).** Retail's `DrawMesh` row 1 immediate draw happens IN PLACE, inside the mesh draw call itself, the instant `curr_detail_surface != null` is observed for a translucent building-shell subset — there is no separate "later" point at which it could draw. acdream's walk stream-batches ORDINARY opaque instances into one deferred submission, so a translucent building-shell instance under building detail cannot draw "in place" in that same sense; `WbDrawDispatcher.SubmitToAlphaQueue`'s `RetailAlphaMeshAction.Immediate` branch instead draws it via `DrawImmediateAlphaInstance`/`DrawImmediateAlphaInstanceRhi` at the entity's own `AlphaSubmitMark` — the point `WalkFrameDriver.Replay` reaches that instance's alpha submission within the walk's existing turn order (after the entity's building/land-cell turn, same relative position an ordinary deferred instance would occupy). | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`SubmitToAlphaQueue`, `DrawImmediateAlphaInstance`); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`DrawImmediateAlphaInstanceRhi`) | The walk's turn order already places a building's alpha-eligible instances at the correct relative position among that turn's other content (retail draws building shells within `DrawBuilding`, itself ordered exactly where the walk visits that building) — moving the draw from "mid-mesh-call" to "at the turn's alpha submission point" changes WHEN within one turn it executes, never WHICH turn, so it still lands between the same neighboring opaque/translucent content retail's own turn order would bracket it with. | A translucent building-shell instance under detail could theoretically composite differently than retail if some OTHER alpha content is submitted between the entity's own opaque stream mark and its alpha submission mark within the SAME turn — no such intervening content exists in the current walk (an entity's alpha submission immediately follows its own classification), so this is a structural note, not an observed symptom. | `D3DPolyRender::DrawMesh` @0x0059d4a0 (row 1); `RenderDeviceD3D::DrawBuilding` @0x0059f2a0 (`curr_detail_surface` install/clear) | diff --git a/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs index 620f3944..4c675120 100644 --- a/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -6,13 +7,32 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// -/// Independent VTank loot-profile lifecycle. Macro settings select this -/// profile by character, but its ordered rules live in their own document. +/// VTank-compatible loot profile lifecycle. .utl (VTClassic's real +/// public format, ) is the ONLY +/// storage/authoring format — round 3 item 9 cutover, matching the +/// Settings/.usd, Route/.af, and Meta/.af cutovers already landed. Real +/// VTank's own loot picker (aa(), uTank2/PluginCore.cs:7127-7154) +/// seeds ONLY [None] — there is no per-character auto loot file and +/// no "mine only" filter for loot at all +/// (docs/research/vtank-kb/01-settings-and-profiles.md section 3). +/// MossTank keeps its own established convention +/// (already load-bearing for the other three stores) as a recorded +/// adaptation rather than a retail behavior. /// internal sealed class MossTankLootProfileStore { public const string ByCharacter = "By char"; - private const string IndexKey = "profiles/loot/index.json"; + + // The pre-cutover roster (round 3 item 9): before this cutover, this + // key was the LIVE index (Names + SelectedByCharacter) — unlike Meta/ + // Route's already-abandoned pre-round-2 rosters, loot's roster was + // still actively read/written until this exact commit. Read-only now, + // consulted ONLY by the one-time SweepLegacyRosterIfNeeded (which also + // converts the SELECTED profile, so there is no separate + // MigrateLegacyIfNeeded here the way Settings/Meta/Route each have — + // the sweep already covers every named profile, selected or not). + private const string LegacyRosterKey = "profiles/loot/index.json"; + private static readonly JsonSerializerOptions Options = new() { WriteIndented = true, @@ -20,62 +40,89 @@ internal sealed class MossTankLootProfileStore }; private readonly IPluginHost _host; - private IndexDocument _index; private string _characterName = string.Empty; private string _selected = ByCharacter; + private bool _rosterSwept; public MossTankLootProfileStore(IPluginHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _index = Read(IndexKey) ?? new IndexDocument(); - _index.Names ??= []; - _index.SelectedByCharacter = new Dictionary( - _index.SelectedByCharacter ?? new Dictionary(), - StringComparer.OrdinalIgnoreCase); } - public string Selected => _selected; + /// + /// The bare name a user typed to select/create this profile (the file + /// name minus its .utl extension) — internal identity + /// () always carries the real, on-disk file + /// name; only display strips it, matching + /// 's own convention. + /// + public string Selected => StripUtl(_selected); public string? RecoveryNotice { get; private set; } - public IReadOnlyList AvailableNames => new[] { ByCharacter } - .Concat(_index.Names) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(name => name.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + + private static string StripUtl(string name) => name.Equals( + ByCharacter, StringComparison.OrdinalIgnoreCase) + ? name + : name.EndsWith(".utl", StringComparison.OrdinalIgnoreCase) + ? name[..^4] + : name; + + private string Server => _host.Automation.Character.WorldName; + private IPluginStorage VtankStorage => _host.VtankProfiles; + private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0; + + public IReadOnlyList AvailableNames + { + get + { + var names = new List { ByCharacter }; + foreach (VtankProfileDirectory.ProfileEntry entry in + VtankProfileDirectory.ListLootProfiles(VtankStorage)) + { + if (entry.FileName.Length == 0) + continue; // VTank's own "[None]"/MossTank's "[By char]" sentinels. + names.Add(StripUtl(entry.FileName)); + } + return names; + } + } public bool BindCharacter(string? characterName) { string normalized = string.IsNullOrWhiteSpace(characterName) ? string.Empty : characterName.Trim(); - if (string.Equals( - normalized, - _characterName, - StringComparison.OrdinalIgnoreCase)) - { + if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase)) return false; - } _characterName = normalized; - _selected = _index.SelectedByCharacter.TryGetValue( - SelectionKey(), - out string? selected) - && IsKnown(selected) - ? CanonicalName(selected) - : ByCharacter; + VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles + ? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server) + : null; + _selected = binding is { LootFileName.Length: > 0 } bound + ? bound.LootFileName + : ByCharacter; return true; } public bool Select(string? name) { string normalized = name?.Trim() ?? string.Empty; - if (!IsKnown(normalized)) + if (normalized.Length == 0) return false; - _selected = CanonicalName(normalized); - _index.SelectedByCharacter[SelectionKey()] = _selected; - SaveIndex(); - return true; + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + _selected = ByCharacter; + WriteBinding(); + return true; + } + + string candidate = ToFileName(normalized); + if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null) + { + _selected = candidate; + WriteBinding(); + return true; + } + return false; } public bool Create( @@ -97,32 +144,17 @@ internal sealed class MossTankLootProfileStore return false; } - LootProfileDocument? currentDocument = copyCurrent - ? Read(CurrentKey()) - : null; - var document = new LootProfileDocument + string fileName = ToFileName(normalized); + var profile = new VtankLootProfile { - Rules = copyCurrent - ? current.Select(LootRuleDocument.From).ToArray() - : [], + Rules = copyCurrent ? current.ToList() : [], SalvageCombine = copyCurrent - ? (settings?.SalvageCombine.Clone() - ?? currentDocument?.SalvageCombine?.Clone() - ?? new VtankSalvageCombineSettings()) + ? settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings() : new VtankSalvageCombineSettings(), - UnknownBlocks = copyCurrent - ? currentDocument?.UnknownBlocks ?? [] - : [], }; - Write(ProfileKey(normalized, byCharacter: false), document); - if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) - _index.Names.Add(normalized); - _selected = _index.Names.First(entry => entry.Equals( - normalized, - StringComparison.OrdinalIgnoreCase)); - _index.SelectedByCharacter[SelectionKey()] = _selected; - SaveIndex(); - WriteLegacyExport(_selected, document); + WriteUtl(fileName, profile); + _selected = fileName; + WriteBinding(); notice = copyCurrent ? $"Copied loot rules to {_selected}." : $"Created loot profile {_selected}."; @@ -133,18 +165,23 @@ internal sealed class MossTankLootProfileStore public bool LoadCurrent(List target, LootSettings? settings = null) { ArgumentNullException.ThrowIfNull(target); - LootProfileDocument? document = Read(CurrentKey()); - if (document is null) + SweepLegacyRosterIfNeeded(); + string fileName = CurrentFileName(); + string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; + if (text is null) return false; - target.Clear(); - foreach (LootRuleDocument rule in document.Rules ?? []) - target.Add(rule.ToRule()); - if (settings is not null) + if (!VtankLootProfileSerializer.TryRead(text, out VtankLootProfile profile, out string error)) { - settings.SalvageCombine = - document.SalvageCombine?.Clone() - ?? new VtankSalvageCombineSettings(); + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "loot", fileName, text, new FormatException(error)); + _host.Log.Warn(RecoveryNotice); + return false; } + ApplyMossTankExpressions(profile); + target.Clear(); + target.AddRange(profile.Rules); + if (settings is not null) + settings.SalvageCombine = profile.SalvageCombine.Clone(); return true; } @@ -160,20 +197,22 @@ internal sealed class MossTankLootProfileStore string normalized = name?.Trim() ?? string.Empty; if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)) normalized = normalized[..^4]; - if (!IsKnown(normalized)) + if (normalized.Length == 0) return false; - string canonical = CanonicalName(normalized); - string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - ? ProfileKey(_characterName, byCharacter: true) - : ProfileKey(canonical, byCharacter: false); - LootProfileDocument? document = Read(key); - if (document is null) + string fileName = normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? CurrentFileName() + : ToFileName(normalized); + string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; + if (text is null || !VtankLootProfileSerializer.TryRead( + text, out VtankLootProfile profile, out _)) + { return false; + } + ApplyMossTankExpressions(profile); target.Clear(); - foreach (LootRuleDocument rule in document.Rules ?? []) - target.Add(rule.ToRule()); + target.AddRange(profile.Rules); return true; } @@ -181,17 +220,22 @@ internal sealed class MossTankLootProfileStore IReadOnlyList rules, LootSettings? settings = null) { - LootProfileDocument? existing = Read(CurrentKey()); - var document = new LootProfileDocument + string fileName = CurrentFileName(); + var profile = new VtankLootProfile { - Rules = rules.Select(LootRuleDocument.From).ToArray(), - SalvageCombine = settings?.SalvageCombine.Clone() - ?? existing?.SalvageCombine?.Clone() - ?? new VtankSalvageCombineSettings(), - UnknownBlocks = existing?.UnknownBlocks ?? [], + Rules = rules.ToList(), + SalvageCombine = settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings(), }; - Write(CurrentKey(), document); - WriteLegacyExport(LegacyProfileName(), document); + // Preserve UnknownBlocks/SourceVersion from whatever is already on + // disk (a drop-in real .utl may carry blocks MossTank does not + // understand yet) rather than discarding them on every save. + string? existingText = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; + if (existingText is not null + && VtankLootProfileSerializer.TryRead(existingText, out VtankLootProfile existing, out _)) + { + profile.UnknownBlocks = existing.UnknownBlocks; + } + WriteUtl(fileName, profile); } public void ClearCurrent(List target, LootSettings? settings = null) @@ -239,47 +283,271 @@ internal sealed class MossTankLootProfileStore return false; } - var document = new LootProfileDocument - { - Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(), - SalvageCombine = imported.SalvageCombine.Clone(), - UnknownBlocks = imported.UnknownBlocks.Select( - VtankLootExtraBlockDocument.From).ToArray(), - }; - Write(ProfileKey(normalized, byCharacter: false), document); - if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) - _index.Names.Add(normalized); - _selected = _index.Names.First(entry => entry.Equals( - normalized, - StringComparison.OrdinalIgnoreCase)); - _index.SelectedByCharacter[SelectionKey()] = _selected; - SaveIndex(); + string fileName = ToFileName(normalized); + WriteUtl(fileName, imported); + _selected = fileName; + WriteBinding(); target.Clear(); target.AddRange(imported.Rules); if (settings is not null) settings.SalvageCombine = imported.SalvageCombine.Clone(); - WriteLegacyExport(_selected, document); notice = $"Imported VTClassic loot profile {_selected}."; return true; } - private bool IsKnown(string? name) => name is not null - && (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - || _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase)); + // ------------------------------------------------------------------ + // Legacy JSON -> .utl migration (round 3 item 9). + // ------------------------------------------------------------------ - private string CanonicalName(string name) => name.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? ByCharacter - : _index.Names.First(entry => entry.Equals( - name, - StringComparison.OrdinalIgnoreCase)); + /// + /// Round 3 item 9: a ONE-TIME sweep (guarded by ) + /// over the pre-cutover roster at . Unlike + /// Settings/Meta/Route's own per-selection migration (which only ever + /// converted whichever ONE profile happened to be selected), this + /// single pass converts EVERY named profile the roster lists AND this + /// character's own "By char" JSON document, since the roster was still + /// the live mechanism right up to this cutover — there is no separate + /// "already migrated one, sweep the rest" split here. + /// + private void SweepLegacyRosterIfNeeded() + { + if (_rosterSwept) + return; + _rosterSwept = true; + if (!_host.Storage.IsAvailable) + return; - private string CurrentKey() => _selected.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? ProfileKey(_characterName, byCharacter: true) - : ProfileKey(_selected, byCharacter: false); + // This character's own "By char" JSON document, if not already a + // real .utl file. + string byCharacterFileName = CurrentFileName(); + if (VtankStorage.ReadText(byCharacterFileName) is null) + { + LootProfileDocument? byCharacter = ReadLegacyJson( + ProfileKey(_characterName, byCharacter: true)); + if (byCharacter is not null) + { + WriteUtl(byCharacterFileName, byCharacter.ToVtankProfile()); + _host.Storage.Delete(ProfileKey(_characterName, byCharacter: true)); + } + } + + LegacyRosterDocument? roster = ReadLegacyRoster(); + if (roster?.Names is not { Count: > 0 } names) + return; + + var remaining = new List(); + int migrated = 0; + foreach (string rawName in names) + { + string name = (rawName ?? string.Empty).Trim(); + if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + continue; // stale/invalid row; drop it rather than loop on it forever. + + string legacyKey = ProfileKey(name, byCharacter: false); + LootProfileDocument? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = ToFileName(name); + if (VtankStorage.ReadText(fileName) is null) + WriteUtl(fileName, legacy.ToVtankProfile()); + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + if (remaining.Count > 0) + { + _host.Storage.WriteText( + LegacyRosterKey, + JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, Options)); + } + else + { + _host.Storage.Delete(LegacyRosterKey); + } + _host.Log.Warn($"Migrated {migrated} legacy MossTank named loot profile(s) from the old roster."); + } + + private LegacyRosterDocument? ReadLegacyRoster() + { + string? json = null; + try + { + json = _host.Storage.ReadText(LegacyRosterKey); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "loot", LegacyRosterKey, json, error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private LootProfileDocument? ReadLegacyJson(string key) + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "loot", key, json, error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + // ------------------------------------------------------------------ + // File naming, storage plumbing. + // ------------------------------------------------------------------ + + private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "utl") + : _selected; + + private static string ToFileName(string bareName) => + bareName.EndsWith(".utl", StringComparison.OrdinalIgnoreCase) + ? bareName + : bareName + ".utl"; + + private void WriteBinding() + { + if (!CanBindFiles || !VtankStorage.IsAvailable) + return; + VtankProfileDirectory.VtankCharacterBinding existing = + VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server) + ?? new VtankProfileDirectory.VtankCharacterBinding( + string.Empty, CurrentFileName(), string.Empty, null); + VtankProfileDirectory.WriteCharacterBinding( + VtankStorage, + _characterName, + Server, + existing with { LootFileName = CurrentFileName() }); + } + + private void WriteUtl(string fileName, VtankLootProfile profile) + { + if (!VtankStorage.IsAvailable) + return; + AttachMossTankExpressions(profile); + try + { + VtankStorage.WriteText(fileName, VtankLootProfileSerializer.Write(profile)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}"); + } + } + + // ------------------------------------------------------------------ + // MossTank rule-expression preservation (round 3 item 9): VTClassic's + // real .utl requirement grammar cannot express an arbitrary MossTank + // Expression string at all (VtankLootProfileSerializer.ExportRequirements + // replaces an empty VtankRequirements list with a "safely disabled" + // placeholder requirement for VTClassic compatibility) — fine when + // .utl was only ever a courtesy export mirror alongside the + // authoritative JSON store, but silently DESTRUCTIVE now that .utl is + // the SOLE store: every MossTank-authored rule's Expression would be + // permanently lost on its very first save/reload cycle. Preserved + // instead in a MossTank-owned length-delimited block + // (VtankLootProfileSerializer's own UnknownBlocks mechanism already + // round-trips any block type it doesn't recognize as "SalvageCombine" + // verbatim — exactly the "a foreign reader keeps what it doesn't + // understand" contract this needs) so a real VTClassic reading the + // same file simply ignores it as an unknown block, the same as any + // other extension block. + // ------------------------------------------------------------------ + + private const string MossTankRulesBlockType = "MossTankRuleExpressions"; + + private static void AttachMossTankExpressions(VtankLootProfile profile) + { + profile.UnknownBlocks.RemoveAll(static block => + string.Equals(block.Type, MossTankRulesBlockType, StringComparison.Ordinal)); + + var payload = new StringBuilder(); + payload.Append(profile.Rules.Count.ToString(CultureInfo.InvariantCulture)).Append("\r\n"); + foreach (LootRule rule in profile.Rules) + { + // A rule with real VtankRequirements (imported from a genuine + // VTClassic file, never touched by MossTank's own editor) has + // nothing of ours to preserve — record an empty slot so load + // leaves its VtankRequirements-derived state alone. + string expression = rule.VtankRequirements.Count > 0 ? string.Empty : rule.Expression; + payload.Append(expression.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n"); + payload.Append(expression); + } + profile.UnknownBlocks.Add(new VtankLootExtraBlock + { + Type = MossTankRulesBlockType, + Payload = payload.ToString(), + }); + } + + private static void ApplyMossTankExpressions(VtankLootProfile profile) + { + VtankLootExtraBlock? block = profile.UnknownBlocks.FirstOrDefault(candidate => + string.Equals(candidate.Type, MossTankRulesBlockType, StringComparison.Ordinal)); + if (block is null) + return; + profile.UnknownBlocks.Remove(block); + + string payload = block.Payload ?? string.Empty; + int position = 0; + if (!TryReadPayloadLine(payload, ref position, out string countText) + || !int.TryParse(countText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count)) + { + return; // Corrupt/foreign block reusing our type name; ignore rather than throw. + } + int limit = Math.Min(count, profile.Rules.Count); + for (int index = 0; index < limit; index++) + { + if (!TryReadPayloadLine(payload, ref position, out string lengthText) + || !int.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int length) + || length < 0 + || length > payload.Length - position) + { + return; // Truncated/corrupt; stop applying rather than throw. + } + string expression = payload.Substring(position, length); + position += length; + if (expression.Length == 0) + continue; + profile.Rules[index].Expression = expression; + profile.Rules[index].VtankRequirements.Clear(); + } + } + + private static bool TryReadPayloadLine(string text, ref int position, out string line) + { + if (position > text.Length) + { + line = string.Empty; + return false; + } + int start = position; + while (position < text.Length && text[position] is not ('\r' or '\n')) + position++; + line = text[start..position]; + if (position < text.Length && text[position] == '\r') + position++; + if (position < text.Length && text[position] == '\n') + position++; + return true; + } private static string ProfileKey(string value, bool byCharacter) { @@ -290,90 +558,13 @@ internal sealed class MossTankLootProfileStore return $"profiles/loot/{hash}.json"; } - private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName) - ? "_default" - : _characterName; + // ------------------------------------------------------------------ + // Migration-only shapes: the OLD JSON roster and per-profile document, + // kept solely so SweepLegacyRosterIfNeeded can recover them once. + // Nothing else in this file writes either shape again. + // ------------------------------------------------------------------ - private T? Read(string key) where T : class - { - if (!_host.Storage.IsAvailable) - return null; - string? json = null; - try - { - json = _host.Storage.ReadText(key); - return string.IsNullOrWhiteSpace(json) - ? null - : JsonSerializer.Deserialize(json, Options); - } - catch (Exception error) - { - RecoveryNotice = MossTankProfileRecovery.Preserve( - _host, - "loot", - key, - json, - error); - _host.Log.Warn(RecoveryNotice); - return null; - } - } - - private void Write(string key, T document) - { - if (!_host.Storage.IsAvailable) - return; - try - { - _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); - } - catch (Exception error) - { - _host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}"); - } - } - - private void SaveIndex() => Write(IndexKey, _index); - - private void WriteLegacyExport(string name, LootProfileDocument document) - { - if (!_host.Storage.IsAvailable) - return; - try - { - _host.Storage.WriteText( - $"exports/{LegacyFileName(name)}.utl", - VtankLootProfileSerializer.Write(document.ToVtankProfile())); - } - catch (Exception error) - { - _host.Log.Warn( - $"MossTank VTClassic loot export could not be saved: {error.Message}"); - } - } - - private string LegacyProfileName() => _selected.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? string.IsNullOrWhiteSpace(_characterName) - ? ByCharacter - : _characterName - : _selected; - - private static string LegacyFileName(string name) - { - char[] invalid = Path.GetInvalidFileNameChars(); - var result = new StringBuilder(name.Length); - foreach (char value in name.Trim()) - { - result.Append(value is '/' or '\\' || invalid.Contains(value) - ? '_' - : value); - } - return result.Length == 0 ? "Loot" : result.ToString(); - } - - private sealed class IndexDocument + private sealed class LegacyRosterDocument { public int Version { get; set; } = 1; public List Names { get; set; } = []; diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs index b093482d..ec6af748 100644 --- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -241,6 +241,37 @@ internal static class VtankProfileDirectory return entries; } + /// + /// VTank's real loot-profile list (aa(), + /// uTank2/PluginCore.cs:7127-7154) seeds ONLY + /// — retail has no per-character auto loot + /// file and no "mine only" filter for loot at all (docs/research/vtank-kb/01-settings-and-profiles.md + /// section 3: "the loot default has no equivalent auto-name; loot + /// profiles default to none") — then every non--- file across + /// whatever loot-profile extension family that build understands. + /// Round 3 item 9: MossTank keeps its own established + /// convention (already load-bearing for + /// Settings/Nav/Meta) as a SECOND seed entry for internal consistency + /// across all four stores — a recorded adaptation (register), not a + /// retail behavior — while still seeding retail's own + /// first. + /// + public static IReadOnlyList ListLootProfiles(IPluginStorage storage) + { + var entries = new List + { + new(string.Empty, NoneLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(storage, ".utl")) + { + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)) + continue; + entries.Add(new ProfileEntry(fileName, fileName)); + } + return entries; + } + /// /// The per-character spell-tracking cache filename (dm class, /// refs/vtank/decompiled/dm.cs:391): always diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 0d5bddc5..857723fd 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -850,6 +850,84 @@ public sealed class MossTankPanelTests Assert.Equal("Corpse range 2m", second.LootRangeText); } + /// + /// Reproduces MossTankLootProfileStore's pre-cutover hashed JSON key + /// (its own private ProfileKey is not directly callable from a + /// test — the hash and identity string are reproduced verbatim here + /// since the format is the migration contract itself, byte for byte). + /// + private static string LegacyLootProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(identity))); + return $"profiles/loot/{hash}.json"; + } + + /// + /// Round 3 item 9: unlike Meta/Route (whose pre-cutover roster was + /// already abandoned before their own cutovers), loot's + /// "profiles/loot/index.json" was still the LIVE mechanism right up to + /// this exact commit, so this single one-time sweep must convert BOTH + /// this character's own "By char" JSON document AND every other named + /// profile the roster still lists — not just whichever one happened to + /// be selected. + /// + [Fact] + public void LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce() + { + var storage = new MemoryStorage(); + var automation = new FakeAutomation { Name = "Barris" }; + storage.Text[LegacyLootProfileKey("Barris", byCharacter: true)] = """ + { + "Rules": [ + { "Name": "Coins", "Expression": "name ~= coin", "Action": 0, "Priority": 3 } + ] + } + """; + storage.Text["profiles/loot/index.json"] = """ + { + "Version": 1, + "Names": ["Farming"], + "SelectedByCharacter": {} + } + """; + storage.Text[LegacyLootProfileKey("Farming", byCharacter: false)] = """ + { + "Rules": [ + { "Name": "Salvage", "Expression": "name ~= salvage", "Action": 0, "Priority": 1 } + ] + } + """; + + var panel = new MossTankPanel(new FakeHost(automation, storage)); + + string byCharacterFile = VtankProfileDirectory.AutoCharacterFileName( + "Barris", string.Empty, "utl"); + Assert.True(storage.Text.ContainsKey(byCharacterFile)); + Assert.True(storage.Text.ContainsKey("Farming.utl")); + Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Barris", byCharacter: true))); + Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Farming", byCharacter: false))); + Assert.False(storage.Text.ContainsKey("profiles/loot/index.json")); + + // The "By char" profile is already loaded (panel construction binds + // and loads it); the raw .utl round-trips VTClassic-side, but the + // human expression text is only visible through the store's own + // load path (MossTankLootProfileStore.ApplyMossTankExpressions), + // which the panel's rule rows expose. + Assert.Contains("name ~= coin", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal); + + panel.SelectLootProfile("Farming"); + Assert.Contains("name ~= salvage", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal); + + // Idempotent: a fresh panel against the same storage sweeps nothing + // more (there is no roster key left to read) and keeps both values. + var reloaded = new MossTankPanel(new FakeHost( + new FakeAutomation { Name = "Barris" }, storage)); + Assert.Contains("name ~= coin", Assert.Single(reloaded.LootRuleRows), StringComparison.Ordinal); + } + [Fact] public void LootProfilesAreIndependentNamedDocuments() { @@ -870,9 +948,9 @@ public sealed class MossTankPanelTests Assert.Single(panel.LootRuleRows); panel.SelectLootProfile("Currency"); Assert.Equal(2, panel.LootRuleRows.Count); - Assert.Contains( - storage.Text.Keys, - key => key.StartsWith("profiles/loot/", StringComparison.Ordinal)); + // Round 3 item 9: a named loot profile is now a real .utl file, not + // a hashed JSON document under profiles/loot/. + Assert.True(storage.Text.ContainsKey("Currency.utl")); } [Fact] @@ -988,7 +1066,10 @@ public sealed class MossTankPanelTests Assert.Equal("Legacy", panel.LootProfileName); Assert.Single(panel.LootRuleRows); Assert.Contains("KeepUpTo", panel.LootRuleRows[0], StringComparison.Ordinal); - string exported = storage.Text["exports/Legacy.utl"]; + // Round 3 item 9: the imported profile is now written directly as + // the real "Legacy.utl" file — there is no separate exports/ + // mirror any more (WriteLegacyExport is deleted). + string exported = storage.Text["Legacy.utl"]; Assert.True(VtankLootProfileSerializer.TryRead( exported, out VtankLootProfile roundTrip, diff --git a/tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs index 5bb36558..d7161056 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs @@ -176,6 +176,10 @@ public sealed class ProfileGiveControllerTests public IUiRegistry Ui => NoOpUiRegistry.Instance; public IPluginStorage Storage => storage; public IAutomationSurface Automation => automation; + // Round 3 item 9: MossTankLootProfileStore now reads/writes real + // .utl files through VtankProfiles, not the JSON-only Storage — + // same backing store here (the key namespaces never collide). + public IPluginStorage VtankProfiles => storage; } private sealed class MemoryStorage : IPluginStorage