diff --git a/.gitattributes b/.gitattributes index c1965c21..d0d48c53 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,14 @@ -.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file +.github/workflows/*.lock.yml linguist-generated=true merge=ours + +# VTank file-compatibility fixtures (Campaign VT slice 1, Part A): these are +# real VTank .usd/.ast/.utl profiles and real metaf .af/.met/.nav samples +# whose exact CRLF-vs-LF byte layout is asserted by round-trip tests +# (VtankSettingsProfileSerializerTests, MetafSerializerTests). `-text` +# disables git's line-ending normalization entirely so a checkout on any OS +# or core.autocrlf setting reproduces the committed bytes exactly. +tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/**/*.usd -text +tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/**/*.ast -text +tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/**/*.utl -text +tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/**/*.af -text +tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/**/*.met -text +tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/**/*.nav -text \ No newline at end of file diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1670411f..299ffa13 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -162,6 +162,12 @@ namespace nor a machine-specific path. Hosts without durable storage expose The additive `List(prefix)` operation enumerates only keys inside that same authenticated namespace, allowing plugins to discover explicit import/export files without receiving a filesystem path or crossing plugin ownership. +`IPluginHost.VtankProfiles` is a second, unscoped `IPluginStorage` — one +shared external location (a real installed VTank's own profile folder, or a +host-composed portable default) rather than per-plugin data — so a +VTank-compatible plugin (`AcDream.Plugins.MossTank.VtankProfileDirectory`) +can enumerate real `.usd`/`.ast`/`.af` files through the same BCL-only +contract without ever touching `System.IO` or resolving its own path. `IPluginHost.Automation` is the additive gameplay-automation projection. Its character, spell, magic, chat, combat, equipment, item, loot, fellowship, diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 131ee70c..18f8c858 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) — 92 active rows (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,8 @@ 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) | | AD-121 | **Filed 2026-09-05 at #476 F1.** The opt-in retained Vulkan screenshot path requires the actual capture allocation to be host-coherent. CaptureBackbuffer rejects noncoherent memory with NotSupportedException before waiting or reading; it does not implement atom-safe isolated cache invalidation. | src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs (RecordBackbufferCapture, CaptureBackbuffer); VulkanBackbufferCaptureValidityTests | After the copy, COPY/TRANSFER_WRITE to HOST/HOST_READ publishes the exact capture range; the existing DeviceWaitIdle supplies completion. A capture-local coherence check avoids stale mapped bytes without changing shared heap preferences, generic buffer reads or neighboring suballocations. Default capture-off gameplay is unchanged. | A device whose capture allocation is only host-visible cannot take these opt-in screenshots until a separately bounded isolated invalidation path exists. Failure is explicit; no successful PNG is fabricated. This is a diagnostic capability restriction, not a retail equivalence claim. | Device::GetScreenshotSurface00439160 and RenderDeviceD3D::GenerateSurfaceFromFrontBuffer005A0210 capture completed retail front-buffer contents; #476 paired proof is in476-capture-lifetime-diagnosis.md. Vulkan host visibility/completion requirements are in476-fix-round1-host-visibility.md. | @@ -494,10 +496,11 @@ reducing the current active total to **159**. | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 47 active rows (TS-68 retired 2026-08-28 by #360 — complete allegiance/house/MOTD grammar and wire; TS-69 retired 2026-08-28 by #361 — day/log/render now execute locally; TS-85 filed 2026-08-16 at #409 (client-wide retail tooltip system), REWRITTEN same-day at the F3 review round — two unported tooltip sub-mechanisms: (1) the `m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0` truncated-text auto-tooltip (187 of 430 live-DAT-probed tooltip-property-authoring elements have no literal StringInfo text and show nothing; the ORIGINAL filing's "dynamic InqProperty(0x49) override" framing was FALSE — retail's own base `InqProperty` reads the same authored bags this port already does, so most of those 187 show nothing in retail too — see the row's own full text for the correction), and (2) the per-element P0x3D wrap-max-width override (RetailTooltipPresenter always wraps at the display width, the confirmed retail fallback — no probed element authors P0x3D); TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); 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) +## 4. Temporary stopgap (TS) — 47 active rows (TS-86 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 11 — the real .usd MyMonsters table is preserved byte-for-byte but never read/written; CombatSettings.Rules lives only in the JSON side-car, deferred to slice 3; TS-68 retired 2026-08-28 by #360 — complete allegiance/house/MOTD grammar and wire; TS-69 retired 2026-08-28 by #361 — day/log/render now execute locally; TS-85 filed 2026-08-16 at #409 (client-wide retail tooltip system), REWRITTEN same-day at the F3 review round — two unported tooltip sub-mechanisms: (1) the `m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0` truncated-text auto-tooltip (187 of 430 live-DAT-probed tooltip-property-authoring elements have no literal StringInfo text and show nothing; the ORIGINAL filing's "dynamic InqProperty(0x49) override" framing was FALSE — retail's own base `InqProperty` reads the same authored bags this port already does, so most of those 187 show nothing in retail too — see the row's own full text for the correction), and (2) the per-element P0x3D wrap-max-width override (RetailTooltipPresenter always wraps at the display width, the confirmed retail fallback — no probed element authors P0x3D); TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); 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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-86 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 11.** The real `.usd` `MyMonsters` table (VTank's own monster-rule persistence, 21 columns) is preserved byte-for-byte by `VtankSettingsProfileSerializer` but never parsed into `CombatSettings.Rules`/`MonsterRule` on load and never regenerated from them on save — `CombatSettings.Rules` lives only in MossTank's JSON side-car. `MonsterRules.cs`/`MonsterExpression.cs` (the rule-grammar evaluator itself) is a faithful, well-cited port with no material gap (`docs/research/vtank-kb/03-combat.md` section 8 item 6) — only the real-file round trip is missing. | `src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs:24-30` (preserves the table byte-for-byte, does not parse it); `src/AcDream.Plugins.MossTank/MossTankProfileStore.cs` (`SideCarDocument.CombatRules`) | Explicitly scheduled debt: slice 3 ports the `MyMonsters` table into the real `.usd`, replacing the side-car copy. Not implemented this round by design (out of round 3 item 11's scope). | A drop-in `.usd` from real VTank (or hand-edited) has its `MyMonsters` rows completely ignored by acdream, which uses whatever the side-car separately holds instead; a real VTank opening an acdream-saved `.usd` sees stale/absent `MyMonsters` rows regardless of MossTank's own rule editor. | `d1` (`d1.cs`) Monster Rules table owner; `docs/research/vtank-kb/03-combat.md` section 3 + section 8 item 6. | | TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** **CA5-GATE CORRECTIONS (2026-08-24, owner retail-render oracle): (a) `SkillInfoRegion::GetTooltip`'s compose is `formula + " " + description` — the row below has the operand order backwards (" "+formula, "no separator"); the operator+ left operand is the InqSkillFormula output, and retail renders formula-first-line/description-below. (b) The "grow further if vertical scroll overflow" branch this row calls "a structural no-op" is NOT one — it is retail's SECOND sizing pass (`StartTooltip @0x0045DE90`: measure-wrap at max width → clamped root resize → `RecalculateGlyphList` RE-WRAP at the final clamped width → height growth for the extra lines), which is what makes long tooltips multi-line; both now ported in `RetailTooltipPresenter.ApplyTooltipText`/`RetailSkillFormula.BuildTooltip`.** **#430 CORRECTION (2026-08-24): the character-panel port below set the TEXT but omitted the popup LOCATOR on the runtime-built rows, so these tooltips never mounted until the CA5-adjacent fix gave rows the shared 0x10000395/0x21000041 skin (live-DAT probed; UiItemSlot precedent).** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | diff --git a/docs/launch-options.md b/docs/launch-options.md index 7518a463..39484a95 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -238,6 +238,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` | | `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | | `ACDREAM_VULKAN_PROBE_FRAMES` | `=` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` | +| `ACDREAM_VTANK_PROFILE_DIR` | `=` | Overrides the directory `IPluginHost.VtankProfiles` (a VTank-compatible plugin's real `.usd`/`.ast`/`.af` profile storage — see `AcDream.Plugins.MossTank.VtankProfileDirectory`) is rooted at, composed as a `FilePluginStorage`. Set it to a real installed VTank's own profile folder (e.g. `C:\Games\VirindiPlugins\VirindiTank`) for direct interop. | Redirects only that one plugin-storage root; no other startup behavior changes. An unset/empty-string value is treated as "no override" (`NullIfEmpty`); whitespace-only is NOT special-cased (matches every other `NullIfEmpty`-read flag, e.g. `ACDREAM_AC_DIR`/`ACDREAM_UI_PROBE_SCRIPT`) and would be used as a literal (almost certainly invalid) root. **Warning:** MossTank's own `.cdf` writes name its Nav/Meta profiles with the `.af` (metaf text) extension, not real VTank's native binary `.nav`/`.met` — pointed at a real installed VTank folder, that same character's Nav/Meta profiles become unloadable by the real VTank client sharing the directory (Settings `.usd` and Loot `.utl` stay real/binary-compatible; see register row AD-122). | unset → `/vtank` | `RuntimeOptions.VtankProfileDirectoryOverride` → `Program.cs` (composes `AppPluginHost`'s `vtankProfiles` argument). The Headless host has no equivalent override (its path overrides are `HeadlessPathOverrides`, not env vars) and always uses `HeadlessPathSet.VtankProfilesDirectory` (`/vtank`). | | `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs` → `MovementTruthDiagnosticController` | ## Permanent diagnostics diff --git a/docs/research/vtank-kb/03-combat.md b/docs/research/vtank-kb/03-combat.md index a01ac2fb..98c27182 100644 --- a/docs/research/vtank-kb/03-combat.md +++ b/docs/research/vtank-kb/03-combat.md @@ -538,6 +538,23 @@ and `PetDeviceCatalog.cs` — but was not compared step-by-step against `CombatController.cs`. Effect: minor DPS/time loss from unnecessary re-wields when several adjacent monsters need different weapons, lower impact than 1–3 above. +6. **The real `.usd` `MyMonsters` table is preserved but never read or + written — a persistence gap, not a rule-grammar gap.** Round 3 item 11: + `CombatSettings.Rules` (the live `MonsterRule` list `MonsterRules.cs`/ + `MonsterExpression.cs` evaluate against, item 5's own faithful port) + lives ONLY in MossTank's JSON side-car + (`MossTankProfileStore.SideCarDocument.CombatRules`); the real 21-column + `MyMonsters` table `VtankSettingsProfileSerializer` round-trips inside + the `.usd` file (`VtankSettingsProfileSerializer.cs:24-30`: "every table + other than Settings … is preserved byte-for-byte") is never parsed into + `MonsterRule`s on load and never regenerated from them on save. Effect: + a drop-in `.usd` from real VTank (or a hand-edited one) keeps its + `MyMonsters` rows completely inert in acdream — MossTank always uses + whatever the side-car separately holds instead, and a real VTank opening + an acdream-saved `.usd` would see stale/absent `MyMonsters` rows + regardless of what MossTank's own rule editor currently shows. Deferred + to a future slice (slice 3) that ports the table; NOT implemented this + round (see `docs/architecture/retail-divergence-register.md`). `AutoAttackPower.cs` (melee power table) and `MonsterRules.cs` / `MonsterExpression.cs` (rule expression grammar) were both checked in diff --git a/docs/research/vtank-kb/06-navigation-and-nav.md b/docs/research/vtank-kb/06-navigation-and-nav.md index 09500ec4..38a04f13 100644 --- a/docs/research/vtank-kb/06-navigation-and-nav.md +++ b/docs/research/vtank-kb/06-navigation-and-nav.md @@ -514,14 +514,20 @@ tests (`tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs`, **Can a real `.nav` file load today? Yes.** `VtankNavRouteSerializer.TryLoad` implements the exact header/route-type/waypoint-record grammar in §1, -including the correct discard of the placeholder line, the correct -per-type payload for all ten waypoint types, and — notably — correctly -special-cases Portal2/UseNPC to overwrite the (meaningless) outer header -coordinate with the embedded `d`-record's own coordinate (`VtankNavRouteSerializer.cs:151-157`). -It is wired to actual import via `MossTankRouteProfileStore.TryImportLegacy`, -which reads a `.nav` text file from plugin storage by filename. Manual -verification against `bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips -cleanly against the documented grammar. +including the correct discard of the placeholder line, and the correct +per-type payload for all ten waypoint types. Portal2/UseNPC now keep both +coordinate triples on `RouteWaypoint` — the (meaningless, per §1.2) outer +header coordinate in `Position` and the embedded `d`-record's own real +target coordinate in `ReferencePosition` (`VtankNavRouteSerializer.cs:127-145`; +Campaign VT slice-1 Part A fix round — a prior port overwrote `Position` +with the `d`-record instead of keeping both, which made a `.af` round trip +of the same waypoint lossy). `Navigation.TickUse` searches for the live +world object using `ReferencePosition`, matching retail's "real target +coordinate" role for that field. It is wired to actual import via +`MossTankRouteProfileStore.TryImportLegacy`, which reads a `.nav` text file +from plugin storage by filename. Manual verification against +`bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips cleanly against +the documented grammar. Ranked by impact (highest first): @@ -535,6 +541,7 @@ Ranked by impact (highest first): | 6 | **Portal2/UseNPC candidate filter omits the ground truth's `item.c()==0` gate.** | `e9.g()`/`fa.g()` only consider candidates where `item.c() == 0` — i.e. the object is neither contained nor equipped (`fu.c()` returns its Container or Wielder instance id, `fu.cs:66-87`) in addition to name+class+proximity. | `TryFindObject` is opaque from this file (defined elsewhere in the plugin abstraction); could not confirm whether an equivalent filter exists. | **Low** — flagged for follow-up rather than asserted as missing. | | 7 | **Chat-color gate on UseNPC's "got a response" detection is dropped.** | `fa.a(ChatTextInterceptEventArgs)` only accepts color-3 "tells you" or color-0 "gives you" lines (`fa.cs:160-177`). | `HasNpcResponse` (`Navigation.cs:829-848`) matches on text content and sender name only, with no color/channel check, plus an extra `Sender.Equals(npcName)` branch not present in ground truth. | **Low** — small false-positive risk (any channel's text matching the phrase would complete the node), unlikely to matter in practice given the fairly specific phrase match. | | 8 | **Door frame-count debounce vs. time-based retry.** | Portal2's post-use verification waits `PluginCore.@do >= startFrame + 2` — at least two *rendered frames*, not a duration — before checking arrival (`e9.cs:126-139`). | acdream's equivalents are all elapsed-seconds based (`UseRetrySeconds`, etc., `Navigation.cs:181` and throughout). | **Low** — a frame-based debounce doesn't map cleanly onto acdream's tick model in the first place; noted for completeness, not actionable. | +| 9 | **`.af` (metaf) cannot represent a strafe jump's direction at all.** | The binary `.nav` format's Jump record ends with one combined line encoding both charge-ms and a direction digit ∈ {3=Forward,4=StrafeLeft,5=StrafeRight} (§1.2 row 9, `di.cs:116-172`). | metaf's `NJump` class (`metaf_monolithic.py:11708-11821`, both `ImportFromMetAF`/`ExportToMetAF`) has no direction field whatsoever — only x/y/z, heading, holdShift, and delay-ms. `RouteWaypoint.JumpDirection` survives a `.nav`⇄model round trip exactly, but a route saved to `.af` and reloaded always comes back `Forward` regardless of what it held before the save, because the `.af` text itself never carried the value. | **Medium** — a real, unavoidable format limitation (not a porting gap): confirmed by reading metaf's own class end to end, not inferred. `MetafSerializer`'s `.af` writer does not claim otherwise and the reader does not force-assign `Forward` (it leaves the model's own default), but the value is still lost across a save-as-`.af`/reload cycle for StrafeLeft/StrafeRight waypoints. | Correctly and precisely ported (confirmed, not a gap — listed since they were non-obvious and worth recording as verified rather than re-litigated): diff --git a/docs/research/vtank-kb/07-meta-and-expressions.md b/docs/research/vtank-kb/07-meta-and-expressions.md index 81227994..f61b7d31 100644 --- a/docs/research/vtank-kb/07-meta-and-expressions.md +++ b/docs/research/vtank-kb/07-meta-and-expressions.md @@ -727,6 +727,7 @@ line counts). This is an unusually precise reverse-engineering result — the | 3 | **`MonsterNameCountWithinDistance`'s name pattern is case-insensitive in MossTank, case-sensitive in retail** | `d7.c()` compiles with `RegexOptions.Compiled` only (`d7.cs:26`) — case-sensitive | `Meta.cs`'s `MonsterCount` helper (`Meta.cs:536-552`) compiles with `RegexOptions.IgnoreCase \| RegexOptions.CultureInvariant` | **Medium-high.** A pattern authored against retail's case-sensitive matching (e.g. deliberately excluding a differently-cased variant name) will over-match in MossTank. Note `ChatMessage`/`ChatMessageCapture` do **not** have this divergence — MossTank's `ChatMatch` (`Meta.cs:463-513`) is correctly case-sensitive (`RegexOptions.CultureInvariant` only), matching `hl.cs`/`c5.cs`. | | 4 | **`;` sequence-operator value convention is inverted vs. retail (but matches UtilityBelt, and was chosen deliberately)** | Retail VTank: `a;b` evaluates to **`a`** (the left/first operand), discarding `b`'s value, while still executing `b` for side effects (`ExpressionEvaluator.cs:787-790`); `;` is an ordinary operator that can nest anywhere via normal precedence | `ExpressionProgram.Evaluate` (`ExpressionEngine.cs:20-27`) treats top-level `;`-separated statements as a `Node[]` program and returns the value of the **last** statement — this matches UtilityBelt's own audited grammar ("multiple `;`-separated statements, returning the final result", `2026-08-26-mosstank-vtank-utilitybelt-research.md:237`), which the campaign explicitly chose as MossTank's baseline dialect (§3.1 there) | **Medium, and by design, not an oversight.** Retail VTank and UtilityBelt already disagree with each other on `;`'s return value; MossTank correctly implements UtilityBelt's convention. The compat risk is narrower than a plain bug: only a `.met` authored *against retail's own* semantics (chaining `sideeffect[]; realcheck[]` and relying on the *first* value) evaluates to the opposite result once imported. | | 5 | **Watchdog ring pre-fill differs (far sentinel vs. arm-time position)** | All 10 position-history slots start as a sentinel far from any real coordinate (`(1000,1000,1000)`, `h7.cs:45-49`), guaranteeing expiry cannot even be *reachable* until one full `timeSpan` window has elapsed and every slot has been overwritten with a real sample | `SetWatchdog` (`Meta.cs:585-596`) pre-fills all 10 slots with the **current** position at arm time | **Lower-medium.** Both converge on "earliest possible expiry is ~one `TimeSpanSeconds` after arming" in the common case, but they diverge for a watchdog that is armed, then the player leaves and returns to very near the arm point before the window closes — retail's sentinel-seeded ring cannot spuriously read the arm-time position as one of its 10 samples, MossTank's can. | +| 6 | **`MetaRule.Enabled` (a per-rule disable-without-delete toggle) has no metaf-compatible representation at all** | Real VTank/metaf has no such concept — `metaf_monolithic.py` has zero occurrences of "enabled"/"disabled" anywhere; a rule in a `.met`/`.af` is simply present or absent | `MetafSerializer.SaveMeta` (Campaign VT slice-1 item H) **refuses to save** a profile containing any disabled rule by default (throws `InvalidOperationException` naming the count) rather than silently dropping it — the caller must opt in via `SaveMeta(profile, dropDisabledRules: true)` to accept the loss explicitly. Round 2 (Campaign VT slice 1 Part A) made `.af` the SOLE authoritative Meta store — `MossTankMetaProfileStore` has no separate JSON storage or `.af` "convenience mirror" any more, so this refusal now blocks the save of the profile itself: a disabled rule makes that profile file genuinely **unsaveable** until the user re-enables or deletes the rule (the file on disk keeps its last successfully-saved content in the meantime; round 3 item 12 corrected this row, which previously described the pre-cutover "legacy-export mirror" design). | **Low** (by design, not a bug): this is a MossTank-only UI extension with no VTank equivalent to diverge from, and the refusal is loud (a user-visible notice) rather than a silent data loss. | ### 5.3 Confirmed non-gaps (the format/engine is otherwise unusually faithful) diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 06cf8f02..09138531 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -13,7 +13,8 @@ public sealed class AppPluginHost : IPluginHost IAutomationSurface automation, IPluginStorage? storage = null, IPluginCommandRegistry? commands = null, - IPluginLootClassifierRegistry? lootClassifiers = null) + IPluginLootClassifierRegistry? lootClassifiers = null, + IPluginStorage? vtankProfiles = null) { Log = log; State = state; @@ -25,6 +26,7 @@ public sealed class AppPluginHost : IPluginHost Commands = commands ?? NoOpPluginCommandRegistry.Instance; LootClassifiers = lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance; + VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance; } public bool HasUi => true; @@ -37,4 +39,5 @@ public sealed class AppPluginHost : IPluginHost public IPluginStorage Storage { get; } public IPluginCommandRegistry Commands { get; } public IPluginLootClassifierRegistry LootClassifiers { get; } + public IPluginStorage VtankProfiles { get; } } diff --git a/src/AcDream.App/Plugins/FilePluginStorage.cs b/src/AcDream.App/Plugins/FilePluginStorage.cs index 85a0c60a..70081cbc 100644 --- a/src/AcDream.App/Plugins/FilePluginStorage.cs +++ b/src/AcDream.App/Plugins/FilePluginStorage.cs @@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage public IReadOnlyList List(string prefix) { - string directory = Resolve(prefix); + ArgumentNullException.ThrowIfNull(prefix); + // An empty prefix means "the storage root itself" — Resolve() + // rejects an empty/whitespace key (every other caller of it means + // one specific file or sub-directory), so this is handled directly + // rather than relaxing that guard for every other use. + string directory = prefix.Length == 0 ? _root : Resolve(prefix); if (!Directory.Exists(directory)) return Array.Empty(); return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) diff --git a/src/AcDream.App/Plugins/VtankProfilesDefault.cs b/src/AcDream.App/Plugins/VtankProfilesDefault.cs new file mode 100644 index 00000000..63db4bd1 --- /dev/null +++ b/src/AcDream.App/Plugins/VtankProfilesDefault.cs @@ -0,0 +1,16 @@ +namespace AcDream.App.Plugins; + +/// +/// The graphical host's default root for IPluginHost.VtankProfiles +/// when RuntimeOptions.VtankProfileDirectoryOverride +/// (ACDREAM_VTANK_PROFILE_DIR) is unset — extracted out of +/// Program.cs's inline composition into its own pure, injectable-root +/// function so the "built with +/// only, never a hard-coded Windows path" guarantee is a real, failable unit +/// test rather than something only checkable by reading the source. +/// +internal static class VtankProfilesDefault +{ + internal static string Resolve(string dataDirectory) => + Path.Combine(dataDirectory, "vtank"); +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index b736280d..1edd786a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -185,7 +185,10 @@ var host = new AppPluginHost( new FilePluginStorage( Path.Combine(applicationPaths.ConfigDirectory, "plugins")), automation.PluginCommands, - lootClassifiers); + lootClassifiers, + new FilePluginStorage( + runtimeOptions.VtankProfileDirectoryOverride + ?? VtankProfilesDefault.Resolve(applicationPaths.DataDirectory))); GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index ea094108..dc8be991 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -136,6 +136,17 @@ public sealed record RuntimeOptions( /// process configuration directly. public IReadOnlyList PluginTags { get; init; } = []; + /// + /// ACDREAM_VTANK_PROFILE_DIR override for the directory a + /// VTank-compatible plugin's IPluginHost.VtankProfiles storage is + /// rooted at (real .usd/.ast/.af files — e.g. a real + /// installed VTank's own profile folder for direct interop). + /// (the default) means "no opinion": the host + /// composes applicationPaths.DataDirectory/vtank instead. See + /// docs/launch-options.md. + /// + public string? VtankProfileDirectoryOverride { get; init; } + /// /// Build options from the process environment. Used by /// Program.cs at startup. @@ -271,6 +282,7 @@ public sealed record RuntimeOptions( LoginCommandDelayMs: 500) { PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")), + VtankProfileDirectoryOverride = NullIfEmpty(env("ACDREAM_VTANK_PROFILE_DIR")), }; } diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 852bf063..8b75afae 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; public IPluginStorage Storage => _storage; + /// + /// Forwarded, not scoped, unlike : the VTank + /// profile folder is one shared external location (real VTank's own + /// files, or a host-composed portable default), not per-plugin data — + /// scoping it under this plugin's manifest id would defeat the whole + /// point of pointing it at a real installed VTank profile directory. + /// + public IPluginStorage VtankProfiles => _inner.VtankProfiles; public IPluginCommandRegistry Commands => _commands; public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers; diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index c620c52a..3a73be8b 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -61,6 +61,8 @@ internal sealed class HeadlessProcessHost : IDisposable Path.Combine(AppContext.BaseDirectory, "plugins"), paths.PluginsDirectory, ]; + var vtankProfiles = new AcDream.Headless.Plugins.FilePluginStorage( + paths.VtankProfilesDirectory); HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; // FA6: constructed unconditionally — cheap, and every non-gate @@ -110,7 +112,8 @@ internal sealed class HeadlessProcessHost : IDisposable timeProvider, contentLease: contentLease, gateCoordinator: gateCoordinator, - pluginRoots: pluginRoots)); + pluginRoots: pluginRoots, + vtankProfiles: vtankProfiles)); } catch { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 9ff1ed74..0dbde856 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -3,6 +3,7 @@ using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Plugins; using AcDream.Headless.Policies; +using AcDream.Plugin.Abstractions; using AcDream.Content.CharGen; using AcDream.Core.Chat; using AcDream.Core.Net.Messages; @@ -266,7 +267,8 @@ internal sealed class HeadlessSessionHost : IDisposable IHeadlessBotPolicy? policyOverride = null, IRuntimePlacementProjectionSink? placementSinkOverride = null, FellowshipAllegianceGateCoordinator? gateCoordinator = null, - IEnumerable? pluginRoots = null) + IEnumerable? pluginRoots = null, + IPluginStorage? vtankProfiles = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -367,7 +369,8 @@ internal sealed class HeadlessSessionHost : IDisposable descriptor.Id, pluginRoots ?? [], descriptor.Plugins, - pluginCommands); + pluginCommands, + vtankProfiles); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index e8e110e2..1ee907d9 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet( internal string PluginsDirectory => Path.Combine(DataDirectory, "plugins"); + /// + /// Default root for + /// (Campaign VT slice-1 fix round, item F). No override mechanism of its + /// own yet — unlike the graphical host's ACDREAM_VTANK_PROFILE_DIR, + /// which only exists on AcDream.App.RuntimeOptions — because + /// headless path overrides already go through HeadlessPathOverrides + /// (config file / --data-dir), not environment variables. + /// + internal string VtankProfilesDirectory => + Path.Combine(DataDirectory, "vtank"); + internal static HeadlessPathSet Resolve( HeadlessPathOverrides overrides, IHeadlessPlatformEnvironment? platform = null) diff --git a/src/AcDream.Headless/Plugins/FilePluginStorage.cs b/src/AcDream.Headless/Plugins/FilePluginStorage.cs new file mode 100644 index 00000000..34db9b45 --- /dev/null +++ b/src/AcDream.Headless/Plugins/FilePluginStorage.cs @@ -0,0 +1,99 @@ +using System.Text; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Plugins; + +/// +/// Crash-safe filesystem — byte-identical +/// contract to AcDream.App.Plugins.FilePluginStorage. Duplicated +/// rather than shared: AcDream.Headless does not (and per the +/// no-window/graphical layer split should not) reference AcDream.App, +/// and no shared "platform plugins" library exists yet to host one copy of +/// this ~70-line class for both hosts. Promoting it there is a reasonable +/// future cleanup, not required for Campaign VT slice-1 item F. +/// +internal sealed class FilePluginStorage : IPluginStorage +{ + private readonly string _root; + + internal FilePluginStorage(string root) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + _root = Path.GetFullPath(root); + } + + public bool IsAvailable => true; + + public string? ReadText(string key) + { + string path = Resolve(key); + return File.Exists(path) + ? File.ReadAllText(path, Encoding.UTF8) + : null; + } + + public IReadOnlyList List(string prefix) + { + ArgumentNullException.ThrowIfNull(prefix); + // An empty prefix means "the storage root itself" — Resolve() + // rejects an empty/whitespace key (every other caller of it means + // one specific file or sub-directory), so this is handled directly + // rather than relaxing that guard for every other use. + string directory = prefix.Length == 0 ? _root : Resolve(prefix); + if (!Directory.Exists(directory)) + return Array.Empty(); + return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(_root, path) + .Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public void WriteText(string key, string content) + { + ArgumentNullException.ThrowIfNull(content); + string path = Resolve(key); + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string temporary = Path.Combine( + directory, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(temporary, content, new UTF8Encoding(false)); + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public bool Delete(string key) + { + string path = Resolve(key); + if (!File.Exists(path)) + return false; + File.Delete(path); + return true; + } + + private string Resolve(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (Path.IsPathRooted(key)) + throw new ArgumentException("Plugin storage keys must be relative.", nameof(key)); + string path = Path.GetFullPath(Path.Combine(_root, key)); + string relative = Path.GetRelativePath(_root, path); + if (Path.IsPathRooted(relative) + || relative.Equals("..", StringComparison.Ordinal) + || relative.StartsWith( + ".." + Path.DirectorySeparatorChar, + StringComparison.Ordinal)) + { + throw new ArgumentException("Plugin storage key escapes its root.", nameof(key)); + } + return path; + } +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 23909378..807a588a 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -39,17 +39,20 @@ internal sealed class HeadlessPluginHost internal HeadlessPluginHost( GameRuntime runtime, IPluginLogger logger, - IPluginCommandRegistry? commands = null) + IPluginCommandRegistry? commands = null, + IPluginStorage? vtankProfiles = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); Log = logger ?? throw new ArgumentNullException(nameof(logger)); Commands = commands ?? NoOpPluginCommandRegistry.Instance; + VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance; _eventSubscription = runtime.Subscribe(this); } public bool HasUi => false; public IPluginLogger Log { get; } public IPluginCommandRegistry Commands { get; } + public IPluginStorage VtankProfiles { get; } public IGameState State => this; public IEvents Events => this; public ISelectionService Selection => _runtime.ActionOwner.Selection; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index 9110cb07..df9e4be7 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable string sessionId, IEnumerable roots, IReadOnlyList? allowList, - IPluginCommandRegistry? commands = null) + IPluginCommandRegistry? commands = null, + IPluginStorage? vtankProfiles = null) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(diagnostics); @@ -59,7 +60,8 @@ internal sealed class HeadlessPluginSession : IDisposable diagnostics, sessionId, () => runtime.Generation.Value), - commands); + commands, + vtankProfiles); var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status), diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 5b030725..5e93fff8 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -42,4 +42,22 @@ public interface IPluginHost /// host kind. /// IAutomationSurface Automation { get; } + + /// + /// Storage rooted at the VTank profile folder (real .usd/ + /// .ast/.af files, VTank's own naming rules) rather than + /// this plugin's own scoped directory — see + /// AcDream.Plugins.MossTank.VtankProfileDirectory, which + /// enumerates through this property exclusively (no System.IO, + /// no per-OS portable-default fallback of its own) so directory + /// discovery stays entirely host-composed. Defaults to the inert + /// (IsAvailable false) when the + /// host has no opinion. A graphical host may root this at a real + /// installed VTank's own profile directory for direct interop, or at + /// its own portable per-OS default under + /// ApplicationPathSet.DataDirectory; that discovery belongs + /// entirely to the host composing this property, never to the plugin + /// reading it. + /// + IPluginStorage VtankProfiles => NoOpPluginStorage.Instance; } diff --git a/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj b/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj index a9474e63..92abd08a 100644 --- a/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj +++ b/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj @@ -24,5 +24,6 @@ + diff --git a/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs b/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs index a1291ac5..0002d418 100644 --- a/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs +++ b/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs @@ -174,11 +174,11 @@ internal sealed class AttackSpellCatalog { if (shape == AttackSpellShape.Streak) return 1; - if (settings.UseArcs && target.Distance >= settings.ArcRange) + if (ShouldUseArc(settings, target)) return shape == AttackSpellShape.Arc ? 2 : 3; return shape == AttackSpellShape.Direct ? 2 : 3; } - if (settings.UseArcs && target.Distance >= settings.ArcRange) + if (ShouldUseArc(settings, target)) { if (shape == AttackSpellShape.Arc) return 1; @@ -203,6 +203,19 @@ internal sealed class AttackSpellCatalog }; } + /// + /// VTank's real 3-way UseArcs switch (refs/vtank/decompiled/hi.cs:515-538): + /// No never arcs, Yes always arcs, AtRange arcs only + /// once the target is at or beyond . + /// + private static bool ShouldUseArc(CombatSettings settings, PluginCombatTarget target) => + settings.UseArcs switch + { + UseArcsMode.Yes => true, + UseArcsMode.AtRange => target.Distance >= settings.ArcRange, + _ => false, + }; + private static bool MatchesPrimaryShape( AttackSpellShape shape, MonsterRuleActions actions, diff --git a/src/AcDream.Plugins.MossTank/CombatController.cs b/src/AcDream.Plugins.MossTank/CombatController.cs index cf52b5ea..640434bc 100644 --- a/src/AcDream.Plugins.MossTank/CombatController.cs +++ b/src/AcDream.Plugins.MossTank/CombatController.cs @@ -243,11 +243,11 @@ internal sealed class CombatController _untilScan -= Math.Max(0d, elapsedSeconds); if (_untilScan <= 0d) { - float acquisitionRange = navigationEnabled + double acquisitionRange = navigationEnabled ? Math.Max(_settings.MaximumRange, _settings.ApproachDistance) : _settings.MaximumRange; _targets = _host.Automation.Combat.CaptureHostileTargets( - acquisitionRange); + (float)acquisitionRange); foreach (uint ghost in _failures.ObserveTargets( _targets, _now, @@ -588,7 +588,7 @@ internal sealed class CombatController float maximumRange = spell.BaseRangeConstant + (spell.BaseRangeModifier * skill.Current) - - _settings.SpellRangeFudge; + - (float)_settings.SpellRangeFudge; return maximumRange <= 0f || target.ObjectId == 0u || target.Distance <= MathF.Min(75f, maximumRange); @@ -1087,15 +1087,15 @@ internal sealed class CombatController targetObjectId, kind, height, - _settings.CollisionProjectileRadius, - _settings.CollisionStepDistance, + (float)_settings.CollisionProjectileRadius, + (float)_settings.CollisionStepDistance, _settings.MaximumCollisionChecksPerTick) : _host.Automation.Projectiles.EvaluatePath( targetObjectId, kind, height, - _settings.CollisionProjectileRadius, - _settings.CollisionStepDistance, + (float)_settings.CollisionProjectileRadius, + (float)_settings.CollisionStepDistance, _settings.MaximumCollisionChecksPerTick); if (_settings.ShowCollisionDebug && result.DebugSamples.Count > 0) { diff --git a/src/AcDream.Plugins.MossTank/CombatSettings.cs b/src/AcDream.Plugins.MossTank/CombatSettings.cs index e7ca4d09..d0a325fd 100644 --- a/src/AcDream.Plugins.MossTank/CombatSettings.cs +++ b/src/AcDream.Plugins.MossTank/CombatSettings.cs @@ -22,6 +22,23 @@ internal enum DebuffSelectionMethod Skill = 2, } +/// +/// VTank's real 3-way "UseArcs" enum +/// (refs/vtank/decompiled/hi.cs:515-538, switch on +/// f3.f("UseArcs")): No always picks the direct-shape spell, +/// AtRange picks the arc shape only once the target is at or beyond +/// ArcRange, and Yes always picks the arc shape regardless of +/// distance. A prior port collapsed this onto a single bool (effectively +/// only distinguishing "No" from "AtRange"), which cannot represent "Yes" +/// at all — see . +/// +internal enum UseArcsMode +{ + No = 1, + AtRange = 2, + Yes = 3, +} + internal enum PetRangeMode { AttackDistance = 0, @@ -52,25 +69,30 @@ internal sealed class CombatSettings public bool Enabled { get; set; } = true; /// VTank's hunt-cast skill margin. public int HuntSkillExcessOverDifficulty { get; set; } = 25; - public float MaximumRange { get; set; } = 5f; + // Declared tDouble in VTank's own Settings table (VtankOptionCatalog): + // double, not float, so a loaded .usd round-trips this setting exactly + // instead of losing precision below float's ~7-digit guarantee (item I, + // slice-1 fix round). Physics/combat consumers that want a float cast + // at their own use site. + public double MaximumRange { get; set; } = 5d; /// /// Monsters nearer than this are not valid attack targets. VTank applies /// this before priority and angle/range ranking. /// - public float MinimumRange { get; set; } + public double MinimumRange { get; set; } /// /// VTank's Approach Distance. Zero disables monster approach; otherwise /// navigation may close a selected target from this range down to /// . /// - public float ApproachDistance { get; set; } + public double ApproachDistance { get; set; } public bool IdlePeaceMode { get; set; } public bool StopMacroOnDeath { get; set; } = true; public bool JumpOutWandCasting { get; set; } public bool DoJiggle { get; set; } public TargetSelectionMethod SelectionMethod { get; set; } = TargetSelectionMethod.Both; - public float TargetSelectAngleRange { get; set; } = 5f; + public double TargetSelectAngleRange { get; set; } = 5d; public bool TargetLock { get; set; } public PluginAttackHeight AttackHeight { get; set; } = PluginAttackHeight.Medium; @@ -83,16 +105,16 @@ internal sealed class CombatSettings DebuffSelectionMethod.Skill; public double DebuffPrecastSeconds { get; set; } = 5d; public bool SwitchWandsToDebuff { get; set; } - public bool UseArcs { get; set; } = true; - public float SpellRangeFudge { get; set; } = 1f; + public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange; + public double SpellRangeFudge { get; set; } = 1d; public bool UseBreakableTurnTo { get; set; } = true; public bool UseProjectileAwareness { get; set; } = true; - public float CollisionProjectileRadius { get; set; } = 0.4f; - public float CollisionStepDistance { get; set; } = 0.7f; + public double CollisionProjectileRadius { get; set; } = 0.4d; + public double CollisionStepDistance { get; set; } = 0.7d; public bool ShowCollisionDebug { get; set; } public int MaximumCollisionChecksPerTick { get; set; } = 500; - public float ArcRange { get; set; } = 5f; - public float RingDistance { get; set; } = 5f; + public double ArcRange { get; set; } = 5d; + public double RingDistance { get; set; } = 5d; public int MinimumRingTargets { get; set; } = 4; public bool DeleteGhostMonsters { get; set; } = true; public int GhostMonsterSpellAttemptCount { get; set; } = 200; @@ -102,7 +124,7 @@ internal sealed class CombatSettings public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d; public bool SummonPets { get; set; } = true; public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance; - public float PetCustomRange { get; set; } = 5f; + public double PetCustomRange { get; set; } = 5d; public int PetMonsterDensity { get; set; } = 1; public int PetRefillCountIdle { get; set; } = 3; public int PetRefillCountNormal { get; set; } = 1; diff --git a/src/AcDream.Plugins.MossTank/Looting.cs b/src/AcDream.Plugins.MossTank/Looting.cs index 5474be54..36016d04 100644 --- a/src/AcDream.Plugins.MossTank/Looting.cs +++ b/src/AcDream.Plugins.MossTank/Looting.cs @@ -89,8 +89,10 @@ internal sealed class LootSettings public bool CombineSalvage { get; set; } = true; public int ManaStoneLootCount { get; set; } = 4; public int ManaTankMinimumMana { get; set; } = 1000; - public float CorpseApproachRange { get; set; } = 40f; - public float CorpseMinimumApproachRange { get; set; } = 3.36f; + // Declared tDouble in VTank's own Settings table (item I, slice-1 fix + // round) — see CombatSettings.MaximumRange's doc comment for why. + public double CorpseApproachRange { get; set; } = 40d; + public double CorpseMinimumApproachRange { get; set; } = 3.36d; public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d; public double CorpseItemAppearanceTimeoutSeconds { get; set; } = 6d; public double CorpseItemIdentifyTimeoutSeconds { get; set; } = 60d; @@ -524,7 +526,7 @@ internal sealed class LootController _scanRemaining = Math.Clamp(_settings.ScanIntervalSeconds, 0.05d, 5d); IReadOnlyList corpses = loot.CaptureCorpses( - Math.Clamp(_settings.CorpseApproachRange, 2f, 100f)); + (float)Math.Clamp(_settings.CorpseApproachRange, 2d, 100d)); PruneCorpseCache(); foreach (PluginLootContainer seen in corpses) _corpseFirstSeen.TryAdd(seen.ObjectId, _lifetime); diff --git a/src/AcDream.Plugins.MossTank/Meta.cs b/src/AcDream.Plugins.MossTank/Meta.cs index e361d4b6..3251d6db 100644 --- a/src/AcDream.Plugins.MossTank/Meta.cs +++ b/src/AcDream.Plugins.MossTank/Meta.cs @@ -77,6 +77,17 @@ internal sealed class MetaAction public double Number { get; set; } public double SecondaryNumber { get; set; } public List Children { get; set; } = []; + /// + /// The parsed route for a + /// action. Replaces a prior port's binary "uTank2 NAV 1.2" text blob + /// carried in : both the .af importer + /// (MetafSerializer.ResolveEmbeddedNavs) and the .met + /// importer (VtankMetaProfileSerializer.ReadEmbeddedNavigation) + /// now produce this typed model directly, and + /// MetaEngine.LoadEmbeddedNavigationRoute consumes it without a + /// re-parse. Null only for an unresolved/never-defined tag. + /// + public NavigationSettings? EmbeddedRoute { get; set; } } internal sealed class MetaRule @@ -102,7 +113,7 @@ internal sealed class MetaServices static () => double.PositiveInfinity; public Func CountMonstersByPriority { get; init; } = static (_, _) => 0; - public Action LoadEmbeddedNavigationRoute { get; init; } = static _ => { }; + public Action LoadEmbeddedNavigationRoute { get; init; } = static _ => { }; public Func GetOption { get; init; } = static _ => ExpressionValue.Zero; public Func SetOption { get; init; } = @@ -379,7 +390,7 @@ internal sealed class MetaEngine } return true; case MetaActionKind.LoadEmbeddedNavigationRoute: - _services.LoadEmbeddedNavigationRoute(action.Text); + _services.LoadEmbeddedNavigationRoute(action.EmbeddedRoute); return true; case MetaActionKind.CallMetaState: if (_callStack.Count >= MaximumCallDepth) diff --git a/src/AcDream.Plugins.MossTank/MetafSerializer.cs b/src/AcDream.Plugins.MossTank/MetafSerializer.cs new file mode 100644 index 00000000..dfdd49cc --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MetafSerializer.cs @@ -0,0 +1,1332 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Reads and writes the metaf .af human-readable format for VTank Meta +/// profiles and navigation routes — ported line-by-line from +/// C:\Users\erikn\source\repos\metas\metaf_monolithic.py (GPLv3; grammar +/// read and cited by line, never copied verbatim, per the owner's 2026-09-06 +/// "MossTank does not implement .met and does not author .nav" +/// decision — .af is the only storage/authoring format from here on; +/// the binary readers remain as one-shot import only). +/// +/// Grammar citations (line numbers in metaf_monolithic.py): +/// ~~ end-of-line/whole-line comments and the {} string +/// delimiters (py:163-260); the condition/action keyword + argument regex +/// table rx.getParms (py:224-286); STATE:/IF:/DO: +/// line shape and the collapsed IF:/DO: layout (py:12363-12375, +/// Meta.CollapseIfDo py:12735-12768 — this writer emits the collapsed +/// form directly instead of reproducing the two-pass append-then-collapse +/// algorithm); nested All/Any/Not strict tab-depth +/// nesting (CAll/CAny.ImportFromMetAF, py:7098-7154); nav node +/// lines at exactly one tab (py:10728-11820); EmbedNav's separate +/// NAV: body + optional 7-double affine transform (py:9368-9498, +/// ENavXF py:286). +/// +internal static class MetafSerializer +{ + private const string Comment = "~~"; + + // The exact text metaf's own OutputText.metaHeader/navHeader constants + // emit (metaf_monolithic.py:365-434), copied verbatim from a real + // fixture rather than retyped from the Python source (Campaign VT + // slice-1 fix round, item E) -- every line's tab count matters for byte + // identity. Joined with bare "\n" (matching how metaf's own multi-line + // string constant is written as ONE f.line entry containing embedded + // newlines) plus one trailing "\n": the CRLF this writer's own + // string.Join("\r\n", lines) inserts after that whole entry, combined + // with the header's own trailing "\n", produces the single blank line + // real metaf output has between the header and the first STATE:/NAV: + // line -- confirmed byte-for-byte against Fixtures/vtank/af/bella.af + // (meta) and nav_ab.af (nav-only). + private static readonly string[] MetaHeaderLines = + [ + "~~ {", + "~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse", + "", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.\t", + "~~\t\tGet metaf here: https://github.com/JJEII/metaf/\t\t\t\t\t\t\t\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized structural designators:\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tSTATE:\t\t\t\tDO:\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tIF:\t\t\t\t\tNAV:\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized CONDITION (IF:) operation keywords:\t\t\t\t\t\t\t\t\t", + "~~\t\tNever\t\t\t\tNavEmpty\t\t\tMobsInDist_Priority\t\tNot\t\t\t\t", + "~~\t\tAlways\t\t\t\tDeath\t\t\t\tNeedToBuff\t\t\t\tPSecsInStateGE\t", + "~~\t\tAll\t\t\t\t\tVendorOpen\t\t\tNoMobsInDist\t\t\tSecsOnSpellGE\t", + "~~\t\tAny\t\t\t\t\tVendorClosed\t\tBlockE\t\t\t\t\tBuPercentGE\t\t", + "~~\t\tChatMatch\t\t\tItemCountLE\t\t\tCellE\t\t\t\t\tDistToRteGE\t\t", + "~~\t\tMainSlotsLE\t\t\tItemCountGE\t\t\tIntoPortal\t\t\t\tExpr\t\t\t", + "~~\t\tSecsInStateGE\t\tMobsInDist_Name\t\tExitPortal\t\t\t\tChatCapture\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized ACTION (DO:) operation keywords:\t\t\t\t\t\t\t\t\t\t", + "~~\t\tNone\t\t\t\tEmbedNav\t\t\tChatExpr\t\t\t\tSetOpt\t\t\t", + "~~\t\tSetState\t\t\tCallState\t\t\tSetWatchdog\t\t\t\tCreateView\t\t", + "~~\t\tChat\t\t\t\tReturn\t\t\t\tClearWatchdog\t\t\tDestroyView\t\t", + "~~\t\tDoAll\t\t\t\tDoExpr\t\t\t\tGetOpt\t\t\t\t\tDestroyAllViews\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized NAV types:\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tcircular\t\t\tfollow\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tlinear\t\t\t\tonce\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized NAV NODE types:\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tflw\t\t\t\t\tvnd\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tpnt\t\t\t\t\tptl\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\trcl\t\t\t\t\ttlk\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tpau\t\t\t\t\tchk\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tcht\t\t\t\t\tjmp\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tprt (deprecated in VTank)\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ }\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + ]; + private static readonly string MetaHeader = string.Join("\n", MetaHeaderLines) + "\n"; + + private static readonly string[] NavOutputHeaderLines = + [ + "~~ {", + "~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse", + "", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.\t", + "~~\t\tGet metaf here: https://github.com/JJEII/metaf/\t\t\t\t\t\t\t\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized structural designators in a NAV-ONLY file:\t\t\t\t\t\t\t", + "~~\t\tNAV:\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized NAV types:\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tcircular\t\t\tfollow\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tlinear\t\t\t\tonce\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ All recognized NAV NODE types:\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tflw\t\t\t\t\tvnd\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tpnt\t\t\t\t\tptl\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\trcl\t\t\t\t\ttlk\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tpau\t\t\t\t\tchk\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tcht\t\t\t\t\tjmp\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~\t\tprt (deprecated in VTank)\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ \t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "", + "~~ \t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + "~~ \t\t\t\tREMEMBER THAT NAV-ONLY FILES MUST CONTAIN EXACTLY ONE NAV!\t\t\t\t", + "~~ }\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t", + ]; + private static readonly string NavOutputHeader = string.Join("\n", NavOutputHeaderLines) + "\n"; + + // ------------------------------------------------------------------ + // Grammar building blocks (rx._D/_I/_H/_S/_L, metaf_monolithic.py:172-176) + // ------------------------------------------------------------------ + private const string D = @"[+\-]?(([1-9][0-9]*\.|[0-9]?\.)([0-9]+([eE][+\-]?[0-9]+)|[0-9]+)|([1-9][0-9]*|0))"; + private const string I = @"[+\-]?([1-9][0-9]*|0)"; + private const string H = @"[A-F0-9]{8}"; + private const string S = @"[{]([^{}]|\{\{|\})*[}]"; + + private static readonly Regex TrailingComment = new( + @"\s*(~~.*)?$", RegexOptions.Compiled); + private static readonly Regex BlankOrCommentLine = new( + @"^\s*(~~.*)?$", RegexOptions.Compiled); + private static readonly Regex LeadIn = new( + @"^(?[\t]*)(?STATE:|IF:|DO:|NAV:)", RegexOptions.Compiled); + private static readonly Regex AnyConditionOp = new( + @"^(?[\t]*)\s*(?Never|Always|All|Any|ChatMatch|MainSlotsLE|SecsInStateGE|NavEmpty|Death|VendorOpen|VendorClosed|ItemCountLE|ItemCountGE|MobsInDist_Name|MobsInDist_Priority|NeedToBuff|NoMobsInDist|BlockE|CellE|IntoPortal|ExitPortal|Not|PSecsInStateGE|SecsOnSpellGE|BuPercentGE|DistToRteGE|Expr|ChatCapture)", + RegexOptions.Compiled); + private static readonly Regex AnyActionOp = new( + @"^(?[\t]*)\s*(?None|SetState|DoAll|EmbedNav|CallState|Return|DoExpr|ChatExpr|Chat|SetWatchdog|ClearWatchdog|GetOpt|SetOpt|CreateView|DestroyView|DestroyAllViews)", + RegexOptions.Compiled); + private static readonly Regex AnyNavNodeType = new( + @"^\t(?flw|pnt|prt|rcl|pau|cht|vnd|ptl|tlk|chk|jmp)", RegexOptions.Compiled); + private static readonly Regex NavHeader = new( + @"^\s+(?[a-zA-Z_][a-zA-Z0-9_]*)\s+(?circular|linear|once|follow)$", + RegexOptions.Compiled); + private static readonly Regex EmptyArgs = new(@"^$", RegexOptions.Compiled); + private static readonly Regex XfArgs = new( + $@"^\s*(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s*$", + RegexOptions.Compiled); + + // rx.getParms — one entry per condition/action keyword (metaf_monolithic.py:224-260). + private static readonly Dictionary ConditionArgs = new() + { + ["Never"] = EmptyArgs, + ["Always"] = EmptyArgs, + ["All"] = EmptyArgs, + ["Any"] = EmptyArgs, + ["ChatMatch"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["MainSlotsLE"] = new($@"^\s+(?{I})$", RegexOptions.Compiled), + ["SecsInStateGE"] = new($@"^\s+(?{I})$", RegexOptions.Compiled), + ["NavEmpty"] = EmptyArgs, + ["Death"] = EmptyArgs, + ["VendorOpen"] = EmptyArgs, + ["VendorClosed"] = EmptyArgs, + ["ItemCountLE"] = new($@"^\s+(?{I})\s+(?{S})$", RegexOptions.Compiled), + ["ItemCountGE"] = new($@"^\s+(?{I})\s+(?{S})$", RegexOptions.Compiled), + ["MobsInDist_Name"] = new($@"^\s+(?{I})\s+(?{D})\s+(?{S})$", RegexOptions.Compiled), + // Argument order is count, distance, priority — "i" (Number) is the + // threshold monster COUNT compared against, "d" (SecondaryNumber) + // is the DISTANCE band, and the trailing "i2" (TertiaryNumber) is + // the PRIORITY level; see Meta.cs's runtime evaluation: + // `CountMonstersByPriority(priority: TertiaryNumber, + // distance: SecondaryNumber) >= Number`. Not exercised by any + // committed real fixture (only appears in the auto-completion + // header banner text) — see MobsInDist_PriorityRoundTripsAllThreeNumbersDistinctly + // for the synthesized proof. + ["MobsInDist_Priority"] = new($@"^\s+(?{I})\s+(?{D})\s+(?{I})$", RegexOptions.Compiled), + ["NeedToBuff"] = EmptyArgs, + ["NoMobsInDist"] = new($@"^\s+(?{D})$", RegexOptions.Compiled), + ["BlockE"] = new($@"^\s+(?{H})$", RegexOptions.Compiled), + ["CellE"] = new($@"^\s+(?{H})$", RegexOptions.Compiled), + ["IntoPortal"] = EmptyArgs, + ["ExitPortal"] = EmptyArgs, + // "Not" itself takes no same-line args (its operand is a nested condition). + ["Not"] = EmptyArgs, + ["PSecsInStateGE"] = new($@"^\s+(?{I})$", RegexOptions.Compiled), + ["SecsOnSpellGE"] = new($@"^\s+(?{I})\s+(?{I})$", RegexOptions.Compiled), + ["BuPercentGE"] = new($@"^\s+(?{I})$", RegexOptions.Compiled), + ["DistToRteGE"] = new($@"^\s+(?{D})$", RegexOptions.Compiled), + ["Expr"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["ChatCapture"] = new($@"^\s+(?{S})\s+(?{S})$", RegexOptions.Compiled), + }; + + private static readonly Dictionary ActionArgs = new() + { + ["None"] = EmptyArgs, + ["SetState"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["Chat"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["DoAll"] = EmptyArgs, + ["EmbedNav"] = new( + $@"^\s+(?[a-zA-Z_][a-zA-Z0-9_]*)\s+(?{S})(\s+(?{S}))?$", + RegexOptions.Compiled), + ["CallState"] = new($@"^\s+(?{S})\s+(?{S})$", RegexOptions.Compiled), + ["Return"] = EmptyArgs, + ["DoExpr"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["ChatExpr"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["SetWatchdog"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{S})$", RegexOptions.Compiled), + ["ClearWatchdog"] = EmptyArgs, + ["GetOpt"] = new($@"^\s+(?{S})\s+(?{S})$", RegexOptions.Compiled), + ["SetOpt"] = new($@"^\s+(?{S})\s+(?{S})$", RegexOptions.Compiled), + ["CreateView"] = new($@"^\s+(?{S})\s+(?{S})$", RegexOptions.Compiled), + ["DestroyView"] = new($@"^\s+(?{S})$", RegexOptions.Compiled), + ["DestroyAllViews"] = EmptyArgs, + }; + + private static readonly Dictionary NavArgs = new() + { + ["flw"] = new($@"^\s+(?{H})\s+(?{S})$", RegexOptions.Compiled), + ["pnt"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})$", RegexOptions.Compiled), + ["prt"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{H})$", RegexOptions.Compiled), + ["rcl"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{S})$", RegexOptions.Compiled), + ["pau"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})$", RegexOptions.Compiled), + ["cht"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{S})$", RegexOptions.Compiled), + ["vnd"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{H})\s+(?{S})$", RegexOptions.Compiled), + ["ptl"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{I})\s+(?{S})$", RegexOptions.Compiled), + ["tlk"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{I})\s+(?{S})$", RegexOptions.Compiled), + ["chk"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})$", RegexOptions.Compiled), + ["jmp"] = new($@"^\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{D})\s+(?{S})\s+(?{D})$", RegexOptions.Compiled), + }; + + // Rule.ConditionContentTabLevel / ActionContentTabLevel (metaf_monolithic.py:12058-12059). + private const int ConditionDepth = 2; + private const int ActionDepth = 3; + + // ------------------------------------------------------------------ + // Cursor: mirrors metaf's FileLines (f.L = line index, f.C = column). + // ------------------------------------------------------------------ + private sealed class Cursor(string[] lines) + { + public string[] Lines { get; } = lines; + public int L { get; set; } + public int C { get; set; } + + public FormatException Error(string message) => + new($"line {Math.Min(L + 1, Lines.Length)}: {message}"); + + /// Skips forward to the next non-blank, non-comment-only line. + public void SkipBlank() + { + while (L < Lines.Length && BlankOrCommentLine.IsMatch(Lines[L])) + L++; + } + + public string RemainderOfCurrentLine() => TrailingComment.Replace( + Lines[L][Math.Min(C, Lines[L].Length)..], string.Empty); + } + + // ================================================================== + // Meta (STATE:/IF:/DO:/NAV: — a full profile, embedded navs included). + // ================================================================== + + public static bool TryLoadMeta( + string text, + ISpellCatalog spells, + out MetaProfile profile, + out string error) + { + ArgumentNullException.ThrowIfNull(spells); + try + { + var cursor = new Cursor(SplitLines(text)); + var embeddedNavs = new Dictionary Nodes, + uint FollowTargetId, string FollowTargetName)>(StringComparer.Ordinal); + var parsed = new MetaProfile(); + cursor.SkipBlank(); + while (cursor.L < cursor.Lines.Length) + { + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (!lead.Success) + throw cursor.Error("expected 'STATE:' or 'NAV:'."); + if (lead.Groups["type"].Value == "NAV:") + { + ReadNavBlock(cursor, embeddedNavs, spells); + continue; + } + if (lead.Groups["type"].Value != "STATE:") + throw cursor.Error("expected 'STATE:' or 'NAV:'."); + ReadState(cursor, parsed); + } + // Second pass: EmbedNav actions reference navs that may be defined + // anywhere in the file (metaf allows forward references — a NAV: + // block can appear before or after the STATE that embeds it). + ResolveEmbeddedNavs(parsed, embeddedNavs); + profile = parsed; + error = string.Empty; + return true; + } + catch (Exception exception) when (exception is FormatException or RegexMatchTimeoutException) + { + profile = new MetaProfile(); + error = exception.Message; + return false; + } + } + + /// + /// Saves as .af. Real VTank/metaf has + /// no concept of a "disabled" rule at all — Rule/State + /// carry no such flag (confirmed: metaf_monolithic.py has zero + /// occurrences of "enabled"/"disabled" anywhere), so + /// is a MossTank-only extension with no + /// metaf-compatible marker to preserve it through. Rather than silently + /// dropping a disabled rule (irrecoverable once re-imported — see + /// docs/research/vtank-kb/07-meta-and-expressions.md section 5), + /// this overload throws when the profile has any: callers that + /// understand and accept the loss (e.g. a legacy-export convenience + /// copy sitting alongside MossTank's own fully-fidelity JSON storage) + /// must say so explicitly via + /// . + /// + public static string SaveMeta(MetaProfile profile) => + SaveMeta(profile, dropDisabledRules: false); + + /// See 's class doc for why exists. + public static string SaveMeta(MetaProfile profile, bool dropDisabledRules) + { + ArgumentNullException.ThrowIfNull(profile); + if (!dropDisabledRules) + { + int disabledCount = profile.Rules.Count(static rule => !rule.Enabled); + if (disabledCount > 0) + { + throw new InvalidOperationException( + $"SaveMeta: {disabledCount} disabled rule(s) have no metaf-compatible " + + "representation and would be silently dropped. Call " + + "SaveMeta(profile, dropDisabledRules: true) to accept that loss " + + "explicitly."); + } + } + var lines = new List { MetaHeader }; + // EmbedNav's referenced Nav is a separate NAV: block elsewhere in + // the file (metaf's own layout — see the class doc comment); the + // tag itself carries no runtime meaning, but matching metaf's own + // Meta.GenerateUniqueNavTag scheme (py:12515-12522 — "nav{counter}" + // plus a sanitized "__name" suffix, counter starting at 0) keeps + // the writer's byte-for-byte fidelity against metaf's own emission. + var embedTags = new Dictionary(ReferenceEqualityComparer.Instance); + // A List, not just the Dictionary above (item J, slice-1 fix + // round): Dictionary enumeration order happens to + // match insertion order in the current runtime absent removals, + // but that is an implementation detail, never a documented BCL + // contract — the NAV: block write order below must not depend on + // it. embedTags itself stays a Dictionary purely for WriteAction's + // O(1) action->tag lookup. + var embedOrder = new List<(MetaAction Action, string Tag)>(); + int embedCounter = 0; + foreach (MetaRule rule in profile.Rules.Where(static rule => rule.Enabled)) + AssignEmbedTags(rule.Action, embedTags, embedOrder, ref embedCounter); + + foreach (IGrouping stateGroup in profile.Rules + .Where(static rule => rule.Enabled) + .GroupBy(static rule => rule.State, StringComparer.Ordinal)) + { + // State.ExportToMetAF (metaf_monolithic.py:12463-12467) wraps + // every state in an editor-fold comment pair; this is metaf's + // own mechanical emission, not the VirindiTank editor's, so it + // is now reproduced for byte identity rather than stripped by + // the test. + lines.Add($"STATE: {{{stateGroup.Key}}} ~~ {{"); + foreach (MetaRule rule in stateGroup) + { + WriteCondition(lines, rule.Condition, "IF:", 1, ConditionDepth); + WriteAction(lines, rule.Action, "DO:", 2, ActionDepth, embedTags); + } + lines.Add("~~ }"); + } + if (embedOrder.Count > 0) + { + // Meta.ExportToMetAF (metaf_monolithic.py:12784-12790): a blank + // line, this exact separator (no space either side of the "~~" + // delimiters), then another blank line, before any NAV: block. + lines.Add(string.Empty); + lines.Add("~~========================= ONLY NAVS APPEAR BELOW THIS LINE =========================~~"); + lines.Add(string.Empty); + } + foreach ((MetaAction action, string tag) in embedOrder) + { + if (action.EmbeddedRoute is { } nav) + WriteNavBlock(lines, tag, nav); + } + return string.Join("\r\n", lines) + "\r\n"; + } + + private static readonly Regex NonTagCharacter = new(@"[^a-zA-Z0-9_]", RegexOptions.Compiled); + + private static void AssignEmbedTags( + MetaAction action, + Dictionary tags, + List<(MetaAction Action, string Tag)> order, + ref int counter) + { + if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute) + { + string sanitized = NonTagCharacter.Replace(action.SecondaryText ?? string.Empty, "_"); + string postfix = sanitized.Length > 0 ? $"__{sanitized}" : string.Empty; + string tag = $"nav{counter}{postfix}"; + tags[action] = tag; + order.Add((action, tag)); + counter++; + } + foreach (MetaAction child in action.Children) + AssignEmbedTags(child, tags, order, ref counter); + } + + /// Internal (not private) so other one-shot import readers can share this no-op. + internal sealed class NoOpSpells : ISpellCatalog + { + public static NoOpSpells Instance { get; } = new(); + public IReadOnlyList KnownSelfBuffs => []; + public bool TryGet(uint spellId, out PluginSpellInfo info) + { + info = default; + return false; + } + } + + // ------------------------------------------------------------------ + // STATE / Rule (IF:/DO:) reading — State.ImportFromMetAF, Rule.ImportFromMetAF. + // ------------------------------------------------------------------ + + private static void ReadState(Cursor cursor, MetaProfile profile) + { + Match header = LeadIn.Match(cursor.Lines[cursor.L]); + string headerRemainder = TrailingComment.Replace( + cursor.Lines[cursor.L][(header.Groups["tabs"].Length + "STATE:".Length)..], + string.Empty); + Match name = Regex.Match(headerRemainder, $@"^\s+(?{S})$"); + if (!name.Success) + throw cursor.Error("'STATE:' must be followed by a delimited string name."); + string stateName = StripDelimiters(name.Groups["s"].Value); + cursor.L++; + + int rulesRead = 0; + cursor.SkipBlank(); + while (cursor.L < cursor.Lines.Length) + { + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (!lead.Success) + throw cursor.Error("expected 'STATE:', 'IF:', or 'NAV:'."); + if (lead.Groups["type"].Value is "STATE:" or "NAV:") + break; + if (lead.Groups["type"].Value != "IF:") + throw cursor.Error("expected an 'IF:' line to start a new Rule."); + + cursor.C = 0; + MetaCondition condition = ReadTopLevelCondition(cursor); + MetaAction action = ReadTopLevelAction(cursor); + profile.Rules.Add(new MetaRule + { + State = stateName, + Condition = condition, + Action = action, + Enabled = true, + }); + rulesRead++; + cursor.SkipBlank(); + } + if (rulesRead == 0) + throw cursor.Error($"state '{stateName}' has no rules (every state needs at least one)."); + } + + private static MetaCondition ReadTopLevelCondition(Cursor cursor) + { + cursor.SkipBlank(); + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (!lead.Success || lead.Groups["type"].Value != "IF:") + throw cursor.Error("expected an 'IF:' line."); + cursor.C = lead.Length; + Match op = AnyConditionOp.Match(cursor.Lines[cursor.L][cursor.C..]); + if (!op.Success) + throw cursor.Error("expected a Condition operation after 'IF:'."); + cursor.C += op.Groups["op"].Index + op.Groups["op"].Length; + return ReadCondition(cursor, op.Groups["op"].Value, ConditionDepth); + } + + private static MetaAction ReadTopLevelAction(Cursor cursor) + { + cursor.SkipBlank(); + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (!lead.Success || lead.Groups["type"].Value != "DO:") + throw cursor.Error("expected a 'DO:' line."); + cursor.C = lead.Length; + Match op = AnyActionOp.Match(cursor.Lines[cursor.L][cursor.C..]); + if (!op.Success) + throw cursor.Error("expected an Action operation after 'DO:'."); + cursor.C += op.Groups["op"].Index + op.Groups["op"].Length; + return ReadAction(cursor, op.Groups["op"].Value, ActionDepth); + } + + // ------------------------------------------------------------------ + // Conditions (28 keywords). Same-line args are matched from cursor.C to + // end of the current line; children of All/Any/Not are read from + // subsequent lines at exactly depth+1 tabs (CAll/CAny.ImportFromMetAF). + // ------------------------------------------------------------------ + + private static MetaCondition ReadCondition(Cursor cursor, string keyword, int depth) + { + // CNot.ImportFromMetAF (py:7811-7826): unlike every other operator, + // Not's operand follows immediately on the SAME line/column (not a + // depth+1 line) and does NOT increase depth for its own child. + if (keyword == "Not") + { + var not = new MetaCondition { Kind = MetaConditionKind.Not }; + string notRemainder = cursor.Lines[cursor.L][cursor.C..]; + Match nested = AnyConditionOp.Match(notRemainder); + if (!nested.Success) + throw cursor.Error("'Not' must be followed by a Condition operation on the same line."); + cursor.C += nested.Groups["op"].Index + nested.Groups["op"].Length; + not.Children.Add(ReadCondition(cursor, nested.Groups["op"].Value, depth)); + return not; + } + + var value = new MetaCondition { Kind = ConditionKind(keyword) }; + string remainder = cursor.RemainderOfCurrentLine(); + cursor.L++; + Regex argRegex = ConditionArgs[keyword]; + Match args = argRegex.Match(remainder); + if (!args.Success) + throw cursor.Error($"'{keyword}' arguments do not match the expected shape."); + + switch (keyword) + { + case "ChatMatch": + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "MainSlotsLE" or "SecsInStateGE" or "PSecsInStateGE" or "BuPercentGE": + value.Number = ParseInt(args.Groups["i"].Value); + break; + case "ItemCountLE" or "ItemCountGE": + value.Number = ParseInt(args.Groups["i"].Value); + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "MobsInDist_Name": + value.Number = ParseInt(args.Groups["i"].Value); + value.SecondaryNumber = ParseDouble(args.Groups["d"].Value); + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "MobsInDist_Priority": + value.Number = ParseInt(args.Groups["i"].Value); + value.SecondaryNumber = ParseDouble(args.Groups["d"].Value); + value.TertiaryNumber = ParseInt(args.Groups["i2"].Value); + break; + case "NoMobsInDist" or "DistToRteGE": + value.Number = ParseDouble(args.Groups["d"].Value); + break; + case "BlockE" or "CellE": + value.Number = ParseHexAsInt(args.Groups["h"].Value); + break; + case "SecsOnSpellGE": + // getInfo: "time (seconds), SpellID" — Number carries the + // spell id (SpellTimeLeft's lookup key) and SecondaryNumber + // the seconds threshold, matching MetaEngine.SpellTimeLeft's + // existing >= comparison against SecondaryNumber. + value.SecondaryNumber = ParseInt(args.Groups["i"].Value); + value.Number = ParseInt(args.Groups["i2"].Value); + break; + case "Expr": + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "ChatCapture": + value.Text = StripDelimiters(args.Groups["s"].Value); + value.SecondaryText = StripDelimiters(args.Groups["s2"].Value); + break; + case "All" or "Any": + ReadNestedConditions(cursor, value, depth); + break; + } + return value; + } + + private static void ReadNestedConditions(Cursor cursor, MetaCondition parent, int depth) + { + while (true) + { + cursor.SkipBlank(); + if (cursor.L >= cursor.Lines.Length) + throw cursor.Error("hit end-of-file inside an All/Any condition."); + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (lead.Success) + { + if (lead.Groups["type"].Value == "DO:") + return; + throw cursor.Error("missing 'DO:' to close this Rule."); + } + Match op = AnyConditionOp.Match(cursor.Lines[cursor.L]); + if (!op.Success) + throw cursor.Error("expected a Condition operation or 'DO:'."); + int tabs = op.Groups["tabs"].Length; + if (tabs <= ConditionDepth) + throw cursor.Error("not tabbed in enough to be inside an All/Any condition."); + if (tabs <= depth) + return; // this line belongs to an ancestor All/Any, not us. + if (tabs > depth + 1) + throw cursor.Error("tabbed in too far inside an All/Any condition."); + cursor.C = op.Groups["op"].Index + op.Groups["op"].Length; + parent.Children.Add(ReadCondition(cursor, op.Groups["op"].Value, depth + 1)); + } + } + + // ------------------------------------------------------------------ + // Actions (16 keywords). Same shape/traversal rules as conditions. + // ------------------------------------------------------------------ + + private static MetaAction ReadAction(Cursor cursor, string keyword, int depth) + { + var value = new MetaAction { Kind = ActionKind(keyword) }; + string remainder = cursor.RemainderOfCurrentLine(); + cursor.L++; + Regex argRegex = ActionArgs[keyword]; + Match args = argRegex.Match(remainder); + if (!args.Success) + throw cursor.Error($"'{keyword}' arguments do not match the expected shape."); + + switch (keyword) + { + case "SetState" or "Chat" or "DoExpr" or "ChatExpr": + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "CallState": + value.Text = StripDelimiters(args.Groups["s"].Value); + value.SecondaryText = StripDelimiters(args.Groups["s2"].Value); + break; + case "SetWatchdog": + value.Number = ParseDouble(args.Groups["d"].Value); + value.SecondaryNumber = ParseDouble(args.Groups["d2"].Value); + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "GetOpt" or "SetOpt" or "CreateView": + value.Text = StripDelimiters(args.Groups["s"].Value); + value.SecondaryText = StripDelimiters(args.Groups["s2"].Value); + break; + case "DestroyView": + value.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "EmbedNav": + // Placeholder; ReadTopLevelAction/ReadAction's caller resolves + // this once every NAV: block in the file has been read (a + // forward reference is legal — see ResolveEmbeddedNavs). + value.SecondaryText = args.Groups["l"].Value; // tag + value.Text = StripDelimiters(args.Groups["s"].Value); // display name (temp) + break; + case "DoAll": + ReadNestedActions(cursor, value, depth); + break; + } + return value; + } + + private static void ReadNestedActions(Cursor cursor, MetaAction parent, int depth) + { + while (true) + { + cursor.SkipBlank(); + if (cursor.L >= cursor.Lines.Length) + return; // DoAll may legally be the last thing in the file. + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (lead.Success) + return; // STATE:/IF:/DO:/NAV: all end this DoAll. + Match op = AnyActionOp.Match(cursor.Lines[cursor.L]); + if (!op.Success) + throw cursor.Error("expected an Action operation."); + int tabs = op.Groups["tabs"].Length; + if (tabs <= ActionDepth) + throw cursor.Error("not tabbed in enough to be inside a DoAll action."); + if (tabs <= depth) + return; + if (tabs > depth + 1) + throw cursor.Error("tabbed in too far inside a DoAll action."); + cursor.C = op.Groups["op"].Index + op.Groups["op"].Length; + parent.Children.Add(ReadAction(cursor, op.Groups["op"].Value, depth + 1)); + } + } + + /// + /// EmbedNav stores the referenced Nav's tag in + /// (see ) until every NAV: block has been parsed; + /// this walks the rule tree once more and replaces it with the real + /// display name plus the typed + /// (an unresolved tag gets an empty + /// rather than null, matching the prior blob path's "0 waypoints" + /// fallback). + /// + private static void ResolveEmbeddedNavs( + MetaProfile profile, + IReadOnlyDictionary Nodes, uint FollowTargetId, string FollowTargetName)> navs) + { + foreach (MetaRule rule in profile.Rules) + ResolveEmbeddedNavs(rule.Action, navs); + } + + private static void ResolveEmbeddedNavs( + MetaAction action, + IReadOnlyDictionary Nodes, uint FollowTargetId, string FollowTargetName)> navs) + { + if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute) + { + string tag = action.SecondaryText; + string displayName = action.Text; + action.SecondaryText = displayName; + action.Text = string.Empty; + action.EmbeddedRoute = navs.TryGetValue(tag, out var nav) + ? BuildNavSettings(nav.NavType, nav.Nodes, nav.FollowTargetId, nav.FollowTargetName) + : new NavigationSettings(); + } + foreach (MetaAction child in action.Children) + ResolveEmbeddedNavs(child, navs); + } + + private static NavigationSettings BuildNavSettings( + string navType, + List nodes, + uint followTargetId, + string followTargetName) + { + var settings = new NavigationSettings(); + ApplyNavBody(settings, navType, nodes, followTargetId, followTargetName); + return settings; + } + + // ================================================================== + // NAV: blocks (both inside a full .af meta and in a nav-only file). + // ================================================================== + + public static bool TryLoadNav( + string text, + NavigationSettings target, + ISpellCatalog spells, + out string error) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(spells); + try + { + var cursor = new Cursor(SplitLines(text)); + var navs = new Dictionary Nodes, + uint FollowTargetId, string FollowTargetName)>(StringComparer.Ordinal); + cursor.SkipBlank(); + while (cursor.L < cursor.Lines.Length) + { + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (!lead.Success) + throw cursor.Error("expected 'NAV:' (or a STATE: rule section, ignored for a route load)."); + if (lead.Groups["type"].Value == "NAV:") + { + ReadNavBlock(cursor, navs, spells); + continue; + } + // A nav route load tolerates (and ignores) STATE:/IF:/DO: + // sections, e.g. loading a route out of a full meta .af. + SkipState(cursor); + } + if (navs.Count == 0) + throw cursor.Error("no NAV: block found."); + var (navType, nodes, followId, followName) = navs.First().Value; + ApplyNavBody(target, navType, nodes, followId, followName); + error = string.Empty; + return true; + } + catch (Exception exception) when (exception is FormatException or RegexMatchTimeoutException) + { + error = exception.Message; + return false; + } + } + + public static string SaveNav(NavigationSettings source) + { + ArgumentNullException.ThrowIfNull(source); + // "nav0" matches metaf's own tag for the sole Nav in a stand-alone + // nav-only conversion (Meta.GenerateUniqueNavTag starts its counter + // at 0; every committed nav_*.af fixture's tag is literally "nav0"). + var lines = new List { NavOutputHeader }; + WriteNavBlock(lines, "nav0", source); + return string.Join("\r\n", lines) + "\r\n"; + } + + private static void SkipState(Cursor cursor) + { + Match header = LeadIn.Match(cursor.Lines[cursor.L]); + _ = header; + cursor.L++; + cursor.SkipBlank(); + while (cursor.L < cursor.Lines.Length) + { + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (!lead.Success) + throw cursor.Error("expected 'STATE:', 'IF:', or 'NAV:'."); + if (lead.Groups["type"].Value is "STATE:" or "NAV:") + return; + cursor.C = 0; + _ = ReadTopLevelCondition(cursor); + _ = ReadTopLevelAction(cursor); + cursor.SkipBlank(); + } + } + + private static void ReadNavBlock( + Cursor cursor, + Dictionary Nodes, uint FollowTargetId, string FollowTargetName)> navs, + ISpellCatalog spells) + { + Match header = LeadIn.Match(cursor.Lines[cursor.L]); + string headerRemainder = TrailingComment.Replace( + cursor.Lines[cursor.L][(header.Groups["tabs"].Length + "NAV:".Length)..], + string.Empty); + Match nav = NavHeader.Match(headerRemainder); + if (!nav.Success) + { + throw cursor.Error( + "'NAV:' must be followed by a tag literal and a nav type (circular/linear/once/follow)."); + } + string tag = nav.Groups["l"].Value; + string navType = nav.Groups["l2"].Value; + cursor.L++; + cursor.SkipBlank(); + + var nodes = new List(); + uint followTargetId = 0u; + string followTargetName = string.Empty; + while (true) + { + cursor.SkipBlank(); + if (cursor.L >= cursor.Lines.Length) + break; + Match lead = LeadIn.Match(cursor.Lines[cursor.L]); + if (lead.Success) + break; + Match nodeOp = AnyNavNodeType.Match(cursor.Lines[cursor.L]); + if (!nodeOp.Success) + throw cursor.Error("expected a nav node line (one tab, then a node keyword)."); + string keyword = nodeOp.Groups["type"].Value; + cursor.C = nodeOp.Length; + string remainder = cursor.RemainderOfCurrentLine(); + cursor.L++; + Match args = NavArgs[keyword].Match(remainder); + if (!args.Success) + throw cursor.Error($"'{keyword}' arguments do not match the expected shape."); + + if (keyword == "flw") + { + followTargetId = ParseHexAsUInt(args.Groups["h"].Value); + followTargetName = StripDelimiters(args.Groups["s"].Value); + continue; + } + nodes.Add(ReadNavNode(keyword, args, spells)); + } + navs[tag] = (navType, nodes, followTargetId, followTargetName); + } + + private static RouteWaypoint ReadNavNode(string keyword, Match args, ISpellCatalog spells) + { + double x = args.Groups["d"].Success ? ParseDouble(args.Groups["d"].Value) : 0d; + double y = args.Groups["d2"].Success ? ParseDouble(args.Groups["d2"].Value) : 0d; + double z = args.Groups["d3"].Success ? ParseDouble(args.Groups["d3"].Value) : 0d; + var waypoint = new RouteWaypoint + { + Type = NodeWaypointType(keyword), + Position = new PluginNavigationPosition(0u, x, y, z, 0f, IsOutdoor: true), + }; + switch (keyword) + { + case "prt": + waypoint.ObjectId = ParseHexAsUInt(args.Groups["h"].Value); + break; + case "rcl": + waypoint.RecallSpellName = StripDelimiters(args.Groups["s"].Value); + waypoint.RecallSpellId = ResolveSpellIdByName(spells, waypoint.RecallSpellName); + break; + case "pau": + // NPause.ImportFromMetAF's own comment (py:11163) says the + // field is "PauseInMilliseconds" despite getInfo's stale + // "(in seconds)" label (py: getInfo["pau"]) — confirmed + // against real fixture data (nav_ab.af pau values match the + // binary .nav's millisecond field 1:1, unscaled). + waypoint.DurationMilliseconds = checked((int)Math.Round( + ParseDouble(args.Groups["d4"].Value), + MidpointRounding.AwayFromZero)); + break; + case "cht": + waypoint.Text = StripDelimiters(args.Groups["s"].Value); + break; + case "vnd": + waypoint.ObjectId = ParseHexAsUInt(args.Groups["h"].Value); + waypoint.ObjectName = StripDelimiters(args.Groups["s"].Value); + break; + case "ptl" or "tlk": + // metaf's own grammar carries TWO coordinate triples + // (metaf_monolithic.py:356-357,11482,11618 — + // "FORMAT: ptl/tlk myx myy myz tgtx tgty tgtz tgtObjectClass + // tgtName"): d/d2/d3 (already folded into the shared + // waypoint.Position above) is "myxyz" — where the character + // stood when the waypoint was authored; d4/d5/d6 is + // "tgtxyz" — the portal/NPC object's own recorded position, + // kept separately so a reload can still disambiguate which + // object at that name to interact with. + waypoint.ObjectName = StripDelimiters(args.Groups["s"].Value); + waypoint.LegacyObjectClass = ParseInt(args.Groups["i"].Value); + waypoint.LegacyReferenceValid = true; + waypoint.ReferencePosition = new PluginNavigationPosition( + 0u, + ParseDouble(args.Groups["d4"].Value), + ParseDouble(args.Groups["d5"].Value), + ParseDouble(args.Groups["d6"].Value), + 0f, + IsOutdoor: true); + break; + case "jmp": + waypoint.JumpHeadingDegrees = checked((float)ParseDouble(args.Groups["d4"].Value)); + waypoint.JumpRun = StripDelimiters(args.Groups["s"].Value) == "True"; + // Round 3 item 5: retail/VTank's own jump state machine + // clamps the charge duration to 2000 ms at EXECUTION time + // (bi.a, bi.cs:502-505 — the clamp is inside the "start + // charging" call, not the file format), and metaf's own + // NJump carries no such ceiling either + // (metaf_monolithic.py:11708-11820). A ROUND TRIP through + // .af must therefore preserve the authored value verbatim — + // clamping here (a prior slice-1 misreading of the retail + // ceiling as a LOAD-time clamp) silently rewrote an + // authored 5000 ms waypoint to 2000 ms on every load, even + // when the file is never executed. NavigationController.TickJump + // (Navigation.cs) applies the real bi.a ceiling at the one + // place retail actually enforces it: the moment the jump + // starts charging. metaf's NJump class carries no strafe- + // direction field at all (metaf_monolithic.py:11708-11821, + // confirmed by reading ImportFromMetAF/ExportToMetAF end to + // end) — the .af format cannot represent JumpDirection, + // full stop. JumpDirection is left at the model's own + // default (Forward) rather than assigned here, so this load + // path never claims to have decoded a value it did not + // read; see docs/research/vtank-kb/06-navigation-and-nav.md + // section 6 for the recorded representational loss. + waypoint.JumpChargeMilliseconds = checked((int)Math.Round( + ParseDouble(args.Groups["d5"].Value), + MidpointRounding.AwayFromZero)); + break; + } + return waypoint; + } + + private static void ApplyNavBody( + NavigationSettings target, + string navType, + List nodes, + uint followTargetId, + string followTargetName) + { + target.Mode = navType switch + { + "circular" => RouteMode.Circular, + "linear" => RouteMode.Linear, + "follow" => RouteMode.Target, + "once" => RouteMode.Once, + _ => throw new FormatException($"unknown nav type '{navType}'."), + }; + target.Waypoints.Clear(); + if (target.Mode == RouteMode.Target) + { + target.FollowTargetObjectId = followTargetId; + target.FollowTargetName = followTargetName; + return; + } + target.Waypoints.AddRange(nodes); + } + + // ------------------------------------------------------------------ + // Writing. + // ------------------------------------------------------------------ + + private static void WriteCondition( + List lines, + MetaCondition value, + string label, + int labelTabs, + int depth) + { + string tabs = new('\t', labelTabs); + lines.Add($"{tabs}{label}\t{RenderConditionHead(value)}"); + if (value.Kind is MetaConditionKind.All or MetaConditionKind.Any) + { + foreach (MetaCondition child in value.Children) + WriteConditionNested(lines, child, depth + 1); + } + } + + private static void WriteConditionNested(List lines, MetaCondition value, int depth) + { + lines.Add(new string('\t', depth) + RenderConditionHead(value)); + if (value.Kind is MetaConditionKind.All or MetaConditionKind.Any) + { + foreach (MetaCondition child in value.Children) + WriteConditionNested(lines, child, depth + 1); + } + } + + /// + /// The condition's own same-line text (keyword + inline args). "Not" + /// recurses inline (its operand shares Not's line — CNot.ExportToMetAF, + /// py:7827-7832); "All"/"Any" only emit their bare keyword here — their + /// children are separate subsequent lines, written by the caller. + /// + private static string RenderConditionHead(MetaCondition value) + { + string keyword = ConditionKeyword(value.Kind); + if (keyword == "Not") + { + return "Not " + RenderConditionHead( + value.Children.FirstOrDefault() ?? MetaCondition.Always()); + } + return keyword switch + { + "ChatMatch" or "Expr" => $"{keyword} {{{value.Text}}}", + "MainSlotsLE" or "SecsInStateGE" or "PSecsInStateGE" or "BuPercentGE" => + $"{keyword} {FormatNumber(value.Number)}", + "ItemCountLE" or "ItemCountGE" => + $"{keyword} {FormatNumber(value.Number)} {{{value.Text}}}", + "MobsInDist_Name" => + $"{keyword} {FormatNumber(value.Number)} {FormatNumber(value.SecondaryNumber)} {{{value.Text}}}", + "MobsInDist_Priority" => + $"{keyword} {FormatNumber(value.Number)} {FormatNumber(value.SecondaryNumber)} {FormatNumber(value.TertiaryNumber)}", + "NoMobsInDist" or "DistToRteGE" => $"{keyword} {FormatNumber(value.Number)}", + "BlockE" or "CellE" => $"{keyword} {FormatHex(value.Number)}", + "SecsOnSpellGE" => $"{keyword} {FormatNumber(value.SecondaryNumber)} {FormatNumber(value.Number)}", + "ChatCapture" => $"{keyword} {{{value.Text}}} {{{value.SecondaryText}}}", + _ => keyword, + }; + } + + private static void WriteAction( + List lines, + MetaAction value, + string label, + int labelTabs, + int depth, + IReadOnlyDictionary embedTags) + { + string tabs = new('\t', labelTabs); + string keyword = ActionKeyword(value.Kind); + lines.Add($"{tabs}{label}\t{RenderActionSameLine(keyword, value, embedTags)}"); + if (keyword == "DoAll") + { + foreach (MetaAction child in value.Children) + WriteActionNested(lines, child, depth + 1, embedTags); + } + } + + private static void WriteActionNested( + List lines, + MetaAction value, + int depth, + IReadOnlyDictionary embedTags) + { + string keyword = ActionKeyword(value.Kind); + lines.Add(new string('\t', depth) + RenderActionSameLine(keyword, value, embedTags)); + if (keyword == "DoAll") + { + foreach (MetaAction child in value.Children) + WriteActionNested(lines, child, depth + 1, embedTags); + } + } + + private static string RenderActionSameLine( + string keyword, + MetaAction value, + IReadOnlyDictionary embedTags) => keyword switch + { + "SetState" or "Chat" or "DoExpr" or "ChatExpr" => $"{keyword} {{{value.Text}}}", + "CallState" => $"{keyword} {{{value.Text}}} {{{value.SecondaryText}}}", + "SetWatchdog" => + $"{keyword} {FormatNumber(value.Number)} {FormatNumber(value.SecondaryNumber)} {{{value.Text}}}", + "GetOpt" or "SetOpt" or "CreateView" => $"{keyword} {{{value.Text}}} {{{value.SecondaryText}}}", + // ADestroyView.ExportToMetAF (py:10662-10663) concatenates two + // separate literal spaces before the brace — a genuine metaf quirk, + // not a typo here. + "DestroyView" => $"{keyword} {{{value.Text}}}", + "EmbedNav" => $"{keyword} {(embedTags.TryGetValue(value, out string? tag) ? tag : "unresolved")} {{{value.SecondaryText}}}", + _ => keyword, + }; + + private static void WriteNavBlock(List lines, string tag, NavigationSettings source) + { + string navType = source.Mode switch + { + RouteMode.Circular => "circular", + RouteMode.Linear => "linear", + RouteMode.Target => "follow", + RouteMode.Once => "once", + _ => throw new InvalidOperationException("unknown navigation mode."), + }; + // Nav.ExportToMetAF (metaf_monolithic.py:12050-12054) wraps every + // NAV: block in the same editor-fold comment pair State does, + // unconditionally (including the single-"flw"-node Target/follow + // case) — metaf's own mechanical emission, reproduced for byte + // identity. + lines.Add($"NAV: {tag} {navType} ~~ {{"); + if (source.Mode == RouteMode.Target) + { + lines.Add( + $"\tflw {FormatHex(source.FollowTargetObjectId)} {{{source.FollowTargetName}}}"); + lines.Add("~~ }"); + return; + } + foreach (RouteWaypoint waypoint in source.Waypoints) + lines.Add(RenderNavNode(waypoint)); + lines.Add("~~ }"); + } + + private static string RenderNavNode(RouteWaypoint waypoint) + { + double x = waypoint.Position.EastWest; + double y = waypoint.Position.NorthSouth; + double z = waypoint.Position.Elevation; + return waypoint.Type switch + { + RouteWaypointType.Point or RouteWaypointType.Checkpoint => + $"\t{NodeKeyword(waypoint.Type)} {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)}", + RouteWaypointType.Portal => + $"\tprt {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {FormatHex(waypoint.ObjectId)}", + RouteWaypointType.Recall => + $"\trcl {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {{{waypoint.RecallSpellName}}}", + RouteWaypointType.Pause => + $"\tpau {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {FormatNumber(waypoint.DurationMilliseconds)}", + RouteWaypointType.ChatCommand => + $"\tcht {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {{{waypoint.Text}}}", + RouteWaypointType.OpenVendor => + $"\tvnd {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {FormatHex(waypoint.ObjectId)} {{{waypoint.ObjectName}}}", + RouteWaypointType.PortalByName or RouteWaypointType.UseNpc => + $"\t{NodeKeyword(waypoint.Type)} {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} " + + $"{FormatNumber(waypoint.ReferencePosition.EastWest)} {FormatNumber(waypoint.ReferencePosition.NorthSouth)} {FormatNumber(waypoint.ReferencePosition.Elevation)} " + + $"{waypoint.LegacyObjectClass} {{{waypoint.ObjectName}}}", + RouteWaypointType.Jump => + $"\tjmp {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {FormatNumber(waypoint.JumpHeadingDegrees)} " + + $"{{{(waypoint.JumpRun ? "True" : "False")}}} {FormatNumber(waypoint.JumpChargeMilliseconds)}", + _ => throw new InvalidOperationException($"unknown waypoint type {waypoint.Type}."), + }; + } + + // ------------------------------------------------------------------ + // Small helpers. + // ------------------------------------------------------------------ + + private static string[] SplitLines(string text) => (text ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n'); + + private static string StripDelimiters(string value) => + value.Length >= 2 ? value[1..^1] : string.Empty; + + private static int ParseInt(string text) => int.Parse( + text, NumberStyles.Integer, CultureInfo.InvariantCulture); + + private static double ParseDouble(string text) => double.Parse( + text, NumberStyles.Float, CultureInfo.InvariantCulture); + + private static int ParseHexAsInt(string text) + { + uint unsigned = uint.Parse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return unchecked((int)unsigned); + } + + private static uint ParseHexAsUInt(string text) => + uint.Parse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + private static string FormatNumber(double value) => value.ToString(CultureInfo.InvariantCulture); + + private static string FormatHex(double value) => unchecked((uint)checked((int)value)) + .ToString("X8", CultureInfo.InvariantCulture); + + private static string FormatHex(uint value) => value.ToString("X8", CultureInfo.InvariantCulture); + + private static uint ResolveSpellIdByName(ISpellCatalog spells, string name) + { + if (string.IsNullOrWhiteSpace(name)) + return 0u; + foreach (PluginSpellInfo spell in spells.KnownSelfBuffs) + { + if (spell.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + return spell.SpellId; + } + foreach (PluginSpellInfo spell in spells.KnownCombatSpells) + { + if (spell.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + return spell.SpellId; + } + return 0u; + } + + private static RouteWaypointType NodeWaypointType(string keyword) => keyword switch + { + "pnt" => RouteWaypointType.Point, + "prt" => RouteWaypointType.Portal, + "rcl" => RouteWaypointType.Recall, + "pau" => RouteWaypointType.Pause, + "cht" => RouteWaypointType.ChatCommand, + "vnd" => RouteWaypointType.OpenVendor, + "ptl" => RouteWaypointType.PortalByName, + "tlk" => RouteWaypointType.UseNpc, + "chk" => RouteWaypointType.Checkpoint, + "jmp" => RouteWaypointType.Jump, + _ => throw new FormatException($"unknown nav node keyword '{keyword}'."), + }; + + private static string NodeKeyword(RouteWaypointType type) => type switch + { + RouteWaypointType.Point => "pnt", + RouteWaypointType.Portal => "prt", + RouteWaypointType.Recall => "rcl", + RouteWaypointType.Pause => "pau", + RouteWaypointType.ChatCommand => "cht", + RouteWaypointType.OpenVendor => "vnd", + RouteWaypointType.PortalByName => "ptl", + RouteWaypointType.UseNpc => "tlk", + RouteWaypointType.Checkpoint => "chk", + RouteWaypointType.Jump => "jmp", + _ => throw new InvalidOperationException($"unknown waypoint type {type}."), + }; + + private static MetaConditionKind ConditionKind(string keyword) => keyword switch + { + "Never" => MetaConditionKind.Never, + "Always" => MetaConditionKind.Always, + "All" => MetaConditionKind.All, + "Any" => MetaConditionKind.Any, + "ChatMatch" => MetaConditionKind.ChatMessage, + "MainSlotsLE" => MetaConditionKind.PackSlotsLessThanOrEqual, + "SecsInStateGE" => MetaConditionKind.SecondsInStateGreaterThanOrEqual, + "NavEmpty" => MetaConditionKind.NavigationRouteEmpty, + "Death" => MetaConditionKind.CharacterDeath, + "VendorOpen" => MetaConditionKind.AnyVendorOpen, + "VendorClosed" => MetaConditionKind.VendorClosed, + "ItemCountLE" => MetaConditionKind.InventoryItemCountLessThanOrEqual, + "ItemCountGE" => MetaConditionKind.InventoryItemCountGreaterThanOrEqual, + "MobsInDist_Name" => MetaConditionKind.MonsterNameCountWithinDistance, + "MobsInDist_Priority" => MetaConditionKind.MonsterPriorityCountWithinDistance, + "NeedToBuff" => MetaConditionKind.NeedToBuff, + "NoMobsInDist" => MetaConditionKind.NoMonstersWithinDistance, + "BlockE" => MetaConditionKind.LandblockEquals, + "CellE" => MetaConditionKind.LandcellEquals, + "IntoPortal" => MetaConditionKind.PortalspaceEntered, + "ExitPortal" => MetaConditionKind.PortalspaceExited, + "Not" => MetaConditionKind.Not, + "PSecsInStateGE" => MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, + "SecsOnSpellGE" => MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual, + "BuPercentGE" => MetaConditionKind.BurdenPercentGreaterThanOrEqual, + "DistToRteGE" => MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, + "Expr" => MetaConditionKind.Expression, + "ChatCapture" => MetaConditionKind.ChatMessageCapture, + _ => throw new FormatException($"unknown Condition keyword '{keyword}'."), + }; + + private static string ConditionKeyword(MetaConditionKind kind) => kind switch + { + MetaConditionKind.Never => "Never", + MetaConditionKind.Always => "Always", + MetaConditionKind.All => "All", + MetaConditionKind.Any => "Any", + MetaConditionKind.ChatMessage => "ChatMatch", + MetaConditionKind.PackSlotsLessThanOrEqual => "MainSlotsLE", + MetaConditionKind.SecondsInStateGreaterThanOrEqual => "SecsInStateGE", + MetaConditionKind.NavigationRouteEmpty => "NavEmpty", + MetaConditionKind.CharacterDeath => "Death", + MetaConditionKind.AnyVendorOpen => "VendorOpen", + MetaConditionKind.VendorClosed => "VendorClosed", + MetaConditionKind.InventoryItemCountLessThanOrEqual => "ItemCountLE", + MetaConditionKind.InventoryItemCountGreaterThanOrEqual => "ItemCountGE", + MetaConditionKind.MonsterNameCountWithinDistance => "MobsInDist_Name", + MetaConditionKind.MonsterPriorityCountWithinDistance => "MobsInDist_Priority", + MetaConditionKind.NeedToBuff => "NeedToBuff", + MetaConditionKind.NoMonstersWithinDistance => "NoMobsInDist", + MetaConditionKind.LandblockEquals => "BlockE", + MetaConditionKind.LandcellEquals => "CellE", + MetaConditionKind.PortalspaceEntered => "IntoPortal", + MetaConditionKind.PortalspaceExited => "ExitPortal", + MetaConditionKind.Not => "Not", + MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => "PSecsInStateGE", + MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => "SecsOnSpellGE", + MetaConditionKind.BurdenPercentGreaterThanOrEqual => "BuPercentGE", + MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => "DistToRteGE", + MetaConditionKind.Expression => "Expr", + MetaConditionKind.ChatMessageCapture => "ChatCapture", + _ => throw new InvalidOperationException($"unsupported Meta condition {kind}."), + }; + + private static MetaActionKind ActionKind(string keyword) => keyword switch + { + "None" => MetaActionKind.None, + "SetState" => MetaActionKind.SetMetaState, + "Chat" => MetaActionKind.ChatCommand, + "DoAll" => MetaActionKind.All, + "EmbedNav" => MetaActionKind.LoadEmbeddedNavigationRoute, + "CallState" => MetaActionKind.CallMetaState, + "Return" => MetaActionKind.ReturnFromCall, + "DoExpr" => MetaActionKind.ExpressionAction, + "ChatExpr" => MetaActionKind.ChatExpression, + "SetWatchdog" => MetaActionKind.SetWatchdog, + "ClearWatchdog" => MetaActionKind.ClearWatchdog, + "GetOpt" => MetaActionKind.GetVtankOption, + "SetOpt" => MetaActionKind.SetVtankOption, + "CreateView" => MetaActionKind.CreateView, + "DestroyView" => MetaActionKind.DestroyView, + "DestroyAllViews" => MetaActionKind.DestroyAllViews, + _ => throw new FormatException($"unknown Action keyword '{keyword}'."), + }; + + private static string ActionKeyword(MetaActionKind kind) => kind switch + { + MetaActionKind.None => "None", + MetaActionKind.SetMetaState => "SetState", + MetaActionKind.ChatCommand => "Chat", + MetaActionKind.All => "DoAll", + MetaActionKind.LoadEmbeddedNavigationRoute => "EmbedNav", + MetaActionKind.CallMetaState => "CallState", + MetaActionKind.ReturnFromCall => "Return", + MetaActionKind.ExpressionAction => "DoExpr", + MetaActionKind.ChatExpression => "ChatExpr", + MetaActionKind.SetWatchdog => "SetWatchdog", + MetaActionKind.ClearWatchdog => "ClearWatchdog", + MetaActionKind.GetVtankOption => "GetOpt", + MetaActionKind.SetVtankOption => "SetOpt", + MetaActionKind.CreateView => "CreateView", + MetaActionKind.DestroyView => "DestroyView", + MetaActionKind.DestroyAllViews => "DestroyAllViews", + _ => throw new InvalidOperationException($"unsupported Meta action {kind}."), + }; +} diff --git a/src/AcDream.Plugins.MossTank/MossTankCommands.cs b/src/AcDream.Plugins.MossTank/MossTankCommands.cs index 08a47647..178a2797 100644 --- a/src/AcDream.Plugins.MossTank/MossTankCommands.cs +++ b/src/AcDream.Plugins.MossTank/MossTankCommands.cs @@ -241,10 +241,7 @@ internal sealed partial class MossTankPanel if (_profiles.Create( name, copyCurrent: true, - _combatSettings, - _buffSettings, - _vitalSettings, - _inventorySettings, + _allSettings, _noBuffItemNames, out string notice)) { @@ -271,7 +268,17 @@ internal sealed partial class MossTankPanel WriteVtank("Usage: /vt nav [save/load] [filename]"); return; } - name = StripExtension(name, ".nav"); + // Item J (slice-1 fix round): .af is the only VTank-compatible + // storage format now (Campaign VT slice 1 Part A); ".nav" is + // stripped for legacy-typed names, ".af" for names copy-pasted + // from a real .af file (the VtankProfiles directory, or an + // imports/ drop-in for TryImportLegacy below) — without both, + // "/vt nav save Foo.af" would have produced a doubled "Foo.af.af" + // file. Round 3 item 12: this comment previously cited an + // "exports/nav/" mirror directory that no longer exists — route + // profiles have written directly to their real .af file since the + // round 2 step 2-3 cutover, with no separate export copy. + name = StripExtension(name, ".nav", ".af"); if (operation == "save") { _routeProfiles.Create( @@ -353,7 +360,9 @@ internal sealed partial class MossTankPanel WriteVtank("Usage: /vt meta [save/load] [filename]"); return; } - name = StripExtension(name, ".met", ".json"); + // Item J (slice-1 fix round): .af is the only VTank-compatible + // storage format now — see the analogous nav-command comment above. + name = StripExtension(name, ".met", ".json", ".af"); if (operation == "save") { _metaProfiles.Create( @@ -420,19 +429,28 @@ internal sealed partial class MossTankPanel WriteVtank("Option set: Invalid option specified."); return; } - if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value)) + canonical = VtankOptionCatalog.Canonical(name); + VtankSettingValueType declaredType = VtankOptionCatalog.DeclaredType(canonical); + if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, declaredType, out ExpressionValue value)) { - WriteVtank("Option set: Invalid value specified."); + // Round 3 item 6: retail's exact failure text + // (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612) + // names the option and the CLR type its Settings row + // actually declares (gy's own m_a, ToString()'d). + WriteVtank( + "Option set: Invalid value specified. Proper type of " + + canonical + " is " + DeclaredClrTypeName(declaredType) + "."); return; } - canonical = VtankOptionCatalog.Canonical(name); SetMetaOption(canonical, value); if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase)) { - int count = _profiles.SetOptionInAll( - canonical, - ToMonsterValue(value)); - WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}"); + // Round 3 item 6: retail's exact bk.a text + // (refs/vtank/decompiled/bk.cs:32) — no "= value" suffix, + // and the count is every .usd file scanned, not just the + // ones that already had a row for this name. + int count = _profiles.SetOptionInAll(canonical, _allSettings); + WriteVtank($"Done saving setting {canonical} to all profiles. (Changed {count} profiles)"); } else { @@ -754,9 +772,9 @@ internal sealed partial class MossTankPanel private void TestPet() { IReadOnlyList targets = _host.Automation.Combat - .CaptureHostileTargets(_combatSettings.PetRangeMode == PetRangeMode.Custom + .CaptureHostileTargets((float)(_combatSettings.PetRangeMode == PetRangeMode.Custom ? _combatSettings.PetCustomRange - : _combatSettings.MaximumRange); + : _combatSettings.MaximumRange)); PetAutomationChoice choice = PetAutomation.Select( _host.Automation.Items.CaptureOwnedItems(), targets, @@ -962,22 +980,107 @@ internal sealed partial class MossTankPanel return result; } - private static bool TryParseOptionValue(string source, out ExpressionValue value) + /// + /// Free-form parse (no catalog type to check against) for callers like + /// the Advanced Options editor, which can target names outside the 137- + /// row catalog: best-effort bool, then number, then string. + /// + private static bool TryParseOptionValue(string source, out ExpressionValue value) => + TryParseOptionValue(source, declaredType: null, out value); + + /// + /// Round 3 item 6: /vt opt set/setinall validate the typed + /// value against the catalog's OWN declared + /// (retail's eSettingValueType) rather than accepting anything — + /// a value that does not parse as that type fails with retail's exact + /// "Proper type of X is Y." text (). + /// is only for + /// the free-form Advanced Options editor, which keeps the original lax + /// best-effort behavior. + /// + private static bool TryParseOptionValue( + string source, + VtankSettingValueType? declaredType, + out ExpressionValue value) { - if (bool.TryParse(source, out bool boolean)) + if (source.Length == 0) { - value = ExpressionValue.Boolean(boolean); - return true; + value = default; + return false; } - if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) + switch (declaredType) { - value = ExpressionValue.Number(number); - return true; + case VtankSettingValueType.Bool: + if (bool.TryParse(source, out bool boolean)) + { + value = ExpressionValue.Boolean(boolean); + return true; + } + value = default; + return false; + case VtankSettingValueType.Int: + case VtankSettingValueType.Enum: + if (int.TryParse( + source, NumberStyles.Integer, CultureInfo.InvariantCulture, out int integer)) + { + value = ExpressionValue.Number(integer); + return true; + } + value = default; + return false; + case VtankSettingValueType.Double: + case VtankSettingValueType.Single: + if (double.TryParse( + source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) + { + value = ExpressionValue.Number(number); + return true; + } + value = default; + return false; + case VtankSettingValueType.String: + value = ExpressionValue.String(source); + return true; + default: + // No declared type (free-form editor) or VtankSettingValueType.Custom + // (the one non-scalar row, RechargeHandlerSet, never reached + // through /vt opt set): keep the original lax parse. + if (bool.TryParse(source, out bool freeBoolean)) + { + value = ExpressionValue.Boolean(freeBoolean); + return true; + } + if (double.TryParse( + source, NumberStyles.Float, CultureInfo.InvariantCulture, out double freeNumber)) + { + value = ExpressionValue.Number(freeNumber); + return true; + } + value = ExpressionValue.String(source); + return true; } - value = ExpressionValue.String(source); - return source.Length != 0; } + /// + /// The CLR type name retail's exact failure text names + /// (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612: + /// cw2[1].b().ToString() — the Settings row's underlying + /// gy cell type, 'd). Retail stores an + /// Enum-declared row as a plain int cell (confirmed against the + /// UseArcs row in the shipped defaultsettings.usd fixture), so it also + /// reads "System.Int32". + /// + private static string DeclaredClrTypeName(VtankSettingValueType type) => type switch + { + VtankSettingValueType.Bool => "System.Boolean", + VtankSettingValueType.Double => "System.Double", + VtankSettingValueType.Int => "System.Int32", + VtankSettingValueType.Single => "System.Single", + VtankSettingValueType.String => "System.String", + VtankSettingValueType.Enum => "System.Int32", + _ => "System.Object", + }; + private bool TryParseCoordinates(string source, out PluginNavigationPosition position) { position = default; diff --git a/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs index 620f3944..2444c9cf 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,45 @@ 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); + } + + /// + /// Round 3 item 10: deletes the currently selected named loot profile's + /// real .utl file, then falls back to . + /// Refuses for itself — there is no file to + /// delete, only a reset (see ), matching + /// 's own contract. + /// + public bool Delete(out string notice) + { + if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in loot profile and cannot be deleted."; + return false; + } + string fileName = _selected; + if (VtankStorage.IsAvailable) + VtankStorage.Delete(fileName); + _selected = ByCharacter; + WriteBinding(); + notice = $"Deleted loot profile {StripUtl(fileName)}."; + return true; } public void ClearCurrent(List target, LootSettings? settings = null) @@ -239,47 +306,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 +581,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/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs index 35eecd04..719e6c0f 100644 --- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -5,47 +5,76 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; -/// Independent VTank-style By-char/named Meta profile lifetime. +/// +/// VTank-compatible Meta profile lifecycle. .af (metaf's human- +/// readable grammar, see ) is the ONLY storage +/// and authoring format — real VTank's own ac() meta-profile picker +/// (docs/research/vtank-kb/01-settings-and-profiles.md section 3) +/// seeds [None]/[By char] then every non--- file: named +/// Meta profiles are plain shared files (unlike the Settings picker, there +/// is no per-character [Char] suffix sub-profile carve-out here), +/// while the per-character auto file uses VTank's --Name_Server.af +/// naming and is therefore hidden from that listing by construction. +/// internal sealed class MossTankMetaProfileStore { public const string ByCharacter = "By char"; - private const string IndexKey = "profiles/meta/index.json"; - private static readonly JsonSerializerOptions Options = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - }; + + // The pre-cutover roster (round 3 item 2): before the .af cutover this + // store kept a flat Names list here (unlike the settings roster, named + // Meta profiles were never owner-scoped — one shared, globally-hashed + // key per name). The cutover stopped reading this key at all, orphaning + // every named profile except whichever one a character had selected + // (that one alone still converts, via MigrateLegacyIfNeeded). Read-only: + // consulted ONLY by the one-time SweepLegacyRosterIfNeeded. + private const string LegacyRosterKey = "profiles/meta/index.json"; private readonly IPluginHost _host; - private IndexDocument _index; private string _character = string.Empty; private string _selected = ByCharacter; + private string? _pendingLegacyBareName; + private bool _rosterSwept; public MossTankMetaProfileStore(IPluginHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _index = Read(IndexKey) ?? new IndexDocument(); - _index.Names ??= []; - _index.SelectedByCharacter = new Dictionary( - _index.SelectedByCharacter ?? [], - StringComparer.OrdinalIgnoreCase); } - private List Names => _index.Names ??= []; - - private Dictionary SelectedByCharacter => - _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 .af extension) — internal identity () + /// always carries the real, on-disk file name; only display strips it. + /// + public string Selected => StripAf(_selected); public string? RecoveryNotice { get; private set; } - public IReadOnlyList AvailableNames => new[] { ByCharacter } - .Concat(Names) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - ? 0 : 1) - .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + public string? SaveNotice { get; private set; } + + private string Server => _host.Automation.Character.WorldName; + private IPluginStorage VtankStorage => _host.VtankProfiles; + private bool CanBindFiles => _character.Length > 0 && Server.Length > 0; + + private static string StripAf(string name) => name.Equals( + ByCharacter, StringComparison.OrdinalIgnoreCase) + ? name + : name.EndsWith(".af", StringComparison.OrdinalIgnoreCase) + ? name[..^3] + : name; + + public IReadOnlyList AvailableNames + { + get + { + var names = new List { ByCharacter }; + foreach (VtankProfileDirectory.ProfileEntry entry in + VtankProfileDirectory.ListMetaProfiles(VtankStorage)) + { + if (entry.FileName.Length == 0) + continue; // VTank's own "[None]"/"[By char]" sentinels. + names.Add(StripAf(entry.FileName)); + } + return names; + } + } public bool BindCharacter(string? characterName) { @@ -55,33 +84,106 @@ internal sealed class MossTankMetaProfileStore if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase)) return false; _character = normalized; - _selected = SelectedByCharacter.TryGetValue( - CharacterKey(), - out string? selected) - && IsKnown(selected) - ? Canonical(selected) - : ByCharacter; + _pendingLegacyBareName = null; + VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles + ? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server) + : null; + _selected = binding is { MetaFileName.Length: > 0 } bound + ? bound.MetaFileName + : ByCharacter; return true; } - public MetaProfile LoadCurrent() => - Read(CurrentKey()) ?? new MetaProfile(); - - public void SaveCurrent(MetaProfile profile) + public MetaProfile LoadCurrent() { - Write(CurrentKey(), profile); - WriteLegacyExport(LegacyProfileName(), profile); + SweepLegacyRosterIfNeeded(); + MigrateLegacyIfNeeded(); + string fileName = CurrentFileName(); + string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; + if (text is null) + return new MetaProfile(); + if (!MetafSerializer.TryLoadMeta(text, _host.Automation.Spells, out MetaProfile profile, out string error)) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "meta", fileName, text, new FormatException(error)); + _host.Log.Warn(RecoveryNotice); + return new MetaProfile(); + } + return profile; + } + + /// + /// Saves as this selection's .af file. + /// Real VTank/metaf has no way to represent + /// (a MossTank-only extension) — rather than + /// silently dropping a disabled rule, the save is refused entirely (the + /// file on disk keeps its last good content) and + /// carries a user-visible reason. Returns only + /// when the file was actually written. + /// + public bool SaveCurrent(MetaProfile profile) + { + string fileName = CurrentFileName(); + string text; + try + { + text = MetafSerializer.SaveMeta(profile); + } + catch (InvalidOperationException error) + { + SaveNotice = $"Meta profile '{fileName}' was NOT saved: {error.Message}"; + _host.Log.Warn(SaveNotice); + return false; + } + if (!VtankStorage.IsAvailable) + return false; + try + { + VtankStorage.WriteText(fileName, text); + } + catch (Exception error) + { + SaveNotice = $"Meta profile '{fileName}' could not be saved: {error.Message}"; + _host.Log.Warn(SaveNotice); + return false; + } + SaveNotice = null; + return true; } public bool Select(string? name) { string normalized = Normalize(name); - if (!IsKnown(normalized)) + if (normalized.Length == 0) return false; - _selected = Canonical(normalized); - SelectedByCharacter[CharacterKey()] = _selected; - SaveIndex(); - return true; + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + _selected = ByCharacter; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + + string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase) + ? normalized + : normalized + ".af"; + if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null) + { + _selected = plain; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + + if (_host.Storage.IsAvailable + && _host.Storage.ReadText(LegacyNamedKey(normalized)) is not null) + { + _selected = plain; + _pendingLegacyBareName = normalized; + WriteBinding(); + return true; + } + return false; } public bool Create( @@ -97,19 +199,18 @@ internal sealed class MossTankMetaProfileStore notice = "Enter a unique Meta profile name (1-64 characters)."; return false; } - MetaProfile document = copyCurrent - ? Clone(current) - : new MetaProfile(); - Write(NamedKey(normalized), document); - if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) - Names.Add(normalized); - _selected = normalized; - SelectedByCharacter[CharacterKey()] = normalized; - SaveIndex(); - WriteLegacyExport(normalized, document); + string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase) + ? normalized + : normalized + ".af"; + MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile(); + if (!SaveTo(fileName, document, out notice)) + return false; + _selected = fileName; + _pendingLegacyBareName = null; + WriteBinding(); notice = copyCurrent - ? $"Copied Meta profile to {normalized}." - : $"Created Meta profile {normalized}."; + ? $"Copied Meta profile to {fileName}." + : $"Created Meta profile {fileName}."; return true; } @@ -139,20 +240,48 @@ internal sealed class MossTankMetaProfileStore notice = $"VTank Meta file '{normalized}.met' was not found in imports."; return false; } - if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error)) + if (!VtankMetaProfileSerializer.TryLoad( + source, _host.Automation.Spells, out profile, out string error)) { notice = $"Could not import {Path.GetFileName(key)}: {error}"; return false; } - if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) - Names.Add(normalized); - _selected = Names.First(existing => existing.Equals( - normalized, - StringComparison.OrdinalIgnoreCase)); - SelectedByCharacter[CharacterKey()] = _selected; - SaveIndex(); - SaveCurrent(profile); - notice = $"Imported VTank Meta profile {_selected}."; + string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase) + ? normalized + : normalized + ".af"; + if (!SaveTo(fileName, profile, out string saveNotice)) + { + notice = saveNotice; + return false; + } + _selected = fileName; + _pendingLegacyBareName = null; + WriteBinding(); + notice = $"Imported VTank Meta profile {fileName}."; + return true; + } + + /// + /// Round 3 item 10: deletes the currently selected named Meta profile's + /// real .af file, then falls back to . + /// Refuses for itself — there is no file to + /// delete, only a reset (see ), matching + /// 's own contract. + /// + public bool Delete(out string notice) + { + if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in Meta profile and cannot be deleted."; + return false; + } + string fileName = _selected; + if (VtankStorage.IsAvailable) + VtankStorage.Delete(fileName); + _selected = ByCharacter; + _pendingLegacyBareName = null; + WriteBinding(); + notice = $"Deleted Meta profile {fileName}."; return true; } @@ -163,124 +292,237 @@ internal sealed class MossTankMetaProfileStore return empty; } - private string CurrentKey() => _selected.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? $"profiles/meta/by-character/{Hash(_character)}.json" - : NamedKey(_selected); + // ------------------------------------------------------------------ + // Legacy JSON -> .af migration. + // ------------------------------------------------------------------ - private static string NamedKey(string name) => - $"profiles/meta/named/{Hash(name)}.json"; - - private string CharacterKey() => - string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character; - - private bool IsKnown(string name) => name.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - || Names.Contains(name, StringComparer.OrdinalIgnoreCase); - - private string Canonical(string name) => name.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? ByCharacter - : Names.First(existing => existing.Equals( - name, - StringComparison.OrdinalIgnoreCase)); - - private void SaveIndex() => Write(IndexKey, _index); - - private void WriteLegacyExport(string name, MetaProfile profile) + /// + /// Round 3 item 2: a ONE-TIME sweep (guarded by ) + /// over the pre-cutover roster at . Unlike + /// — which only ever converts the + /// ONE currently-selected profile — this converts every OTHER named + /// Meta profile the old roster still lists. Named Meta profiles were + /// never owner-scoped (one shared, globally-hashed key per name), so — + /// unlike the settings roster — every entry converts regardless of + /// which character is currently bound. + /// + private void SweepLegacyRosterIfNeeded() { + if (_rosterSwept) + return; + _rosterSwept = true; if (!_host.Storage.IsAvailable) return; - try + List? names = ReadRosterNames(); + if (names is not { Count: > 0 }) + 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 = LegacyNamedKey(name); + MetaProfile? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase) + ? name + : name + ".af"; + if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null) + { + if (!SaveTo(fileName, legacy, out string notice)) + { + // Representational loss (a disabled rule) — same gate as + // MigrateLegacyIfNeeded: keep the row so this can be + // retried once the user resolves it. + _host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}"); + remaining.Add(name); + continue; + } + } + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + + if (remaining.Count > 0) { _host.Storage.WriteText( - $"exports/{LegacyFileName(name)}.met", - VtankMetaProfileSerializer.Save(profile)); + LegacyRosterKey, + JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions)); + } + else + { + _host.Storage.Delete(LegacyRosterKey); + } + _host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster."); + } + + private List? ReadRosterNames() + { + string? json = null; + try + { + json = _host.Storage.ReadText(LegacyRosterKey); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, JsonOptions)?.Names; } catch (Exception error) { - _host.Log.Warn( - $"MossTank VTank Meta export could not be saved: {error.Message}"); + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "meta", LegacyRosterKey, json, error); + _host.Log.Warn(RecoveryNotice); + return null; } } - private string LegacyProfileName() => _selected.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character - : _selected; - - private static string LegacyFileName(string name) + private void MigrateLegacyIfNeeded() { - char[] invalid = Path.GetInvalidFileNameChars(); - var result = new StringBuilder(name.Length); - foreach (char value in name.Trim()) + string fileName = CurrentFileName(); + if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null) { - result.Append(value is '/' or '\\' || invalid.Contains(value) - ? '_' - : value); + _pendingLegacyBareName = null; + return; } - return result.Length == 0 ? "Meta" : result.ToString(); + string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName + ? LegacyNamedKey(bareName) + : LegacyByCharacterKey(); + MetaProfile? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + { + _pendingLegacyBareName = null; + return; + } + if (!SaveTo(fileName, legacy, out string notice)) + { + // Representational loss (a disabled rule) blocks the migration + // outright rather than silently dropping it: the legacy JSON + // stays put (still fully readable) until the user resolves it. + _host.Log.Warn($"MossTank could not migrate legacy Meta profile: {notice}"); + _pendingLegacyBareName = null; + return; + } + if (_host.Storage.IsAvailable) + _host.Storage.Delete(legacyKey); + _pendingLegacyBareName = null; + _host.Log.Warn($"Migrated legacy MossTank Meta profile '{legacyKey}' to '{fileName}'."); } - private T? Read(string key) + private MetaProfile? ReadLegacyJson(string key) { if (!_host.Storage.IsAvailable) - return default; + return null; string? json = null; try { json = _host.Storage.ReadText(key); return string.IsNullOrWhiteSpace(json) - ? default - : JsonSerializer.Deserialize(json, Options); + ? null + : JsonSerializer.Deserialize(json, JsonOptions); } catch (Exception error) { - RecoveryNotice = MossTankProfileRecovery.Preserve( - _host, - "meta", - key, - json, - error); + RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "meta", key, json, error); _host.Log.Error(RecoveryNotice, error); - return default; + return null; } } - private void Write(string key, T value) + private bool SaveTo(string fileName, MetaProfile profile, out string notice) { - if (!_host.Storage.IsAvailable) - return; + string text; try { - _host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options)); + text = MetafSerializer.SaveMeta(profile); + } + catch (InvalidOperationException error) + { + notice = $"Meta profile '{fileName}' was NOT saved: {error.Message}"; + SaveNotice = notice; + _host.Log.Warn(notice); + return false; + } + if (!VtankStorage.IsAvailable) + { + notice = "VTank Meta storage is unavailable."; + return false; + } + try + { + VtankStorage.WriteText(fileName, text); } catch (Exception error) { - _host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error); + notice = $"Meta profile '{fileName}' could not be saved: {error.Message}"; + SaveNotice = notice; + _host.Log.Warn(notice); + return false; } + SaveNotice = null; + notice = string.Empty; + return true; + } + + // ------------------------------------------------------------------ + // File naming, storage plumbing. + // ------------------------------------------------------------------ + + private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af") + : _selected; + + private void WriteBinding() + { + if (!CanBindFiles || !VtankStorage.IsAvailable) + return; + VtankProfileDirectory.VtankCharacterBinding existing = + VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server) + ?? new VtankProfileDirectory.VtankCharacterBinding( + string.Empty, string.Empty, string.Empty, CurrentFileName()); + VtankProfileDirectory.WriteCharacterBinding( + VtankStorage, + _character, + Server, + existing with { MetaFileName = CurrentFileName() }); } private static MetaProfile Clone(MetaProfile profile) => JsonSerializer.Deserialize( - JsonSerializer.Serialize(profile, Options), - Options) ?? new MetaProfile(); + JsonSerializer.Serialize(profile, JsonOptions), + JsonOptions) ?? new MetaProfile(); private static string Normalize(string? name) => name?.Trim() ?? string.Empty; + private string LegacyByCharacterKey() => + $"profiles/meta/by-character/{Hash(_character)}.json"; + + private static string LegacyNamedKey(string name) => + $"profiles/meta/named/{Hash(name)}.json"; + private static string Hash(string value) { byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant())); return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant(); } - private sealed class IndexDocument + private static readonly JsonSerializerOptions JsonOptions = new() { - public List? Names { get; set; } = []; - public Dictionary? SelectedByCharacter { get; set; } = []; + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + // Read-only: the pre-cutover roster shape at LegacyRosterKey, kept + // solely so SweepLegacyRosterIfNeeded can recover it once. + private sealed class LegacyRosterDocument + { + public List Names { get; set; } = []; } } diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 586305fd..a487505b 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -48,6 +48,14 @@ internal sealed partial class MossTankPanel private readonly CombatSettings _combatSettings = new(); private readonly InventorySettings _inventorySettings = new(); private readonly NavigationSettings _navigationSettings = new(); + /// + /// One stable bundle of every live settings object the VTank-catalog + /// Settings profile (.usd) covers, built once so + /// 's Load/Save/Create/ClearCurrent/ + /// SetOptionInAll surface never has to repeat five constructor + /// parameters at every call site. + /// + private readonly VtankSettingsProfileSerializer.AllSettings _allSettings; private readonly MossTankProfileStore _profiles; private readonly MossTankLootProfileStore _lootProfiles; private readonly MossTankRouteProfileStore _routeProfiles; @@ -182,14 +190,17 @@ internal sealed partial class MossTankPanel { _host = host; _firstRunGuidancePending = NeedsFirstRunGuidance(host); + _allSettings = new VtankSettingsProfileSerializer.AllSettings + { + Combat = _combatSettings, + Buffs = _buffSettings, + Vitals = _vitalSettings, + Inventory = _inventorySettings, + Navigation = _navigationSettings, + }; _profiles = new MossTankProfileStore(host); _profiles.BindCharacter(host.Automation.Character.Name); - _profiles.LoadCurrent( - _combatSettings, - _buffSettings, - _vitalSettings, - _inventorySettings, - _noBuffItemNames); + _profiles.LoadCurrent(_allSettings, _noBuffItemNames); _lootProfiles = new MossTankLootProfileStore(host); _lootProfiles.BindCharacter(host.Automation.Character.Name); if (!_lootProfiles.LoadCurrent( @@ -202,7 +213,7 @@ internal sealed partial class MossTankPanel } _routeProfiles = new MossTankRouteProfileStore(host); _routeProfiles.BindCharacter(host.Automation.Character.Name); - if (!_routeProfiles.LoadCurrent(_navigationSettings)) + if (!_routeProfiles.LoadCurrent(_navigationSettings, host.Automation.Spells)) _routeProfiles.SaveCurrent(_navigationSettings); _combat = new CombatController(host, _combatSettings, _vitalSettings); _buffCasterPreparer = new BuffCasterPreparer( @@ -583,6 +594,7 @@ internal sealed partial class MossTankPanel public Action CopyLootProfile => () => CreateLootProfileCore(copyCurrent: true); public Action ClearLootProfile => ClearLootProfileCore; + public Action DeleteLootProfile => DeleteLootProfileCore; public Action CloseLootEditor => () => _lootEditorVisible = false; public Action SelectLootRule => SelectLootRuleCore; public Action SetLootExpressionDraft => value => @@ -745,6 +757,7 @@ internal sealed partial class MossTankPanel public Action CopyRouteProfile => () => CreateRouteProfileCore(copyCurrent: true); public Action ClearRouteProfile => ClearRouteProfileCore; + public Action DeleteRouteProfile => DeleteRouteProfileCore; public Action SetFollowTarget => CaptureFollowTarget; // ── Meta profile / editor ──────────────────────────────────────────── @@ -815,6 +828,7 @@ internal sealed partial class MossTankPanel public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false); public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true); public Action ClearMetaProfile => ClearMetaProfileCore; + public Action DeleteMetaProfile => DeleteMetaProfileCore; // ── Profiles tab ───────────────────────────────────────────────────── public IReadOnlyList MacroProfileNames => _profiles.AvailableNames; @@ -839,6 +853,7 @@ internal sealed partial class MossTankPanel public Action CreateProfile => () => CreateProfileCore(copyCurrent: false); public Action CopyProfile => () => CreateProfileCore(copyCurrent: true); public Action ClearProfile => ClearProfileCore; + public Action DeleteProfile => DeleteProfileCore; public Action ToggleMineOnly => () => { string before = _profiles.Selected; @@ -1413,6 +1428,17 @@ internal sealed partial class MossTankPanel _lootEditorNotice = $"Cleared {_lootProfiles.Selected}."; } + private void DeleteLootProfileCore() + { + if (!_lootProfiles.Delete(out string notice)) + { + _lootEditorNotice = notice; + return; + } + LoadLootProfile(); + _lootEditorNotice = notice; + } + private void LoadLootProfile() { if (!_lootProfiles.LoadCurrent( @@ -1587,7 +1613,15 @@ internal sealed partial class MossTankPanel AddRouteWaypoint(new RouteWaypoint { Type = type, - Position = target.Position, + // Retail's own header x/y/z for Portal2/UseNPC is dead weight — + // e9/fa extend `at`, whose position accessor is the player's + // OWN live position, never read back into anything meaningful + // (docs/research/vtank-kb/06-navigation-and-nav.md section 1.2). + // ReferencePosition is the embedded d-record: the real target + // coordinate matched against live world objects by name+class+ + // proximity (Navigation.cs's TickUse). + Position = _host.Automation.Navigation.Snapshot.Position, + ReferencePosition = target.Position, ObjectId = target.ObjectId, ObjectName = target.Name, }); @@ -1764,9 +1798,20 @@ internal sealed partial class MossTankPanel _routeNotice = $"Cleared {_routeProfiles.Selected}."; } + private void DeleteRouteProfileCore() + { + if (!_routeProfiles.Delete(out string notice)) + { + _routeNotice = notice; + return; + } + LoadRouteProfile(); + _routeNotice = notice; + } + private void LoadRouteProfile() { - if (!_routeProfiles.LoadCurrent(_navigationSettings)) + if (!_routeProfiles.LoadCurrent(_navigationSettings, _host.Automation.Spells)) _routeProfiles.SaveCurrent(_navigationSettings); if (_initialized) ApplyPersistedOptionOverrides(); @@ -2225,9 +2270,9 @@ internal sealed partial class MossTankPanel } _metaProfile.Rules.Add(rule); _selectedMetaRule = _metaProfile.Rules.Count - 1; - SaveMetaProfile(); RefreshMetaEditor(); - _metaNotice = $"Added rule in {rule.State}."; + if (SaveMetaProfile()) + _metaNotice = $"Added rule in {rule.State}."; } private void ApplyMetaRuleCore() @@ -2244,9 +2289,9 @@ internal sealed partial class MossTankPanel } replacement.Id = current.Id; _metaProfile.Rules[_selectedMetaRule] = replacement; - SaveMetaProfile(); RefreshMetaEditor(); - _metaNotice = $"Updated rule in {replacement.State}."; + if (SaveMetaProfile()) + _metaNotice = $"Updated rule in {replacement.State}."; } private bool TryBuildMetaRule(out MetaRule rule, out string error) @@ -2301,9 +2346,9 @@ internal sealed partial class MossTankPanel _selectedMetaRule = Math.Min( _selectedMetaRule, Math.Max(0, _metaProfile.Rules.Count - 1)); - SaveMetaProfile(); RefreshMetaEditor(); - _metaNotice = $"Removed rule from {selected.State}."; + if (SaveMetaProfile()) + _metaNotice = $"Removed rule from {selected.State}."; } private void MoveMetaRule(int direction) @@ -2318,9 +2363,9 @@ internal sealed partial class MossTankPanel _metaProfile.Rules.RemoveAt(_selectedMetaRule); _metaProfile.Rules.Insert(destination, rule); _selectedMetaRule = destination; - SaveMetaProfile(); RefreshMetaEditor(); - _metaNotice = $"Moved {rule.State} rule."; + if (SaveMetaProfile()) + _metaNotice = $"Moved {rule.State} rule."; } private void SelectMetaProfileCore(string name) @@ -2360,6 +2405,17 @@ internal sealed partial class MossTankPanel _metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}."; } + private void DeleteMetaProfileCore() + { + if (!_metaProfiles.Delete(out string notice)) + { + _metaNotice = notice; + return; + } + LoadMetaProfile(); + _metaNotice = notice; + } + private void LoadMetaProfile() { _metaProfile = _metaProfiles.LoadCurrent(); @@ -2370,7 +2426,22 @@ internal sealed partial class MossTankPanel RefreshMetaEditor(); } - private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile); + /// + /// Saves the current Meta profile as .af. Returns + /// when the save was refused (a disabled rule + /// has no metaf-compatible representation) — callers must not overwrite + /// with a generic success message in that + /// case, since already + /// carries the user-visible reason. + /// + private bool SaveMetaProfile() + { + if (_metaProfiles.SaveCurrent(_metaProfile)) + return true; + if (_metaProfiles.SaveNotice is { } notice) + _metaNotice = notice; + return false; + } private static string DescribeMetaCondition(MetaCondition condition) => condition.Kind switch @@ -2408,19 +2479,16 @@ internal sealed partial class MossTankPanel return nearest; } - private void LoadEmbeddedNavigationRoute(string source) + private void LoadEmbeddedNavigationRoute(NavigationSettings? route) { _navigation.Reset(); - if (!VtankNavRouteSerializer.TryLoad( - source, - _navigationSettings, - _host.Automation.Spells, - out string error)) + if (route is null) { - _routeNotice = $"Embedded route rejected: {error}"; - _host.Log.Warn($"MossTank Meta embedded route rejected: {error}"); + _routeNotice = "Embedded route rejected: unresolved Nav tag."; + _host.Log.Warn("MossTank Meta embedded route rejected: unresolved Nav tag."); return; } + VtankNavRouteSerializer.Apply(route, _navigationSettings); _routeProfiles.SaveCurrent(_navigationSettings); _selectedRouteWaypoint = 0; RefreshRouteEditor(); @@ -2528,7 +2596,7 @@ internal sealed partial class MossTankPanel _combatSettings.DebuffPrecastSeconds), "switchwandstodebuff" => ExpressionValue.Boolean( _combatSettings.SwitchWandsToDebuff), - "usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs), + "usearcs" => ExpressionValue.Number((int)_combatSettings.UseArcs), "deleteghostmonsters" => ExpressionValue.Boolean( _combatSettings.DeleteGhostMonsters), "ghostmonsterspellattemptcount" => ExpressionValue.Number( @@ -2998,7 +3066,8 @@ internal sealed partial class MossTankPanel _combatSettings.SwitchWandsToDebuff = value.IsTruthy; break; case "usearcs": - _combatSettings.UseArcs = value.IsTruthy; + _combatSettings.UseArcs = (UseArcsMode)Math.Clamp( + value.AsInt32("UseArcs"), 1, 3); break; case "deleteghostmonsters": _combatSettings.DeleteGhostMonsters = value.IsTruthy; @@ -3414,10 +3483,7 @@ internal sealed partial class MossTankPanel if (!_profiles.Create( _profileNameDraft, copyCurrent, - _combatSettings, - _buffSettings, - _vitalSettings, - _inventorySettings, + _allSettings, _noBuffItemNames, out string notice)) { @@ -3431,24 +3497,25 @@ internal sealed partial class MossTankPanel private void ClearProfileCore() { - _profiles.ClearCurrent( - _combatSettings, - _buffSettings, - _vitalSettings, - _inventorySettings, - _noBuffItemNames); + _profiles.ClearCurrent(_allSettings, _noBuffItemNames); _profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults."; ResetProfileConsumers(); } + private void DeleteProfileCore() + { + if (!_profiles.Delete(out string notice)) + { + _profileLifecycleNotice = notice; + return; + } + LoadSelectedProfile(); + _profileLifecycleNotice = notice; + } + private void LoadSelectedProfile() { - _profiles.LoadCurrent( - _combatSettings, - _buffSettings, - _vitalSettings, - _inventorySettings, - _noBuffItemNames); + _profiles.LoadCurrent(_allSettings, _noBuffItemNames); LoadLootProfile(); LoadRouteProfile(); ApplyPersistedOptionOverrides(); @@ -3532,12 +3599,7 @@ internal sealed partial class MossTankPanel private void SaveProfile() { - _profiles.SaveCurrent( - _combatSettings, - _buffSettings, - _vitalSettings, - _inventorySettings, - _noBuffItemNames); + _profiles.SaveCurrent(_allSettings, _noBuffItemNames); _lootProfiles.SaveCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot); diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs index 7f501444..9de10c84 100644 --- a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs @@ -1,19 +1,42 @@ -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// -/// VTank-compatible macro profile lifecycle. "By char" resolves to a distinct -/// durable document per character; named profiles are explicit shared copies. +/// VTank-compatible macro profile lifecycle. Settings-table state (the 137 +/// catalog names in ) is stored the way real +/// VTank stores it: a .usd database per profile, read/written through +/// , listed and named by +/// 's real rules ("By char" auto file, +/// --Name_Server_ sub-profiles, the .cdf per-character +/// binding). Only state that has no VTank setting name at all — item/ +/// consumable name lists, the monster-rule table, and a handful of +/// MossTank-only knobs — still lives in a small JSON side-car keyed by the +/// exact .usd file name (never a hash), so it travels with whichever +/// file is selected without owning any VTank-visible identity of its own. /// internal sealed class MossTankProfileStore { public const string ByCharacter = "By char"; - private const string LegacyKey = "profile.json"; - private const string IndexKey = "profiles/index.json"; + + // Ancient pre-hash single-profile fallback (the very first shape this + // store ever wrote). Read-only: migration consults it, nothing else does. + private const string OldSingleProfileKey = "profile.json"; + // The pre-cutover full index document (round 3 item 2): its OLD shape + // carried the entire named-profile roster (Profiles: [{Name, Owner}]) + // plus MineOnly/SelectedByCharacter. The settings cutover (round 2 step + // 1) started reusing this exact key for a MineOnly-only shape, which + // silently discarded any roster a pre-cutover install still had on disk + // the next time SetMineOnly() saved — read-only here now, and consulted + // ONLY for the one-time roster sweep (SweepLegacyRosterIfNeeded); + // MineOnly itself lives at the NEW PreferencesKey below from now on so + // this key is never again written in the flattened shape that caused + // the loss. + private const string LegacyRosterKey = "profiles/index.json"; + private const string PreferencesKey = "profiles/macro/preferences.json"; + private const string SideCarDirectory = "profiles/macro/sidecar/"; + private static readonly JsonSerializerOptions Options = new() { WriteIndented = true, @@ -21,45 +44,49 @@ internal sealed class MossTankProfileStore }; private readonly IPluginHost _host; - private ProfileIndex _index; + private PreferencesDocument _preferences; private string _characterName = string.Empty; private string _selected = ByCharacter; + private string? _pendingLegacyBareName; + private VtankDatabase? _currentDatabase; + private string? _currentDatabaseFileName; + private bool _rosterSwept; public MossTankProfileStore(IPluginHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _index = Read(IndexKey) ?? new ProfileIndex(); - _index.Profiles ??= []; - _index.SelectedByCharacter = new Dictionary( - _index.SelectedByCharacter - ?? new Dictionary(), - StringComparer.OrdinalIgnoreCase); + _preferences = ReadJson(PreferencesKey) + ?? ReadJson(LegacyRosterKey) + ?? new PreferencesDocument(); } public string Selected => _selected; - public bool MineOnly => _index.MineOnly; + public bool MineOnly => _preferences.MineOnly; public string? RecoveryNotice { get; private set; } + private string Server => _host.Automation.Character.WorldName; + private IPluginStorage VtankStorage => _host.VtankProfiles; + + /// + /// Real on-disk .usd file names visible to the bound character + /// (VTank's own listing/naming/mine-only rules), with + /// first. Unlike VTank's own combo box, this + /// never surfaces the separate "[Default]" reset entry — + /// is the equivalent action here. + /// public IReadOnlyList AvailableNames { get { - IEnumerable entries = _index.Profiles; - if (MineOnly && !string.IsNullOrWhiteSpace(_characterName)) + var names = new List { ByCharacter }; + foreach (VtankProfileDirectory.ProfileEntry entry in VtankProfileDirectory.ListSettingsProfiles( + VtankStorage, _characterName, Server, MineOnly, CurrentFileName())) { - entries = entries.Where(entry => string.Equals( - entry.Owner, - _characterName, - StringComparison.OrdinalIgnoreCase)); + if (entry.FileName.Length == 0) + continue; // VTank's own "[Default]"/"[By char]" sentinels. + names.Add(entry.FileName); } - return new[] { ByCharacter } - .Concat(entries.Select(static entry => entry.Name)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(static name => name.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + return names; } } @@ -73,52 +100,97 @@ internal sealed class MossTankProfileStore return false; _characterName = normalized; - string selectionKey = CharacterSelectionKey(); - _selected = _index.SelectedByCharacter.TryGetValue( - selectionKey, - out string? selected) - && IsKnown(selected) - ? CanonicalName(selected) - : ByCharacter; - if (!_index.SelectedByCharacter.ContainsKey(selectionKey)) - { - _index.SelectedByCharacter[selectionKey] = _selected; - SaveIndex(); - } + _pendingLegacyBareName = null; + _currentDatabase = null; + _currentDatabaseFileName = null; + VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles + ? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server) + : null; + _selected = binding is { SettingsFileName.Length: > 0 } bound + ? bound.SettingsFileName + : ByCharacter; return true; } + /// + /// 's .cdf naming requires a + /// non-blank character name AND server (the host's world-name surface is + /// not always populated yet — before world entry, or in a host that + /// never advertises one). Neither is an error; every .cdf read/ + /// write degrades to "no binding known" rather than throwing. + /// + private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0; + public void SetMineOnly(bool value) { - if (_index.MineOnly == value) + if (_preferences.MineOnly == value) return; - _index.MineOnly = value; - if (!AvailableNames.Contains(_selected, StringComparer.OrdinalIgnoreCase)) - { + _preferences.MineOnly = value; + if (!AvailableNames.Contains(_selected, StringComparer.Ordinal)) _selected = ByCharacter; - _index.SelectedByCharacter[CharacterSelectionKey()] = _selected; - } - SaveIndex(); + SavePreferences(); } + /// + /// Selects : an exact listed file name, a bare + /// name resolving to this character's own --Name_Server_ + /// sub-profile, or (one-time) a bare name matching a not-yet-migrated + /// legacy JSON profile — selection succeeds and the actual JSON→.usd + /// conversion happens on the next . + /// public bool Select(string? name) { string normalized = NormalizeName(name); - if (!IsKnown(normalized)) + if (normalized.Length == 0) return false; - _selected = CanonicalName(normalized); - _index.SelectedByCharacter[CharacterSelectionKey()] = _selected; - SaveIndex(); - return true; + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + _selected = ByCharacter; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + + string? existing = AvailableNames.FirstOrDefault(candidate => + candidate.Equals(normalized, StringComparison.Ordinal)) + ?? AvailableNames.FirstOrDefault(candidate => + candidate.Equals(normalized, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + _selected = existing; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + + string subProfile = VtankProfileDirectory.SubProfilePrefix(_characterName, Server) + + normalized + ".usd"; + if (VtankStorage.IsAvailable && VtankStorage.ReadText(subProfile) is not null) + { + _selected = subProfile; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + + if (_host.Storage.IsAvailable + && _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null) + { + // Not on disk as a .usd yet: LoadCurrent's migration materializes + // it at this exact sub-profile file name. + _selected = subProfile; + _pendingLegacyBareName = normalized; + WriteBinding(); + return true; + } + + return false; } public bool Create( string? name, bool copyCurrent, - CombatSettings combat, - BuffSettings buffs, - VitalSettings vitals, - InventorySettings inventory, + VtankSettingsProfileSerializer.AllSettings settings, ISet noBuffItemNames, out string notice) { @@ -126,106 +198,422 @@ internal sealed class MossTankProfileStore if (!ValidNamedProfile(normalized, out notice)) return false; - ProfileDocument document = copyCurrent - ? ProfileDocument.Capture( - combat, buffs, vitals, inventory, noBuffItemNames) - : ProfileDocument.CreateDefaults(); - Write(ProfileKey(normalized, byCharacter: false), document); - int existing = _index.Profiles.FindIndex(entry => string.Equals( - entry.Name, - normalized, - StringComparison.OrdinalIgnoreCase)); - var entry = new ProfileEntry { Name = normalized, Owner = _characterName }; - if (existing >= 0) - _index.Profiles[existing] = entry; + string fileName = VtankProfileDirectory.SubProfilePrefix(_characterName, Server) + + normalized + ".usd"; + VtankDatabase database; + SideCarDocument sidecar; + if (copyCurrent) + { + database = VtankSettingsProfileSerializer.CreateNew(settings); + sidecar = SideCarDocument.Capture(settings, noBuffItemNames); + } else - _index.Profiles.Add(entry); - _selected = normalized; - _index.SelectedByCharacter[CharacterSelectionKey()] = normalized; - SaveIndex(); - document.Apply(combat, buffs, vitals, inventory, noBuffItemNames); + { + database = VtankDefaultSettingsDatabase.Parse(); + ApplyFromDatabase(database, settings); + sidecar = SideCarDocument.CreateDefaults(); + sidecar.Apply(settings, noBuffItemNames, _host.Log); + } + WriteUsdText(fileName, database.Render()); + WriteJson(SideCarKey(fileName), sidecar); + _currentDatabase = database; + _currentDatabaseFileName = fileName; + _selected = fileName; + _pendingLegacyBareName = null; + WriteBinding(); notice = copyCurrent - ? $"Copied current settings to {normalized}." - : $"Created profile {normalized}."; + ? $"Copied current settings to {fileName}." + : $"Created profile {fileName}."; + return true; + } + + /// + /// Deletes the currently selected named profile's .usd file and + /// its side-car, then falls back to . Refuses + /// for itself — there is no file to delete, + /// only a reset (see ). + /// + public bool Delete(out string notice) + { + if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in character profile and cannot be deleted."; + return false; + } + string fileName = _selected; + if (VtankStorage.IsAvailable) + VtankStorage.Delete(fileName); + if (_host.Storage.IsAvailable) + _host.Storage.Delete(SideCarKey(fileName)); + _selected = ByCharacter; + _pendingLegacyBareName = null; + _currentDatabase = null; + _currentDatabaseFileName = null; + WriteBinding(); + notice = $"Deleted profile {fileName}."; return true; } public void LoadCurrent( - CombatSettings combat, - BuffSettings buffs, - VitalSettings vitals, - InventorySettings inventory, + VtankSettingsProfileSerializer.AllSettings settings, ISet noBuffItemNames) { - ProfileDocument? document = Read(CurrentProfileKey()); - if (document is null - && _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + SweepLegacyRosterIfNeeded(); + MigrateLegacyIfNeeded(settings, noBuffItemNames); + string fileName = CurrentFileName(); + string? text = ReadUsdText(fileName); + if (text is null) { - document = Read(LegacyKey); + // VTank's own behavior for a missing file: seed from the shipped + // template/defaults rather than leaving the live settings alone. + VtankDatabase fresh = VtankDefaultSettingsDatabase.Parse(); + ApplyFromDatabase(fresh, settings); + SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames, _host.Log); + _currentDatabase = fresh; + _currentDatabaseFileName = fileName; + return; } - (document ?? ProfileDocument.CreateDefaults()).Apply( - combat, - buffs, - vitals, - inventory, - noBuffItemNames); + + VtankDatabase database; + try + { + database = VtankSettingsProfileSerializer.Load(text, settings); + } + catch (FormatException error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "macro", fileName, text, error); + _host.Log.Warn(RecoveryNotice); + VtankDatabase fresh = VtankDefaultSettingsDatabase.Parse(); + ApplyFromDatabase(fresh, settings); + SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames, _host.Log); + _currentDatabase = fresh; + _currentDatabaseFileName = fileName; + return; + } + _currentDatabase = database; + _currentDatabaseFileName = fileName; + (ReadJson(SideCarKey(fileName)) ?? SideCarDocument.CreateDefaults()) + .Apply(settings, noBuffItemNames, _host.Log); } public void SaveCurrent( - CombatSettings combat, - BuffSettings buffs, - VitalSettings vitals, - InventorySettings inventory, - ISet noBuffItemNames) => Write( - CurrentProfileKey(), - ProfileDocument.Capture( - combat, buffs, vitals, inventory, noBuffItemNames)); - - public void ClearCurrent( - CombatSettings combat, - BuffSettings buffs, - VitalSettings vitals, - InventorySettings inventory, + VtankSettingsProfileSerializer.AllSettings settings, ISet noBuffItemNames) { - ProfileDocument defaults = ProfileDocument.CreateDefaults(); - defaults.Apply(combat, buffs, vitals, inventory, noBuffItemNames); - Write(CurrentProfileKey(), defaults); + string fileName = CurrentFileName(); + VtankDatabase database = fileName.Equals(_currentDatabaseFileName, StringComparison.Ordinal) + && _currentDatabase is not null + ? _currentDatabase + : VtankSettingsProfileSerializer.CreateNew(settings); + string text = VtankSettingsProfileSerializer.Save(database, settings); + WriteUsdText(fileName, text); + WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames)); + _currentDatabase = database; + _currentDatabaseFileName = fileName; + } + + public void ClearCurrent( + VtankSettingsProfileSerializer.AllSettings settings, + ISet noBuffItemNames) + { + VtankDatabase database = VtankDefaultSettingsDatabase.Parse(); + ApplyFromDatabase(database, settings); + SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames, _host.Log); + string fileName = CurrentFileName(); + WriteUsdText(fileName, database.Render()); + WriteJson(SideCarKey(fileName), SideCarDocument.CreateDefaults()); + _currentDatabase = database; + _currentDatabaseFileName = fileName; } /// - /// VTank's opt setinall: update every named profile and every - /// character profile known to the durable index, including the active - /// character even when it has never selected a named profile. + /// VTank's opt setinall (bk.a, refs/vtank/decompiled/bk.cs:6-35): + /// patch every known .usd file's Settings row for + /// (including the currently bound character's + /// own file even if it has never been saved before), leaving every + /// other row and every other table untouched. The live value has + /// already been applied by the caller (SetMetaOption) before + /// this runs; this only persists that same value into every profile + /// file. Round 3 item 6: when a file's Settings table has no row for + /// this name at all, bk.a APPENDS a new one + /// (bk.cs:22-27: cw3[0]=name, cw3[1]=value, every other + /// column left at its default-constructed gy — a "void" cell, + /// gy.cs:98-101) rather than skipping that file, and the caller + /// reports files.Length (every .usd file scanned, + /// regardless of whether that file's own row already existed) — + /// returns that same file count, not a + /// per-row "successfully changed" tally. /// - public int SetOptionInAll(string name, MonsterValue value) + public int SetOptionInAll(string name, VtankSettingsProfileSerializer.AllSettings current) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - var keys = new HashSet(StringComparer.Ordinal); - foreach (ProfileEntry entry in _index.Profiles) - keys.Add(ProfileKey(entry.Name, byCharacter: false)); - foreach (string character in _index.SelectedByCharacter.Keys) - { - keys.Add(ProfileKey( - character.Equals("_default", StringComparison.OrdinalIgnoreCase) - ? string.Empty - : character, - byCharacter: true)); - } - keys.Add(ProfileKey(_characterName, byCharacter: true)); + if (!VtankOptionCatalog.IsKnown(name) || !VtankStorage.IsAvailable) + return 0; + string canonical = VtankOptionCatalog.Canonical(name); + VtankCell? captured = VtankSettingsProfileSerializer.Capture(canonical, current); + if (captured is null) + return 0; - foreach (string key in keys) + var fileNames = new HashSet(StringComparer.Ordinal); + foreach (string key in VtankStorage.List(string.Empty)) { - ProfileDocument document = Read(key) - ?? ProfileDocument.CreateDefaults(); - document.Combat ??= CombatProfileDocument.Capture(new CombatSettings()); - document.Combat.DynamicSettings ??= - new Dictionary( - StringComparer.OrdinalIgnoreCase); - document.Combat.DynamicSettings[name] = DynamicSettingDocument.From(value); - Write(key, document); + if (!key.Contains('/', StringComparison.Ordinal) + && key.EndsWith(".usd", StringComparison.OrdinalIgnoreCase)) + { + fileNames.Add(key); + } + } + fileNames.Add(CurrentFileName()); + + // MossTankPanel.ApplyPersistedOptionOverrides replays the side-car's + // CombatDynamicSettings snapshot onto the live typed fields after + // every store reload (route/meta/settings) — a mechanism this store + // does not own but must not fight. Left unsynced, a profile file's + // own stale side-car (captured whenever it was last active) would + // silently re-clobber the value setinall just wrote into that same + // file's .usd row the next time it is loaded. Keep both + // representations in lock-step instead of only one of them. + DynamicSettingDocument? dynamicDocument = current.Combat.DynamicSettings.TryGetValue( + canonical, out MonsterValue liveDynamicValue) + ? DynamicSettingDocument.From(liveDynamicValue) + : null; + + foreach (string fileName in fileNames) + { + string? text = ReadUsdText(fileName); + VtankDatabase database; + try + { + database = string.IsNullOrEmpty(text) + ? VtankDefaultSettingsDatabase.Parse() + : VtankDatabase.Parse(text); + } + catch (FormatException error) + { + _host.Log.Warn($"MossTank could not update '{fileName}' for setinall: {error.Message}"); + continue; + } + VtankTable? table = database.Find("Settings"); + int nameColumn = table?.ColumnIndex("Setting") ?? -1; + int valueColumn = table?.ColumnIndex("Value") ?? -1; + if (table is null || nameColumn < 0 || valueColumn < 0) + continue; + VtankRow? row = table.Rows.FirstOrDefault(candidate => + candidate.Cells[nameColumn].AsString().Equals( + canonical, StringComparison.OrdinalIgnoreCase)); + if (row is null) + { + // bk.cs:22-27: append a brand-new row rather than skip this + // file. Every column other than [0]=name/[1]=value stays a + // void gy() cell (gy.cs:98-101 writes only "0" for one). + row = new VtankRow(); + for (int column = 0; column < table.ColumnNames.Count; column++) + row.Cells.Add(new VtankCell { Tag = "0" }); + row.Cells[nameColumn] = VtankCell.String(canonical); + table.Rows.Add(row); + } + row.Cells[valueColumn] = captured; + WriteUsdText(fileName, database.Render()); + if (fileName.Equals(_currentDatabaseFileName, StringComparison.Ordinal)) + _currentDatabase = database; + if (dynamicDocument is not null) + { + SideCarDocument sidecar = ReadJson(SideCarKey(fileName)) + ?? SideCarDocument.CreateDefaults(); + sidecar.CombatDynamicSettings[canonical] = dynamicDocument; + WriteJson(SideCarKey(fileName), sidecar); + } + } + // bk.cs:32: PluginCore.a(... + files.Length + " profiles)") reports + // the TOTAL file count scanned, not a per-file "did it actually + // change" tally. + return fileNames.Count; + } + + // ------------------------------------------------------------------ + // Legacy JSON -> .usd/.json side-car migration. + // ------------------------------------------------------------------ + + /// + /// Round 3 item 2: a ONE-TIME sweep (guarded by , + /// so repeat calls from are a no-op) over the + /// pre-cutover roster document at . Unlike + /// — which only ever converts the + /// ONE currently-selected profile — this converts every OTHER named + /// profile the old roster still lists, so a character's whole macro + /// library survives the cutover rather than only whichever profile + /// happened to be selected at the time. + /// + /// A roster entry's real legacy document has no server dimension at all + /// (pre-cutover named profiles were one flat, globally-hashed key), but + /// its NEW .usd home is a per-(character,server) sub-profile + /// () — the same + /// target already uses for the + /// selected profile. That requires a server, which is only known for + /// the CURRENTLY bound character, so an entry owned by a different + /// character is left in the roster (rewritten back minus whatever this + /// pass did convert) for that character's own future session to finish. + /// + private void SweepLegacyRosterIfNeeded() + { + if (_rosterSwept) + return; + _rosterSwept = true; + LegacyRosterDocument? roster = ReadJson(LegacyRosterKey); + if (roster?.Profiles is not { Count: > 0 } profiles) + return; + + var remaining = new List(); + int migrated = 0; + foreach (LegacyRosterEntry entry in profiles) + { + string name = (entry.Name ?? string.Empty).Trim(); + if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + continue; // stale/invalid row; drop it rather than loop on it forever. + + bool ownedByCurrentCharacter = string.IsNullOrWhiteSpace(entry.Owner) + || entry.Owner.Trim().Equals(_characterName, StringComparison.OrdinalIgnoreCase); + if (!ownedByCurrentCharacter) + { + remaining.Add(entry); + continue; + } + + string legacyKey = LegacyProfileKey(name, byCharacter: false); + LegacyProfileDocument? legacy = ReadJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = VtankProfileDirectory.SubProfilePrefix(_characterName, Server) + + name + ".usd"; + if (ReadUsdText(fileName) is null) + { + var settings = new VtankSettingsProfileSerializer.AllSettings + { + Combat = new CombatSettings(), + Buffs = new BuffSettings(), + Vitals = new VitalSettings(), + Inventory = new InventorySettings(), + Navigation = new NavigationSettings(), + }; + var noBuffItemNames = new HashSet(StringComparer.Ordinal); + legacy.Apply( + settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, + noBuffItemNames, _host.Log); + VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings); + WriteUsdText(fileName, database.Render()); + WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames)); + } + if (_host.Storage.IsAvailable) + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + + if (remaining.Count > 0) + WriteJson(LegacyRosterKey, new LegacyRosterDocument { Profiles = remaining }); + else if (_host.Storage.IsAvailable) + _host.Storage.Delete(LegacyRosterKey); + // MineOnly's own migration to the new PreferencesKey is otherwise + // lazy (only SetMineOnly() writes it) — make it durable the moment + // this sweep runs so a user who never toggles the checkbox still + // stops depending on the old, now-legacy-only key. + WriteJson(PreferencesKey, _preferences); + _host.Log.Warn( + $"Migrated {migrated} legacy MossTank named settings profile(s) from the old roster."); + } + + /// + /// Runs once per store (per selection) when the resolved .usd + /// file does not exist yet but the equivalent old JSON profile document + /// does: applies the JSON onto the live settings, writes the real + /// .usd + side-car pair, and deletes the JSON key. Never + /// overwrites an existing .usd file, and is a no-op (not an + /// error) when neither exists — a brand-new profile. + /// + private void MigrateLegacyIfNeeded( + VtankSettingsProfileSerializer.AllSettings settings, + ISet noBuffItemNames) + { + string fileName = CurrentFileName(); + if (ReadUsdText(fileName) is not null) + { + _pendingLegacyBareName = null; + return; + } + + string? legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName + ? LegacyProfileKey(bareName, byCharacter: false) + : _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? LegacyProfileKey(_characterName, byCharacter: true) + : null; + LegacyProfileDocument? legacy = legacyKey is null ? null : ReadJson(legacyKey); + legacyKey ??= OldSingleProfileKey; + if (legacy is null + && _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + legacy = ReadJson(OldSingleProfileKey); + legacyKey = OldSingleProfileKey; + } + if (legacy is null) + { + _pendingLegacyBareName = null; + return; + } + + legacy.Apply(settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames, _host.Log); + VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings); + WriteUsdText(fileName, database.Render()); + WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames)); + _currentDatabase = database; + _currentDatabaseFileName = fileName; + if (_host.Storage.IsAvailable) + _host.Storage.Delete(legacyKey); + _pendingLegacyBareName = null; + _host.Log.Warn( + $"Migrated legacy MossTank settings profile '{legacyKey}' to '{fileName}'."); + } + + // ------------------------------------------------------------------ + // File naming, storage plumbing. + // ------------------------------------------------------------------ + + private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "usd") + : _selected; + + private void WriteBinding() + { + if (!CanBindFiles || !VtankStorage.IsAvailable) + return; + VtankProfileDirectory.VtankCharacterBinding existing = + VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server) + ?? new VtankProfileDirectory.VtankCharacterBinding( + CurrentFileName(), string.Empty, string.Empty, null); + VtankProfileDirectory.WriteCharacterBinding( + VtankStorage, + _characterName, + Server, + existing with { SettingsFileName = CurrentFileName() }); + } + + private static void ApplyFromDatabase( + VtankDatabase database, + VtankSettingsProfileSerializer.AllSettings target) + { + VtankTable? settings = database.Find("Settings"); + int nameColumn = settings?.ColumnIndex("Setting") ?? -1; + int valueColumn = settings?.ColumnIndex("Value") ?? -1; + if (settings is null || nameColumn < 0 || valueColumn < 0) + return; + foreach (VtankRow row in settings.Rows) + { + VtankSettingsProfileSerializer.Apply( + row.Cells[nameColumn].AsString(), row.Cells[valueColumn], target); } - return keys.Count; } private static bool ValidNamedProfile(string name, out string notice) @@ -244,39 +632,48 @@ internal sealed class MossTankProfileStore return true; } - private bool IsKnown(string name) => - name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - || _index.Profiles.Any(entry => entry.Name.Equals( - name, - StringComparison.OrdinalIgnoreCase)); - - private string CanonicalName(string name) => - name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - ? ByCharacter - : _index.Profiles.First(entry => entry.Name.Equals( - name, - StringComparison.OrdinalIgnoreCase)).Name; - - private string CurrentProfileKey() => _selected.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? ProfileKey(_characterName, byCharacter: true) - : ProfileKey(_selected, byCharacter: false); - - private static string ProfileKey(string value, bool byCharacter) + private static string LegacyProfileKey(string value, bool byCharacter) { string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant(); string hash = Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(identity))); return $"profiles/macro/{hash}.json"; } - private string CharacterSelectionKey() => string.IsNullOrWhiteSpace( - _characterName) ? "_default" : _characterName; + private static string SideCarKey(string fileName) => + SideCarDirectory + Sanitize(fileName) + ".json"; + + private static string Sanitize(string value) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new System.Text.StringBuilder(value.Length); + foreach (char c in value) + result.Append(invalid.Contains(c) ? '_' : c); + return result.ToString(); + } + private static string NormalizeName(string? name) => name?.Trim() ?? string.Empty; - private T? Read(string key) where T : class + private string? ReadUsdText(string fileName) => + VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; + + private void WriteUsdText(string fileName, string text) + { + if (!VtankStorage.IsAvailable) + return; + try + { + VtankStorage.WriteText(fileName, text); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank could not save settings profile '{fileName}': {error.Message}"); + } + } + + private T? ReadJson(string key) where T : class { if (!_host.Storage.IsAvailable) return null; @@ -291,17 +688,13 @@ internal sealed class MossTankProfileStore catch (Exception error) { RecoveryNotice = MossTankProfileRecovery.Preserve( - _host, - "macro", - key, - json, - error); + _host, "macro", key, json, error); _host.Log.Warn(RecoveryNotice); return null; } } - private void Write(string key, T document) + private void WriteJson(string key, T document) { if (!_host.Storage.IsAvailable) return; @@ -315,93 +708,341 @@ internal sealed class MossTankProfileStore } } - private void SaveIndex() => Write(IndexKey, _index); + private void SavePreferences() => WriteJson(PreferencesKey, _preferences); - private sealed class ProfileIndex + private sealed class PreferencesDocument { - public int Version { get; set; } = 1; public bool MineOnly { get; set; } = true; - public List Profiles { get; set; } = []; - public Dictionary SelectedByCharacter { get; set; } = - new(StringComparer.OrdinalIgnoreCase); } - private sealed class ProfileEntry + // ------------------------------------------------------------------ + // Read-only: the pre-cutover full roster shape at LegacyRosterKey, kept + // solely so SweepLegacyRosterIfNeeded can recover it once. Nothing here + // writes the OLD "Version"/"SelectedByCharacter" fields back — only a + // residual Profiles list (entries this session couldn't yet convert). + // ------------------------------------------------------------------ + + private sealed class LegacyRosterDocument + { + public List Profiles { get; set; } = []; + } + + private sealed class LegacyRosterEntry { public string Name { get; set; } = string.Empty; public string Owner { get; set; } = string.Empty; } - private sealed class ProfileDocument + // ------------------------------------------------------------------ + // MossTank-only residual state: everything with no VTank setting name + // (item/consumable name lists, the monster-rule table, and a handful of + // MossTank-invented knobs the catalog does not cover). Keyed by the + // real .usd file name so it travels with whichever profile is selected. + // ------------------------------------------------------------------ + + private sealed class SideCarDocument { - public int Version { get; set; } = 6; - // Version-2 compatibility fields remain at the top level. + public int Version { get; set; } = 1; public string[] ItemNames { get; set; } = []; public string[] ConsumableNames { get; set; } = []; - public Dictionary ConsumableCategories - { get; set; } = new(StringComparer.Ordinal); + public Dictionary ConsumableCategories { get; set; } = + new(StringComparer.Ordinal); public string[] NoBuffItemNames { get; set; } = []; - public CombatProfileDocument? Combat { get; set; } - public BuffProfileDocument? Buffs { get; set; } - public VitalProfileDocument? Vitals { get; set; } - public InventoryProfileDocument? Inventory { get; set; } + public float CombatAttackPower { get; set; } = 0.5f; + public double CombatScanIntervalSeconds { get; set; } = 0.25d; + public string CombatMetaState { get; set; } = "Default"; + public Dictionary CombatDynamicSettings { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + public MonsterRuleDocument[] CombatRules { get; set; } = + [MonsterRuleDocument.From(new MonsterRule("DEFAULT", 0))]; + public bool BuffAttributes { get; set; } = true; + public bool BuffProtections { get; set; } = true; + public bool BuffAuras { get; set; } = true; + public bool BuffBanes { get; set; } = true; + public bool BuffRegeneration { get; set; } = true; + public bool BuffOther { get; set; } + public bool BuffTrainedSkillsOnly { get; set; } = true; + public bool VitalsEnabled { get; set; } = true; + public double InventoryScanIntervalSeconds { get; set; } = 0.25d; + // InventoryEnableLooting was deleted here (round 3, item 1): it + // duplicated the real "EnableLooting" .usd Settings row + // (VtankSettingsProfileSerializer "enablelooting" case), and this + // side-car's own Apply() ran AFTER the .usd was applied — so a + // drop-in profile with looting on loaded with it off, clobbered by + // whatever this field's own JSON default/stale value happened to be. + // settings.Inventory.Loot.Enabled is now owned exclusively by the + // .usd file; this side-car must never touch it again. + public string InventoryLootClassifierId { get; set; } = string.Empty; + public double InventoryLootScanIntervalSeconds { get; set; } = 0.25d; + public LootRuleDocument[] InventoryLootRules { get; set; } = []; - public static ProfileDocument Capture( - CombatSettings combat, - BuffSettings buffs, - VitalSettings vitals, - InventorySettings inventory, + public static SideCarDocument Capture( + VtankSettingsProfileSerializer.AllSettings settings, ISet noBuffItemNames) => new() { - ItemNames = Sorted(combat.CombatItemNames), - ConsumableNames = Sorted(combat.ConsumableNames), - ConsumableCategories = combat.ConsumableCategories.ToDictionary( + ItemNames = Sorted(settings.Combat.CombatItemNames), + ConsumableNames = Sorted(settings.Combat.ConsumableNames), + ConsumableCategories = settings.Combat.ConsumableCategories.ToDictionary( static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal), NoBuffItemNames = Sorted(noBuffItemNames), - Combat = CombatProfileDocument.Capture(combat), - Buffs = BuffProfileDocument.Capture(buffs), - Vitals = VitalProfileDocument.Capture(vitals), - Inventory = InventoryProfileDocument.Capture(inventory), + CombatAttackPower = settings.Combat.AttackPower, + CombatScanIntervalSeconds = settings.Combat.ScanIntervalSeconds, + CombatMetaState = settings.Combat.MetaState, + CombatDynamicSettings = settings.Combat.DynamicSettings.ToDictionary( + static pair => pair.Key, + static pair => DynamicSettingDocument.From(pair.Value), + StringComparer.OrdinalIgnoreCase), + CombatRules = settings.Combat.Rules.Select(MonsterRuleDocument.From).ToArray(), + BuffAttributes = settings.Buffs.BuffAttributes, + BuffProtections = settings.Buffs.BuffProtections, + BuffAuras = settings.Buffs.BuffAuras, + BuffBanes = settings.Buffs.BuffBanes, + BuffRegeneration = settings.Buffs.BuffRegeneration, + BuffOther = settings.Buffs.BuffOther, + BuffTrainedSkillsOnly = settings.Buffs.BuffTrainedSkillsOnly, + VitalsEnabled = settings.Vitals.Enabled, + InventoryScanIntervalSeconds = settings.Inventory.ScanIntervalSeconds, + InventoryLootClassifierId = settings.Inventory.Loot.ExternalClassifierId, + InventoryLootScanIntervalSeconds = settings.Inventory.Loot.ScanIntervalSeconds, + InventoryLootRules = settings.Inventory.Loot.Rules + .Select(LootRuleDocument.From) + .ToArray(), }; - public static ProfileDocument CreateDefaults() => Capture( - new CombatSettings(), - new BuffSettings(), - new VitalSettings(), - new InventorySettings(), + public static SideCarDocument CreateDefaults() => Capture( + new VtankSettingsProfileSerializer.AllSettings + { + Combat = new CombatSettings(), + Buffs = new BuffSettings(), + Vitals = new VitalSettings(), + Inventory = new InventorySettings(), + Navigation = new NavigationSettings(), + }, new HashSet(StringComparer.Ordinal)); + public void Apply( + VtankSettingsProfileSerializer.AllSettings settings, + ISet noBuffItemNames, + IPluginLogger? logger = null) + { + Replace(settings.Combat.CombatItemNames, ItemNames); + settings.Combat.CombatItemObjectIds.Clear(); + Replace(settings.Combat.ConsumableNames, ConsumableNames); + settings.Combat.ConsumableCategories.Clear(); + foreach ((string itemName, ConsumableCategory category) in + ConsumableCategories ?? new Dictionary()) + { + if (settings.Combat.ConsumableNames.Contains(itemName)) + settings.Combat.ConsumableCategories[itemName] = category; + } + Replace(noBuffItemNames, NoBuffItemNames); + settings.Combat.AttackPower = Math.Clamp(CombatAttackPower, 0f, 1f); + settings.Combat.ScanIntervalSeconds = Math.Clamp(CombatScanIntervalSeconds, 0.05d, 5d); + settings.Combat.MetaState = string.IsNullOrWhiteSpace(CombatMetaState) + ? "Default" + : CombatMetaState; + settings.Combat.DynamicSettings.Clear(); + foreach ((string optionName, DynamicSettingDocument setting) in + CombatDynamicSettings ?? new Dictionary()) + { + settings.Combat.DynamicSettings[optionName] = setting.ToValue(); + } + settings.Combat.Rules.Clear(); + foreach (MonsterRuleDocument rule in CombatRules ?? []) + { + try { settings.Combat.Rules.Add(rule.ToRule()); } + catch (FormatException error) + { + // Round 3 item 12: log rather than silently drop a + // corrupt side-car monster rule. + logger?.Warn($"MossTank could not restore a monster rule: {error.Message}"); + } + } + if (!settings.Combat.Rules.Any(static rule => rule.IsDefault)) + settings.Combat.Rules.Add(new MonsterRule("DEFAULT", 0)); + + settings.Buffs.BuffAttributes = BuffAttributes; + settings.Buffs.BuffProtections = BuffProtections; + settings.Buffs.BuffAuras = BuffAuras; + settings.Buffs.BuffBanes = BuffBanes; + settings.Buffs.BuffRegeneration = BuffRegeneration; + settings.Buffs.BuffOther = BuffOther; + settings.Buffs.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly; + + settings.Vitals.Enabled = VitalsEnabled; + + settings.Inventory.ScanIntervalSeconds = Math.Clamp( + InventoryScanIntervalSeconds, 0.05d, 10d); + // settings.Inventory.Loot.Enabled is NOT touched here — see the + // field-removal comment above. The .usd's own "EnableLooting" + // row (applied before this side-car, in LoadCurrent) owns it. + settings.Inventory.Loot.ExternalClassifierId = InventoryLootClassifierId?.Trim() + ?? string.Empty; + settings.Inventory.Loot.ScanIntervalSeconds = Math.Clamp( + InventoryLootScanIntervalSeconds, 0.05d, 5d); + settings.Inventory.Loot.Rules.Clear(); + foreach (LootRuleDocument rule in InventoryLootRules ?? []) + settings.Inventory.Loot.Rules.Add(rule.ToRule()); + } + } + + private static string[] Sorted(IEnumerable values) => values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .OrderBy(static value => value, StringComparer.Ordinal) + .ToArray(); + + private static void Replace(ISet target, IEnumerable? values) + { + target.Clear(); + if (values is null) + return; + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + target.Add(value); + } + } + + private sealed class DynamicSettingDocument + { + public MonsterValueKind Kind { get; set; } + public double Number { get; set; } + public string Text { get; set; } = string.Empty; + public bool Boolean { get; set; } + + public static DynamicSettingDocument From(MonsterValue value) => new() + { + Kind = value.Kind, + Number = value.Number, + Text = value.Text, + Boolean = value.Boolean, + }; + + public MonsterValue ToValue() => Kind switch + { + MonsterValueKind.Number => MonsterValue.FromNumber(Number), + MonsterValueKind.Boolean => MonsterValue.FromBoolean(Boolean), + _ => MonsterValue.FromText(Text ?? string.Empty), + }; + } + + private sealed class LootRuleDocument + { + public string Name { get; set; } = "Rule"; + public string Expression { get; set; } = "*"; + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + + public static LootRuleDocument From(LootRule rule) => new() + { + Name = rule.Name, + Expression = rule.Expression, + Action = rule.Action, + KeepCount = rule.KeepCount, + Priority = rule.Priority, + }; + + public LootRule ToRule() => new() + { + Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(), + Expression = string.IsNullOrWhiteSpace(Expression) ? "*" : Expression.Trim(), + Action = Action, + KeepCount = Math.Clamp(KeepCount, 0, 100000), + Priority = Math.Clamp(Priority, -1000, 1000), + }; + } + + private sealed class MonsterRuleDocument + { + public string Expression { get; set; } = "DEFAULT"; + public MonsterActionFlags Flags { get; set; } = MonsterActionFlags.Attack; + public int Priority { get; set; } + public MonsterDamageType DamageType { get; set; } = MonsterDamageType.Auto; + public MonsterDamageType ExtraVulnerability { get; set; } = MonsterDamageType.Auto; + public uint WeaponObjectId { get; set; } + public uint OffhandObjectId { get; set; } + public string WeaponName { get; set; } = string.Empty; + public string OffhandName { get; set; } = string.Empty; + public MonsterDamageType PetDamageType { get; set; } = MonsterDamageType.PlayerAuto; + + public static MonsterRuleDocument From(MonsterRule rule) => new() + { + Expression = rule.Expression, + Flags = rule.Actions.Flags, + Priority = rule.Actions.Priority, + DamageType = rule.Actions.DamageType, + ExtraVulnerability = rule.Actions.ExtraVulnerability, + WeaponObjectId = rule.Actions.WeaponObjectId, + OffhandObjectId = rule.Actions.OffhandObjectId, + WeaponName = rule.Actions.WeaponName, + OffhandName = rule.Actions.OffhandName, + PetDamageType = rule.Actions.PetDamageType, + }; + + public MonsterRule ToRule() => new(Expression, new MonsterRuleActions + { + Flags = Flags, + Priority = Math.Clamp(Priority, -1, 4), + DamageType = DamageType, + ExtraVulnerability = ExtraVulnerability, + WeaponObjectId = WeaponObjectId, + OffhandObjectId = OffhandObjectId, + WeaponName = WeaponName ?? string.Empty, + OffhandName = OffhandName ?? string.Empty, + PetDamageType = PetDamageType, + }); + } + + // ------------------------------------------------------------------ + // Migration-only: the OLD full profile document shape (pre-cutover), + // kept solely so a not-yet-migrated JSON profile can still be read once + // and converted. Nothing else in this file writes this shape again. + // ------------------------------------------------------------------ + + private sealed class LegacyProfileDocument + { + public int Version { get; set; } = 6; + public string[] ItemNames { get; set; } = []; + public string[] ConsumableNames { get; set; } = []; + public Dictionary ConsumableCategories { get; set; } = + new(StringComparer.Ordinal); + public string[] NoBuffItemNames { get; set; } = []; + public LegacyCombatProfileDocument? Combat { get; set; } + public LegacyBuffProfileDocument? Buffs { get; set; } + public LegacyVitalProfileDocument? Vitals { get; set; } + public LegacyInventoryProfileDocument? Inventory { get; set; } + public void Apply( CombatSettings combat, BuffSettings buffs, VitalSettings vitals, InventorySettings inventory, - ISet noBuffItemNames) + ISet noBuffItemNames, + IPluginLogger? logger = null) { - (Combat ?? CombatProfileDocument.Capture(new CombatSettings())) - .Apply(combat); - (Buffs ?? BuffProfileDocument.Capture(new BuffSettings())).Apply(buffs); - (Vitals ?? VitalProfileDocument.Capture(new VitalSettings())).Apply(vitals); - (Inventory ?? InventoryProfileDocument.Capture(new InventorySettings())) - .Apply(inventory); + (Combat ?? LegacyCombatProfileDocument.Capture(new CombatSettings())).Apply(combat, logger); + (Buffs ?? LegacyBuffProfileDocument.Capture(new BuffSettings())).Apply(buffs); + (Vitals ?? LegacyVitalProfileDocument.Capture(new VitalSettings())).Apply(vitals); + (Inventory ?? LegacyInventoryProfileDocument.Capture(new InventorySettings())).Apply(inventory); Replace(combat.CombatItemNames, ItemNames); combat.CombatItemObjectIds.Clear(); Replace(combat.ConsumableNames, ConsumableNames); combat.ConsumableCategories.Clear(); - foreach ((string name, ConsumableCategory category) in - ConsumableCategories - ?? new Dictionary()) + foreach ((string itemName, ConsumableCategory category) in + ConsumableCategories ?? new Dictionary()) { - if (combat.ConsumableNames.Contains(name)) - combat.ConsumableCategories[name] = category; + if (combat.ConsumableNames.Contains(itemName)) + combat.ConsumableCategories[itemName] = category; } Replace(noBuffItemNames, NoBuffItemNames); } } - private sealed class InventoryProfileDocument + private sealed class LegacyInventoryProfileDocument { public bool ManaChargesWhenOff { get; set; } = true; public bool AutoStack { get; set; } = true; @@ -430,7 +1071,7 @@ internal sealed class MossTankProfileStore public bool CombineSalvage { get; set; } = true; public int ManaStoneLootCount { get; set; } = 4; public int ManaTankMinimumMana { get; set; } = 1000; - public float CorpseApproachRange { get; set; } = 40f; + public double CorpseApproachRange { get; set; } = 40d; public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d; public int BlacklistCorpseOpenAttemptCount { get; set; } = 30; public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d; @@ -439,52 +1080,44 @@ internal sealed class MossTankProfileStore public double LootScanIntervalSeconds { get; set; } = 0.25d; public LootRuleDocument[] LootRules { get; set; } = []; - public static InventoryProfileDocument Capture(InventorySettings settings) => - new() - { - ManaChargesWhenOff = settings.ManaChargesWhenOff, - AutoStack = settings.AutoStack, - AutoCram = settings.AutoCram, - AutoCraftItems = settings.AutoCraftItems, - SplitPeas = settings.SplitPeas, - CriticalComponentMinimum = settings.CriticalComponentMinimum, - NormalComponentMinimum = settings.NormalComponentMinimum, - IdleComponentMinimum = settings.IdleComponentMinimum, - IdleHealthKitCount = settings.IdleHealthKitCount, - IdleStaminaKitCount = settings.IdleStaminaKitCount, - IdleManaKitCount = settings.IdleManaKitCount, - IdleHealthFoodCount = settings.IdleHealthFoodCount, - IdleStaminaFoodCount = settings.IdleStaminaFoodCount, - IdleManaFoodCount = settings.IdleManaFoodCount, - RefillWornMana = settings.RefillWornMana, - RefillWornManaPercent = settings.RefillWornManaPercent, - ScanIntervalSeconds = settings.ScanIntervalSeconds, - EnableLooting = settings.Loot.Enabled, - LootClassifierId = settings.Loot.ExternalClassifierId, - LootPriorityBoost = settings.Loot.PriorityBoost, - LootAllCorpses = settings.Loot.LootAllCorpses, - LootFellowCorpses = settings.Loot.LootFellowCorpses, - LootOnlyRareCorpses = settings.Loot.LootOnlyRareCorpses, - ReadUnknownScrolls = settings.Loot.ReadUnknownScrolls, - CombineSalvage = settings.Loot.CombineSalvage, - ManaStoneLootCount = settings.Loot.ManaStoneLootCount, - ManaTankMinimumMana = settings.Loot.ManaTankMinimumMana, - CorpseApproachRange = settings.Loot.CorpseApproachRange, - CorpseOpenTimeoutSeconds = - settings.Loot.CorpseOpenTimeoutSeconds, - BlacklistCorpseOpenAttemptCount = - settings.Loot.BlacklistCorpseOpenAttemptCount, - BlacklistCorpseOpenTimeoutSeconds = - settings.Loot.BlacklistCorpseOpenTimeoutSeconds, - CorpseCacheTimeoutMinutes = - settings.Loot.CorpseCacheTimeoutMinutes, - CorpseLootItemMaxAttempts = - settings.Loot.CorpseLootItemMaxAttempts, - LootScanIntervalSeconds = settings.Loot.ScanIntervalSeconds, - LootRules = settings.Loot.Rules - .Select(LootRuleDocument.From) - .ToArray(), - }; + public static LegacyInventoryProfileDocument Capture(InventorySettings settings) => new() + { + ManaChargesWhenOff = settings.ManaChargesWhenOff, + AutoStack = settings.AutoStack, + AutoCram = settings.AutoCram, + AutoCraftItems = settings.AutoCraftItems, + SplitPeas = settings.SplitPeas, + CriticalComponentMinimum = settings.CriticalComponentMinimum, + NormalComponentMinimum = settings.NormalComponentMinimum, + IdleComponentMinimum = settings.IdleComponentMinimum, + IdleHealthKitCount = settings.IdleHealthKitCount, + IdleStaminaKitCount = settings.IdleStaminaKitCount, + IdleManaKitCount = settings.IdleManaKitCount, + IdleHealthFoodCount = settings.IdleHealthFoodCount, + IdleStaminaFoodCount = settings.IdleStaminaFoodCount, + IdleManaFoodCount = settings.IdleManaFoodCount, + RefillWornMana = settings.RefillWornMana, + RefillWornManaPercent = settings.RefillWornManaPercent, + ScanIntervalSeconds = settings.ScanIntervalSeconds, + EnableLooting = settings.Loot.Enabled, + LootClassifierId = settings.Loot.ExternalClassifierId, + LootPriorityBoost = settings.Loot.PriorityBoost, + LootAllCorpses = settings.Loot.LootAllCorpses, + LootFellowCorpses = settings.Loot.LootFellowCorpses, + LootOnlyRareCorpses = settings.Loot.LootOnlyRareCorpses, + ReadUnknownScrolls = settings.Loot.ReadUnknownScrolls, + CombineSalvage = settings.Loot.CombineSalvage, + ManaStoneLootCount = settings.Loot.ManaStoneLootCount, + ManaTankMinimumMana = settings.Loot.ManaTankMinimumMana, + CorpseApproachRange = settings.Loot.CorpseApproachRange, + CorpseOpenTimeoutSeconds = settings.Loot.CorpseOpenTimeoutSeconds, + BlacklistCorpseOpenAttemptCount = settings.Loot.BlacklistCorpseOpenAttemptCount, + BlacklistCorpseOpenTimeoutSeconds = settings.Loot.BlacklistCorpseOpenTimeoutSeconds, + CorpseCacheTimeoutMinutes = settings.Loot.CorpseCacheTimeoutMinutes, + CorpseLootItemMaxAttempts = settings.Loot.CorpseLootItemMaxAttempts, + LootScanIntervalSeconds = settings.Loot.ScanIntervalSeconds, + LootRules = settings.Loot.Rules.Select(LootRuleDocument.From).ToArray(), + }; public void Apply(InventorySettings settings) { @@ -493,12 +1126,9 @@ internal sealed class MossTankProfileStore settings.AutoCram = AutoCram; settings.AutoCraftItems = AutoCraftItems; settings.SplitPeas = SplitPeas; - settings.CriticalComponentMinimum = Math.Clamp( - CriticalComponentMinimum, 0, 1000); - settings.NormalComponentMinimum = Math.Clamp( - NormalComponentMinimum, 0, 1000); - settings.IdleComponentMinimum = Math.Clamp( - IdleComponentMinimum, 0, 1000); + settings.CriticalComponentMinimum = Math.Clamp(CriticalComponentMinimum, 0, 1000); + settings.NormalComponentMinimum = Math.Clamp(NormalComponentMinimum, 0, 1000); + settings.IdleComponentMinimum = Math.Clamp(IdleComponentMinimum, 0, 1000); settings.IdleHealthKitCount = Math.Clamp(IdleHealthKitCount, 0, 1000); settings.IdleStaminaKitCount = Math.Clamp(IdleStaminaKitCount, 0, 1000); settings.IdleManaKitCount = Math.Clamp(IdleManaKitCount, 0, 1000); @@ -506,118 +1136,54 @@ internal sealed class MossTankProfileStore settings.IdleStaminaFoodCount = Math.Clamp(IdleStaminaFoodCount, 0, 1000); settings.IdleManaFoodCount = Math.Clamp(IdleManaFoodCount, 0, 1000); settings.RefillWornMana = RefillWornMana; - settings.RefillWornManaPercent = Math.Clamp( - RefillWornManaPercent, - 0, - 99); - settings.ScanIntervalSeconds = Math.Clamp( - ScanIntervalSeconds, - 0.05d, - 10d); + settings.RefillWornManaPercent = Math.Clamp(RefillWornManaPercent, 0, 99); + settings.ScanIntervalSeconds = Math.Clamp(ScanIntervalSeconds, 0.05d, 10d); settings.Loot.Enabled = EnableLooting; - settings.Loot.ExternalClassifierId = LootClassifierId?.Trim() - ?? string.Empty; + settings.Loot.ExternalClassifierId = LootClassifierId?.Trim() ?? string.Empty; settings.Loot.PriorityBoost = LootPriorityBoost; settings.Loot.LootAllCorpses = LootAllCorpses; settings.Loot.LootFellowCorpses = LootFellowCorpses; settings.Loot.LootOnlyRareCorpses = LootOnlyRareCorpses; settings.Loot.ReadUnknownScrolls = ReadUnknownScrolls; settings.Loot.CombineSalvage = CombineSalvage; - settings.Loot.ManaStoneLootCount = Math.Clamp( - ManaStoneLootCount, - 0, - 100); - settings.Loot.ManaTankMinimumMana = Math.Clamp( - ManaTankMinimumMana, - 1, - int.MaxValue); - settings.Loot.CorpseApproachRange = Math.Clamp( - CorpseApproachRange, - 2f, - 100f); - settings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp( - CorpseOpenTimeoutSeconds, - 0.25d, - 30d); + settings.Loot.ManaStoneLootCount = Math.Clamp(ManaStoneLootCount, 0, 100); + settings.Loot.ManaTankMinimumMana = Math.Clamp(ManaTankMinimumMana, 1, int.MaxValue); + settings.Loot.CorpseApproachRange = Math.Clamp(CorpseApproachRange, 2f, 100f); + settings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp(CorpseOpenTimeoutSeconds, 0.25d, 30d); settings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp( - BlacklistCorpseOpenAttemptCount, - 1, - 1000); + BlacklistCorpseOpenAttemptCount, 1, 1000); settings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp( - BlacklistCorpseOpenTimeoutSeconds, - 1d, - 3600d); - settings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp( - CorpseCacheTimeoutMinutes, - 1d, - 1440d); - settings.Loot.CorpseLootItemMaxAttempts = Math.Clamp( - CorpseLootItemMaxAttempts, - 1, - 100); - settings.Loot.ScanIntervalSeconds = Math.Clamp( - LootScanIntervalSeconds, - 0.05d, - 5d); + BlacklistCorpseOpenTimeoutSeconds, 1d, 3600d); + settings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp(CorpseCacheTimeoutMinutes, 1d, 1440d); + settings.Loot.CorpseLootItemMaxAttempts = Math.Clamp(CorpseLootItemMaxAttempts, 1, 100); + settings.Loot.ScanIntervalSeconds = Math.Clamp(LootScanIntervalSeconds, 0.05d, 5d); settings.Loot.Rules.Clear(); foreach (LootRuleDocument rule in LootRules ?? []) settings.Loot.Rules.Add(rule.ToRule()); } } - private sealed class LootRuleDocument - { - public string Name { get; set; } = "Rule"; - public string Expression { get; set; } = "*"; - public LootAction Action { get; set; } = LootAction.Keep; - public int KeepCount { get; set; } = 1; - public int Priority { get; set; } - - public static LootRuleDocument From(LootRule rule) => new() - { - Name = rule.Name, - Expression = rule.Expression, - Action = rule.Action, - KeepCount = rule.KeepCount, - Priority = rule.Priority, - }; - - public LootRule ToRule() => new() - { - Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(), - Expression = string.IsNullOrWhiteSpace(Expression) - ? "*" - : Expression.Trim(), - Action = Action, - KeepCount = Math.Clamp(KeepCount, 0, 100000), - Priority = Math.Clamp(Priority, -1000, 1000), - }; - } - - private sealed class CombatProfileDocument + private sealed class LegacyCombatProfileDocument { public bool Enabled { get; set; } = true; - public float MaximumRange { get; set; } = 5f; - public float ApproachDistance { get; set; } + public double MaximumRange { get; set; } = 5d; + public double ApproachDistance { get; set; } public bool IdlePeaceMode { get; set; } - public TargetSelectionMethod SelectionMethod { get; set; } = - TargetSelectionMethod.Both; - public float TargetSelectAngleRange { get; set; } = 5f; + public TargetSelectionMethod SelectionMethod { get; set; } = TargetSelectionMethod.Both; + public double TargetSelectAngleRange { get; set; } = 5d; public bool TargetLock { get; set; } - public PluginAttackHeight AttackHeight { get; set; } = - PluginAttackHeight.Medium; + public PluginAttackHeight AttackHeight { get; set; } = PluginAttackHeight.Medium; public float AttackPower { get; set; } = 0.5f; public bool AutoAttackPower { get; set; } = true; public bool UseRecklessness { get; set; } = true; public double ScanIntervalSeconds { get; set; } = 0.25; public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One; - public DebuffSelectionMethod DebuffSelectionMethod { get; set; } = - DebuffSelectionMethod.Skill; + public DebuffSelectionMethod DebuffSelectionMethod { get; set; } = DebuffSelectionMethod.Skill; public double DebuffPrecastSeconds { get; set; } = 5d; public bool SwitchWandsToDebuff { get; set; } - public bool UseArcs { get; set; } = true; - public float ArcRange { get; set; } = 5f; - public float RingDistance { get; set; } = 5f; + public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange; + public double ArcRange { get; set; } = 5d; + public double RingDistance { get; set; } = 5d; public int MinimumRingTargets { get; set; } = 4; public bool DeleteGhostMonsters { get; set; } = true; public int GhostMonsterSpellAttemptCount { get; set; } = 200; @@ -627,7 +1193,7 @@ internal sealed class MossTankProfileStore public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d; public bool SummonPets { get; set; } = true; public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance; - public float PetCustomRange { get; set; } = 5f; + public double PetCustomRange { get; set; } = 5d; public int PetMonsterDensity { get; set; } = 1; public int PetRefillCountIdle { get; set; } = 3; public int PetRefillCountNormal { get; set; } = 1; @@ -637,7 +1203,7 @@ internal sealed class MossTankProfileStore public MonsterRuleDocument[] Rules { get; set; } = [MonsterRuleDocument.From(new MonsterRule("DEFAULT", 0))]; - public static CombatProfileDocument Capture(CombatSettings value) => new() + public static LegacyCombatProfileDocument Capture(CombatSettings value) => new() { Enabled = value.Enabled, MaximumRange = value.MaximumRange, @@ -679,15 +1245,14 @@ internal sealed class MossTankProfileStore Rules = value.Rules.Select(MonsterRuleDocument.From).ToArray(), }; - public void Apply(CombatSettings value) + public void Apply(CombatSettings value, IPluginLogger? logger = null) { value.Enabled = Enabled; value.MaximumRange = Math.Clamp(MaximumRange, 2f, 100f); value.ApproachDistance = Math.Clamp(ApproachDistance, 0f, 100f); value.IdlePeaceMode = IdlePeaceMode; value.SelectionMethod = SelectionMethod; - value.TargetSelectAngleRange = Math.Clamp( - TargetSelectAngleRange, 2f, value.MaximumRange); + value.TargetSelectAngleRange = Math.Clamp(TargetSelectAngleRange, 2f, value.MaximumRange); value.TargetLock = TargetLock; value.AttackHeight = AttackHeight; value.AttackPower = Math.Clamp(AttackPower, 0f, 1f); @@ -703,108 +1268,41 @@ internal sealed class MossTankProfileStore value.RingDistance = Math.Clamp(RingDistance, 1f, 100f); value.MinimumRingTargets = Math.Clamp(MinimumRingTargets, 1, 25); value.DeleteGhostMonsters = DeleteGhostMonsters; - value.GhostMonsterSpellAttemptCount = Math.Clamp( - GhostMonsterSpellAttemptCount, 1, 1000); - value.BlacklistMonsterAttemptCount = Math.Clamp( - BlacklistMonsterAttemptCount, 1, 20); - value.BlacklistMonsterTimeoutSeconds = Math.Clamp( - BlacklistMonsterTimeoutSeconds, 1d, 3600d); + value.GhostMonsterSpellAttemptCount = Math.Clamp(GhostMonsterSpellAttemptCount, 1, 1000); + value.BlacklistMonsterAttemptCount = Math.Clamp(BlacklistMonsterAttemptCount, 1, 20); + value.BlacklistMonsterTimeoutSeconds = Math.Clamp(BlacklistMonsterTimeoutSeconds, 1d, 3600d); value.DeleteGhostMonstersByHealthTracker = DeleteGhostMonstersByHealthTracker; - value.GhostDeleteHealthTrackerSeconds = Math.Clamp( - GhostDeleteHealthTrackerSeconds, 1d, 300d); + value.GhostDeleteHealthTrackerSeconds = Math.Clamp(GhostDeleteHealthTrackerSeconds, 1d, 300d); value.SummonPets = SummonPets; value.PetRangeMode = PetRangeMode; value.PetCustomRange = Math.Clamp(PetCustomRange, 1f, 100f); value.PetMonsterDensity = Math.Clamp(PetMonsterDensity, 1, 25); value.PetRefillCountIdle = Math.Clamp(PetRefillCountIdle, 0, 3); value.PetRefillCountNormal = Math.Clamp(PetRefillCountNormal, 0, 3); - value.MetaState = string.IsNullOrWhiteSpace(MetaState) - ? "Default" - : MetaState; + value.MetaState = string.IsNullOrWhiteSpace(MetaState) ? "Default" : MetaState; value.DynamicSettings.Clear(); - foreach ((string name, DynamicSettingDocument setting) in + foreach ((string settingName, DynamicSettingDocument setting) in DynamicSettings ?? new Dictionary()) { - value.DynamicSettings[name] = setting.ToValue(); + value.DynamicSettings[settingName] = setting.ToValue(); } value.Rules.Clear(); foreach (MonsterRuleDocument rule in Rules ?? []) { try { value.Rules.Add(rule.ToRule()); } - catch (FormatException) { } + catch (FormatException error) + { + // Round 3 item 12: log rather than silently drop a + // corrupt legacy-JSON monster rule during migration. + logger?.Warn($"MossTank could not restore a legacy monster rule: {error.Message}"); + } } if (!value.Rules.Any(static rule => rule.IsDefault)) value.Rules.Add(new MonsterRule("DEFAULT", 0)); } } - private sealed class DynamicSettingDocument - { - public MonsterValueKind Kind { get; set; } - public double Number { get; set; } - public string Text { get; set; } = string.Empty; - public bool Boolean { get; set; } - - public static DynamicSettingDocument From(MonsterValue value) => new() - { - Kind = value.Kind, - Number = value.Number, - Text = value.Text, - Boolean = value.Boolean, - }; - - public MonsterValue ToValue() => Kind switch - { - MonsterValueKind.Number => MonsterValue.FromNumber(Number), - MonsterValueKind.Boolean => MonsterValue.FromBoolean(Boolean), - _ => MonsterValue.FromText(Text ?? string.Empty), - }; - } - - private sealed class MonsterRuleDocument - { - public string Expression { get; set; } = "DEFAULT"; - public MonsterActionFlags Flags { get; set; } = MonsterActionFlags.Attack; - public int Priority { get; set; } - public MonsterDamageType DamageType { get; set; } = MonsterDamageType.Auto; - public MonsterDamageType ExtraVulnerability { get; set; } = - MonsterDamageType.Auto; - public uint WeaponObjectId { get; set; } - public uint OffhandObjectId { get; set; } - public string WeaponName { get; set; } = string.Empty; - public string OffhandName { get; set; } = string.Empty; - public MonsterDamageType PetDamageType { get; set; } = - MonsterDamageType.PlayerAuto; - - public static MonsterRuleDocument From(MonsterRule rule) => new() - { - Expression = rule.Expression, - Flags = rule.Actions.Flags, - Priority = rule.Actions.Priority, - DamageType = rule.Actions.DamageType, - ExtraVulnerability = rule.Actions.ExtraVulnerability, - WeaponObjectId = rule.Actions.WeaponObjectId, - OffhandObjectId = rule.Actions.OffhandObjectId, - WeaponName = rule.Actions.WeaponName, - OffhandName = rule.Actions.OffhandName, - PetDamageType = rule.Actions.PetDamageType, - }; - - public MonsterRule ToRule() => new(Expression, new MonsterRuleActions - { - Flags = Flags, - Priority = Math.Clamp(Priority, -1, 4), - DamageType = DamageType, - ExtraVulnerability = ExtraVulnerability, - WeaponObjectId = WeaponObjectId, - OffhandObjectId = OffhandObjectId, - WeaponName = WeaponName ?? string.Empty, - OffhandName = OffhandName ?? string.Empty, - PetDamageType = PetDamageType, - }); - } - - private sealed class BuffProfileDocument + private sealed class LegacyBuffProfileDocument { public bool Enabled { get; set; } = true; public bool IdleBuffTopoff { get; set; } @@ -818,8 +1316,18 @@ internal sealed class MossTankProfileStore public bool BuffRegeneration { get; set; } = true; public bool BuffOther { get; set; } public bool BuffTrainedSkillsOnly { get; set; } = true; + public double BuffCastRecastSeconds { get; set; } = 30d; + public double BuffCastRecastResetSeconds { get; set; } = 30d; + public bool FastCastBuffs { get; set; } + public bool RandomHelperBuffs { get; set; } + public double RandomHelperIntervalSeconds { get; set; } = 5d; + public string BlacklistedSpellComponents { get; set; } = string.Empty; + public string ProtectionElements { get; set; } = "ALFCBPS"; + public int ProtectionProfileMode { get; set; } = 2; + public string BaneElements { get; set; } = "ALFCBPS"; + public int BaneProfileMode { get; set; } = 2; - public static BuffProfileDocument Capture(BuffSettings value) => new() + public static LegacyBuffProfileDocument Capture(BuffSettings value) => new() { Enabled = value.Enabled, IdleBuffTopoff = value.IdleBuffTopoff, @@ -833,18 +1341,25 @@ internal sealed class MossTankProfileStore BuffRegeneration = value.BuffRegeneration, BuffOther = value.BuffOther, BuffTrainedSkillsOnly = value.BuffTrainedSkillsOnly, + BuffCastRecastSeconds = value.BuffCastRecastSeconds, + BuffCastRecastResetSeconds = value.BuffCastRecastResetSeconds, + FastCastBuffs = value.FastCastBuffs, + RandomHelperBuffs = value.RandomHelperBuffs, + RandomHelperIntervalSeconds = value.RandomHelperIntervalSeconds, + BlacklistedSpellComponents = value.BlacklistedSpellComponents, + ProtectionElements = value.ProtectionElements, + ProtectionProfileMode = value.ProtectionProfileMode, + BaneElements = value.BaneElements, + BaneProfileMode = value.BaneProfileMode, }; public void Apply(BuffSettings value) { value.Enabled = Enabled; value.IdleBuffTopoff = IdleBuffTopoff; - value.IdleBuffTopoffSeconds = Math.Clamp( - IdleBuffTopoffSeconds, 30d, 7200d); - value.RebuffWhenUnderSeconds = Math.Clamp( - RebuffWhenUnderSeconds, 30d, 1800d); - value.SkillExcessOverDifficulty = Math.Clamp( - SkillExcessOverDifficulty, -100, 100); + value.IdleBuffTopoffSeconds = Math.Clamp(IdleBuffTopoffSeconds, 30d, 7200d); + value.RebuffWhenUnderSeconds = Math.Clamp(RebuffWhenUnderSeconds, 30d, 1800d); + value.SkillExcessOverDifficulty = Math.Clamp(SkillExcessOverDifficulty, -100, 100); value.BuffAttributes = BuffAttributes; value.BuffProtections = BuffProtections; value.BuffAuras = BuffAuras; @@ -852,10 +1367,20 @@ internal sealed class MossTankProfileStore value.BuffRegeneration = BuffRegeneration; value.BuffOther = BuffOther; value.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly; + value.BuffCastRecastSeconds = Math.Clamp(BuffCastRecastSeconds, 0d, 3600d); + value.BuffCastRecastResetSeconds = Math.Clamp(BuffCastRecastResetSeconds, 0d, 3600d); + value.FastCastBuffs = FastCastBuffs; + value.RandomHelperBuffs = RandomHelperBuffs; + value.RandomHelperIntervalSeconds = Math.Clamp(RandomHelperIntervalSeconds, 0.25d, 3600d); + value.BlacklistedSpellComponents = BlacklistedSpellComponents ?? string.Empty; + value.ProtectionElements = ProtectionElements ?? "ALFCBPS"; + value.ProtectionProfileMode = Math.Clamp(ProtectionProfileMode, 1, 8); + value.BaneElements = BaneElements ?? "ALFCBPS"; + value.BaneProfileMode = Math.Clamp(BaneProfileMode, 1, 8); } } - private sealed class VitalProfileDocument + private sealed class LegacyVitalProfileDocument { public bool Enabled { get; set; } = true; public double NormalHealth { get; set; } = 0.75; @@ -869,7 +1394,7 @@ internal sealed class MossTankProfileStore public double HelperMana { get; set; } = 0.01; public bool HelpOthers { get; set; } = true; - public static VitalProfileDocument Capture(VitalSettings value) => new() + public static LegacyVitalProfileDocument Capture(VitalSettings value) => new() { Enabled = value.Enabled, NormalHealth = value.NormalHealth, @@ -901,22 +1426,4 @@ internal sealed class MossTankProfileStore private static double Clamp(double value) => Math.Clamp(value, 0d, 1d); } - - private static string[] Sorted(IEnumerable values) => values - .Where(static value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .OrderBy(static value => value, StringComparer.Ordinal) - .ToArray(); - - private static void Replace(ISet target, IEnumerable? values) - { - target.Clear(); - if (values is null) - return; - foreach (string value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - target.Add(value); - } - } } diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs index e1773615..5035eb04 100644 --- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -6,45 +6,86 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// -/// Independent VTank navigation-profile lifecycle. The selected route is -/// remembered per character; "By char" is a private route document and named -/// profiles are reusable copies. +/// VTank-compatible navigation-route lifecycle. .af is the ONLY +/// storage/authoring format — but only for the fields metaf's own +/// NAV: grammar actually represents (, +/// , +/// / +/// — see +/// /). +/// Every other field +/// (Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/OpenDoors/ +/// Door*) is a real VTank Settings-table row with its own catalog name +/// (EnableNav, NavPriorityBoost, NavCloseStopRange, +/// …) already owned end-to-end by 's +/// .usd profile — this store never touches them, matching real +/// VTank's own split between the Settings-scoped nav prefs and the +/// per-route file. +/// +/// Named route files carry a nav_ prefix (metaf's own observed +/// convention for a stand-alone nav-only .af, see the committed +/// nav_*.af fixtures) so a route and a Meta profile sharing the same +/// user-typed name never collide in the shared +/// directory. /// internal sealed class MossTankRouteProfileStore { public const string ByCharacter = "By char"; - private const string IndexKey = "profiles/route/index.json"; - private static readonly JsonSerializerOptions Options = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - }; + private const string NavPrefix = "nav_"; + // The pre-cutover roster (round 3 item 2): before the .af cutover this + // store kept a flat Names list here (named routes were never + // owner-scoped — one shared, globally-hashed key per name). The + // cutover stopped reading this key at all, orphaning every named route + // except whichever one a character had selected (that one alone still + // converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by + // the one-time SweepLegacyRosterIfNeeded. + private const string LegacyRosterKey = "profiles/route/index.json"; private readonly IPluginHost _host; - private IndexDocument _index; private string _characterName = string.Empty; private string _selected = ByCharacter; + private string? _pendingLegacyBareName; + private bool _rosterSwept; public MossTankRouteProfileStore(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; + public string Selected => Strip(_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 string Server => _host.Automation.Character.WorldName; + private IPluginStorage VtankStorage => _host.VtankProfiles; + private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0; + + private static string Strip(string fileName) + { + if (fileName.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + return fileName; + string value = fileName; + if (value.StartsWith(NavPrefix, StringComparison.Ordinal)) + value = value[NavPrefix.Length..]; + if (value.EndsWith(".af", StringComparison.OrdinalIgnoreCase)) + value = value[..^3]; + return value; + } + + public IReadOnlyList AvailableNames + { + get + { + var names = new List { ByCharacter }; + foreach (VtankProfileDirectory.ProfileEntry entry in + VtankProfileDirectory.ListNavigationProfiles(VtankStorage)) + { + if (entry.FileName.Length == 0) + continue; // VTank's own "[None]"/"[By char]" sentinels. + names.Add(Strip(entry.FileName)); + } + return names; + } + } public bool BindCharacter(string? characterName) { @@ -54,24 +95,46 @@ internal sealed class MossTankRouteProfileStore if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase)) return false; _characterName = normalized; - _selected = _index.SelectedByCharacter.TryGetValue( - SelectionKey(), - out string? selected) - && IsKnown(selected) - ? CanonicalName(selected) - : ByCharacter; + _pendingLegacyBareName = null; + VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles + ? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server) + : null; + _selected = binding is { NavFileName.Length: > 0 } bound + ? bound.NavFileName + : ByCharacter; return true; } public bool Select(string? name) { - string normalized = name?.Trim() ?? string.Empty; - if (!IsKnown(normalized)) + string normalized = Normalize(name); + if (normalized.Length == 0) return false; - _selected = CanonicalName(normalized); - _index.SelectedByCharacter[SelectionKey()] = _selected; - SaveIndex(); - return true; + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + _selected = ByCharacter; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + + string candidate = ToFileName(normalized); + if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null) + { + _selected = candidate; + _pendingLegacyBareName = null; + WriteBinding(); + return true; + } + if (_host.Storage.IsAvailable + && _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null) + { + _selected = candidate; + _pendingLegacyBareName = normalized; + WriteBinding(); + return true; + } + return false; } public bool Create( @@ -80,7 +143,7 @@ internal sealed class MossTankRouteProfileStore NavigationSettings current, out string notice) { - string normalized = name?.Trim() ?? string.Empty; + string normalized = Normalize(name); if (normalized.Length is < 1 or > 64) { notice = "Enter a route profile name (1-64 characters)."; @@ -91,46 +154,40 @@ internal sealed class MossTankRouteProfileStore notice = "'By char' is the built-in route profile."; return false; } - Write( - ProfileKey(normalized, byCharacter: false), - copyCurrent - ? RouteDocument.Capture(current) - : new RouteDocument()); - 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, copyCurrent ? current : new NavigationSettings()); + string fileName = ToFileName(normalized); + NavigationSettings source = copyCurrent ? current : new NavigationSettings(); + WriteAf(fileName, MetafSerializer.SaveNav(source)); + _selected = fileName; + _pendingLegacyBareName = null; + WriteBinding(); notice = copyCurrent - ? $"Copied route to {_selected}." - : $"Created route profile {_selected}."; + ? $"Copied route to {Strip(fileName)}." + : $"Created route profile {Strip(fileName)}."; return true; } - public bool LoadCurrent(NavigationSettings target) + public bool LoadCurrent(NavigationSettings target, ISpellCatalog spells) { ArgumentNullException.ThrowIfNull(target); - RouteDocument? document = Read(CurrentKey()); - if (document is null) + ArgumentNullException.ThrowIfNull(spells); + SweepLegacyRosterIfNeeded(); + MigrateLegacyIfNeeded(target, spells); + string fileName = CurrentFileName(); + string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; + if (text is null) return false; - document.Apply(target); + if (!MetafSerializer.TryLoadNav(text, target, spells, out string error)) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "route", fileName, text, new FormatException(error)); + _host.Log.Warn(RecoveryNotice); + return false; + } return true; } - public void SaveCurrent(NavigationSettings settings) - { - Write(CurrentKey(), RouteDocument.Capture(settings)); - WriteLegacyExport( - _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - ? string.IsNullOrWhiteSpace(_characterName) - ? ByCharacter - : _characterName - : _selected, - settings); - } + public void SaveCurrent(NavigationSettings settings) => + WriteAf(CurrentFileName(), MetafSerializer.SaveNav(settings)); public bool TryImportLegacy( string? name, @@ -138,7 +195,7 @@ internal sealed class MossTankRouteProfileStore ISpellCatalog spells, out string notice) { - string normalized = name?.Trim() ?? string.Empty; + string normalized = Normalize(name); if (!_host.Storage.IsAvailable || normalized.Length == 0) { notice = "Legacy navigation storage is unavailable."; @@ -162,64 +219,163 @@ internal sealed class MossTankRouteProfileStore notice = $"Could not import {Path.GetFileName(key)}: {error}"; return false; } - 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(); - SaveCurrent(target); - notice = $"Imported VTank navigation profile {_selected}."; + string fileName = ToFileName(normalized); + WriteAf(fileName, MetafSerializer.SaveNav(target)); + _selected = fileName; + _pendingLegacyBareName = null; + WriteBinding(); + notice = $"Imported VTank navigation profile {Strip(fileName)}."; return true; } + /// + /// Round 3 item 10: deletes the currently selected named route's real + /// .af file, then falls back to . + /// Refuses for itself — there is no file to + /// delete, only a reset (see ), matching + /// 's own contract. + /// + public bool Delete(out string notice) + { + if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in route profile and cannot be deleted."; + return false; + } + string fileName = _selected; + if (VtankStorage.IsAvailable) + VtankStorage.Delete(fileName); + _selected = ByCharacter; + _pendingLegacyBareName = null; + WriteBinding(); + notice = $"Deleted route profile {Strip(fileName)}."; + return true; + } + + /// + /// Resets only the fields this store owns (Mode/Waypoints/FollowTarget) + /// — Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/ + /// OpenDoors/Door* belong to the Settings profile and are left alone. + /// public void ClearCurrent(NavigationSettings target) { - target.Enabled = false; - target.Priority = false; target.Mode = RouteMode.Circular; - target.MinimumDistanceMeters = 2d; target.FollowTargetObjectId = 0u; target.FollowTargetName = string.Empty; - target.FollowAroundCorners = true; - target.OpenDoors = false; target.Waypoints.Clear(); SaveCurrent(target); } - private bool IsKnown(string? name) => name is not null - && (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - || _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase)); + // ------------------------------------------------------------------ + // Legacy JSON -> .af migration. + // ------------------------------------------------------------------ - private string CanonicalName(string name) => name.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? ByCharacter - : _index.Names.First(entry => entry.Equals( - name, - StringComparison.OrdinalIgnoreCase)); - - private string CurrentKey() => _selected.Equals( - ByCharacter, - StringComparison.OrdinalIgnoreCase) - ? ProfileKey(_characterName, byCharacter: true) - : ProfileKey(_selected, byCharacter: false); - - private static string ProfileKey(string value, bool byCharacter) + /// + /// Round 3 item 2: a ONE-TIME sweep (guarded by ) + /// over the pre-cutover roster at . Unlike + /// — which only ever converts the + /// ONE currently-selected route — this converts every OTHER named route + /// the old roster still lists, using a fresh + /// per entry so the live/selected route is never touched. Named routes + /// were never owner-scoped (one shared, globally-hashed key per name), + /// so every entry converts regardless of which character is currently + /// bound. + /// + private void SweepLegacyRosterIfNeeded() { - string identity = (byCharacter ? "char:" : "named:") - + value.Trim().ToUpperInvariant(); - string hash = Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes(identity))); - return $"profiles/route/{hash}.json"; + if (_rosterSwept) + return; + _rosterSwept = true; + if (!_host.Storage.IsAvailable) + return; + List? names = ReadRosterNames(); + if (names is not { Count: > 0 }) + 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 = LegacyProfileKey(name, byCharacter: false); + LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = ToFileName(name); + if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null) + { + var scratch = new NavigationSettings(); + legacy.ApplyRouteOnly(scratch); + WriteAf(fileName, MetafSerializer.SaveNav(scratch)); + } + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + + if (remaining.Count > 0) + { + _host.Storage.WriteText( + LegacyRosterKey, + JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions)); + } + else + { + _host.Storage.Delete(LegacyRosterKey); + } + _host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster."); } - private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName) - ? "_default" - : _characterName; + private List? ReadRosterNames() + { + string? json = null; + try + { + json = _host.Storage.ReadText(LegacyRosterKey); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, JsonOptions)?.Names; + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } - private T? Read(string key) where T : class + private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells) + { + string fileName = CurrentFileName(); + if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null) + { + _pendingLegacyBareName = null; + return; + } + string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName + ? LegacyProfileKey(bareName, byCharacter: false) + : LegacyProfileKey(_characterName, byCharacter: true); + LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + { + _pendingLegacyBareName = null; + return; + } + legacy.ApplyRouteOnly(target); + WriteAf(fileName, MetafSerializer.SaveNav(target)); + if (_host.Storage.IsAvailable) + _host.Storage.Delete(legacyKey); + _pendingLegacyBareName = null; + _host.Log.Warn($"Migrated legacy MossTank route profile '{legacyKey}' to '{fileName}'."); + } + + private LegacyRouteDocument? ReadLegacyJson(string key) { if (!_host.Storage.IsAvailable) return null; @@ -229,28 +385,56 @@ internal sealed class MossTankRouteProfileStore json = _host.Storage.ReadText(key); return string.IsNullOrWhiteSpace(json) ? null - : JsonSerializer.Deserialize(json, Options); + : JsonSerializer.Deserialize(json, JsonOptions); } catch (Exception error) { - RecoveryNotice = MossTankProfileRecovery.Preserve( - _host, - "route", - key, - json, - error); + RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", key, json, error); _host.Log.Warn(RecoveryNotice); return null; } } - private void Write(string key, T document) + // ------------------------------------------------------------------ + // File naming, storage plumbing. + // ------------------------------------------------------------------ + + // Round 3 item 3: the hidden "--" prefix MUST come before the "nav_" + // kind marker (--nav_Name_Server.af) — putting the marker first + // (nav_--Name_Server.af, the pre-fix shape) means the filename does not + // start with "--" at all, defeating the StartsWith("--") hidden-file + // check both ListNavigationProfiles and ListMetaProfiles rely on and + // leaking this character's private per-character route to every other + // character's picker. + private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix) + : _selected; + + private static string ToFileName(string bareName) => + NavPrefix + bareName + ".af"; + + private void WriteBinding() { - if (!_host.Storage.IsAvailable) + if (!CanBindFiles || !VtankStorage.IsAvailable) + return; + VtankProfileDirectory.VtankCharacterBinding existing = + VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server) + ?? new VtankProfileDirectory.VtankCharacterBinding( + string.Empty, string.Empty, CurrentFileName(), null); + VtankProfileDirectory.WriteCharacterBinding( + VtankStorage, + _characterName, + Server, + existing with { NavFileName = CurrentFileName() }); + } + + private void WriteAf(string fileName, string text) + { + if (!VtankStorage.IsAvailable) return; try { - _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + VtankStorage.WriteText(fileName, text); } catch (Exception error) { @@ -258,47 +442,35 @@ internal sealed class MossTankRouteProfileStore } } - private void SaveIndex() => Write(IndexKey, _index); + private static string Normalize(string? name) => name?.Trim() ?? string.Empty; - private void WriteLegacyExport(string name, NavigationSettings settings) + private static string LegacyProfileKey(string value, bool byCharacter) { - if (!_host.Storage.IsAvailable) - return; - try - { - _host.Storage.WriteText( - $"exports/{LegacyFileName(name)}.nav", - VtankNavRouteSerializer.Save(settings)); - } - catch (Exception error) - { - _host.Log.Warn( - $"MossTank VTank navigation export could not be saved: {error.Message}"); - } + string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/route/{hash}.json"; } - private static string LegacyFileName(string name) + private static readonly JsonSerializerOptions JsonOptions = new() { - 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 ? "Route" : result.ToString(); - } + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; - private sealed class IndexDocument + // Read-only: the pre-cutover roster shape at LegacyRosterKey, kept + // solely so SweepLegacyRosterIfNeeded can recover it once. + private sealed class LegacyRosterDocument { - public int Version { get; set; } = 1; public List Names { get; set; } = []; - public Dictionary SelectedByCharacter { get; set; } = - new(StringComparer.OrdinalIgnoreCase); } - private sealed class RouteDocument + // ------------------------------------------------------------------ + // Migration-only: the OLD full route document shape, kept solely so a + // not-yet-migrated JSON profile can still be read once and converted. + // Only the fields this store still owns (Mode/Waypoints/FollowTarget) + // are applied — the rest belonged to the Settings profile even before + // the cutover and are left to whatever that profile already holds. + private sealed class LegacyRouteDocument { public int Version { get; set; } = 1; public bool Enabled { get; set; } @@ -312,50 +484,20 @@ internal sealed class MossTankRouteProfileStore public double DoorIdentifyRangeMeters { get; set; } = 20d; public double DoorOpenRangeMeters { get; set; } = 4d; public int DoorLockpickExcessThreshold { get; set; } = -50; - public WaypointDocument[] Waypoints { get; set; } = []; + public LegacyWaypointDocument[] Waypoints { get; set; } = []; - public static RouteDocument Capture(NavigationSettings value) => new() + public void ApplyRouteOnly(NavigationSettings value) { - Enabled = value.Enabled, - Priority = value.Priority, - Mode = value.Mode, - MinimumDistanceMeters = value.MinimumDistanceMeters, - FollowTargetObjectId = value.FollowTargetObjectId, - FollowTargetName = value.FollowTargetName, - FollowAroundCorners = value.FollowAroundCorners, - OpenDoors = value.OpenDoors, - DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters, - DoorOpenRangeMeters = value.DoorOpenRangeMeters, - DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold, - Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(), - }; - - public void Apply(NavigationSettings value) - { - value.Enabled = Enabled; - value.Priority = Priority; value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular; - value.MinimumDistanceMeters = Math.Clamp( - MinimumDistanceMeters, - 0.5d, - 50d); value.FollowTargetObjectId = FollowTargetObjectId; value.FollowTargetName = FollowTargetName ?? string.Empty; - value.FollowAroundCorners = FollowAroundCorners; - value.OpenDoors = OpenDoors; - value.DoorIdentifyRangeMeters = Math.Clamp( - DoorIdentifyRangeMeters, 1d, 100d); - value.DoorOpenRangeMeters = Math.Clamp( - DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters); - value.DoorLockpickExcessThreshold = Math.Clamp( - DoorLockpickExcessThreshold, -500, 500); value.Waypoints.Clear(); - foreach (WaypointDocument waypoint in Waypoints ?? []) + foreach (LegacyWaypointDocument waypoint in Waypoints ?? []) value.Waypoints.Add(waypoint.ToWaypoint()); } } - private sealed class WaypointDocument + private sealed class LegacyWaypointDocument { public RouteWaypointType Type { get; set; } public uint CellId { get; set; } @@ -364,6 +506,12 @@ internal sealed class MossTankRouteProfileStore public double Elevation { get; set; } public float HeadingDegrees { get; set; } public bool IsOutdoor { get; set; } + public uint ReferenceCellId { get; set; } + public double ReferenceEastWest { get; set; } + public double ReferenceNorthSouth { get; set; } + public double ReferenceElevation { get; set; } + public float ReferenceHeadingDegrees { get; set; } + public bool ReferenceIsOutdoor { get; set; } public uint ObjectId { get; set; } public string ObjectName { get; set; } = string.Empty; public int LegacyObjectClass { get; set; } @@ -378,40 +526,18 @@ internal sealed class MossTankRouteProfileStore public int JumpChargeMilliseconds { get; set; } = 1000; public RouteJumpDirection JumpDirection { get; set; } - public static WaypointDocument From(RouteWaypoint value) => new() - { - Type = value.Type, - CellId = value.Position.CellId, - EastWest = value.Position.EastWest, - NorthSouth = value.Position.NorthSouth, - Elevation = value.Position.Elevation, - HeadingDegrees = value.Position.HeadingDegrees, - IsOutdoor = value.Position.IsOutdoor, - ObjectId = value.ObjectId, - ObjectName = value.ObjectName, - LegacyObjectClass = value.LegacyObjectClass, - LegacyReferenceValid = value.LegacyReferenceValid, - Text = value.Text, - DurationMilliseconds = value.DurationMilliseconds, - Recall = value.Recall, - RecallSpellId = value.RecallSpellId, - RecallSpellName = value.RecallSpellName, - JumpHeadingDegrees = value.JumpHeadingDegrees, - JumpRun = value.JumpRun, - JumpChargeMilliseconds = value.JumpChargeMilliseconds, - JumpDirection = value.JumpDirection, - }; - public RouteWaypoint ToWaypoint() => new() { Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point, Position = new PluginNavigationPosition( - CellId, - EastWest, - NorthSouth, - Elevation, - HeadingDegrees, - IsOutdoor), + CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor), + ReferencePosition = new PluginNavigationPosition( + ReferenceCellId, + ReferenceEastWest, + ReferenceNorthSouth, + ReferenceElevation, + ReferenceHeadingDegrees, + ReferenceIsOutdoor), ObjectId = ObjectId, ObjectName = ObjectName ?? string.Empty, LegacyObjectClass = LegacyObjectClass, @@ -421,17 +547,15 @@ internal sealed class MossTankRouteProfileStore Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone, RecallSpellId = RecallSpellId, RecallSpellName = RecallSpellName ?? string.Empty, - JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) - ? JumpHeadingDegrees - : 0f, + JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f, JumpRun = JumpRun, - JumpChargeMilliseconds = Math.Clamp( - JumpChargeMilliseconds, - 0, - 10_000), - JumpDirection = Enum.IsDefined(JumpDirection) - ? JumpDirection - : RouteJumpDirection.Forward, + // Round 3 item 5: no load-time clamp here either — the real + // bi.a 2000 ms ceiling is retail's EXECUTION-time behavior + // (NavigationController.TickJump), not a storage-format limit; + // this legacy migration path preserves whatever the pre-cutover + // JSON authored, exactly like the .af load path now does. + JumpChargeMilliseconds = JumpChargeMilliseconds, + JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward, }; } } diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs index 839972ad..fc3622c5 100644 --- a/src/AcDream.Plugins.MossTank/Navigation.cs +++ b/src/AcDream.Plugins.MossTank/Navigation.cs @@ -44,6 +44,17 @@ internal sealed class RouteWaypoint { public RouteWaypointType Type { get; set; } public PluginNavigationPosition Position { get; set; } + /// + /// The second of the two coordinate triples VTank's own Portal2/UseNPC + /// waypoint records carry (metaf's "ptl"/"tlk" nodes: "myx myy myz tgtx + /// tgty tgtz" — metaf_monolithic.py:356-357,11482,11618; the + /// binary .nav record's own trailing xyz). + /// is where the bot stands to use the object ("myxyz"); this is the + /// portal/NPC object's own recorded position ("objxyz"), used to + /// disambiguate which object at that name to interact with. Unused for + /// every other waypoint type. + /// + public PluginNavigationPosition ReferencePosition { get; set; } public uint ObjectId { get; set; } public string ObjectName { get; set; } = string.Empty; /// Decal ObjectClass retained for exact VTank NAV interchange. @@ -64,6 +75,7 @@ internal sealed class RouteWaypoint { Type = Type, Position = Position, + ReferencePosition = ReferencePosition, ObjectId = ObjectId, ObjectName = ObjectName, LegacyObjectClass = LegacyObjectClass, @@ -185,6 +197,13 @@ internal sealed class NavigationController private const double RecallExitDistanceMeters = 2.4d; private const double JumpLaunchGraceSeconds = 0.25d; private const double JumpCompletionTimeoutSeconds = 3d; + // Round 3 item 5: retail's real jump-charge ceiling + // (refs/vtank/decompiled/bi.cs:502-505, bi.a — "if (A_2 > 2000.0) A_2 = + // 2000.0" at the moment the jump starts charging) belongs HERE, at + // execution, not at .af/legacy-JSON load — a waypoint's authored + // JumpChargeMilliseconds round-trips through storage unclamped; only + // the actual in-game charge duration is capped. + private const int JumpChargeCeilingMilliseconds = 2000; private const double CheckpointRetrySeconds = 15d; private const double FollowBreadcrumbSpacingMeters = 0.096d; private const double FollowPathCaptureRangeMeters = 240d; @@ -711,9 +730,15 @@ internal sealed class NavigationController StringComparison.OrdinalIgnoreCase)); if (!currentStillExists) { + // Search near the object's own recorded position ("tgtxyz" + // in metaf's ptl/tlk grammar), not where the bot was + // standing when the waypoint was authored ("myxyz" — + // waypoint.Position): the object can be meaningfully far + // from the approach point (e.g. a portal at the far end of + // a room). if (!_host.Automation.Navigation.TryFindObject( waypoint.ObjectName, - waypoint.Position, + waypoint.ReferencePosition, ObjectReacquireRadiusMeters, out PluginNavigationObject reacquired)) { @@ -941,15 +966,16 @@ internal sealed class NavigationController } _jumpChargeElapsed += elapsedSeconds; - bool hold = _jumpChargeElapsed * 1000d - < Math.Max(0, waypoint.JumpChargeMilliseconds); + int effectiveChargeMilliseconds = Math.Clamp( + waypoint.JumpChargeMilliseconds, 0, JumpChargeCeilingMilliseconds); + bool hold = _jumpChargeElapsed * 1000d < effectiveChargeMilliseconds; if (hold) { PluginMovementIntent intent = JumpIntent(waypoint, jump: true); _hadMovementIntent = _host.Automation.Navigation .SetMovementIntent(intent) == PluginNavigationCommandStatus.Accepted; - _status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms."; + _status = $"Charging jump: {effectiveChargeMilliseconds}ms."; return true; } PluginMovementIntent release = JumpIntent(waypoint, jump: false); diff --git a/src/AcDream.Plugins.MossTank/PetAutomation.cs b/src/AcDream.Plugins.MossTank/PetAutomation.cs index b70c5b42..d710247e 100644 --- a/src/AcDream.Plugins.MossTank/PetAutomation.cs +++ b/src/AcDream.Plugins.MossTank/PetAutomation.cs @@ -117,9 +117,9 @@ internal sealed class PetAutomation if (!settings.SummonPets || activeOwnedPetCount > 0) return PetAutomationChoice.None; - float range = settings.PetRangeMode == PetRangeMode.Custom + float range = (float)(settings.PetRangeMode == PetRangeMode.Custom ? settings.PetCustomRange - : settings.MaximumRange; + : settings.MaximumRange); int density = Math.Max(1, settings.PetMonsterDensity); var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>(); foreach (PluginCombatTarget target in targets) diff --git a/src/AcDream.Plugins.MossTank/VitalPlan.cs b/src/AcDream.Plugins.MossTank/VitalPlan.cs index 637b3f68..d1bb3101 100644 --- a/src/AcDream.Plugins.MossTank/VitalPlan.cs +++ b/src/AcDream.Plugins.MossTank/VitalPlan.cs @@ -35,9 +35,11 @@ public sealed class VitalSettings public double HelperHealth { get; set; } = 0.20; public double HelperStamina { get; set; } = 0.01; public double HelperMana { get; set; } = 0.01; - public float HelperHealthDistance { get; set; } = 59.6f; - public float HelperStaminaDistance { get; set; } = 59.6f; - public float HelperManaDistance { get; set; } = 32f; + // Declared tDouble in VTank's own Settings table (item I, slice-1 fix + // round) — see CombatSettings.MaximumRange's doc comment for why. + public double HelperHealthDistance { get; set; } = 59.6d; + public double HelperStaminaDistance { get; set; } = 59.6d; + public double HelperManaDistance { get; set; } = 32d; public bool HelpOthers { get; set; } = true; public bool UseHealersHeart { get; set; } = true; @@ -46,6 +48,19 @@ public sealed class VitalSettings public bool ClearLevelBoostFlagOnCast { get; set; } = true; public int DropToPeaceModeRetryCount { get; set; } = 34; public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet"; + /// + /// The real VTank RechargeHandlerSet nested table (5 columns: + /// Vital/HandlerString/MinPercent/MaxPercent/Stance). Defaults to VTank's + /// own shipped 26-row table (), + /// not an independently hand-typed replica; loading a real .usd + /// profile via replaces it + /// with that profile's own table. 's + /// hand-ported switch-based ordering is retained only as a defensive + /// fallback for the case where every row happens to have been cleared + /// explicitly and none match a given (vital, stance, percent) band. + /// + public IReadOnlyList RechargeHandlerRows { get; set; } = + VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows; public bool UseKitsInMagicMode { get; set; } = true; public bool GoToPeaceModeToUseKits { get; set; } public int MinimumHealKitSuccessChance { get; set; } = 95; diff --git a/src/AcDream.Plugins.MossTank/VitalRecharge.cs b/src/AcDream.Plugins.MossTank/VitalRecharge.cs index 9785a94f..b87d3cca 100644 --- a/src/AcDream.Plugins.MossTank/VitalRecharge.cs +++ b/src/AcDream.Plugins.MossTank/VitalRecharge.cs @@ -33,6 +33,43 @@ internal enum VitalRechargeMethod Food, } +/// +/// One row of VTank's real RechargeHandlerSet nested table +/// (refs/vtank/uTank2.Resources.defaultsettings.usd, Settings row +/// 137's Value cell — a 5-column TABLE: Vital/HandlerString/MinPercent/ +/// MaxPercent/Stance, 26 seed rows verified against the live fixture). +/// Vital uses VTank's own raw encoding (1=Health, 2=Stamina, +/// 3=Mana — distinct from this port's which reuses +/// AC property-id values); Stance is 1=Magic, 2=Melee/Missile +/// (matches this planner's magicMode bool exactly, confirmed by +/// reproducing every existing hand-ported default list from the parsed +/// table). MinPercent/MaxPercent bound the current-vital +/// percentage this row applies at. +/// +public readonly record struct RechargeHandlerRow( + int Vital, + string HandlerString, + int MinPercent, + int MaxPercent, + int Stance) +{ + internal static VitalKind? ToVitalKind(int vital) => vital switch + { + 1 => VitalKind.Health, + 2 => VitalKind.Stamina, + 3 => VitalKind.Mana, + _ => null, + }; + + internal static int FromVitalKind(VitalKind vital) => vital switch + { + VitalKind.Health => 1, + VitalKind.Stamina => 2, + VitalKind.Mana => 3, + _ => 0, + }; +} + /// /// VTank's default RechargeHandlerSet, including its stance- and /// current-percentage-dependent order. The host supplies raw inventory and @@ -60,7 +97,10 @@ internal static class VitalRechargePlanner vital, mode == PluginCombatMode.Magic, percent, - settings.RechargeHandlerSet); + settings.RechargeHandlerSet, + settings.RechargeHandlerRows.Count != 0 + ? settings.RechargeHandlerRows + : null); IReadOnlyList items = automation.Items.CaptureOwnedItems(); @@ -107,7 +147,7 @@ internal static class VitalRechargePlanner IReadOnlyList members = automation.Fellowship.CaptureMembers(); - foreach ((VitalKind vital, double threshold, float distance, uint baseSpell) + foreach ((VitalKind vital, double threshold, double distance, uint baseSpell) in new[] { (VitalKind.Health, settings.HelperHealth, @@ -118,7 +158,7 @@ internal static class VitalRechargePlanner settings.HelperManaDistance, (uint)SpellId.GiftOfEssence), }) { - PluginFellowMember? target = Lowest(members, vital, threshold, distance); + PluginFellowMember? target = Lowest(members, vital, threshold, (float)distance); if (target is not { } fellow) continue; if (vital == VitalKind.Health @@ -205,12 +245,36 @@ internal static class VitalRechargePlanner return true; } + /// + /// is , + /// which defaults to VTank's own shipped 26-row table + /// () + /// rather than an empty list, so is the + /// path every ordinary call takes. The hardcoded defaults switch + /// below is retained ONLY as a defensive net for a caller that + /// deliberately passes an empty/null row set or an unrecognized + /// (vital, stance, percent) band the real table happens not to cover — + /// not as the primary source of truth it used to be before the real + /// table became parseable. + /// internal static IReadOnlyList Handlers( VitalKind vital, bool magicMode, int currentPercent, - string? handlerSet = null) + string? handlerSet = null, + IReadOnlyList? handlerRows = null) { + if (handlerRows is { Count: > 0 } + && TryHandlersFromRows( + vital, + magicMode, + currentPercent, + handlerRows, + out VitalRechargeMethod[] fromRows)) + { + return fromRows; + } + IReadOnlyList defaults; if (magicMode) { @@ -288,6 +352,81 @@ internal static class VitalRechargePlanner : defaults; } + /// + /// VTank's real table-driven handler-order lookup: collect every row + /// matching (Vital, Stance) whose [MinPercent,MaxPercent] band contains + /// the current percentage, IN FILE ORDER (duplicates included — VTank's + /// own shipped table has them, e.g. row 17 of the default table repeats + /// "Recharge With Food" for the non-magic Health band; a duplicate is + /// harmless, since re-trying the same handler a second time just fails + /// identically). Confirmed against every hand-ported default list this + /// planner shipped before this table was parseable — see + /// docs/research/vtank-kb/01-settings-and-profiles.md row 137 and + /// the RechargeHandlerSet fixture. Unknown HandlerString tokens are + /// skipped (forward-compatible with a future VTank build's new method). + /// + private static bool TryHandlersFromRows( + VitalKind vital, + bool magicMode, + int currentPercent, + IReadOnlyList rows, + out VitalRechargeMethod[] handlers) + { + int vitalCode = RechargeHandlerRow.FromVitalKind(vital); + int stance = magicMode ? 1 : 2; + var matched = new List(); + foreach (RechargeHandlerRow row in rows) + { + if (row.Vital != vitalCode + || row.Stance != stance + || currentPercent < row.MinPercent + || currentPercent > row.MaxPercent) + { + continue; + } + if (TryParseHandlerToken(row.HandlerString, out VitalRechargeMethod method)) + matched.Add(method); + } + handlers = [.. matched]; + return handlers.Length != 0; + } + + private static bool TryParseHandlerToken(string source, out VitalRechargeMethod method) + { + string normalized = source.Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .ToLowerInvariant(); + switch (normalized) + { + case "regularspell": + method = VitalRechargeMethod.RegularSpell; + return true; + case "staminatohealth": + method = VitalRechargeMethod.StaminaToHealth; + return true; + case "manatohealth": + method = VitalRechargeMethod.ManaToHealth; + return true; + case "healthtostamina": + method = VitalRechargeMethod.HealthToStamina; + return true; + case "healthtomana": + method = VitalRechargeMethod.HealthToMana; + return true; + case "kit": + case "kitrecharge": + method = VitalRechargeMethod.Kit; + return true; + case "food": + case "rechargewithfood": + method = VitalRechargeMethod.Food; + return true; + default: + method = default; + return false; + } + } + private static string HandlerContext( VitalKind vital, bool magicMode, diff --git a/src/AcDream.Plugins.MossTank/VtankDefaultSettings.usd b/src/AcDream.Plugins.MossTank/VtankDefaultSettings.usd new file mode 100644 index 00000000..1ac32854 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankDefaultSettings.usd @@ -0,0 +1,2270 @@ +10 +AntiExtraBuffSpells +1 +ExemplarId +n +0 +AssistItems +2 +Object +Type +n +n +0 +BuffedItems +2 +Object +Spell +n +n +0 +ExtraBuffSpells +1 +ExemplarId +n +0 +GemFoodItems +2 +Name +Spell +n +n +2 +s +Asheron's Benediction +i +3810 +s +Blackmoor's Favor +i +3811 +ItemUseSpecifiers +2 +Object +Uses +y +n +0 +MyMonsters +21 +MonsterName +AttackPriority +DamageType +WeaponToUse +Imperil +Vuln +Yield +GravityW +Attack +Ring +Broadside +Fester +WeakeningCurse +FesteringCurse +Corruption +DestructiveCurse +Corrosion +Streak +SecondaryVuln +SecondaryEquip +PetDamageType +y +n +n +n +n +n +n +n +n +n +n +n +n +n +n +n +n +n +n +n +n +1 +s + +i +1 +i +8 +i +-1 +b +False +b +False +b +False +b +False +b +True +b +False +b +False +b +False +b +False +b +False +b +False +b +False +b +False +b +True +i +98 +i +0 +i +101 +Settings +4 +Setting +Value +Description +SettingType +y +n +n +n +137 +s +EnableLooting +b +False +s + +i +1 +s +EnableNav +b +False +s + +i +1 +s +EnableBuffing +b +True +s + +i +1 +s +EnableCombat +b +True +s + +i +1 +s +SpellDiffExcessThreshold-Hunt +i +25 +s + +i +3 +s +SpellDiffExcessThreshold-Buff +i +5 +s + +i +3 +s +ArrowheadFletchDiffExcessThreshold +i +10 +s + +i +3 +s +Recharge-Norm-HitP +i +75 +s + +i +3 +s +Recharge-Norm-Stam +i +50 +s + +i +3 +s +Recharge-Norm-Mana +i +50 +s + +i +3 +s +Recharge-NoTarg-HitP +i +1 +s + +i +3 +s +Recharge-NoTarg-Stam +i +1 +s + +i +3 +s +Recharge-NoTarg-Mana +i +1 +s + +i +3 +s +Recharge-Helper-HitP +i +20 +s + +i +3 +s +Recharge-Helper-Stam +i +1 +s + +i +3 +s +Recharge-Helper-Mana +i +1 +s + +i +3 +s +DoHelp +b +True +s +If true, allies are healed/restamed/given mana. The fellowship window must be open to help fellows. +i +1 +s +AttackDistance +d +0.0208333333333333 +s + +i +2 +s +AttackMinimumDistance +d +0 +s + +i +2 +s +ApproachDistance +d +0 +s + +i +2 +s +RingDistance +d +0.0208333333333333 +s + +i +2 +s +CorpseApproachRange-Max +d +0 +s + +i +2 +s +CorpseApproachRange-Min +d +0.014 +s + +i +2 +s +NavCloseStopRange +d +0.00833333333333333 +s + +i +2 +s +NavFarStopRange +d +999999 +s + +i +2 +s +UsePortalDistance +d +0.0166666666666667 +s + +i +2 +s +HelperDistanceHitP +d +0.310416666666667 +s + +i +2 +s +HelperDistanceStam +d +0.310416666666667 +s + +i +2 +s +HelperDistanceMana +d +0.166666666666667 +s + +i +2 +s +MinimumRingTargets +i +4 +s + +i +3 +s +DefaultMeleeAttackHeight +i +2 +s + +i +3 +s +CastDispelSelf +b +False +s +Casts dispel life magic self VII when the macro has a vuln on it. +i +1 +s +UseDispelItems +b +False +s +Uses either a Gem of Stillness or a Condensed Dispel Potion when the macro has a vuln on it. +i +1 +s +AutoCram +b +False +s +Moves items from the main pack to side packs. +i +1 +s +AutoStack +b +True +s +Stacks stackable items. +i +1 +s +ReadUnknownScrolls +b +True +s +Loots and reads unknown scrolls (looting must be enabled). +i +1 +s +UseDispelDrum +b +False +s + +i +1 +s +SwitchWandsToDebuff +b +False +s +If enabled, the macro forces a wand switch to the appropriately selected wand for the monster, even to debuff it. Without this setting, wands are only switched when a war attack is required. +i +1 +s +AutoCraftItems +b +True +s +When enabled, the macro crafts known components that result in items in the gems/food panel, if your character has none. For instance, this allows the macro to create blue kits out of bobo comps if "Plentiful Healing Kit" is listed in the gems/food panel. +i +1 +s +UseHealersHeart +b +True +s +When set to true and you have a healer's heart in your wand list, the macro uses it for helper. +i +1 +s +JumpOutWandCasting +b +False +s +For the healer's heart, when enabled, the macro jumps right after using the heart. This causes the heal to go out immediately and your character to have to stand there doing nothing until the animation timer is up. +i +1 +s +LootAllCorpses +b +False +s +Causes the macro to loot corpses killed by neither you nor your fellows. +i +1 +s +LootFellowCorpses +b +False +s +Causes the macro to loot corpses killed by your fellows. +i +1 +s +DoJiggle +b +False +s +Secret spell dodge mode. +i +1 +s +RandomHelperBuffs +b +False +s +Casts buffs on nearby players randomly. +i +1 +s +RandomHelperIntervalSeconds +d +5 +s + +i +2 +s +IdlePeaceMode +b +False +s +Loots, navigates, and salvages in peace mode. +i +1 +s +TargetLock +b +False +s +Causes the macro to attack the selected monster first, as long as it is a valid target. +i +1 +s +StopMacroOnDeath +b +True +s +Causes the macro to become disabled when the character dies. +i +1 +s +UseArcs +i +1 +s +Arc war spells are used rather than bolts, if both are known for the best level the macro is able to cast. +i +6 +s +ArcRange +d +0.0208333333333333 +s + +i +2 +s +TargetSelectMethod +i +3 +s +Selects how targets are given preference. When set to 'both', nearby targets are chosen by angle and distant targets are chosen in order of distance. +i +6 +s +TargetSelectAngleRange +d +0.0208333333333333 +s +Above this range, when target select is 'both', targets are selected by range. +i +2 +s +IdleBuffTopoff +b +False +s +When the macro is idle, it recasts buffs. +i +1 +s +IdleBuffTopoffTimeSeconds +i +1200 +s + +i +3 +s +RebuffTimeRemainingSeconds +i +300 +s + +i +3 +s +RefillWornMana +b +True +s +Uses mana charge types specified in the gems/food panel to refill worn items. +i +1 +s +RefillWornMana-Item-ManaPercent +i +33 +s + +i +3 +s +BuffProfile-Prots +s +ALFCBPS +s + +i +5 +s +BuffProfile-Banes +s +ALFCBPS +s + +i +5 +s +BuffProfile_Prots +i +2 +s +Specifies which self prots are cast. +i +6 +s +BuffProfile_Banes +i +2 +s +Specifies which self banes are cast. +i +6 +s +DebuffEachFirst +i +1 +s +Cast debuffs for each valid target before beginning attack. One: debuffs one monster then attacks it. Priority: debuffs all monsters of the same priority, then attacks. All: debuffs all monsters before attacking regardless of priority. +i +6 +s +AutoAttackPower +b +True +s +When true, the macro sets melee attack power automatically based on weapon damage type. +i +1 +s +LootPriorityBoost +b +False +s +When true, corpses are looted before attacking monsters. +i +1 +s +CorpseCacheTimeoutMinutes +d +60 +s +The time in minutes after which a corpse will be removed from the corpse cache. +i +2 +s +CorpseItemAppearanceTimeoutSeconds +d +6 +s +The time in seconds the macro will wait for all items on a corpse to appear. +i +2 +s +CorpseItemIDTimeoutSeconds +d +60 +s +The time in seconds the macro will wait for all needed items on a corpse to be ID'd. +i +2 +s +DebuffSelectionMethod +i +2 +s +Selects what the macro prefers when looking for a way to cast debuffs. Select 'Skill' when fighting monsters with high magic defense. +i +6 +s +ManaStoneLootCount +i +4 +s +The maximum number of usable mana stones the macro will try to keep in your inventory when looting. +i +3 +s +ManaTankMinimumMana +i +1000 +s +The minimum mana required for an item to be used to fill mana stones. +i +3 +s +SplitPeas +b +True +s +Specifies if the macro should automatically split peas. Components you would like to keep supplied by splitting should be added to your consumables list. +i +1 +s +SpellCompMin-Critical +i +4 +s +The minimum number of each type of spell component. If you have fewer, a pea will be split. 'Critical' splitting is higher priority than healing. +i +3 +s +SpellCompMin-Normal +i +20 +s +The minimum number of each type of spell component. If you have fewer, a pea will be split. 'Normal' splitting has a lower priority than normal buffing. +i +3 +s +SpellCompMin-Idle +i +20 +s +The minimum number of each type of spell component. If you have fewer, a pea will be split. 'Idle' splitting is only done while idle. +i +3 +s +RechargeBoostTimeSeconds +d +5 +s +Maximum time for the recharge boost effect to last. Boost causes the macro to prefer to recharge once it switches to magic mode to recharge. +i +2 +s +RechargeBoostAmount +i +40 +s +Number of h/s/m points used for recharge boost. Boost causes the macro to prefer to recharge once it switches to magic mode to recharge. +i +3 +s +UseSpecialAmmo +i +0 +s +Specifies whether the macro should craft and use special ammunition types. 'Raider' indicates Raider Lightning ammo, and 'SCurrency' indicates ammo from Colosseum, Graveyard, and Paradox-touched Olthoi Infested Area. +i +6 +s +OpenDoors +b +False +s + +i +1 +s +DoorIDRange +d +0.0833333333333333 +s +The range at which the macro will ID doors, when opening. +i +2 +s +DoorOpenRange +d +0.0166666666666667 +s +The range at which the macro will attempt to open a door. +i +2 +s +DoorLockpickDiffExcessThreshold +i +-50 +s +The number of lockpick skill points above a door's pick difficulty that is required for a door to be considered pickable. +i +3 +s +ManaChargesWhenOff +b +True +s +When true, mana charges and stones are used to keep equipped mana filled even when the macro is disabled. +i +1 +s +AutoFellowManagement +b +True +s +When true, automatically recruits players on request, and manages waiting lists and votes when acting as the fellowship leader. +i +1 +s +MinimumHealKitSuccessChance +i +95 +s +The lowest chance of success, in percent, that the macro will accept when attempting a heal with a health kit. +i +3 +s +UseKitsInMagicMode +b +True +s +When true, the macro uses available healing kits to heal when in magic mode (with a higher priority than spells). +i +1 +s +StaminaToHealthMultiplier +d +1.9 +s +For a Stamina to Health spell to be used, it must be this much better than a normal healing spell. +i +2 +s +ManaToHealthMultiplier +d +2.8 +s +For a Mana to Health spell to be used, it must be this much better than a normal healing spell. +i +2 +s +NavPriorityBoost +b +False +s +Causes the macro to navigate before attacking. +i +1 +s +DeleteGhostMonsters +b +True +s +Attempts to detect when monsters appear to be there but really do not exist, and signals the client to delete them. +i +1 +s +GhostMonsterSpellAttemptCount +i +200 +s +The number of times the macro must attempt to cast a spell on a monster, without the spell starting, before that monster is marked as a ghost. +i +3 +s +WhoYouGonnaCall +b +True +s + +i +1 +s +BlacklistMonsterAttemptCount +i +4 +s +The number of successful attacks that must miss a target before it is blacklisted. +i +3 +s +BlacklistMonsterTimeoutSeconds +i +120 +s +The amount of time a monster is blacklisted for, for being unhittable. +i +3 +s +CombineSalvage +b +True +s +When true and looting is enabled, salvage bags are combined according to the ranges specified by the current loot profile. +i +1 +s +LootOnlyRareCorpses +b +False +s +When true, only rare-generating corpses are looted. +i +1 +s +DeleteGhostMonstersByHPTracker +b +True +s +An alternate method to force the deletion of ghost monsters which works for melee, missile and mage attacks. +i +1 +s +GhostDeleteHPTrackerSeconds +i +30 +s +The number of seconds that must pass with no receieved health updates for a target before it is considered a ghost. +i +3 +s +GoToPeaceModeToUseKits +b +False +s +Causes the macro to drop to peace mode before using a healing kit. +i +1 +s +UseRecklessness +b +True +s +When true, attacks using recklessness if available. +i +1 +s +DebuffPrecastSeconds +i +5 +s +The number of seconds before a target's debuff is set to expire before the macro will want to recast it. Does not apply to DoT spells. +i +3 +s +ClearLevelBoostFlagOnCast +b +True +s +When true, RechargeBoost applies only to the first spell cast after switching to magic mode. +i +1 +s +IdleCraftCount_HealthKits +i +2 +s +The number of health kits of each type to craft up to when idle. +i +3 +s +IdleCraftCount_StamKits +i +2 +s +The number of stamina kits of each type to craft up to when idle. +i +3 +s +IdleCraftCount_ManaKits +i +2 +s +The number of mana kits of each type to craft up to when idle. +i +3 +s +IdleCraftCount_HealthFood +i +15 +s +The number of health foods/potions of each type to craft up to when idle. +i +3 +s +IdleCraftCount_StamFood +i +15 +s +The number of stamina foods/potions of each type to craft up to when idle. +i +3 +s +IdleCraftCount_ManaFood +i +15 +s +The number of mana foods/potions of each type to craft up to when idle. +i +3 +s +BuffCastRecast_Seconds +i +30 +s +After casting a single buff, the time until rebuffing every other buff is reduced by BuffCastRecast_Seconds for BuffCastRecastReset_Seconds. +i +3 +s +BuffCastRecastReset_Seconds +i +30 +s +After casting a single buff, the time until rebuffing every other buff is reduced by BuffCastRecast_Seconds for BuffCastRecastReset_Seconds. +i +3 +s +EnableMeta +b +False +s + +i +1 +s +BlacklistedSpellComps +s + +s +A list of spell components which will never be used to cast a spell. +i +5 +s +DropToPeaceModeRetryCount +i +34 +s +When trying to switch weapons, the macro will try repeatedly to drop to peace mode. If it fails this many times, assume the client is bugged and try to use a wand. +i +3 +s +FollowAroundCorners +b +True +s +When following a player, follows their path if true. Otherwise, the macro always tries to run directly to where they are now. +i +1 +s +BlacklistCorpseOpenAttemptCount +i +30 +s +The number of times the macro must attempt to open a corpse before it is blacklisted. +i +3 +s +BlacklistCorpseOpenTimeoutSeconds +i +200 +s +The number of seconds an unopenable corpse is blacklisted for. +i +3 +s +SummonPets +b +True +s +When enabled, the macro summons combat pets if available and listed in items. +i +1 +s +PetRangeMode +i +0 +s +Determines what monster range is used when summoning a combat pet. +i +6 +s +PetCustomRange +d +0.0208333333333333 +s + +i +2 +s +PetRefillCount-Idle +i +3 +s +If a pet summon item has this many charges or fewer, it will be refilled when idle. +i +3 +s +PetRefillCount-Normal +i +1 +s +If a pet summon item has this many charges or fewer, it will be refilled in combat. +i +3 +s +CorpseOpenTimeoutSeconds +d +1.5 +s +Time after trying to open a corpse before the macro will retry. +i +2 +s +PetMonsterDensity +i +1 +s +The minimum number of allowed pet targets within pet range before a pet will be summoned. +i +3 +s +CorpseLootItemMaxAttempts +i +20 +s +After trying to loot a particular item off a corpse this number of times, the item will be blacklisted. +i +3 +s +FastCastBuffs +b +False +s +Attempt to fastcast buff spells. May move the character during casting. +i +1 +s +UseBreakableTurnTo +b +True +s +When casting a spell on a target, allows breaking out during turning if another action intervenes. +i +1 +s +UseProjectileAwareness +b +True +s +Checks if arcs, bolts, and arrows will hit a target or strike a wall before firing. May cause lag on slower computers. +i +1 +s +CollisionProjectileRadius +d +0.4 +s +The radius of the test object to use for collision checks. +i +2 +s +CollisionStepDistance +d +0.7 +s +The distance to move the test object during each collision check. Must be less than 2x CollisionProjectileRadius or solid walls may not be seen. +i +2 +s +ShowCollisionDebug +b +False +s + +i +1 +s +MaximumCollisionChecksPerTick +i +500 +s +The maximum number of occluded targets the macro will attempt to evaluate. +i +3 +s +SpellRangeFudge +d +1 +s +Number of meters to subtract from the calculated range of spells, to account for positional inaccuracy. +i +2 +s +BuffWithUntrained-Item +i +80 +s +Above this character level, untrained Item Enchantment will not be used for buffing. +i +3 +s +BuffWithUntrained-Creature +i +80 +s +Above this character level, untrained Creature Enchantment will not be used for buffing. +i +3 +s +BuffWithUntrained-Life +i +80 +s +Above this character level, untrained Life Magic will not be used for buffing. +i +3 +s +AllowDebuffFallback +b +False +s +When true, if a debuff target is blocked by a wall then a nonprojectile debuff will be used instead (such as a spell). +i +1 +s +RechargeHandlerSet +TABLE +5 +Vital +HandlerString +MinPercent +MaxPercent +Stance +n +n +n +n +n +26 +i +1 +s +Stamina to Health +i +0 +i +15 +i +1 +i +1 +s +Mana to Health +i +0 +i +15 +i +1 +i +1 +s +Regular Spell +i +0 +i +15 +i +1 +i +1 +s +Recharge With Food +i +0 +i +15 +i +1 +i +1 +s +Kit Recharge +i +0 +i +100 +i +1 +i +1 +s +Stamina to Health +i +0 +i +100 +i +1 +i +1 +s +Mana to Health +i +0 +i +100 +i +1 +i +1 +s +Regular Spell +i +0 +i +100 +i +1 +i +1 +s +Recharge With Food +i +0 +i +100 +i +1 +i +2 +s +Kit Recharge +i +0 +i +100 +i +1 +i +2 +s +Regular Spell +i +0 +i +100 +i +1 +i +2 +s +Recharge With Food +i +0 +i +100 +i +1 +i +3 +s +Kit Recharge +i +0 +i +100 +i +1 +i +3 +s +Recharge With Food +i +0 +i +100 +i +1 +i +3 +s +Regular Spell +i +0 +i +100 +i +1 +i +1 +s +Recharge With Food +i +0 +i +15 +i +2 +i +1 +s +Kit Recharge +i +0 +i +100 +i +2 +i +1 +s +Recharge With Food +i +0 +i +100 +i +2 +i +1 +s +Stamina to Health +i +0 +i +10 +i +2 +i +1 +s +Regular Spell +i +0 +i +100 +i +2 +i +2 +s +Kit Recharge +i +0 +i +100 +i +2 +i +2 +s +Recharge With Food +i +0 +i +100 +i +2 +i +2 +s +Regular Spell +i +0 +i +100 +i +2 +i +3 +s +Kit Recharge +i +0 +i +100 +i +2 +i +3 +s +Recharge With Food +i +0 +i +100 +i +2 +i +3 +s +Regular Spell +i +0 +i +100 +i +2 +s + +i +7 +SettingsCategories +2 +Setting +Categories +y +n +136 +s +EnableLooting +i +256 +s +EnableNav +i +32 +s +EnableBuffing +i +64 +s +EnableCombat +i +12 +s +SpellDiffExcessThreshold-Hunt +i +8 +s +SpellDiffExcessThreshold-Buff +i +64 +s +ArrowheadFletchDiffExcessThreshold +i +128 +s +Recharge-Norm-HitP +i +2 +s +Recharge-Norm-Stam +i +2 +s +Recharge-Norm-Mana +i +2 +s +Recharge-NoTarg-HitP +i +2 +s +Recharge-NoTarg-Stam +i +2 +s +Recharge-NoTarg-Mana +i +2 +s +Recharge-Helper-HitP +i +2 +s +Recharge-Helper-Stam +i +2 +s +Recharge-Helper-Mana +i +2 +s +DoHelp +i +2 +s +AttackDistance +i +16 +s +AttackMinimumDistance +i +16 +s +ApproachDistance +i +48 +s +RingDistance +i +24 +s +CorpseApproachRange-Max +i +48 +s +CorpseApproachRange-Min +i +48 +s +NavCloseStopRange +i +48 +s +NavFarStopRange +i +48 +s +UsePortalDistance +i +48 +s +HelperDistanceHitP +i +18 +s +HelperDistanceStam +i +18 +s +HelperDistanceMana +i +18 +s +MinimumRingTargets +i +8 +s +DefaultMeleeAttackHeight +i +4 +s +CastDispelSelf +i +2 +s +UseDispelItems +i +2 +s +AutoCram +i +1 +s +AutoStack +i +1 +s +ReadUnknownScrolls +i +256 +s +UseDispelDrum +i +2 +s +SwitchWandsToDebuff +i +8 +s +AutoCraftItems +i +128 +s +UseHealersHeart +i +2 +s +JumpOutWandCasting +i +10 +s +LootAllCorpses +i +256 +s +LootFellowCorpses +i +256 +s +DoJiggle +i +8 +s +RandomHelperBuffs +i +1 +s +RandomHelperIntervalSeconds +i +1 +s +IdlePeaceMode +i +1 +s +TargetLock +i +12 +s +StopMacroOnDeath +i +1 +s +UseArcs +i +8 +s +ArcRange +i +24 +s +TargetSelectMethod +i +12 +s +TargetSelectAngleRange +i +28 +s +IdleBuffTopoff +i +64 +s +IdleBuffTopoffTimeSeconds +i +64 +s +RebuffTimeRemainingSeconds +i +64 +s +RefillWornMana +i +1 +s +RefillWornMana-Item-ManaPercent +i +1 +s +BuffProfile-Prots +i +64 +s +BuffProfile-Banes +i +64 +s +BuffProfile_Prots +i +64 +s +BuffProfile_Banes +i +64 +s +DebuffEachFirst +i +12 +s +AutoAttackPower +i +4 +s +LootPriorityBoost +i +256 +s +CorpseCacheTimeoutMinutes +i +32 +s +CorpseItemAppearanceTimeoutSeconds +i +256 +s +CorpseItemIDTimeoutSeconds +i +256 +s +DebuffSelectionMethod +i +12 +s +ManaStoneLootCount +i +257 +s +ManaTankMinimumMana +i +257 +s +SplitPeas +i +128 +s +SpellCompMin-Critical +i +128 +s +SpellCompMin-Normal +i +128 +s +SpellCompMin-Idle +i +128 +s +RechargeBoostTimeSeconds +i +2 +s +RechargeBoostAmount +i +2 +s +UseSpecialAmmo +i +128 +s +OpenDoors +i +32 +s +DoorIDRange +i +48 +s +DoorOpenRange +i +48 +s +DoorLockpickDiffExcessThreshold +i +160 +s +ManaChargesWhenOff +i +1 +s +AutoFellowManagement +i +1 +s +MinimumHealKitSuccessChance +i +2 +s +UseKitsInMagicMode +i +2 +s +StaminaToHealthMultiplier +i +2 +s +ManaToHealthMultiplier +i +2 +s +NavPriorityBoost +i +32 +s +DeleteGhostMonsters +i +1 +s +GhostMonsterSpellAttemptCount +i +1 +s +WhoYouGonnaCall +i +1 +s +BlacklistMonsterAttemptCount +i +1 +s +BlacklistMonsterTimeoutSeconds +i +1 +s +CombineSalvage +i +256 +s +LootOnlyRareCorpses +i +256 +s +DeleteGhostMonstersByHPTracker +i +13 +s +GhostDeleteHPTrackerSeconds +i +13 +s +GoToPeaceModeToUseKits +i +3 +s +UseRecklessness +i +4 +s +DebuffPrecastSeconds +i +8 +s +ClearLevelBoostFlagOnCast +i +2 +s +IdleCraftCount_HealthKits +i +130 +s +IdleCraftCount_StamKits +i +130 +s +IdleCraftCount_ManaKits +i +130 +s +IdleCraftCount_HealthFood +i +130 +s +IdleCraftCount_StamFood +i +130 +s +IdleCraftCount_ManaFood +i +130 +s +BuffCastRecast_Seconds +i +64 +s +BuffCastRecastReset_Seconds +i +64 +s +EnableMeta +i +1 +s +BlacklistedSpellComps +i +74 +s +DropToPeaceModeRetryCount +i +13 +s +FollowAroundCorners +i +32 +s +BlacklistCorpseOpenAttemptCount +i +257 +s +BlacklistCorpseOpenTimeoutSeconds +i +257 +s +SummonPets +i +12 +s +PetRangeMode +i +28 +s +PetCustomRange +i +28 +s +PetRefillCount-Idle +i +12 +s +PetRefillCount-Normal +i +12 +s +CorpseOpenTimeoutSeconds +i +256 +s +PetMonsterDensity +i +12 +s +CorpseLootItemMaxAttempts +i +256 +s +FastCastBuffs +i +64 +s +UseBreakableTurnTo +i +8 +s +UseProjectileAwareness +i +12 +s +CollisionProjectileRadius +i +12 +s +CollisionStepDistance +i +12 +s +ShowCollisionDebug +i +12 +s +MaximumCollisionChecksPerTick +i +12 +s +SpellRangeFudge +i +8 +s +BuffWithUntrained-Item +i +64 +s +BuffWithUntrained-Creature +i +64 +s +BuffWithUntrained-Life +i +64 +s +AllowDebuffFallback +i +12 +SettingsEnumInfo +3 +Setting +Value +EnumValue +n +n +n +33 +s +UseArcs +i +1 +s +No +s +UseArcs +i +2 +s +At Range +s +UseArcs +i +3 +s +Yes +s +TargetSelectMethod +i +1 +s +By range +s +TargetSelectMethod +i +2 +s +By angle +s +TargetSelectMethod +i +3 +s +Both +s +BuffProfile_Prots +i +1 +s +Custom +s +BuffProfile_Prots +i +2 +s +All +s +BuffProfile_Prots +i +3 +s +None +s +BuffProfile_Prots +i +4 +s +B +s +BuffProfile_Prots +i +5 +s +BPS +s +BuffProfile_Prots +i +6 +s +BPSA +s +BuffProfile_Prots +i +7 +s +ALFC +s +BuffProfile_Prots +i +8 +s +BPSAC +s +BuffProfile_Banes +i +1 +s +Custom +s +BuffProfile_Banes +i +2 +s +All +s +BuffProfile_Banes +i +3 +s +None +s +BuffProfile_Banes +i +4 +s +B +s +BuffProfile_Banes +i +5 +s +BPS +s +BuffProfile_Banes +i +6 +s +BPSA +s +BuffProfile_Banes +i +7 +s +BPSAC +s +BuffProfile_Banes +i +8 +s +ALFC +s +DebuffEachFirst +i +1 +s +One +s +DebuffEachFirst +i +2 +s +Priority +s +DebuffEachFirst +i +3 +s +All +s +DebuffSelectionMethod +i +1 +s +SpellLevel +s +DebuffSelectionMethod +i +2 +s +Skill +s +UseSpecialAmmo +i +0 +s +None +s +UseSpecialAmmo +i +1 +s +Raider +s +UseSpecialAmmo +i +2 +s +SCurrency +s +UseSpecialAmmo +i +3 +s +Both +s +PetRangeMode +i +0 +s +AttackDistance +s +PetRangeMode +i +1 +s +PetCustomRange diff --git a/src/AcDream.Plugins.MossTank/VtankDefaultSettingsDatabase.cs b/src/AcDream.Plugins.MossTank/VtankDefaultSettingsDatabase.cs new file mode 100644 index 00000000..c739de77 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankDefaultSettingsDatabase.cs @@ -0,0 +1,67 @@ +using System.Reflection; + +namespace AcDream.Plugins.MossTank; + +/// +/// The complete .usd shipped by VTank as +/// uTank2.Resources.defaultsettings.usd — every table (Settings, +/// MyMonsters, GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells, +/// ItemUseSpecifiers, SettingsCategories, SettingsEnumInfo, AssistItems, +/// BuffedItems, RechargeHandlerSet, …), byte-for-byte, not a hand-typed +/// C# replica. seeds +/// a brand-new profile from this document (every table, every row's real +/// Description/SettingType) and +/// 's default RechargeHandlerRows is +/// parsed from its RechargeHandlerSet table — both replace a prior +/// port's independently hand-maintained defaults, which could (and did: +/// see the 8fa70c3e4 "26 rows, not 24" fix) silently drift from what VTank +/// itself ships. +/// +internal static class VtankDefaultSettingsDatabase +{ + private const string ResourceSuffix = ".VtankDefaultSettings.usd"; + private static readonly Lazy RawText = new(LoadText); + private static readonly Lazy DefaultRows = new(LoadDefaultRows); + + /// A fresh, independently mutable parse of the embedded document — every call gets its own object graph. + public static VtankDatabase Parse() => VtankDatabase.Parse(RawText.Value); + + /// VTank's own shipped RechargeHandlerSet rows (26, in file order). + public static IReadOnlyList DefaultRechargeHandlerRows => DefaultRows.Value; + + private static string LoadText() + { + Assembly assembly = typeof(VtankDefaultSettingsDatabase).Assembly; + string resource = assembly.GetManifestResourceNames().Single( + static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal)); + using Stream stream = assembly.GetManifestResourceStream(resource) + ?? throw new InvalidOperationException( + "The embedded VTank default settings (.usd) document is missing."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private static RechargeHandlerRow[] LoadDefaultRows() + { + VtankDatabase database = VtankDatabase.Parse(RawText.Value); + VtankTable? settings = database.Find("Settings"); + if (settings is null) + return []; + int nameColumn = settings.ColumnIndex("Setting"); + int valueColumn = settings.ColumnIndex("Value"); + if (nameColumn < 0 || valueColumn < 0) + return []; + foreach (VtankRow row in settings.Rows) + { + if (!row.Cells[nameColumn].AsString().Equals( + "RechargeHandlerSet", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + VtankCell cell = row.Cells[valueColumn]; + if (cell.Tag == "TABLE" && cell.Table is { } table) + return VtankSettingsProfileSerializer.ParseRechargeHandlerSet(table); + } + return []; + } +} diff --git a/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs index f3fc0d32..df3531cb 100644 --- a/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs +++ b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs @@ -23,20 +23,33 @@ internal static class VtankLootRequirementEvaluator [6095] = (28, 80), }; - private static readonly IReadOnlyDictionary - DoubleSpellBonuses = new Dictionary + /// + /// VTClassic's static double-spell-bonus table (ComputedItemInfo.cs:88-139). + /// Change is the real per-row selector KB doc 05 section 2.2 names + /// ("additive unless the static table's Change==1, in which case + /// multiplicative", ComputedItemInfo.cs:244) — carried explicitly + /// here rather than inferred from whether Bonus's truncated + /// integer part happens to equal 1 (a prior port's proxy, which this + /// item replaced: it only worked because every multiplicative bonus in + /// this table happens to be in [1.0, 2.0) and every additive one happens + /// to be under 1.0 — a coincidence of the current 19 rows, not a rule, + /// and it would have silently mis-branched on a future row like an + /// additive 1.5 or a multiplicative 2.0+). + /// + private static readonly IReadOnlyDictionary + DoubleSpellBonuses = new Dictionary { - [3251] = (152, .01), [3250] = (152, .03), - [4670] = (152, .05), [6098] = (152, .07), - [2603] = (VtankDoubleBase + 12, .03), - [2591] = (VtankDoubleBase + 12, .05), - [4666] = (VtankDoubleBase + 12, .07), - [6094] = (VtankDoubleBase + 12, .09), - [2600] = (29, .03), [3985] = (29, .04), - [2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09), - [3201] = (144, 1.05), [3199] = (144, 1.10), - [3202] = (144, 1.15), [3200] = (144, 1.20), - [6086] = (144, 1.25), [6087] = (144, 1.30), + [3251] = (152, .01, false), [3250] = (152, .03, false), + [4670] = (152, .05, false), [6098] = (152, .07, false), + [2603] = (VtankDoubleBase + 12, .03, false), + [2591] = (VtankDoubleBase + 12, .05, false), + [4666] = (VtankDoubleBase + 12, .07, false), + [6094] = (VtankDoubleBase + 12, .09, false), + [2600] = (29, .03, false), [3985] = (29, .04, false), + [2588] = (29, .05, false), [4663] = (29, .07, false), [6091] = (29, .09, false), + [3201] = (144, 1.05, true), [3199] = (144, 1.10, true), + [3202] = (144, 1.15, true), [3200] = (144, 1.20, true), + [6086] = (144, 1.25, true), [6087] = (144, 1.30, true), }; private static readonly IReadOnlyDictionary ArmorColorSlots = @@ -363,54 +376,103 @@ internal static class VtankLootRequirementEvaluator : string.Empty, }; + /// + /// The single source of truth for both "what is this int-typed key's + /// value" () and "does this item actually carry a + /// base value for this key at all" () — item J + /// (slice-1 fix round) consolidated these from two independently + /// hand-maintained key lists that had to be kept in exact sync by hand: + /// a named field added to one switch and forgotten in the other would + /// silently let BuffedInt's KeyExists gate treat a real, + /// always-present field as "raw property bag only", which for a key the + /// item's bag never happens to carry would wrongly refuse a spell bonus + /// it should apply. Returns only when + /// is not one of the named + /// fields AND the raw property bag + /// does not carry it either. + /// + private static bool TryIntValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties, + out int value) + { + switch (key) + { + case 5: value = item.Burden; return true; + case 19: value = item.Value; return true; + case 105: value = checked((int)item.Workmanship); return true; + case 107: value = item.ItemCurrentMana; return true; + case 108: value = item.ItemMaximumMana; return true; + case 131: value = checked((int)item.MaterialType); return true; + case VtankIntBase + 0: value = checked((int)item.WeenieClassId); return true; + case VtankIntBase + 2: value = checked((int)item.ContainerObjectId); return true; + case VtankIntBase + 4: value = item.ItemsCapacity; return true; + case VtankIntBase + 5: value = item.ContainersCapacity; return true; + case VtankIntBase + 6: value = item.StackSize; return true; + case VtankIntBase + 7: value = item.MaximumStackSize; return true; + case VtankIntBase + 8: value = checked((int)item.SpellId); return true; + case VtankIntBase + 9: value = item.ContainerSlot; return true; + case VtankIntBase + 10: value = checked((int)item.WielderObjectId); return true; + case VtankIntBase + 11: value = checked((int)item.EquippedLocation); return true; + case VtankIntBase + 14: value = checked((int)item.ValidLocations); return true; + case VtankIntBase + 18: value = checked((int)item.Useability); return true; + case VtankIntBase + 23: value = checked((int)item.PublicFlags); return true; + case VtankIntBase + 31: value = item.CombatUse; return true; + case VtankIntBase + 32: value = item.WeaponSkill; return true; + case VtankIntBase + 33: value = item.DamageType; return true; + case VtankIntBase + 34: value = item.Damage; return true; + case VtankIntBase + 38: value = item.AppraisedSpellIds.Count; return true; + default: + value = 0; + return properties.Ints?.TryGetValue(key, out value) == true; + } + } + private static int IntValue( uint key, in PluginInventoryItem item, - in PluginItemProperties properties) => key switch + in PluginItemProperties properties) => + TryIntValue(key, item, properties, out int value) ? value : 0; + + /// See — the double-side equivalent of 's consolidation. + private static bool TryDoubleValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties, + out double value) { - 5 => item.Burden, - 19 => item.Value, - 105 => checked((int)item.Workmanship), - 107 => item.ItemCurrentMana, - 108 => item.ItemMaximumMana, - 131 => checked((int)item.MaterialType), - VtankIntBase + 0 => checked((int)item.WeenieClassId), - VtankIntBase + 2 => checked((int)item.ContainerObjectId), - VtankIntBase + 4 => item.ItemsCapacity, - VtankIntBase + 5 => item.ContainersCapacity, - VtankIntBase + 6 => item.StackSize, - VtankIntBase + 7 => item.MaximumStackSize, - VtankIntBase + 8 => checked((int)item.SpellId), - VtankIntBase + 9 => item.ContainerSlot, - VtankIntBase + 10 => checked((int)item.WielderObjectId), - VtankIntBase + 11 => checked((int)item.EquippedLocation), - VtankIntBase + 14 => checked((int)item.ValidLocations), - VtankIntBase + 18 => checked((int)item.Useability), - VtankIntBase + 23 => checked((int)item.PublicFlags), - VtankIntBase + 31 => item.CombatUse, - VtankIntBase + 32 => item.WeaponSkill, - VtankIntBase + 33 => item.DamageType, - VtankIntBase + 34 => item.Damage, - VtankIntBase + 38 => item.AppraisedSpellIds.Count, - _ => properties.Ints?.TryGetValue(key, out int value) == true - ? value - : 0, - }; + switch (key) + { + case VtankDoubleBase + 9: value = item.Workmanship; return true; + case VtankDoubleBase + 11: value = item.DamageVariance; return true; + // These two are named/virtual fields (like the int side's + // VtankIntBase+N cases) that always "exist", regardless of + // whether the underlying remapped raw key (62/63) happens to + // be present in the property bag — matching the prior + // DoubleKeyExists switch's explicit "=> true" for both. + case VtankDoubleBase + 12: + TryRawFloat(properties, 62, out value); + return true; + case VtankDoubleBase + 14: + TryRawFloat(properties, 63, out value); + return true; + default: + return TryRawFloat(properties, key, out value); + } + } private static double DoubleValue( uint key, in PluginInventoryItem item, - in PluginItemProperties properties) => key switch - { - VtankDoubleBase + 9 => item.Workmanship, - VtankDoubleBase + 11 => item.DamageVariance, - VtankDoubleBase + 12 => RawFloat(properties, 62), - VtankDoubleBase + 14 => RawFloat(properties, 63), - _ => RawFloat(properties, key), - }; + in PluginItemProperties properties) => + TryDoubleValue(key, item, properties, out double value) ? value : 0d; - private static double RawFloat(in PluginItemProperties properties, uint key) => - properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d; + private static bool TryRawFloat(in PluginItemProperties properties, uint key, out double value) + { + value = 0d; + return properties.Floats?.TryGetValue(key, out value) == true; + } private static int BuffedInt( uint key, @@ -418,6 +480,15 @@ internal static class VtankLootRequirementEvaluator in PluginItemProperties properties) { int value = IntValue(key, item, properties); + // VTClassic's ComputedItemInfo.GetBuffedLogValueKey only adds the + // spell bonus if the item already carries the base key at all + // (ComputedItemInfo.cs:205, the KeyExistsInt gate — KB doc 05 + // section 2.2 and the section-5 gap-4 finding). Without this gate, + // an item that lacks the key entirely (falls through to the + // zero-defaulted "_ =>" branch below) would still receive a spell + // bonus it never had a base value to buff. + if (!IntKeyExists(key, item, properties)) + return value; foreach (uint spellId in item.AppraisedSpellIds) { if (IntSpellBonuses.TryGetValue(spellId, out var bonus) @@ -435,6 +506,8 @@ internal static class VtankLootRequirementEvaluator in PluginItemProperties properties) { double value = DoubleValue(key, item, properties); + if (!DoubleKeyExists(key, item, properties)) + return value; foreach (uint spellId in item.AppraisedSpellIds) { if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus) @@ -442,11 +515,28 @@ internal static class VtankLootRequirementEvaluator { continue; } - value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus; + value = bonus.Change ? value * bonus.Bonus : value + bonus.Bonus; } return value; } + /// + /// Now driven by (item J, slice-1 fix round) + /// instead of an independently hand-maintained duplicate of its key + /// list — see that method's doc for why the duplication was a hazard. + /// + private static bool IntKeyExists( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => + TryIntValue(key, item, properties, out _); + + private static bool DoubleKeyExists( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => + TryDoubleValue(key, item, properties, out _); + private static double MinimumDamage(in PluginInventoryItem item) => item.Damage - (item.DamageVariance * item.Damage); diff --git a/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs index faa3a5bb..ea8ab181 100644 --- a/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs +++ b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs @@ -1,11 +1,17 @@ using System.Globalization; +using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// -/// Reads and writes VTank's exact line-encoded CondAct Meta database. -/// The format is the public interchange contract used by legacy .met -/// profiles; it is deliberately independent from MossTank's native JSON store. +/// Reads VTank's exact line-encoded CondAct Meta database — a +/// one-shot import path only. Per the owner's 2026-09-06 "MossTank does not +/// implement .met" direction (Campaign VT slice 1 Part A), the +/// writer that used to live here was deleted: .af +/// () is the only storage/authoring format for +/// meta profiles now. This class only converts a legacy binary .met +/// file into a so it can be saved straight back +/// out as .af. /// internal static class VtankMetaProfileSerializer { @@ -20,8 +26,16 @@ internal static class VtankMetaProfileSerializer private const int MaximumRules = 100_000; private const int MaximumNesting = 256; - public static bool TryLoad(string source, out MetaProfile profile, out string error) + public static bool TryLoad(string source, out MetaProfile profile, out string error) => + TryLoad(source, MetafSerializer.NoOpSpells.Instance, out profile, out error); + + public static bool TryLoad( + string source, + ISpellCatalog spells, + out MetaProfile profile, + out string error) { + ArgumentNullException.ThrowIfNull(spells); try { var reader = new LineReader(source); @@ -38,7 +52,7 @@ internal static class VtankMetaProfileSerializer reader.Expect("i"); int actionType = reader.ReadInt(); MetaCondition condition = ReadCondition(reader, conditionType, 0); - MetaAction action = ReadAction(reader, actionType, 0); + MetaAction action = ReadAction(reader, actionType, 0, spells); reader.Expect("s"); parsed.Rules.Add(new MetaRule { @@ -62,23 +76,6 @@ internal static class VtankMetaProfileSerializer } } - public static string Save(MetaProfile source) - { - ArgumentNullException.ThrowIfNull(source); - var writer = new LineWriter(); - writer.Add(Header); - MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray(); - writer.Add(rules.Length); - foreach (MetaRule rule in rules) - { - writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind)); - WriteCondition(writer, rule.Condition, 0); - WriteAction(writer, rule.Action, 0); - writer.Add("s", rule.State ?? string.Empty); - } - return writer.Finish(); - } - private static MetaCondition ReadCondition(LineReader reader, int type, int depth) { CheckDepth(reader, depth); @@ -172,7 +169,7 @@ internal static class VtankMetaProfileSerializer } } - private static MetaAction ReadAction(LineReader reader, int type, int depth) + private static MetaAction ReadAction(LineReader reader, int type, int depth, ISpellCatalog spells) { CheckDepth(reader, depth); var value = new MetaAction { Kind = ActionKind(type) }; @@ -191,11 +188,11 @@ internal static class VtankMetaProfileSerializer for (int index = 0; index < count; index++) { reader.Expect("i"); - value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1)); + value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1, spells)); } break; case 4: - ReadEmbeddedNavigation(reader, value); + ReadEmbeddedNavigation(reader, value, spells); break; case 5: reader.Expect(TablePrefix, "2", "s", "st", "s"); @@ -247,7 +244,7 @@ internal static class VtankMetaProfileSerializer return value; } - private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target) + private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target, ISpellCatalog spells) { reader.Expect("ba"); int serializedCharacters = reader.ReadCount(); @@ -255,7 +252,7 @@ internal static class VtankMetaProfileSerializer int statedNodeCount = reader.ReadCount(); if (serializedCharacters <= 5) { - target.Text = EmptyNavigation(); + target.EmbeddedRoute = new NavigationSettings(); return; } @@ -282,7 +279,15 @@ internal static class VtankMetaProfileSerializer } if (actualNodeCount != statedNodeCount) throw reader.Error("Embedded VTank navigation node counts do not match."); - target.Text = string.Join("\r\n", lines) + "\r\n"; + + // Parse the reassembled "uTank2 NAV 1.2" text through the same + // reader a top-level binary .nav import uses (item B: MetaAction + // carries a typed NavigationSettings, not a re-parse-me blob). + string blob = string.Join("\r\n", lines) + "\r\n"; + var route = new NavigationSettings(); + if (!VtankNavRouteSerializer.TryLoad(blob, route, spells, out string navError)) + throw reader.Error($"Embedded VTank navigation route is invalid: {navError}"); + target.EmbeddedRoute = route; } private static void ReadNavigationNode(LineReader reader, List lines) @@ -304,195 +309,10 @@ internal static class VtankMetaProfileSerializer lines.Add(reader.Read()); } - private static void WriteCondition(LineWriter writer, MetaCondition value, int depth) - { - CheckDepth(depth); - int type = ConditionType(value.Kind); - switch (type) - { - case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20: - writer.Add("i", "0"); - break; - case 2 or 3: - writer.Add(RecursiveTablePrefix); - writer.Add(value.Children.Count); - foreach (MetaCondition child in value.Children) - { - writer.Add("i", ConditionType(child.Kind)); - WriteCondition(writer, child, depth + 1); - } - break; - case 4: - writer.Add("s", value.Text); - break; - case 5 or 6 or 17 or 18 or 22 or 24: - writer.Add("i", IntValue(value.Number)); - break; - case 11 or 12: - writer.Add(TablePrefix, "2", "s", "n", "s", value.Text, - "s", "c", "i", IntValue(value.Number)); - break; - case 13: - writer.Add(TablePrefix, "3", "s", "n", "s", value.Text, - "s", "c", "i", IntValue(value.Number), - "s", "r", "d", Number(value.SecondaryNumber)); - break; - case 14: - writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber), - "s", "c", "i", IntValue(value.Number), - "s", "r", "d", Number(value.SecondaryNumber)); - break; - case 16: - writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number)); - break; - case 21: - if (value.Children.Count != 1) - throw new InvalidOperationException("VTank Meta Not requires exactly one condition."); - writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind)); - WriteCondition(writer, value.Children[0], depth + 1); - break; - case 23: - writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number), - "s", "sec", "i", IntValue(value.SecondaryNumber)); - break; - case 25: - writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number)); - break; - case 26: - writer.Add(TablePrefix, "1", "s", "e", "s", value.Text); - break; - case 28: - writer.Add(TablePrefix, "2", "s", "p", "s", value.Text, - "s", "c", "s", value.SecondaryText); - break; - default: - throw new InvalidOperationException($"Unknown VTank Meta condition type {type}."); - } - } - - private static void WriteAction(LineWriter writer, MetaAction value, int depth) - { - CheckDepth(depth); - int type = ActionType(value.Kind); - switch (type) - { - case 0 or 6: - writer.Add("i", "0"); - break; - case 1 or 2: - writer.Add("s", value.Text); - break; - case 3: - writer.Add(RecursiveTablePrefix); - writer.Add(value.Children.Count); - foreach (MetaAction child in value.Children) - { - writer.Add("i", ActionType(child.Kind)); - WriteAction(writer, child, depth + 1); - } - break; - case 4: - WriteEmbeddedNavigation(writer, value); - break; - case 5: - writer.Add(TablePrefix, "2", "s", "st", "s", value.Text, - "s", "ret", "s", value.SecondaryText); - break; - case 7 or 8: - writer.Add(TablePrefix, "1", "s", "e", "s", value.Text); - break; - case 9: - writer.Add(TablePrefix, "3", "s", "s", "s", value.Text, - "s", "r", "d", Number(value.Number), - "s", "t", "d", Number(value.SecondaryNumber)); - break; - case 10 or 15: - writer.Add(TablePrefix, "0"); - break; - case 11 or 12: - writer.Add(TablePrefix, "2", "s", "o", "s", value.Text, - "s", "v", "s", value.SecondaryText); - break; - case 13: - writer.Add(TablePrefix, "2", "s", "n", "s", value.Text, - "s", "x", "ba", value.SecondaryText.Length); - writer.AddBuggedByteArray(value.SecondaryText); - break; - case 14: - writer.Add(TablePrefix, "1", "s", "n", "s", value.Text); - break; - default: - throw new InvalidOperationException($"Unknown VTank Meta action type {type}."); - } - } - - private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value) - { - string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text; - string normalized = NormalizeNewlines(nav); - string[] navLines = normalized.Split('\n', StringSplitOptions.None); - if (navLines.Length != 0 && navLines[^1].Length == 0) - navLines = navLines[..^1]; - int nodes = NavigationNodeCount(navLines); - string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText; - int characters = name.Length + 2 - + nodes.ToString(CultureInfo.InvariantCulture).Length + 2 - + navLines.Sum(static line => line.Length + 2); - writer.Add("ba", characters, name, nodes); - writer.Add(navLines); - } - - private static int NavigationNodeCount(string[] lines) - { - if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal)) - throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data."); - int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture); - if (mode == 3) - return 1; - if (mode is not (1 or 2 or 4) || lines.Length < 3) - throw new InvalidOperationException("Embedded Meta route has an invalid navigation type."); - return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture); - } - - private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n"; - private static string NormalizeNewlines(string value) => value .Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n'); - private static int ConditionType(MetaConditionKind kind) => kind switch - { - MetaConditionKind.Never => 0, - MetaConditionKind.Always => 1, - MetaConditionKind.All => 2, - MetaConditionKind.Any => 3, - MetaConditionKind.ChatMessage => 4, - MetaConditionKind.PackSlotsLessThanOrEqual => 5, - MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6, - MetaConditionKind.NavigationRouteEmpty => 7, - MetaConditionKind.CharacterDeath => 8, - MetaConditionKind.AnyVendorOpen => 9, - MetaConditionKind.VendorClosed => 10, - MetaConditionKind.InventoryItemCountLessThanOrEqual => 11, - MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12, - MetaConditionKind.MonsterNameCountWithinDistance => 13, - MetaConditionKind.MonsterPriorityCountWithinDistance => 14, - MetaConditionKind.NeedToBuff => 15, - MetaConditionKind.NoMonstersWithinDistance => 16, - MetaConditionKind.LandblockEquals => 17, - MetaConditionKind.LandcellEquals => 18, - MetaConditionKind.PortalspaceEntered => 19, - MetaConditionKind.PortalspaceExited => 20, - MetaConditionKind.Not => 21, - MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22, - MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23, - MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24, - MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25, - MetaConditionKind.Expression => 26, - MetaConditionKind.ChatMessageCapture => 28, - _ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."), - }; - private static MetaConditionKind ConditionKind(int type) => type switch { 0 => MetaConditionKind.Never, @@ -526,27 +346,6 @@ internal static class VtankMetaProfileSerializer _ => throw new FormatException($"Unknown VTank Meta condition type {type}."), }; - private static int ActionType(MetaActionKind kind) => kind switch - { - MetaActionKind.None => 0, - MetaActionKind.SetMetaState => 1, - MetaActionKind.ChatCommand => 2, - MetaActionKind.All => 3, - MetaActionKind.LoadEmbeddedNavigationRoute => 4, - MetaActionKind.CallMetaState => 5, - MetaActionKind.ReturnFromCall => 6, - MetaActionKind.ExpressionAction => 7, - MetaActionKind.ChatExpression => 8, - MetaActionKind.SetWatchdog => 9, - MetaActionKind.ClearWatchdog => 10, - MetaActionKind.GetVtankOption => 11, - MetaActionKind.SetVtankOption => 12, - MetaActionKind.CreateView => 13, - MetaActionKind.DestroyView => 14, - MetaActionKind.DestroyAllViews => 15, - _ => throw new InvalidOperationException($"Unsupported Meta action {kind}."), - }; - private static MetaActionKind ActionKind(int type) => type switch { 0 => MetaActionKind.None, @@ -568,32 +367,12 @@ internal static class VtankMetaProfileSerializer _ => throw new FormatException($"Unknown VTank Meta action type {type}."), }; - private static string Number(double value) - { - if (!double.IsFinite(value)) - throw new InvalidOperationException("VTank Meta numbers must be finite."); - return value.ToString("R", CultureInfo.InvariantCulture); - } - - private static int IntValue(double value) - { - if (!double.IsFinite(value) || value != Math.Truncate(value)) - throw new InvalidOperationException("VTank Meta integer fields require whole numbers."); - return checked((int)value); - } - private static void CheckDepth(LineReader reader, int depth) { if (depth > MaximumNesting) throw reader.Error("VTank Meta nesting is too deep."); } - private static void CheckDepth(int depth) - { - if (depth > MaximumNesting) - throw new InvalidOperationException("VTank Meta nesting is too deep."); - } - private sealed class LineReader { private readonly List _lines; @@ -703,40 +482,4 @@ internal static class VtankMetaProfileSerializer public FormatException Error(string message) => new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}"); } - - private sealed class LineWriter - { - private readonly List _lines = []; - private readonly List _buggedByteArrays = []; - - public void Add(params object?[] values) - { - foreach (object? value in values) - _lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); - } - - public void Add(string[] first, params object?[] rest) - { - Add(first.Cast().ToArray()); - Add(rest); - } - - public void AddBuggedByteArray(string value) - { - _buggedByteArrays.Add(_lines.Count); - _lines.Add(value ?? string.Empty); - } - - public string Finish() - { - foreach (int index in _buggedByteArrays.OrderDescending()) - { - if (index + 1 >= _lines.Count) - throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record."); - _lines[index] += _lines[index + 1]; - _lines.RemoveAt(index + 1); - } - return string.Join("\r\n", _lines) + "\r\n"; - } - } } diff --git a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs index 88c8561a..4ff0f2b9 100644 --- a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs +++ b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs @@ -3,40 +3,27 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; -/// Reader for VTank's verbatim uTank2 NAV 1.2 format. +/// +/// Reader for VTank's verbatim uTank2 NAV 1.2 format — a one-shot +/// import path only. Per the owner's 2026-09-06 "MossTank does not author +/// .nav" direction (Campaign VT slice 1 Part A), the writer that +/// used to live here was deleted: .af () +/// is the only storage/authoring format for navigation routes now. +/// 's embedded-navigation contract (a Meta rule's +/// action, +/// .af tag EmbedNav) is a typed +/// +/// now (round 2 step B, replacing an earlier binary-blob shape), saved and +/// loaded through the SAME / +/// grammar this class no longer +/// owns any writer for — round 3 item 12 cleanup: this doc comment +/// previously cited a "WriteBinaryNavBlob" method that does not +/// exist anywhere in the codebase. +/// internal static class VtankNavRouteSerializer { private const string Header = "uTank2 NAV 1.2"; - public static string Save(NavigationSettings source) - { - ArgumentNullException.ThrowIfNull(source); - var writer = new StringWriter(CultureInfo.InvariantCulture) - { - NewLine = "\r\n", - }; - writer.WriteLine(Header); - writer.WriteLine(source.Mode switch - { - RouteMode.Circular => 1, - RouteMode.Linear => 2, - RouteMode.Target => 3, - RouteMode.Once => 4, - _ => throw new InvalidOperationException("Unknown navigation type."), - }); - if (source.Mode == RouteMode.Target) - { - writer.WriteLine(source.FollowTargetName ?? string.Empty); - writer.WriteLine(unchecked((int)source.FollowTargetObjectId)); - return writer.ToString(); - } - - writer.WriteLine(source.Waypoints.Count); - foreach (RouteWaypoint waypoint in source.Waypoints) - WriteWaypoint(writer, waypoint); - return writer.ToString(); - } - public static bool TryLoad( string source, NavigationSettings target, @@ -145,13 +132,23 @@ internal static class VtankNavRouteSerializer break; case 6: case 7: + // The record's leading eastWest/northSouth/elevation (read + // above into waypoint.Position) is "myxyz" — where the + // character stood when the waypoint was authored. This + // trailing triple is the portal/NPC object's own recorded + // position ("tgtxyz" in metaf's parallel ptl/tlk grammar — + // see MetafSerializer.ReadNavNode), kept separately so a + // reload can still disambiguate which object at that name + // to interact with, matching the record shape exactly: + // the pre-existing single-Position port used to overwrite + // "myxyz" with this value instead of keeping both. waypoint.ObjectName = ReadLine(reader); waypoint.LegacyObjectClass = ReadInt(reader); waypoint.LegacyReferenceValid = ReadBoolean(reader); double referenceEastWest = ReadDouble(reader); double referenceNorthSouth = ReadDouble(reader); double referenceElevation = ReadDouble(reader); - waypoint.Position = Position( + waypoint.ReferencePosition = Position( referenceEastWest, referenceNorthSouth, referenceElevation); @@ -165,76 +162,6 @@ internal static class VtankNavRouteSerializer return waypoint; } - private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint) - { - int type = (int)waypoint.Type; - writer.WriteLine(type.ToString(CultureInfo.InvariantCulture)); - WriteDouble(writer, waypoint.Position.EastWest); - WriteDouble(writer, waypoint.Position.NorthSouth); - WriteDouble(writer, waypoint.Position.Elevation); - writer.WriteLine("0"); - switch (waypoint.Type) - { - case RouteWaypointType.Point: - case RouteWaypointType.Checkpoint: - break; - case RouteWaypointType.Portal: - writer.WriteLine(unchecked((int)waypoint.ObjectId) - .ToString(CultureInfo.InvariantCulture)); - break; - case RouteWaypointType.Recall: - writer.WriteLine(waypoint.RecallSpellId - .ToString(CultureInfo.InvariantCulture)); - break; - case RouteWaypointType.Pause: - writer.WriteLine(waypoint.DurationMilliseconds - .ToString(CultureInfo.InvariantCulture)); - break; - case RouteWaypointType.ChatCommand: - writer.WriteLine(waypoint.Text ?? string.Empty); - break; - case RouteWaypointType.OpenVendor: - writer.WriteLine(unchecked((int)waypoint.ObjectId) - .ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(waypoint.ObjectName ?? string.Empty); - break; - case RouteWaypointType.PortalByName: - case RouteWaypointType.UseNpc: - writer.WriteLine(waypoint.ObjectName ?? string.Empty); - int objectClass = waypoint.LegacyObjectClass != 0 - ? waypoint.LegacyObjectClass - : waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37; - writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(waypoint.LegacyReferenceValid - .ToString(CultureInfo.InvariantCulture)); - WriteDouble(writer, waypoint.Position.EastWest); - WriteDouble(writer, waypoint.Position.NorthSouth); - WriteDouble(writer, waypoint.Position.Elevation); - break; - case RouteWaypointType.Jump: - WriteDouble(writer, waypoint.JumpHeadingDegrees); - writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture)); - string suffix = waypoint.JumpDirection switch - { - RouteJumpDirection.StrafeLeft => "4", - RouteJumpDirection.StrafeRight => "5", - _ => "3", - }; - writer.WriteLine( - waypoint.JumpChargeMilliseconds.ToString( - "0.0000", - CultureInfo.InvariantCulture) - + suffix); - break; - default: - throw new InvalidOperationException( - $"Unknown waypoint type {waypoint.Type}."); - } - } - - private static void WriteDouble(TextWriter writer, double value) => - writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture)); - private static void ParseJump(string source, RouteWaypoint target) { string value = source.Trim(); @@ -277,7 +204,14 @@ internal static class VtankNavRouteSerializer 0f, IsOutdoor: true); - private static void Apply(NavigationSettings source, NavigationSettings target) + /// + /// Copies every field owns from + /// into the live + /// instance (internal, not private, so MetaEngine's embedded- + /// route consumer can reuse the exact same copy the top-level import + /// path uses instead of re-parsing already-typed data). + /// + internal static void Apply(NavigationSettings source, NavigationSettings target) { target.Mode = source.Mode; target.FollowTargetObjectId = source.FollowTargetObjectId; diff --git a/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs index 8947b9e5..8dcb1ecf 100644 --- a/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs +++ b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs @@ -5,6 +5,26 @@ namespace AcDream.Plugins.MossTank; /// uTank2.Resources.defaultsettings.usd. Order is retained because /// /vt opt list presents the database order four entries per line. /// +/// +/// VTank's uTank2.eSettingValueType enum +/// (refs/vtank/decompiled/uTank2/eSettingValueType.cs) — the value +/// each Settings row's own SettingType cell declares. This is the +/// authority for which type tag a Settings row's +/// Value cell must use; it is independent of the CLR type of the C# field +/// the live setting is stored in (e.g. every Recharge-* percentage is a +/// tInt row even though the live field is a double fraction). +/// +internal enum VtankSettingValueType +{ + Bool = 1, + Double = 2, + Int = 3, + Single = 4, + String = 5, + Enum = 6, + Custom = 7, +} + internal static class VtankOptionCatalog { internal static readonly string[] Names = @@ -208,6 +228,151 @@ internal static class VtankOptionCatalog ["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"), }; + // Exact per-row SettingType from the shipped defaultsettings.usd + // Settings table (verified 2026-09-06 against the committed + // Fixtures/vtank/defaultsettings.usd, all 137 rows). + private static readonly IReadOnlyDictionary DeclaredTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["EnableLooting"] = VtankSettingValueType.Bool, + ["EnableNav"] = VtankSettingValueType.Bool, + ["EnableBuffing"] = VtankSettingValueType.Bool, + ["EnableCombat"] = VtankSettingValueType.Bool, + ["SpellDiffExcessThreshold-Hunt"] = VtankSettingValueType.Int, + ["SpellDiffExcessThreshold-Buff"] = VtankSettingValueType.Int, + ["ArrowheadFletchDiffExcessThreshold"] = VtankSettingValueType.Int, + ["Recharge-Norm-HitP"] = VtankSettingValueType.Int, + ["Recharge-Norm-Stam"] = VtankSettingValueType.Int, + ["Recharge-Norm-Mana"] = VtankSettingValueType.Int, + ["Recharge-NoTarg-HitP"] = VtankSettingValueType.Int, + ["Recharge-NoTarg-Stam"] = VtankSettingValueType.Int, + ["Recharge-NoTarg-Mana"] = VtankSettingValueType.Int, + ["Recharge-Helper-HitP"] = VtankSettingValueType.Int, + ["Recharge-Helper-Stam"] = VtankSettingValueType.Int, + ["Recharge-Helper-Mana"] = VtankSettingValueType.Int, + ["DoHelp"] = VtankSettingValueType.Bool, + ["AttackDistance"] = VtankSettingValueType.Double, + ["AttackMinimumDistance"] = VtankSettingValueType.Double, + ["ApproachDistance"] = VtankSettingValueType.Double, + ["RingDistance"] = VtankSettingValueType.Double, + ["CorpseApproachRange-Max"] = VtankSettingValueType.Double, + ["CorpseApproachRange-Min"] = VtankSettingValueType.Double, + ["NavCloseStopRange"] = VtankSettingValueType.Double, + ["NavFarStopRange"] = VtankSettingValueType.Double, + ["UsePortalDistance"] = VtankSettingValueType.Double, + ["HelperDistanceHitP"] = VtankSettingValueType.Double, + ["HelperDistanceStam"] = VtankSettingValueType.Double, + ["HelperDistanceMana"] = VtankSettingValueType.Double, + ["MinimumRingTargets"] = VtankSettingValueType.Int, + ["DefaultMeleeAttackHeight"] = VtankSettingValueType.Int, + ["CastDispelSelf"] = VtankSettingValueType.Bool, + ["UseDispelItems"] = VtankSettingValueType.Bool, + ["AutoCram"] = VtankSettingValueType.Bool, + ["AutoStack"] = VtankSettingValueType.Bool, + ["ReadUnknownScrolls"] = VtankSettingValueType.Bool, + ["UseDispelDrum"] = VtankSettingValueType.Bool, + ["SwitchWandsToDebuff"] = VtankSettingValueType.Bool, + ["AutoCraftItems"] = VtankSettingValueType.Bool, + ["UseHealersHeart"] = VtankSettingValueType.Bool, + ["JumpOutWandCasting"] = VtankSettingValueType.Bool, + ["LootAllCorpses"] = VtankSettingValueType.Bool, + ["LootFellowCorpses"] = VtankSettingValueType.Bool, + ["DoJiggle"] = VtankSettingValueType.Bool, + ["RandomHelperBuffs"] = VtankSettingValueType.Bool, + ["RandomHelperIntervalSeconds"] = VtankSettingValueType.Double, + ["IdlePeaceMode"] = VtankSettingValueType.Bool, + ["TargetLock"] = VtankSettingValueType.Bool, + ["StopMacroOnDeath"] = VtankSettingValueType.Bool, + ["UseArcs"] = VtankSettingValueType.Enum, + ["ArcRange"] = VtankSettingValueType.Double, + ["TargetSelectMethod"] = VtankSettingValueType.Enum, + ["TargetSelectAngleRange"] = VtankSettingValueType.Double, + ["IdleBuffTopoff"] = VtankSettingValueType.Bool, + ["IdleBuffTopoffTimeSeconds"] = VtankSettingValueType.Int, + ["RebuffTimeRemainingSeconds"] = VtankSettingValueType.Int, + ["RefillWornMana"] = VtankSettingValueType.Bool, + ["RefillWornMana-Item-ManaPercent"] = VtankSettingValueType.Int, + ["BuffProfile-Prots"] = VtankSettingValueType.String, + ["BuffProfile-Banes"] = VtankSettingValueType.String, + ["BuffProfile_Prots"] = VtankSettingValueType.Enum, + ["BuffProfile_Banes"] = VtankSettingValueType.Enum, + ["DebuffEachFirst"] = VtankSettingValueType.Enum, + ["AutoAttackPower"] = VtankSettingValueType.Bool, + ["LootPriorityBoost"] = VtankSettingValueType.Bool, + ["CorpseCacheTimeoutMinutes"] = VtankSettingValueType.Double, + ["CorpseItemAppearanceTimeoutSeconds"] = VtankSettingValueType.Double, + ["CorpseItemIDTimeoutSeconds"] = VtankSettingValueType.Double, + ["DebuffSelectionMethod"] = VtankSettingValueType.Enum, + ["ManaStoneLootCount"] = VtankSettingValueType.Int, + ["ManaTankMinimumMana"] = VtankSettingValueType.Int, + ["SplitPeas"] = VtankSettingValueType.Bool, + ["SpellCompMin-Critical"] = VtankSettingValueType.Int, + ["SpellCompMin-Normal"] = VtankSettingValueType.Int, + ["SpellCompMin-Idle"] = VtankSettingValueType.Int, + ["RechargeBoostTimeSeconds"] = VtankSettingValueType.Double, + ["RechargeBoostAmount"] = VtankSettingValueType.Int, + ["UseSpecialAmmo"] = VtankSettingValueType.Enum, + ["OpenDoors"] = VtankSettingValueType.Bool, + ["DoorIDRange"] = VtankSettingValueType.Double, + ["DoorOpenRange"] = VtankSettingValueType.Double, + ["DoorLockpickDiffExcessThreshold"] = VtankSettingValueType.Int, + ["ManaChargesWhenOff"] = VtankSettingValueType.Bool, + ["AutoFellowManagement"] = VtankSettingValueType.Bool, + ["MinimumHealKitSuccessChance"] = VtankSettingValueType.Int, + ["UseKitsInMagicMode"] = VtankSettingValueType.Bool, + ["StaminaToHealthMultiplier"] = VtankSettingValueType.Double, + ["ManaToHealthMultiplier"] = VtankSettingValueType.Double, + ["NavPriorityBoost"] = VtankSettingValueType.Bool, + ["DeleteGhostMonsters"] = VtankSettingValueType.Bool, + ["GhostMonsterSpellAttemptCount"] = VtankSettingValueType.Int, + ["WhoYouGonnaCall"] = VtankSettingValueType.Bool, + ["BlacklistMonsterAttemptCount"] = VtankSettingValueType.Int, + ["BlacklistMonsterTimeoutSeconds"] = VtankSettingValueType.Int, + ["CombineSalvage"] = VtankSettingValueType.Bool, + ["LootOnlyRareCorpses"] = VtankSettingValueType.Bool, + ["DeleteGhostMonstersByHPTracker"] = VtankSettingValueType.Bool, + ["GhostDeleteHPTrackerSeconds"] = VtankSettingValueType.Int, + ["GoToPeaceModeToUseKits"] = VtankSettingValueType.Bool, + ["UseRecklessness"] = VtankSettingValueType.Bool, + ["DebuffPrecastSeconds"] = VtankSettingValueType.Int, + ["ClearLevelBoostFlagOnCast"] = VtankSettingValueType.Bool, + ["IdleCraftCount_HealthKits"] = VtankSettingValueType.Int, + ["IdleCraftCount_StamKits"] = VtankSettingValueType.Int, + ["IdleCraftCount_ManaKits"] = VtankSettingValueType.Int, + ["IdleCraftCount_HealthFood"] = VtankSettingValueType.Int, + ["IdleCraftCount_StamFood"] = VtankSettingValueType.Int, + ["IdleCraftCount_ManaFood"] = VtankSettingValueType.Int, + ["BuffCastRecast_Seconds"] = VtankSettingValueType.Int, + ["BuffCastRecastReset_Seconds"] = VtankSettingValueType.Int, + ["EnableMeta"] = VtankSettingValueType.Bool, + ["BlacklistedSpellComps"] = VtankSettingValueType.String, + ["DropToPeaceModeRetryCount"] = VtankSettingValueType.Int, + ["FollowAroundCorners"] = VtankSettingValueType.Bool, + ["BlacklistCorpseOpenAttemptCount"] = VtankSettingValueType.Int, + ["BlacklistCorpseOpenTimeoutSeconds"] = VtankSettingValueType.Int, + ["SummonPets"] = VtankSettingValueType.Bool, + ["PetRangeMode"] = VtankSettingValueType.Enum, + ["PetCustomRange"] = VtankSettingValueType.Double, + ["PetRefillCount-Idle"] = VtankSettingValueType.Int, + ["PetRefillCount-Normal"] = VtankSettingValueType.Int, + ["CorpseOpenTimeoutSeconds"] = VtankSettingValueType.Double, + ["PetMonsterDensity"] = VtankSettingValueType.Int, + ["CorpseLootItemMaxAttempts"] = VtankSettingValueType.Int, + ["FastCastBuffs"] = VtankSettingValueType.Bool, + ["UseBreakableTurnTo"] = VtankSettingValueType.Bool, + ["UseProjectileAwareness"] = VtankSettingValueType.Bool, + ["CollisionProjectileRadius"] = VtankSettingValueType.Double, + ["CollisionStepDistance"] = VtankSettingValueType.Double, + ["ShowCollisionDebug"] = VtankSettingValueType.Bool, + ["MaximumCollisionChecksPerTick"] = VtankSettingValueType.Int, + ["SpellRangeFudge"] = VtankSettingValueType.Double, + ["BuffWithUntrained-Item"] = VtankSettingValueType.Int, + ["BuffWithUntrained-Creature"] = VtankSettingValueType.Int, + ["BuffWithUntrained-Life"] = VtankSettingValueType.Int, + ["AllowDebuffFallback"] = VtankSettingValueType.Bool, + ["RechargeHandlerSet"] = VtankSettingValueType.Custom, + }; + internal static bool IsKnown(string name) => Names.Contains(name, StringComparer.OrdinalIgnoreCase); @@ -218,4 +383,10 @@ internal static class VtankOptionCatalog Defaults.TryGetValue(name, out MonsterValue value) ? value : MonsterValue.FromNumber(0d); + + /// The eSettingValueType VTank's own Settings row declares for this name. + internal static VtankSettingValueType DeclaredType(string name) => + DeclaredTypes.TryGetValue(name, out VtankSettingValueType type) + ? type + : VtankSettingValueType.Double; } diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs new file mode 100644 index 00000000..ec6af748 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -0,0 +1,404 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Implements VTank's real profile-directory naming/selection rules +/// (docs/research/vtank-kb/01-settings-and-profiles.md section 3): +/// the per-character auto file, the longer --Name_Server_ +/// sub-profile prefix, and which filenames a given character can see. +/// +/// The directory itself is never resolved here: every listing method reads +/// through (an +/// the App/Headless host composes — real installed VTank's own profile +/// folder for direct interop, or a portable per-OS default under +/// ApplicationPathSet.DataDirectory). This class has no +/// System.IO dependency and no portable-default fallback of its +/// own: when the host has no opinion, +/// defaults to the inert NoOpPluginStorage (IsAvailable +/// false), and every listing here degrades to just its built-in entries, +/// exactly like a missing directory used to. +/// +internal static class VtankProfileDirectory +{ + /// The real VTank "--" reserved-prefix marker (section 3). + internal const string HiddenPrefix = "--"; + + /// + /// The nav-profile-only "~~" reserved prefix (section 3) — VTank's own + /// producer/purpose was not determined by the KB research pass either; + /// this only reproduces the filter. + /// + internal const string NavHiddenPrefix = "~~"; + + internal const string ByCharacterLabel = "[By char]"; + internal const string DefaultLabel = "[Default]"; + internal const string NoneLabel = "[None]"; + + /// + /// The nav_ marker (round 3 items 3/4) that distinguishes a + /// stand-alone route .af from a Meta profile sharing the same + /// flat directory and extension + /// — metaf's own observed convention for a nav-only .af (see the + /// committed nav_*.af fixtures). MUST be checked AFTER stripping + /// when the marker is combined with it (see + /// 's + /// marker parameter): a hidden per-character route file is named + /// --nav_Name_Server.af — hidden prefix FIRST, marker SECOND — + /// never nav_--Name_Server.af, which does not start with + /// at all and so defeats every + /// StartsWith("--") hidden-file check in this class. + /// + internal const string NavMarker = "nav_"; + + /// + /// VTank's single per-character default filename + /// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext. + /// + public static string AutoCharacterFileName( + string characterName, + string server, + string extension) => + $"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}"; + + /// + /// Overload for a per-character auto file that ALSO carries a kind + /// marker (currently only , for + /// 's route .af): the + /// hidden prefix always comes first so the file stays hidden from every + /// other character's picker exactly like the unmarked overload above — + /// --{marker}Name_Server.ext, e.g. --nav_Name_Server.af. + /// + public static string AutoCharacterFileName( + string characterName, + string server, + string extension, + string marker) => + $"{HiddenPrefix}{marker}{characterName}_{server}.{extension.TrimStart('.')}"; + + /// + /// The longer, trailing-underscore prefix + /// (uTank2/PluginCore.cs:933,3866, field dw) that marks a + /// *named sub-profile* belonging to one character, distinct from that + /// character's single auto file above. + /// + public static string SubProfilePrefix(string characterName, string server) => + $"{HiddenPrefix}{characterName}_{server}_"; + + /// + /// True when must be hidden from the + /// cross-character profile picker for / + /// : any ---prefixed name that is not + /// this character's own sub-profile family + /// (uTank2/PluginCore.cs:7020,7072,7144). + /// + public static bool IsHiddenFromOtherCharacters( + string fileName, + string characterName, + string server) => + fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal) + && !fileName.StartsWith( + SubProfilePrefix(characterName, server), + StringComparison.Ordinal); + + /// + /// The "[Char] suffix" display form for one of this character's own + /// sub-profiles (uTank2/PluginCore.cs:7016-7046): the + /// --Name_Server_ prefix and the file extension are both + /// stripped. Returns for a filename that is not + /// one of this character's sub-profiles. + /// + public static string? TryDisplayName( + string fileName, + string characterName, + string server) + { + string prefix = SubProfilePrefix(characterName, server); + if (!fileName.StartsWith(prefix, StringComparison.Ordinal)) + return null; + string withoutPrefix = fileName[prefix.Length..]; + int dot = withoutPrefix.LastIndexOf('.'); + string suffix = dot >= 0 ? withoutPrefix[..dot] : withoutPrefix; + return suffix.Length == 0 ? null : $"[Char] {suffix}"; + } + + /// + /// One entry in a profile picker: the real on-disk file name, and the + /// label VTank would show for it. + /// + public readonly record struct ProfileEntry(string FileName, string DisplayName); + + /// + /// VTank's settings-profile list (a0(), + /// uTank2/PluginCore.cs:7057-7115): seeds + /// and first, + /// then every non--- .usd file, plus every one of this + /// character's own --Name_Server_* sub-profiles shown as + /// [Char] suffix. + /// + /// + /// The "Mine only" checkbox (cSettingsShowAll/field a9). + /// VTank's own predicate (uTank2/PluginCore.cs:7020-7024) is NOT + /// "hide every shared file" — a shared (non-sub-profile) file is hidden + /// only when it is neither unchecked NOR the currently selected file, + /// so the profile actually in use never vanishes out from under the + /// user just because they ticked the box afterward. + /// + /// + /// The file name currently assigned to + /// (if any), exempted from filtering per the + /// rule above. Comparison is ordinal (VTank's own file names are + /// case-sensitive on the filesystems it ships for). + /// + public static IReadOnlyList ListSettingsProfiles( + IPluginStorage storage, + string characterName, + string server, + bool mineOnly, + string? currentFileName = null) + { + var entries = new List + { + new(string.Empty, DefaultLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(storage, ".usd")) + { + string? subProfileDisplay = TryDisplayName(fileName, characterName, server); + if (subProfileDisplay is not null) + { + entries.Add(new ProfileEntry(fileName, subProfileDisplay)); + continue; + } + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)) + continue; // someone else's --Name_Server(.usd|_*) family. + if (mineOnly + && !fileName.Equals(currentFileName, StringComparison.Ordinal)) + { + continue; + } + entries.Add(new ProfileEntry(fileName, fileName)); + } + return entries; + } + + /// + /// VTank's navigation-profile list (l(), + /// uTank2/PluginCore.cs:7156-7185): seeds + /// /, then every + /// -marked .af file that starts with + /// neither -- nor ~~. Round 3 item 4: a Meta profile and a + /// route share this same flat, single-extension directory — without + /// the marker check this returned every Meta .af too. + /// + public static IReadOnlyList ListNavigationProfiles(IPluginStorage storage) + { + var entries = new List + { + new(string.Empty, NoneLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(storage, ".af")) + { + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal) + || fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal)) + { + continue; + } + if (!fileName.StartsWith(NavMarker, StringComparison.Ordinal)) + continue; + entries.Add(new ProfileEntry(fileName, fileName)); + } + return entries; + } + + /// + /// VTank's meta-profile list (ac(), + /// uTank2/PluginCore.cs:7187+): seeds + /// /, then every + /// non---, non--marked file (a meta and a + /// nav profile share the same directory and extension here — + /// .af — so the marker is what VTank's own separate + /// .met/.nav extensions used to provide; round 3 item 4: + /// without excluding files this returned every + /// route .af too). + /// + public static IReadOnlyList ListMetaProfiles(IPluginStorage storage) + { + var entries = new List + { + new(string.Empty, NoneLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(storage, ".af")) + { + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)) + continue; + if (fileName.StartsWith(NavMarker, StringComparison.Ordinal)) + continue; + entries.Add(new ProfileEntry(fileName, fileName)); + } + 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 + /// CharacterName_Server.ast — no "--" prefix (unlike the + /// auto settings/nav/meta files), not user-selectable, and note the + /// concatenation ORDER (Name first, then Server) is the reverse of + /// 's Server-then-Name order. + /// + public static string AstFileName(string characterName, string server) => + $"{characterName}_{server}.ast"; + + /// + /// The per-character binding file's own name (da class, + /// refs/vtank/decompiled/da.cs:110): always + /// Server_CharacterName.cdf — Server first, then Name (the + /// reverse of and of the auto + /// --Name_Server.usd naming). + /// + public static string CdfFileName(string characterName, string server) => + $"{server}_{characterName}.cdf"; + + /// The literal version header a valid .cdf starts with (da.cs:15). + internal const string CdfHeader = "uTank2 CDF 1.0"; + + /// + /// One character's currently-assigned profile filenames, read from its + /// .cdf (da.e(), da.cs:105-154). Meta is + /// when the file is short (line 5 is present + /// only when the stream isn't already at EOF, da.cs:125-128 — + /// an older .cdf predating meta support has no line 5 at all). + /// + public readonly record struct VtankCharacterBinding( + string SettingsFileName, + string LootFileName, + string NavFileName, + string? MetaFileName); + + /// + /// Reads /'s + /// .cdf through . Returns + /// when the storage is unavailable, the file is + /// missing, or the file does not start with — + /// real VTank treats a version mismatch identically to a missing file + /// and falls back to that character's auto-created per-character + /// defaults (da.cs:113-121), which this method leaves entirely + /// to the caller rather than fabricating a default binding itself. The + /// legacy .uts.usd settings-filename rewrite + /// (da.cs:130-141) is applied here so callers never see a + /// .uts name. + /// + public static VtankCharacterBinding? TryReadCharacterBinding( + IPluginStorage storage, + string characterName, + string server) + { + ArgumentNullException.ThrowIfNull(storage); + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ArgumentException.ThrowIfNullOrWhiteSpace(server); + if (!storage.IsAvailable) + return null; + string? text = storage.ReadText(CdfFileName(characterName, server)); + if (text is null) + return null; + string[] lines = text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n'); + if (lines.Length < 4 || !string.Equals(lines[0], CdfHeader, StringComparison.Ordinal)) + return null; + string settings = lines[1].EndsWith(".uts", StringComparison.OrdinalIgnoreCase) + ? string.Concat(lines[1].AsSpan(0, lines[1].Length - 4), ".usd") + : lines[1]; + string? meta = lines.Length >= 5 && lines[4].Length > 0 ? lines[4] : null; + return new VtankCharacterBinding(settings, lines[2], lines[3], meta); + } + + /// + /// Writes as / + /// 's .cdf (da.q(), + /// refs/vtank/decompiled/da.cs:156-164): the version header, then + /// settings/loot/nav on lines 2-4, then the meta filename on line 5 only + /// when is non-empty — + /// matching 's own line-5-is-optional + /// parsing exactly. No-ops when the storage is unavailable. + /// + public static void WriteCharacterBinding( + IPluginStorage storage, + string characterName, + string server, + VtankCharacterBinding binding) + { + ArgumentNullException.ThrowIfNull(storage); + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ArgumentException.ThrowIfNullOrWhiteSpace(server); + if (!storage.IsAvailable) + return; + var lines = new List + { + CdfHeader, + binding.SettingsFileName, + binding.LootFileName, + binding.NavFileName, + }; + if (!string.IsNullOrEmpty(binding.MetaFileName)) + lines.Add(binding.MetaFileName); + storage.WriteText( + CdfFileName(characterName, server), + string.Join("\r\n", lines) + "\r\n"); + } + + /// + /// Lists root-level file names matching + /// through alone — no + /// System.IO. VTank's own profile directory is flat, so a key + /// containing '/' (meaning it lives in some deeper storage + /// implementation's sub-directory) is not one of these files and is + /// skipped rather than surfaced as a bogus profile name. + /// + private static IEnumerable EnumerateFileNames(IPluginStorage storage, string extension) + { + if (!storage.IsAvailable) + yield break; + foreach (string key in storage.List(string.Empty) + .Where(key => !key.Contains('/', StringComparison.Ordinal)) + .Where(key => key.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)) + { + yield return key; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs new file mode 100644 index 00000000..f39b4d59 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs @@ -0,0 +1,542 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Loads/saves VTank's real .usd settings profile (the self-describing +/// gy/bd/y grammar in ), +/// mapping every one of the 137 Settings table rows +/// () onto the live +/// // +/// // +/// fields by VTank name. +/// +/// The value transforms (unit scaling, enum offsets) are the exact ones +/// already verified in MossTankPanel.SetMetaOption/GetMetaOption +/// (MossTankPanel.cs:2442-3377) against the decompiled VTank source +/// per docs/research/vtank-kb/01-settings-and-profiles.md — this +/// class duplicates those transforms rather than calling into +/// MossTankPanel because the panel's methods operate on a live +/// session (chat, expression state) that a file-format serializer must not +/// depend on. +/// +/// Every table other than Settings (MyMonsters, GemFoodItems, +/// ExtraBuffSpells, AntiExtraBuffSpells, ItemUseSpecifiers, +/// SettingsCategories, SettingsEnumInfo, AssistItems, BuffedItems, …) is +/// preserved byte-for-byte: mutates the exact +/// object returned, and only +/// replaces a Settings row's Value cell when the live setting's +/// current value differs from what was parsed (a value-equality compare, +/// not a text compare, so an untouched profile round-trips byte-for-byte +/// even if VTank's own float formatting differs subtly from ours). +/// +internal static class VtankSettingsProfileSerializer +{ + private const string SettingsTable = "Settings"; + + internal sealed class AllSettings + { + public required CombatSettings Combat { get; init; } + public required BuffSettings Buffs { get; init; } + public required VitalSettings Vitals { get; init; } + public required InventorySettings Inventory { get; init; } + public required NavigationSettings Navigation { get; init; } + } + + /// + /// Parses and applies every recognized Settings + /// row onto . Returns the parsed + /// so a later can + /// preserve every other table (and every unrecognized Settings row) + /// byte-for-byte. Throws with a + /// line N:-prefixed message on the first structural error. + /// + public static VtankDatabase Load(string text, AllSettings target) + { + ArgumentNullException.ThrowIfNull(target); + VtankDatabase database = VtankDatabase.Parse(text); + VtankTable? settings = database.Find(SettingsTable); + if (settings is null) + throw new FormatException("missing required 'Settings' table."); + int nameColumn = settings.ColumnIndex("Setting"); + int valueColumn = settings.ColumnIndex("Value"); + if (nameColumn < 0 || valueColumn < 0) + throw new FormatException("'Settings' table is missing Setting/Value columns."); + + foreach (VtankRow row in settings.Rows) + { + string name = row.Cells[nameColumn].AsString(); + Apply(name, row.Cells[valueColumn], target); + } + return database; + } + + /// + /// Regenerates the Settings table's Value cells from + /// 's current values (skipping any row whose + /// value is unchanged from what already + /// holds) and re-renders the whole database. Every other table, and any + /// Settings row this serializer does not recognize, is untouched. + /// + public static string Save(VtankDatabase document, AllSettings source) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(source); + VtankTable? settings = document.Find(SettingsTable); + if (settings is null) + throw new FormatException("missing required 'Settings' table."); + int nameColumn = settings.ColumnIndex("Setting"); + int valueColumn = settings.ColumnIndex("Value"); + foreach (VtankRow row in settings.Rows) + { + string name = row.Cells[nameColumn].AsString(); + VtankCell? captured = Capture(name, source); + if (captured is null) + continue; + VtankCell current = row.Cells[valueColumn]; + if (ValuesEqual(current, captured)) + continue; + row.Cells[valueColumn] = captured; + } + return document.Render(); + } + + /// + /// Builds a brand-new database seeded from VTank's own shipped + /// defaultsettings.usd () + /// — every table (MyMonsters, GemFoodItems, ExtraBuffSpells, + /// AntiExtraBuffSpells, ItemUseSpecifiers, SettingsCategories, + /// SettingsEnumInfo, AssistItems, BuffedItems, RechargeHandlerSet, …), + /// not a hand-typed 4-column Settings-only replica: a prior version of + /// this method built only the Settings table, with an empty + /// Description and a hardcoded SettingType of 1 (Bool) for every row + /// regardless of the setting's real declared type. + /// then rewrites every Settings row's Value cell from + /// 's current values, exactly as it would for + /// an existing profile being re-saved untouched. + /// + public static VtankDatabase CreateNew(AllSettings source) + { + ArgumentNullException.ThrowIfNull(source); + VtankDatabase database = VtankDefaultSettingsDatabase.Parse(); + _ = Save(database, source); + return database; + } + + /// + /// Converts a live numeric setting value to the exact + /// shape its declared SettingType + /// requires — the fix for the fidelity defect where 17 tInt + /// settings were written as d and 3 tSingle settings were + /// written as f (VTank's gy.e/gy.f unbox helpers + /// throw on a tag/CLR-type mismatch). + /// The tag is driven by , + /// never by the CLR type of the C# field the caller happens to store + /// the value in. + /// + private static VtankCell Num(string name, double value) => + VtankOptionCatalog.DeclaredType(name) switch + { + VtankSettingValueType.Bool => VtankCell.Bool(value != 0d), + VtankSettingValueType.Int or VtankSettingValueType.Enum => + VtankCell.Int((int)Math.Round(value, MidpointRounding.AwayFromZero)), + VtankSettingValueType.Single => VtankCell.Float((float)value), + VtankSettingValueType.String => VtankCell.String(value.ToString(CultureInfo.InvariantCulture)), + _ => VtankCell.Double(value), + }; + + /// + /// Exact compare on the cell's own type, with no tolerance anywhere — + /// a tag mismatch is a real bug, not something to + /// paper over. Item I (slice-1 fix round) removed the one narrow + /// exception this used to carry: every tDouble-declared distance + /// setting (AttackDistance, ArcRange, …) that used to round-trip + /// through a float-typed live field (CombatSettings.MaximumRange + /// etc., costing precision below float's ~7-digit guarantee) now stores + /// that value as a real double — the field widened to match its + /// declared type instead of the compare being loosened to match the + /// field. Physics/combat consumers that want a float cast it at + /// their own use site. + /// + private static bool ValuesEqual(VtankCell a, VtankCell b) + { + if (a.Tag != b.Tag) + return false; + return a.Tag switch + { + "b" => a.AsBool() == b.AsBool(), + "s" => a.AsString() == b.AsString(), + "i" => a.AsInt() == b.AsInt(), + "u" => a.AsUInt() == b.AsUInt(), + "d" or "f" => a.AsDouble() == b.AsDouble(), + _ => ReferenceEquals(a, b), + }; + } + + // ------------------------------------------------------------------ + // Apply: .usd cell -> live setting. + // ------------------------------------------------------------------ + + /// Internal (not private) so the profile store can seed live settings directly from a parsed database (fresh/default profiles, migration). + internal static void Apply(string rawName, VtankCell cell, AllSettings s) + { + if (!VtankOptionCatalog.IsKnown(rawName)) + return; + string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant(); + CombatSettings c = s.Combat; + BuffSettings b = s.Buffs; + VitalSettings v = s.Vitals; + InventorySettings i = s.Inventory; + NavigationSettings n = s.Navigation; + switch (name) + { + case "enablelooting": i.Loot.Enabled = cell.AsBool(); break; + case "enablenav": n.Enabled = cell.AsBool(); break; + case "enablebuffing": b.Enabled = cell.AsBool(); break; + case "enablecombat": c.Enabled = cell.AsBool(); break; + case "spelldiffexcessthreshold-hunt": c.HuntSkillExcessOverDifficulty = cell.AsInt(); break; + case "spelldiffexcessthreshold-buff": b.SkillExcessOverDifficulty = cell.AsInt(); break; + case "arrowheadfletchdiffexcessthreshold": i.ArrowheadFletchDifficultyExcess = cell.AsInt(); break; + case "recharge-norm-hitp": v.NormalHealth = cell.AsDouble() / 100d; break; + case "recharge-norm-stam": v.NormalStamina = cell.AsDouble() / 100d; break; + case "recharge-norm-mana": v.NormalMana = cell.AsDouble() / 100d; break; + case "recharge-notarg-hitp": v.NoTargetHealth = cell.AsDouble() / 100d; break; + case "recharge-notarg-stam": v.NoTargetStamina = cell.AsDouble() / 100d; break; + case "recharge-notarg-mana": v.NoTargetMana = cell.AsDouble() / 100d; break; + case "recharge-helper-hitp": v.HelperHealth = cell.AsDouble() / 100d; break; + case "recharge-helper-stam": v.HelperStamina = cell.AsDouble() / 100d; break; + case "recharge-helper-mana": v.HelperMana = cell.AsDouble() / 100d; break; + case "dohelp": v.HelpOthers = cell.AsBool(); break; + case "attackdistance": c.MaximumRange = cell.AsDouble() * 240d; break; + case "attackminimumdistance": c.MinimumRange = cell.AsDouble() * 240d; break; + case "approachdistance": c.ApproachDistance = cell.AsDouble() * 240d; break; + case "ringdistance": c.RingDistance = cell.AsDouble() * 240d; break; + case "corpseapproachrange-max": i.Loot.CorpseApproachRange = cell.AsDouble() * 240d; break; + case "corpseapproachrange-min": i.Loot.CorpseMinimumApproachRange = cell.AsDouble() * 240d; break; + case "navclosestoprange": n.MinimumDistanceMeters = cell.AsDouble() * 240d; break; + case "navfarstoprange": n.MaximumDistanceMeters = cell.AsDouble() * 240d; break; + case "useportaldistance": n.PortalUseDistanceMeters = cell.AsDouble() * 240d; break; + case "helperdistancehitp": v.HelperHealthDistance = cell.AsDouble() * 240d; break; + case "helperdistancestam": v.HelperStaminaDistance = cell.AsDouble() * 240d; break; + case "helperdistancemana": v.HelperManaDistance = cell.AsDouble() * 240d; break; + case "minimumringtargets": c.MinimumRingTargets = cell.AsInt(); break; + case "defaultmeleeattackheight": c.AttackHeight = (PluginAttackHeight)cell.AsInt(); break; + case "castdispelself": v.CastDispelSelf = cell.AsBool(); break; + case "usedispelitems": v.UseDispelItems = cell.AsBool(); break; + case "autocram": i.AutoCram = cell.AsBool(); break; + case "autostack": i.AutoStack = cell.AsBool(); break; + case "readunknownscrolls": i.Loot.ReadUnknownScrolls = cell.AsBool(); break; + case "usedispeldrum": v.UseDispelDrum = cell.AsBool(); break; + case "switchwandstodebuff": c.SwitchWandsToDebuff = cell.AsBool(); break; + case "autocraftitems": i.AutoCraftItems = cell.AsBool(); break; + case "usehealersheart": v.UseHealersHeart = cell.AsBool(); break; + case "jumpoutwandcasting": c.JumpOutWandCasting = cell.AsBool(); break; + case "lootallcorpses": i.Loot.LootAllCorpses = cell.AsBool(); break; + case "lootfellowcorpses": i.Loot.LootFellowCorpses = cell.AsBool(); break; + case "dojiggle": c.DoJiggle = cell.AsBool(); break; + case "randomhelperbuffs": b.RandomHelperBuffs = cell.AsBool(); break; + case "randomhelperintervalseconds": b.RandomHelperIntervalSeconds = cell.AsDouble(); break; + case "idlepeacemode": c.IdlePeaceMode = cell.AsBool(); break; + case "targetlock": c.TargetLock = cell.AsBool(); break; + case "stopmacroondeath": c.StopMacroOnDeath = cell.AsBool(); break; + case "usearcs": c.UseArcs = (UseArcsMode)cell.AsInt(); break; + case "arcrange": c.ArcRange = cell.AsDouble() * 240d; break; + case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break; + case "targetselectanglerange": c.TargetSelectAngleRange = cell.AsDouble() * 240d; break; + case "idlebufftopoff": b.IdleBuffTopoff = cell.AsBool(); break; + case "idlebufftopofftimeseconds": b.IdleBuffTopoffSeconds = cell.AsDouble(); break; + case "rebufftimeremainingseconds": b.RebuffWhenUnderSeconds = cell.AsDouble(); break; + case "refillwornmana": i.RefillWornMana = cell.AsBool(); break; + case "refillwornmana-item-manapercent": i.RefillWornManaPercent = cell.AsInt(); break; + case "buffprofile-prots": b.ProtectionElements = cell.AsString(); break; + case "buffprofile-banes": b.BaneElements = cell.AsString(); break; + case "buffprofile_prots": b.ProtectionProfileMode = cell.AsInt(); break; + case "buffprofile_banes": b.BaneProfileMode = cell.AsInt(); break; + case "debuffeachfirst": c.DebuffEachFirst = (DebuffEachFirst)cell.AsInt(); break; + case "autoattackpower": c.AutoAttackPower = cell.AsBool(); break; + case "lootpriorityboost": i.Loot.PriorityBoost = cell.AsBool(); break; + case "corpsecachetimeoutminutes": i.Loot.CorpseCacheTimeoutMinutes = cell.AsDouble(); break; + case "corpseitemappearancetimeoutseconds": i.Loot.CorpseItemAppearanceTimeoutSeconds = cell.AsDouble(); break; + case "corpseitemidtimeoutseconds": i.Loot.CorpseItemIdentifyTimeoutSeconds = cell.AsDouble(); break; + case "debuffselectionmethod": c.DebuffSelectionMethod = (DebuffSelectionMethod)cell.AsInt(); break; + case "manastonelootcount": i.Loot.ManaStoneLootCount = cell.AsInt(); break; + case "manatankminimummana": i.Loot.ManaTankMinimumMana = cell.AsInt(); break; + case "splitpeas": i.SplitPeas = cell.AsBool(); break; + case "spellcompmin-critical": i.CriticalComponentMinimum = cell.AsInt(); break; + case "spellcompmin-normal": i.NormalComponentMinimum = cell.AsInt(); break; + case "spellcompmin-idle": i.IdleComponentMinimum = cell.AsInt(); break; + case "rechargeboosttimeseconds": v.RechargeBoostTimeSeconds = cell.AsDouble(); break; + case "rechargeboostamount": v.RechargeBoostAmount = cell.AsInt(); break; + case "usespecialammo": c.UseSpecialAmmo = cell.AsInt(); break; + case "opendoors": n.OpenDoors = cell.AsBool(); break; + case "dooridrange": n.DoorIdentifyRangeMeters = cell.AsDouble() * 240d; break; + case "dooropenrange": n.DoorOpenRangeMeters = cell.AsDouble() * 240d; break; + case "doorlockpickdiffexcessthreshold": n.DoorLockpickExcessThreshold = cell.AsInt(); break; + case "manachargeswhenoff": i.ManaChargesWhenOff = cell.AsBool(); break; + case "autofellowmanagement": c.AutoFellowManagement = cell.AsBool(); break; + case "minimumhealkitsuccesschance": v.MinimumHealKitSuccessChance = cell.AsInt(); break; + case "usekitsinmagicmode": v.UseKitsInMagicMode = cell.AsBool(); break; + case "staminatohealthmultiplier": v.StaminaToHealthMultiplier = cell.AsDouble(); break; + case "manatohealthmultiplier": v.ManaToHealthMultiplier = cell.AsDouble(); break; + case "navpriorityboost": n.Priority = cell.AsBool(); break; + case "deleteghostmonsters": c.DeleteGhostMonsters = cell.AsBool(); break; + case "ghostmonsterspellattemptcount": c.GhostMonsterSpellAttemptCount = cell.AsInt(); break; + case "whoyougonnacall": c.WhoYouGonnaCall = cell.AsBool(); break; + case "blacklistmonsterattemptcount": c.BlacklistMonsterAttemptCount = cell.AsInt(); break; + case "blacklistmonstertimeoutseconds": c.BlacklistMonsterTimeoutSeconds = cell.AsDouble(); break; + case "combinesalvage": i.Loot.CombineSalvage = cell.AsBool(); break; + case "lootonlyrarecorpses": i.Loot.LootOnlyRareCorpses = cell.AsBool(); break; + case "deleteghostmonstersbyhptracker": c.DeleteGhostMonstersByHealthTracker = cell.AsBool(); break; + case "ghostdeletehptrackerseconds": c.GhostDeleteHealthTrackerSeconds = cell.AsDouble(); break; + case "gotopeacemodetousekits": v.GoToPeaceModeToUseKits = cell.AsBool(); break; + case "userecklessness": c.UseRecklessness = cell.AsBool(); break; + case "debuffprecastseconds": c.DebuffPrecastSeconds = cell.AsDouble(); break; + case "clearlevelboostflagoncast": v.ClearLevelBoostFlagOnCast = cell.AsBool(); break; + case "idlecraftcount_healthkits": i.IdleHealthKitCount = cell.AsInt(); break; + case "idlecraftcount_stamkits": i.IdleStaminaKitCount = cell.AsInt(); break; + case "idlecraftcount_manakits": i.IdleManaKitCount = cell.AsInt(); break; + case "idlecraftcount_healthfood": i.IdleHealthFoodCount = cell.AsInt(); break; + case "idlecraftcount_stamfood": i.IdleStaminaFoodCount = cell.AsInt(); break; + case "idlecraftcount_manafood": i.IdleManaFoodCount = cell.AsInt(); break; + case "buffcastrecast_seconds": b.BuffCastRecastSeconds = cell.AsDouble(); break; + case "buffcastrecastreset_seconds": b.BuffCastRecastResetSeconds = cell.AsDouble(); break; + case "enablemeta": break; // live MetaEngine.Enabled, not a stored settings field. + case "blacklistedspellcomps": + b.BlacklistedSpellComponents = cell.AsString(); + c.BlacklistedSpellComponents = cell.AsString(); + break; + case "droptopeacemoderetrycount": v.DropToPeaceModeRetryCount = cell.AsInt(); break; + case "followaroundcorners": n.FollowAroundCorners = cell.AsBool(); break; + case "blacklistcorpseopenattemptcount": i.Loot.BlacklistCorpseOpenAttemptCount = cell.AsInt(); break; + case "blacklistcorpseopentimeoutseconds": i.Loot.BlacklistCorpseOpenTimeoutSeconds = cell.AsDouble(); break; + case "summonpets": c.SummonPets = cell.AsBool(); break; + case "petrangemode": c.PetRangeMode = (PetRangeMode)cell.AsInt(); break; + case "petcustomrange": c.PetCustomRange = cell.AsDouble() * 240d; break; + case "petrefillcount-idle": c.PetRefillCountIdle = cell.AsInt(); break; + case "petrefillcount-normal": c.PetRefillCountNormal = cell.AsInt(); break; + case "corpseopentimeoutseconds": i.Loot.CorpseOpenTimeoutSeconds = cell.AsDouble(); break; + case "petmonsterdensity": c.PetMonsterDensity = cell.AsInt(); break; + case "corpselootitemmaxattempts": i.Loot.CorpseLootItemMaxAttempts = cell.AsInt(); break; + case "fastcastbuffs": b.FastCastBuffs = cell.AsBool(); break; + case "usebreakableturnto": c.UseBreakableTurnTo = cell.AsBool(); break; + case "useprojectileawareness": c.UseProjectileAwareness = cell.AsBool(); break; + case "collisionprojectileradius": c.CollisionProjectileRadius = cell.AsDouble(); break; + case "collisionstepdistance": c.CollisionStepDistance = cell.AsDouble(); break; + case "showcollisiondebug": c.ShowCollisionDebug = cell.AsBool(); break; + case "maximumcollisioncheckspertick": c.MaximumCollisionChecksPerTick = cell.AsInt(); break; + case "spellrangefudge": c.SpellRangeFudge = cell.AsDouble(); break; + case "buffwithuntrained-item": b.BuffWithUntrainedItemSkill = cell.AsInt(); break; + case "buffwithuntrained-creature": b.BuffWithUntrainedCreatureSkill = cell.AsInt(); break; + case "buffwithuntrained-life": b.BuffWithUntrainedLifeSkill = cell.AsInt(); break; + case "allowdebufffallback": c.AllowDebuffFallback = cell.AsBool(); break; + case "rechargehandlerset": + if (cell.Tag == "TABLE" && cell.Table is { } table) + v.RechargeHandlerRows = ParseRechargeHandlerSet(table); + break; + default: break; + } + } + + // ------------------------------------------------------------------ + // Capture: live setting -> .usd cell. Mirror of Apply above; a name + // Apply ignores (enablemeta, rechargehandlerset) also returns null here + // so Save leaves that row's original cell untouched. + // ------------------------------------------------------------------ + + /// Internal (not private) solely so the 137-setting coverage test can call it directly. + internal static VtankCell? Capture(string rawName, AllSettings s) + { + if (!VtankOptionCatalog.IsKnown(rawName)) + return null; + string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant(); + CombatSettings c = s.Combat; + BuffSettings b = s.Buffs; + VitalSettings v = s.Vitals; + InventorySettings i = s.Inventory; + NavigationSettings n = s.Navigation; + return name switch + { + "enablelooting" => VtankCell.Bool(i.Loot.Enabled), + "enablenav" => VtankCell.Bool(n.Enabled), + "enablebuffing" => VtankCell.Bool(b.Enabled), + "enablecombat" => VtankCell.Bool(c.Enabled), + "spelldiffexcessthreshold-hunt" => Num(name, c.HuntSkillExcessOverDifficulty), + "spelldiffexcessthreshold-buff" => Num(name, b.SkillExcessOverDifficulty), + "arrowheadfletchdiffexcessthreshold" => Num(name, i.ArrowheadFletchDifficultyExcess), + "recharge-norm-hitp" => Num(name, v.NormalHealth * 100d), + "recharge-norm-stam" => Num(name, v.NormalStamina * 100d), + "recharge-norm-mana" => Num(name, v.NormalMana * 100d), + "recharge-notarg-hitp" => Num(name, v.NoTargetHealth * 100d), + "recharge-notarg-stam" => Num(name, v.NoTargetStamina * 100d), + "recharge-notarg-mana" => Num(name, v.NoTargetMana * 100d), + "recharge-helper-hitp" => Num(name, v.HelperHealth * 100d), + "recharge-helper-stam" => Num(name, v.HelperStamina * 100d), + "recharge-helper-mana" => Num(name, v.HelperMana * 100d), + "dohelp" => VtankCell.Bool(v.HelpOthers), + "attackdistance" => Num(name, c.MaximumRange / 240d), + "attackminimumdistance" => Num(name, c.MinimumRange / 240d), + "approachdistance" => Num(name, c.ApproachDistance / 240d), + "ringdistance" => Num(name, c.RingDistance / 240d), + "corpseapproachrange-max" => Num(name, i.Loot.CorpseApproachRange / 240d), + "corpseapproachrange-min" => Num(name, i.Loot.CorpseMinimumApproachRange / 240d), + "navclosestoprange" => Num(name, n.MinimumDistanceMeters / 240d), + "navfarstoprange" => Num(name, n.MaximumDistanceMeters / 240d), + "useportaldistance" => Num(name, n.PortalUseDistanceMeters / 240d), + "helperdistancehitp" => Num(name, v.HelperHealthDistance / 240d), + "helperdistancestam" => Num(name, v.HelperStaminaDistance / 240d), + "helperdistancemana" => Num(name, v.HelperManaDistance / 240d), + "minimumringtargets" => Num(name, c.MinimumRingTargets), + "defaultmeleeattackheight" => Num(name, (int)c.AttackHeight), + "castdispelself" => VtankCell.Bool(v.CastDispelSelf), + "usedispelitems" => VtankCell.Bool(v.UseDispelItems), + "autocram" => VtankCell.Bool(i.AutoCram), + "autostack" => VtankCell.Bool(i.AutoStack), + "readunknownscrolls" => VtankCell.Bool(i.Loot.ReadUnknownScrolls), + "usedispeldrum" => VtankCell.Bool(v.UseDispelDrum), + "switchwandstodebuff" => VtankCell.Bool(c.SwitchWandsToDebuff), + "autocraftitems" => VtankCell.Bool(i.AutoCraftItems), + "usehealersheart" => VtankCell.Bool(v.UseHealersHeart), + "jumpoutwandcasting" => VtankCell.Bool(c.JumpOutWandCasting), + "lootallcorpses" => VtankCell.Bool(i.Loot.LootAllCorpses), + "lootfellowcorpses" => VtankCell.Bool(i.Loot.LootFellowCorpses), + "dojiggle" => VtankCell.Bool(c.DoJiggle), + "randomhelperbuffs" => VtankCell.Bool(b.RandomHelperBuffs), + "randomhelperintervalseconds" => Num(name, b.RandomHelperIntervalSeconds), + "idlepeacemode" => VtankCell.Bool(c.IdlePeaceMode), + "targetlock" => VtankCell.Bool(c.TargetLock), + "stopmacroondeath" => VtankCell.Bool(c.StopMacroOnDeath), + "usearcs" => Num(name, (int)c.UseArcs), + "arcrange" => Num(name, c.ArcRange / 240d), + "targetselectmethod" => Num(name, (int)c.SelectionMethod + 1), + "targetselectanglerange" => Num(name, c.TargetSelectAngleRange / 240d), + "idlebufftopoff" => VtankCell.Bool(b.IdleBuffTopoff), + "idlebufftopofftimeseconds" => Num(name, b.IdleBuffTopoffSeconds), + "rebufftimeremainingseconds" => Num(name, b.RebuffWhenUnderSeconds), + "refillwornmana" => VtankCell.Bool(i.RefillWornMana), + "refillwornmana-item-manapercent" => Num(name, i.RefillWornManaPercent), + "buffprofile-prots" => VtankCell.String(b.ProtectionElements), + "buffprofile-banes" => VtankCell.String(b.BaneElements), + "buffprofile_prots" => Num(name, b.ProtectionProfileMode), + "buffprofile_banes" => Num(name, b.BaneProfileMode), + "debuffeachfirst" => Num(name, (int)c.DebuffEachFirst), + "autoattackpower" => VtankCell.Bool(c.AutoAttackPower), + "lootpriorityboost" => VtankCell.Bool(i.Loot.PriorityBoost), + "corpsecachetimeoutminutes" => Num(name, i.Loot.CorpseCacheTimeoutMinutes), + "corpseitemappearancetimeoutseconds" => Num(name, i.Loot.CorpseItemAppearanceTimeoutSeconds), + "corpseitemidtimeoutseconds" => Num(name, i.Loot.CorpseItemIdentifyTimeoutSeconds), + "debuffselectionmethod" => Num(name, (int)c.DebuffSelectionMethod), + "manastonelootcount" => Num(name, i.Loot.ManaStoneLootCount), + "manatankminimummana" => Num(name, i.Loot.ManaTankMinimumMana), + "splitpeas" => VtankCell.Bool(i.SplitPeas), + "spellcompmin-critical" => Num(name, i.CriticalComponentMinimum), + "spellcompmin-normal" => Num(name, i.NormalComponentMinimum), + "spellcompmin-idle" => Num(name, i.IdleComponentMinimum), + "rechargeboosttimeseconds" => Num(name, v.RechargeBoostTimeSeconds), + "rechargeboostamount" => Num(name, v.RechargeBoostAmount), + "usespecialammo" => Num(name, c.UseSpecialAmmo), + "opendoors" => VtankCell.Bool(n.OpenDoors), + "dooridrange" => Num(name, n.DoorIdentifyRangeMeters / 240d), + "dooropenrange" => Num(name, n.DoorOpenRangeMeters / 240d), + "doorlockpickdiffexcessthreshold" => Num(name, n.DoorLockpickExcessThreshold), + "manachargeswhenoff" => VtankCell.Bool(i.ManaChargesWhenOff), + "autofellowmanagement" => VtankCell.Bool(c.AutoFellowManagement), + "minimumhealkitsuccesschance" => Num(name, v.MinimumHealKitSuccessChance), + "usekitsinmagicmode" => VtankCell.Bool(v.UseKitsInMagicMode), + "staminatohealthmultiplier" => Num(name, v.StaminaToHealthMultiplier), + "manatohealthmultiplier" => Num(name, v.ManaToHealthMultiplier), + "navpriorityboost" => VtankCell.Bool(n.Priority), + "deleteghostmonsters" => VtankCell.Bool(c.DeleteGhostMonsters), + "ghostmonsterspellattemptcount" => Num(name, c.GhostMonsterSpellAttemptCount), + "whoyougonnacall" => VtankCell.Bool(c.WhoYouGonnaCall), + "blacklistmonsterattemptcount" => Num(name, c.BlacklistMonsterAttemptCount), + "blacklistmonstertimeoutseconds" => Num(name, c.BlacklistMonsterTimeoutSeconds), + "combinesalvage" => VtankCell.Bool(i.Loot.CombineSalvage), + "lootonlyrarecorpses" => VtankCell.Bool(i.Loot.LootOnlyRareCorpses), + "deleteghostmonstersbyhptracker" => VtankCell.Bool(c.DeleteGhostMonstersByHealthTracker), + "ghostdeletehptrackerseconds" => Num(name, c.GhostDeleteHealthTrackerSeconds), + "gotopeacemodetousekits" => VtankCell.Bool(v.GoToPeaceModeToUseKits), + "userecklessness" => VtankCell.Bool(c.UseRecklessness), + "debuffprecastseconds" => Num(name, c.DebuffPrecastSeconds), + "clearlevelboostflagoncast" => VtankCell.Bool(v.ClearLevelBoostFlagOnCast), + "idlecraftcount_healthkits" => Num(name, i.IdleHealthKitCount), + "idlecraftcount_stamkits" => Num(name, i.IdleStaminaKitCount), + "idlecraftcount_manakits" => Num(name, i.IdleManaKitCount), + "idlecraftcount_healthfood" => Num(name, i.IdleHealthFoodCount), + "idlecraftcount_stamfood" => Num(name, i.IdleStaminaFoodCount), + "idlecraftcount_manafood" => Num(name, i.IdleManaFoodCount), + "buffcastrecast_seconds" => Num(name, b.BuffCastRecastSeconds), + "buffcastrecastreset_seconds" => Num(name, b.BuffCastRecastResetSeconds), + "blacklistedspellcomps" => VtankCell.String(b.BlacklistedSpellComponents), + "droptopeacemoderetrycount" => Num(name, v.DropToPeaceModeRetryCount), + "followaroundcorners" => VtankCell.Bool(n.FollowAroundCorners), + "blacklistcorpseopenattemptcount" => Num(name, i.Loot.BlacklistCorpseOpenAttemptCount), + "blacklistcorpseopentimeoutseconds" => Num(name, i.Loot.BlacklistCorpseOpenTimeoutSeconds), + "summonpets" => VtankCell.Bool(c.SummonPets), + "petrangemode" => Num(name, (int)c.PetRangeMode), + "petcustomrange" => Num(name, c.PetCustomRange / 240d), + "petrefillcount-idle" => Num(name, c.PetRefillCountIdle), + "petrefillcount-normal" => Num(name, c.PetRefillCountNormal), + "corpseopentimeoutseconds" => Num(name, i.Loot.CorpseOpenTimeoutSeconds), + "petmonsterdensity" => Num(name, c.PetMonsterDensity), + "corpselootitemmaxattempts" => Num(name, i.Loot.CorpseLootItemMaxAttempts), + "fastcastbuffs" => VtankCell.Bool(b.FastCastBuffs), + "usebreakableturnto" => VtankCell.Bool(c.UseBreakableTurnTo), + "useprojectileawareness" => VtankCell.Bool(c.UseProjectileAwareness), + "collisionprojectileradius" => Num(name, c.CollisionProjectileRadius), + "collisionstepdistance" => Num(name, c.CollisionStepDistance), + "showcollisiondebug" => VtankCell.Bool(c.ShowCollisionDebug), + "maximumcollisioncheckspertick" => Num(name, c.MaximumCollisionChecksPerTick), + "spellrangefudge" => Num(name, c.SpellRangeFudge), + "buffwithuntrained-item" => Num(name, b.BuffWithUntrainedItemSkill), + "buffwithuntrained-creature" => Num(name, b.BuffWithUntrainedCreatureSkill), + "buffwithuntrained-life" => Num(name, b.BuffWithUntrainedLifeSkill), + "allowdebufffallback" => VtankCell.Bool(c.AllowDebuffFallback), + _ => null, // "enablemeta" (live engine state) and "rechargehandlerset" + // (no write path exists in real VTank either, section 2 row 137) + // are deliberately left untouched. + }; + } + + /// + /// Parses the RechargeHandlerSet nested TABLE (5 columns: + /// Vital/HandlerString/MinPercent/MaxPercent/Stance — verified against + /// the live defaultsettings.usd fixture, 26 rows). + /// + internal static RechargeHandlerRow[] ParseRechargeHandlerSet(VtankTable table) + { + int vitalColumn = table.ColumnIndex("Vital"); + int handlerColumn = table.ColumnIndex("HandlerString"); + int minColumn = table.ColumnIndex("MinPercent"); + int maxColumn = table.ColumnIndex("MaxPercent"); + int stanceColumn = table.ColumnIndex("Stance"); + if (vitalColumn < 0 || handlerColumn < 0 || minColumn < 0 + || maxColumn < 0 || stanceColumn < 0) + { + return []; + } + var rows = new RechargeHandlerRow[table.Rows.Count]; + for (int i = 0; i < table.Rows.Count; i++) + { + VtankRow row = table.Rows[i]; + rows[i] = new RechargeHandlerRow( + row.Cells[vitalColumn].AsInt(), + row.Cells[handlerColumn].AsString(), + row.Cells[minColumn].AsInt(), + row.Cells[maxColumn].AsInt(), + row.Cells[stanceColumn].AsInt()); + } + return rows; + } + + // No RenderRechargeHandlerSet/write-back helper exists here deliberately: + // real VTank has NO save path for this table at all (docs/research/ + // vtank-kb/01-settings-and-profiles.md section 2, row 137 — tCustom has + // no case in the Advanced Options editor's display/edit switch or in + // "/vt opt set"; the table is owned by a dedicated cRechargeManager + // object, not any control that persists through the generic Settings + // save path). Writing it back would be a MossTank invention, not a + // retail port. A prior version of this file had an unused + // RenderRechargeHandlerSet helper for exactly that invention; it was + // deleted rather than wired in for this reason. +} diff --git a/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs b/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs new file mode 100644 index 00000000..018f5e8e --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs @@ -0,0 +1,438 @@ +using System.Globalization; +using System.Linq; +using System.Text; + +namespace AcDream.Plugins.MossTank; + +/// +/// The self-describing recursive text-database grammar VTank uses for +/// .usd settings profiles and .ast per-character caches. Ported +/// from the decompiled gy/cw/bd/y classes +/// documented in docs/research/vtank-kb/01-settings-and-profiles.md +/// section 1: refs/vtank/decompiled/gy.cs:20-56 (typed cell), +/// cw.cs:9-40 (row), bd.cs:364-395 (table), y.cs:87-104 +/// (whole database). Plain text, CRLF line endings, no compression, no +/// checksum, no top-level version header. +/// +/// One cell (gy) is a type-tag line followed by zero or more value +/// lines: d=double, i=int, u=uint, f=float, +/// s=string (the raw next line, possibly empty), b=bool +/// ("True"/"False"), TABLE=a nested table (recurse into +/// ), ba=a length-prefixed raw-character blob +/// (an int line for the character count, then that many raw characters, +/// not lines, so embedded newlines survive — see ). +/// Any other tag (y.cs:28-41 only ever registers TABLE and +/// ba as named custom types) is genuinely unrecognized to VTank +/// itself: gy.cs:50-55 (read) consumes ONLY the tag line and +/// gy.cs:98-101 (write) emits ONLY a tag line ("0" for a void cell) +/// — no value line either way. This is preserved verbatim so an +/// unrecognized custom type still round-trips byte-for-byte. +/// +internal sealed class VtankCell +{ + // The only tags VTank's gy.a(TextReader)/gy.a(TextWriter) treat as a + // tag+value pair (gy.cs:20-56, gy.cs:60-92). TABLE and ba are each + // handled by their own dedicated branch; everything else is an + // unrecognized/void tag that carries no value line at all. + private static readonly HashSet KnownScalarTags = ["d", "i", "u", "f", "s", "b"]; + + internal VtankCell() { } + + /// The raw type tag: d/i/u/f/s/b/TABLE/ba/other. + public required string Tag { get; init; } + + /// Raw text of the single value line, for every scalar tag except ba. + public string? ScalarText { get; set; } + + /// The nested table payload, only when is TABLE. + public VtankTable? Table { get; set; } + + /// The raw character blob, only when is ba. + public string? BlobText { get; set; } + + public static VtankCell Double(double value) => new() + { + Tag = "d", + ScalarText = FormatDouble(value), + }; + + public static VtankCell Int(int value) => new() + { + Tag = "i", + ScalarText = value.ToString(CultureInfo.InvariantCulture), + }; + + public static VtankCell UInt(uint value) => new() + { + Tag = "u", + ScalarText = value.ToString(CultureInfo.InvariantCulture), + }; + + public static VtankCell Float(float value) => new() + { + Tag = "f", + ScalarText = FormatDouble(value), + }; + + public static VtankCell String(string value) => new() + { + Tag = "s", + // VTank strips embedded newlines from a string cell on write + // (gy.cs:84: `text.Replace("\n", "")`) — a string cell is always + // exactly one line; a blob that needs embedded newlines uses "ba". + ScalarText = value.Replace("\n", string.Empty, StringComparison.Ordinal), + }; + + public static VtankCell Bool(bool value) => new() + { + Tag = "b", + ScalarText = value ? "True" : "False", + }; + + public static VtankCell NestedTable(VtankTable table) => new() + { + Tag = "TABLE", + Table = table, + }; + + public double AsDouble() => double.Parse( + ScalarText ?? "0", + NumberStyles.Float, + CultureInfo.InvariantCulture); + + public int AsInt() => int.Parse( + ScalarText ?? "0", + NumberStyles.Integer, + CultureInfo.InvariantCulture); + + public uint AsUInt() => uint.Parse( + ScalarText ?? "0", + NumberStyles.Integer, + CultureInfo.InvariantCulture); + + public float AsFloat() => float.Parse( + ScalarText ?? "0", + NumberStyles.Float, + CultureInfo.InvariantCulture); + + public string AsString() => ScalarText ?? string.Empty; + + /// + /// VTank's gy bool tag stores the literal text "True" or + /// "False" (C#'s default formatting). + /// + public bool AsBool() => string.Equals(ScalarText, "True", StringComparison.Ordinal); + + /// + /// Formats a double/float the way VTank's own (older .NET Framework) + /// build does: Convert.ToString(double, CultureInfo) + /// (gy.cs:60-61) used the CLR's classic 15-significant-digit + /// general format, not .NET Core's shortest-round-trippable default. + /// Verified against every d-tagged value in the real + /// defaultsettings.usd fixture (e.g. 5/240 renders as + /// 0.0208333333333333 — 15 significant digits). + /// + internal static string FormatDouble(double value) => + value.ToString("G15", CultureInfo.InvariantCulture); + + internal void WriteTo(StringBuilder sb) + { + VtankWriter.AppendLine(sb, Tag); + if (Tag == "TABLE") + { + Table!.WriteTo(sb); + } + else if (Tag == "ba") + { + // A "ba" blob is a raw character count, not a line count: the + // blob may contain embedded newlines. VTank's own writer + // (f6.cs:20-24) WriteLine()s only the length, then Write()s the + // raw characters with NO trailing line terminator — whatever + // structural token follows continues immediately after the + // blob's last character, exactly like the real client's output. + string blob = BlobText ?? string.Empty; + VtankWriter.AppendLine(sb, blob.Length.ToString(CultureInfo.InvariantCulture)); + sb.Append(blob); + } + else if (KnownScalarTags.Contains(Tag)) + { + VtankWriter.AppendLine(sb, ScalarText ?? string.Empty); + } + // else: an unrecognized/void tag is the WHOLE cell (gy.cs:98-101 + // writes only "0", no value line) — nothing further to emit. + } +} + +/// Shared line-terminator helper for every WriteTo in this file (CRLF, matching VTank's own writer). +internal static class VtankWriter +{ + internal static void AppendLine(StringBuilder sb, string text) => + sb.Append(text).Append("\r\n"); +} + +internal sealed class VtankRow +{ + public List Cells { get; } = []; + + internal void WriteTo(StringBuilder sb) + { + foreach (VtankCell cell in Cells) + cell.WriteTo(sb); + } +} + +/// +/// One bd table: a column-name list, a hash-index flag per column, +/// and a row list. Row length is NOT fixed at parse time — a TABLE or +/// ba cell recurses, so the reader must walk cell-by-cell rather than +/// assume a fixed line stride per row (bd.cs:364-395). +/// +internal sealed class VtankTable +{ + public List ColumnNames { get; } = []; + public List IndexFlags { get; } = []; + public List Rows { get; } = []; + + public int ColumnIndex(string name) => ColumnNames.FindIndex( + column => column.Equals(name, StringComparison.Ordinal)); + + internal static VtankTable Read(VtankLineCursor cursor) + { + var table = new VtankTable(); + int columnCount = cursor.ReadInt(); + for (int i = 0; i < columnCount; i++) + table.ColumnNames.Add(cursor.ReadLine()); + for (int i = 0; i < columnCount; i++) + table.IndexFlags.Add(cursor.ReadLine() == "y"); + int rowCount = cursor.ReadInt(); + for (int r = 0; r < rowCount; r++) + { + var row = new VtankRow(); + for (int c = 0; c < columnCount; c++) + row.Cells.Add(VtankDatabaseReader.ReadCell(cursor)); + table.Rows.Add(row); + } + return table; + } + + internal void WriteTo(StringBuilder sb) + { + VtankWriter.AppendLine(sb, ColumnNames.Count.ToString(CultureInfo.InvariantCulture)); + foreach (string column in ColumnNames) + VtankWriter.AppendLine(sb, column); + foreach (bool flag in IndexFlags) + VtankWriter.AppendLine(sb, flag ? "y" : "n"); + VtankWriter.AppendLine(sb, Rows.Count.ToString(CultureInfo.InvariantCulture)); + foreach (VtankRow row in Rows) + row.WriteTo(sb); + } +} + +/// The whole y database: an ordered set of named tables. +internal sealed class VtankDatabase +{ + public List<(string Name, VtankTable Table)> Tables { get; } = []; + + public VtankTable? Find(string name) => Tables + .Where(entry => entry.Name.Equals(name, StringComparison.Ordinal)) + .Select(static entry => entry.Table) + .FirstOrDefault(); + + public static VtankDatabase Parse(string text) + { + var cursor = new VtankLineCursor(text); + var database = new VtankDatabase(); + int tableCount = cursor.ReadInt(); + for (int i = 0; i < tableCount; i++) + { + string name = cursor.ReadLine(); + VtankTable table = VtankTable.Read(cursor); + database.Tables.Add((name, table)); + } + return database; + } + + /// + /// VTank's own y class holds its tables in a + /// SortedDictionary<string, bd> (item J, slice-1 fix + /// round), so a real .usd/.ast always emits its tables in + /// table-name order — confirmed against the committed + /// defaultsettings.usd fixture, whose first several tables + /// (AntiExtraBuffSpells, AssistItems, BuffedItems, ExtraBuffSpells, + /// GemFoodItems, …) are already alphabetical. This port's own + /// is an insertion-ordered list rather than a + /// sorted structure, so every committed real fixture happens to + /// round-trip in order today purely because it was ALREADY sorted the + /// last time real VTank wrote it — this explicit sort on render makes + /// that invariant a property of the writer itself rather than an + /// accident of whatever order the source data happened to arrive in + /// (no observable behavior change for any fixture today; it only + /// matters the day a future code path adds or reorders a table). + /// + /// Round 3 item 12: is used here + /// as a stand-in for .NET Framework's SortedDictionary<string, bd> + /// default key order — that equivalence is confirmed only for the + /// plain-ASCII, single-case table names VTank itself ships + /// (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general + /// claim that ordinal and .NET's culture-aware default string + /// comparer agree for every possible string; a hypothetical future + /// table name using non-ASCII characters or mixed-width comparisons + /// could sort differently under the two. No behavior change is + /// implied or made by this note. + /// + public string Render() + { + var sb = new StringBuilder(); + var ordered = Tables + .OrderBy(static entry => entry.Name, StringComparer.Ordinal) + .ToArray(); + VtankWriter.AppendLine(sb, ordered.Length.ToString(CultureInfo.InvariantCulture)); + foreach ((string name, VtankTable table) in ordered) + { + VtankWriter.AppendLine(sb, name); + table.WriteTo(sb); + } + return sb.ToString(); + } +} + +/// Reads one , recursing into nested tables and blobs. +internal static class VtankDatabaseReader +{ + public static VtankCell ReadCell(VtankLineCursor cursor) + { + string tag = cursor.ReadLine(); + switch (tag) + { + case "TABLE": + return VtankCell.NestedTable(VtankTable.Read(cursor)); + case "ba": + int length = cursor.ReadInt(); + return new VtankCellBuilder(tag) { BlobText = cursor.ReadBlob(length) } + .Build(); + case "d" or "i" or "u" or "f" or "s" or "b": + return new VtankCellBuilder(tag) { ScalarText = cursor.ReadLine() }.Build(); + default: + // Unrecognized/void tag (refs/vtank/decompiled/y.cs:28-41 + // only ever registers TABLE and ba as named custom types): + // VTank's own reader (gy.cs:50-55) consumes ONLY the tag + // line here — no value line follows. + return new VtankCellBuilder(tag).Build(); + } + } + + // Small builder so VtankCell's constructor can stay private to the file + // (its public factory methods are the intended typed-construction path) + // while the reader still needs to synthesize an arbitrary/unknown tag. + private readonly struct VtankCellBuilder(string tag) + { + public string? ScalarText { get; init; } + public string? BlobText { get; init; } + + public VtankCell Build() => VtankCellFactory.Create(tag, ScalarText, BlobText); + } +} + +/// Internal factory seam so the reader can build cells with an arbitrary tag. +internal static class VtankCellFactory +{ + public static VtankCell Create(string tag, string? scalarText, string? blobText) => new() + { + Tag = tag, + ScalarText = scalarText, + BlobText = blobText, + }; +} + +/// +/// Character-oriented cursor over the exact (unnormalized) text of a +/// .usd/.ast file. VTank's own reader is a strict token stream +/// (StreamReader.ReadLine()/TextReader.Read(char[], int, int) +/// calls in a fixed order) — there is no way to resynchronize after a parse +/// error, so every read here throws immediately with a file:line-shaped +/// message on the first unexpected token. +/// +/// The cursor deliberately keeps the ORIGINAL string, never pre-splitting on +/// '\n': a ba blob () reads exactly N raw +/// characters straight out of that string, embedded \r/\n +/// included, matching f6.cs:10-17's TextReader.Read(array, 0, num) +/// exactly — a CRLF-terminated line INSIDE a blob counts as 2 characters +/// toward that length, same as it would for VTank itself. +/// +internal sealed class VtankLineCursor +{ + private readonly string _text; + private int _position; + private int _lineNumber = 1; + + public VtankLineCursor(string text) + { + ArgumentNullException.ThrowIfNull(text); + _text = text; + } + + public int LineNumber => _lineNumber; + + public string ReadLine() + { + if (_position >= _text.Length) + { + throw new FormatException( + $"line {LineNumber}: unexpected end of file (expected another line)."); + } + int newlineIndex = _text.IndexOf('\n', _position); + string line; + if (newlineIndex < 0) + { + line = _text[_position..]; + _position = _text.Length; + } + else + { + line = _text[_position..newlineIndex]; + _position = newlineIndex + 1; + } + if (line.EndsWith('\r')) + line = line[..^1]; + _lineNumber++; + return line; + } + + public int ReadInt() + { + string line = ReadLine(); + if (!int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) + { + throw new FormatException( + $"line {LineNumber - 1}: expected an integer, got '{line}'."); + } + return value; + } + + /// + /// Reads a ba blob's exact character count straight out of the + /// underlying text with no line-oriented interpretation whatsoever + /// (f6.cs:10-17: TextReader.Read(array, 0, num) starting + /// immediately after the length line's terminator) — embedded + /// \r/\n characters are counted and returned verbatim, not + /// folded or re-joined. + /// + public string ReadBlob(int length) + { + if (length <= 0) + return string.Empty; + if (_position + length > _text.Length) + { + throw new FormatException( + $"line {LineNumber}: blob of {length} characters exceeds remaining input."); + } + string blob = _text.Substring(_position, length); + _position += length; + foreach (char c in blob) + { + if (c == '\n') + _lineNumber++; + } + return blob; + } +} diff --git a/src/AcDream.Plugins.MossTank/mosstank.xml b/src/AcDream.Plugins.MossTank/mosstank.xml index 84dfdd7b..6c697b2d 100644 --- a/src/AcDream.Plugins.MossTank/mosstank.xml +++ b/src/AcDream.Plugins.MossTank/mosstank.xml @@ -136,6 +136,9 @@ onclick="{CreateProfile}" />