merge: Campaign VT through slice 7 — VTank knowledge base, drop-in VTank files (.usd/.utl) + metaf .af, the nine MossTank tabs at VTank's layout, headless console, chat routing, plugin markup (columns, plain menus/sliders, resizable panels)
All checks were successful
CI / linux-portable (push) Successful in 3m36s
CI / windows-gate (push) Successful in 6m36s
CI / release (push) Successful in 2m10s

Owner-directed 2026-09-07: merge to main and push. Slices 1 and 7 of
Campaign VT review-closed and owner-looked; headless console proof passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 21:35:06 +02:00
commit f7ff254614
230 changed files with 113399 additions and 25723 deletions

15
.gitattributes vendored
View file

@ -1 +1,14 @@
.github/workflows/*.lock.yml linguist-generated=true merge=ours
.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

View file

@ -211,8 +211,8 @@ jobs:
test -f "$root/libglfw.so.3"
test -f "$root/libopenal.so"
test -f "$root/Rendering/Shaders/mesh_modern.vert"
test -f "$root/plugins/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.dll"
test -f "$root/plugins/AcDream.Plugins.Smoke/plugin.json"
test -f "$root/plugins/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.dll"
test -f "$root/plugins/AcDream.Plugins.MossTank/plugin.json"
test "$(grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App | wc -l)" -eq 1

View file

@ -12,7 +12,6 @@
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
</Folder>

View file

@ -235,7 +235,7 @@ src/
AcDream.UI.Abstractions/ shared UI/input models and contracts
AcDream.UI.ImGui/ developer-tool presentation
AcDream.Plugin.Abstractions/ BCL-only plugin contracts
AcDream.Plugins.Smoke/ example plugin
AcDream.Plugins.MossTank/ example plugin
tests/
AcDream.*.Tests/ layer-matched xUnit projects

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,11 @@ document in the same change; do not leave both claims standing.
before trusting any measurement. Enforced by
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
so does a row whose read site was deleted.
- [`plugin-ui-markup.md`](plugin-ui-markup.md) is the SSOT for the plugin
markup vocabulary (elements, attributes, `{Binding}` rules) plus the
Decal/VirindiViewService-compatible DAT icon grammar (Slice B,
`plans/2026-09-06-plugin-shelf-and-dat-icons.md`) and the movable/
collapsible plugin shelf (Slice A of the same plan).
- [`audit/`](audit/) contains completion and conformance audits.
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
ACE server's complete in-game command catalog and points to the authoritative

View file

@ -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,

File diff suppressed because one or more lines are too long

View file

@ -40,12 +40,16 @@ Assume a flag has a side effect until its row says otherwise.
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
and measurement flag in this document is inert until its variable is
explicitly set — an unset environment runs zero diagnostics. Exactly
five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
six flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run), while
`ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses
the same explicit diagnostic opt-out. That five-flag set is frozen by
the same explicit diagnostic opt-out. `ACDREAM_HEADLESS_CONSOLE` is the
sixth: its unset default is terminal-shaped (on when stdin is a real
console, off when redirected — not unconditionally on like the other
five), but once the variable is SET AT ALL it uses the identical `=0`
override (any other value enables). That six-flag set is frozen by
`LaunchOptionsDocumentationTests` — a new
default-on flag fails the build.
- `=1` means the code tests for exactly the string `1`. Setting `true`,
@ -93,6 +97,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
| `ACDREAM_DAT_DIR` | `=<path>` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) |
| `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) |
| `ACDREAM_FAR_RADIUS` | `=<int>` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) |
| `ACDREAM_HEADLESS_CONSOLE` | `=0` disables (once set at all); any other value enables; unset falls through to the terminal-shaped default | Turns on the headless host's interactive console (docs/plans/2026-09-07-headless-console.md): a background thread reads stdin lines, each drained on the session tick through the SAME plugin-verb/client-slash-command pipeline the graphical chat box uses, with chat/lifecycle/portal output rendered to stdout. Only takes effect for `run` with a single configured session — a multi-session process reports `console: single-session only` via the diagnostics stream and does not attach one. | Starts a background stdin-reader thread and writes plain-text lines to the same stdout stream `HeadlessDiagnosticWriter` already uses for its JSON lines — the two interleave. Only applies to `run`; `--console` (bare flag, no value) always wins over this variable. S1 fix (2026-09-07): the variable itself now wins outright once SET AT ALL — `=0` disables even when stdin is a real terminal, matching every other `=0`-disables flag in this table; only an UNSET variable falls through to the terminal-shaped default. | unset → on when stdin is a real console, off when redirected (`!Console.IsInputRedirected`, checked once in `Program.cs`); set → `!= "0"` | `HeadlessConsoleOptions.Resolve` (`Configuration/HeadlessConsoleOptions.cs`) → `HeadlessEntryPoint.Run``HeadlessProcessHost`'s `consoleEnabled` |
| `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode``SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating |
| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=<int>` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) |
| `ACDREAM_MSAA_SAMPLES` | `=<int>` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) |
@ -143,6 +148,7 @@ config without connecting; `run` connects.
| `--config <path>` | The versioned headless session-configuration document. Required. | — |
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. |
| `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. |
| `--console` | Forces the interactive console on for `run` (bare flag, no value) — see `ACDREAM_HEADLESS_CONSOLE`. | Same side effects as the environment variable; this flag always wins over it. |
| `--help` / `-h` (or no args) | Prints usage, exits 0. | — |
### `AcDream.Launcher`
@ -238,6 +244,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=<feature-name>` (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` | `=<int>` (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` | `=<path>` | 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 writes Nav/Meta profiles under two dedicated subfolders of this directory — `metas/` for `MossTankMetaProfileStore`, `navs/` for `MossTankRouteProfileStore`, owner decision 2026-09-07 (Campaign VT slice 1c) — and its `.cdf` names them with the `.af` (metaf text) extension, not real VTank's native binary `.nav`/`.met`, at those folder-relative paths. Pointed at a real installed VTank folder, that same character's Nav/Meta profiles become unloadable by the real VTank client sharing the directory (wrong format, wrong subfolder — Settings `.usd` and Loot `.utl` stay real/binary-compatible, flat at the directory root; see register row AD-122). **Also:** on first load the Meta and Route stores MOVE any pre-existing flat `.af` files in this directory into `metas/`/`navs/` (one-time, logged, never overwriting a collision) — do not point this at a folder whose flat `.af` files you want left untouched, e.g. the owner's metas repo. | unset → `<ApplicationPathSet.DataDirectory>/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` (`<DataDirectory>/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

View file

@ -203,9 +203,18 @@ plugin. No-window hosts retain the plugin session but expose the no-op UI
capability.
The retained markup vocabulary includes panels, nested groups, labels,
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to
BCL-visible properties/actions on the plugin binding object; visible controls
must correspond to real behavior, never placeholders that report success.
buttons, meters, tabs, lamp-style toggles, scalar sliders, editable fields,
dropdown menus, scrollable lists, and (Slice B,
`docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md`) DAT icons — a standalone
`<icon>` element plus icon-bearing extensions of `<button>` and `<list>`
resolved from a Decal/VirindiViewService-compatible icon-id grammar
(`AcDream.Plugin.Abstractions.PluginIcons.Normalize`) against raw RenderSurface
DIDs, retail's composited spell icon, or a live object's composited item icon.
Controls bind to BCL-visible properties/actions on the plugin binding object;
visible controls must correspond to real behavior, never placeholders that
report success. Full grammar and binding rules: `docs/plugin-ui-markup.md`.
The Slice A movable/collapsible plugin shelf (`plugin-shelf`, drag grip,
Shift+Ctrl+F1 hide/show) is documented there too.
The following was the original pre-D.2b proposal and remains historical
context, not the shipped plugin contract:

View file

@ -0,0 +1,137 @@
# Campaign VT — VT3 slice 1: VTank file compatibility + multi-column list markup
Date: 2026-09-06
Status: PLANNED (implementation starts after VT2 is FINAL)
Contract for two implementers in isolated worktrees, merged back into
`claude/latest-main-sync-497549`. Nothing merges to main; nothing is pushed.
## Part A — VTank files, drop-in (plugin side, `src/AcDream.Plugins.MossTank`)
Oracle docs: `docs/research/vtank-kb/01-settings-and-profiles.md` (§1 grammar,
§3 files/naming/versioning), `05-looting-and-utl.md` §1-2, and for metas/navs
the **metaf** reference: `C:\Users\erikn\source\repos\metas\metaf_monolithic.py`
(GPLv3 port of `metaf.cs`; read it, cite `metaf_monolithic.py:line`, never
paste it) — `06-navigation-and-nav.md` §1 and `07-meta-and-expressions.md` §1
describe the binaries metaf converts from and are the field-semantics oracle
(what each node/condition/action field means), not the file format we write.
Real fixtures: `.usd`/`.utl`/`.ast` from `C:\Games\VirindiPlugins\VirindiTank\`
(read-only), `.af` from `C:\Users\erikn\source\repos\metas\af\` (148 files;
`nav_*.af` are nav-only). Copy the ones used as fixtures into
`tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/` — they are the owner's
own files, small, and the tests must run on Linux CI without those dirs.
A1. `.usd` reader/writer: the self-describing recursive table grammar exactly
as VTank reads and writes it (encoding, table headers, nested tables such as
`RechargeHandlerSet`, unknown-key preservation, version). Round-trip a real
profile byte-for-byte where VTank itself would (document any normalization
VTank applies on save). Map every one of the 137 settings onto the existing
`CombatSettings`/`BuffSettings`/`VitalSettings`/`InventorySettings` fields
by VTank name (the catalog table gives the mapping); the ten fields
`BuffProfileDocument` drops must round-trip; `RechargeHandlerSet` becomes a
real parsed table consumed by `VitalRechargePlanner` (replace the hardcoded
default replica with the parsed default from `defaultsettings.usd`, which is
committed as a fixture).
A2. Profile directory: a configured VTank-profiles path (default on Windows
`C:\Games\VirindiPlugins\VirindiTank`, on Linux `$XDG_DATA_HOME/acdream/vtank`
— through `ApplicationPathSet`/the plugin storage API, never a hard-coded
Windows path in the plugin), VTank's file naming and selection rules (`--`
shared prefix, per-character names, `[By char]`, `.ast`), listing, load,
save, and the Profiles tab bound to real files instead of JSON documents.
JSON stays as the storage of MossTank-only state; profile state moves to
`.usd`.
A3. **metaf `.af` reader/writer** for metas and nav routes (owner decision
2026-09-06: MossTank does not implement `.met` and does not author `.nav`).
Grammar from `metaf_monolithic.py`: `~~` comments; `STATE: {name}` /
`IF: <Cond> args` / `DO: <Action> args` / `NAV: <name> <circular|linear|once|follow>`;
`{...}` strings with doubled braces for literals; the 28 condition and 16
action keywords (`CTypeID`/`ATypeID`, py:15-63) with the per-type argument
lists in each `C*`/`A*`/`N*` class's `ImportFromMetAF`/`ExportToMetAF` pair
(py:6727-11821); nav node lines `<flw|pnt|prt|rcl|pau|cht|vnd|ptl|tlk|chk|jmp> x y z args`
(py:10728-11821); nested `All`/`Any`/`Not`/`DoAll` by indentation exactly as
metaf emits and accepts them; embedded navs inside `EmbedNav`. Load into the
existing `Meta`/`Navigation` models. `.af` becomes the ONLY storage and
authoring format of the meta and route profile stores. The existing binary
`VtankMetaProfileSerializer`/`VtankNavRouteSerializer` are demoted to a
one-shot import (`.met`/`.nav` → model → saved as `.af`); their `Save`
writers and writer tests are deleted (lead's call under the owner's "do not
implement .met" direction — flagged in the closeout for the owner). Save
back as `.af` that metaf itself accepts. Proof: (1) every
`.af` under `metas/af/` parses; (2) parse → write → parse is identical;
(3) for the `met/` and `nav/` samples, our model loaded from metaf's own
`.af` conversion equals our model loaded from the matching `.af` in `af/`
where both exist; (4) the writer's output for at least five fixtures is
byte-identical to metaf's canonical emission after comment stripping. Also:
the `.utl` `BuffedInt/Double` base-key-exists gate AND the branch selector
mis-port found by the KB 05 citation pass (`VtankLootRequirementEvaluator.cs:445`
tests `Bonus` where VTClassic tests `Change`, `ComputedItemInfo.cs:244`; carry
both fields in the table); the jump-charge clamp to
2000 ms applied to `jmp` nodes at load. Each with a real-file fixture and a
round-trip test. Line-numbered parse errors (file:line, what was expected).
Tests: one round-trip test per format on every committed fixture; a
137-setting mapping test (every catalog name maps to exactly one field,
defaults equal `defaultsettings.usd`); Linux-path test; each new test shown
to fail first.
## Part B — multi-column `<list>` (App side, `src/AcDream.App/UI`)
Oracle: `docs/research/vtank-kb/08-ui-views.md` §2-3 (VVS `HudList`
semantics and the proposed extension), `docs/plugin-ui-markup.md`.
B1. Markup, exactly doc 08 §3's proposal: `<list>` accepts child
`<column type="text|check|icon" width="…" [iconkind] [colors]/>` elements,
each binding its own per-row source parallel to the row count (per-attribute
binding like every other element; no new `Plugin.Abstractions` type):
`items="{IReadOnlyList<string>}"` for text (plus an OPTIONAL
`onclick="{Action<int>}"` that fires INSTEAD of selecting — review finding:
none of VTank's eight lists uses row selection, every text cell is an action
target, `PluginCore.cs:7948-8089`), `values="{IReadOnlyList<bool>}"`
+ `onchange="{Action<int>}"` (row index; the plugin flips its own bool) for
check, `values="{IReadOnlyList<uint>}"` + `iconkind` + `onclick="{Action<int>}"`
for icon. Row count = the longest bound column; short columns draw empty
cells. The list's own `selected`/`onchange` stay as today; a click in a
check/icon column fires that column's callback and does NOT change
selection; a click in a text column selects. No header row (VTank's idiom
is `<label>`s above the list; document it). Per-column widths in px; `width="*"` marks an auto column and all auto
columns share the remainder equally (VVS's rule, `Decal3XMLParser.cs:409-418`),
the last column absorbing the rounding slack; a non-last column with a
missing/non-positive width throws at Build; per-cell horizontal clipping;
scrolling as today. Without `<column>` children the element is byte-for-byte the old
single-column list (existing `items`/`icons`/`colors` attributes unchanged).
B2. Widget: extend `UiMarkupList` (or a sibling) — per-cell
text/check/icon (no header row; B1 governs), hit-test to (row, column), keyboard-free. Icons through the
existing `IMarkupIconResolver` with the column's `iconkind`.
B3. Docs: `docs/plugin-ui-markup.md` section for columns; remove the
"single text column" limitation note.
Tests: parse + binding-type tests, draw-level tests with the recording
renderer (column x-offsets, check glyph, icon per row), hit-test
tests for cell clicks, and a MossTank markup contract test proving the
existing single-column lists still bind.
## Reviews
Two Opus lenses per part (architecture; VTank/format fidelity against the
catalog and the fixtures), fix round, narrow re-review. Then merge both
worktree branches into the campaign branch and run the full App + MossTank
suites and the Ubuntu portable closure (`dotnet test` on the MossTank tests
under WSL if available).
## Out of scope
Rebuilding the tabs on the new columns (slice 7, visual gate), any behavior
change in combat/buff/loot/nav (slices 2-6).
## Ledger
- 2026-09-06 Part B landed on `worktree-agent-a4da5c8764f9e7f71` at `959a69482` (6 files, 27 tests). Opus architecture lens: APPROVE-WITH-FIXES (row-bound guard on callbacks, try/finally clip, column-identifying binder contexts, per-frame allocations, width silent-zero, contract test unaware of `<column>`, shared lamp helper). Opus fidelity lens: REJECT — text columns had no per-cell callback (VTank never uses row selection), missing width silently hid the Meta tab's condition column, single-absorber vs VVS's equal-share auto width, short check columns drew nothing, no Meta/Monsters-shaped end-to-end test, no pitch convention. Both lists folded into one fix round (11 items) dispatched on the same worktree; B1 amended above (text `onclick`, `width="*"`), B2's stray "draw header" struck.
- 2026-09-06 Part A landed on `worktree-agent-a4e93dac28a3d6f36` at `17f2143a7` (4 commits; MossTank suite 345 → 424). Implementer deviations: A2 "foundation-only" (stores still JSON-primary with an `.af` sidecar export), a raw-path `IPluginHost.VtankProfileDirectory`, `-text` gitattributes for fixtures. Opus fidelity lens: REJECT — 20/137 settings written with the wrong `gy` type tag (VTank unboxes → crash), `UseArcs` 3-value enum collapsed to bool with the byte-identity test edited to hide it, `ptl`/`tlk` approach coordinates lost on save, meta/route exports collide, `ba` blobs and void tags mis-parsed, `CreateNew` emits a schema VTank would overwrite, no metaf header emitted, `mineOnly` wrong, `.cdf`/`.ast` absent. Opus architecture lens: APPROVE-WITH-FIXES — the raw path violates the architecture doc's storage invariant and is not forwarded by `ScopedPluginHost` (dead on arrival), the new engines have zero production callers, the recharge replica was not replaced, the binary nav writer survived inside `MetafSerializer`, `.met` reader tests deleted with the writer tests, tautological Linux-path pin, `G15` formatting. Fix round 1 (fidelity blockers + host contract: `IPluginStorage VtankProfiles` composed by the host, `ACDREAM_VTANK_PROFILE_DIR`) dispatched on the same worktree; fix round 2 = the store cutover (JSON → `.usd`/`.af` primary with one-time migration, Profiles tab, `/vt` save commands) follows.
- 2026-09-06 Part B fix round landed (`fd521f781``026b51207`, 11 commits; column tests 27 → 46, contract tests 6 → 8). Narrow Opus re-review: all eleven findings CLOSED with mutation-sensitive evidence, legacy path proven byte-identical, verdict MERGE-READY. Merged into the campaign branch at `da0fcb374`. Carry-forwards for the owner's next visual gate: a check column shorter than the list draws unchecked lamps that ignore clicks (documented; author bug in practice); `UiMarkupList.Columns` is sized once at assignment (mutating the list afterwards is unsupported; no in-repo caller does).
- 2026-09-06 full App suite on the merged branch: 7,411 passed / 36 failed / 97 skipped. The 36 are the same environment-gated lanes the Part A architecture reviewer saw on the base commit (installed-DAT physics/streaming/walk-trace, live-mount UI probes, Linux-only pacing/credential tests, the fixture regenerator) — none in markup or plugin code.
- 2026-09-06 Part A fix round 1 landed 3 of 12 items (`9c34b6074` declared-type tags + G15 + per-tag compare; `74283e111` `UseArcs` 3-way enum, test normalizer deleted; `07664b199` character-oriented `ba`/void-tag cursor). MossTank suite 424 → 562. The agent kept a float tolerance for `tDouble` settings stored in `float` fields (root cause deferred to round 1b item I: widen the fields). Round 1b dispatched on the same worktree for the remaining nine items (two-triple `ptl`/`tlk`, typed embedded routes, export collision, full `CreateNew` schema + replica deletion, metaf header, `IPluginStorage VtankProfiles` host contract, `mineOnly`/`.cdf`/`.ast`, restored tests, nits). Round 2 (store cutover) still follows.
- 2026-09-06 Part A fix round 1b landed all nine remaining items (`0219c6e03``d631cc4fe`): two-triple `ptl`/`tlk` (jmp direction loss recorded in KB 06 §6), typed `MetaAction.EmbeddedRoute` with the binary blob writers deleted, `exports/meta/` vs `exports/nav/`, `CreateNew` seeded from the embedded `defaultsettings.usd` with the hand-typed recharge replica deleted, metaf header + real byte identity, `IPluginStorage VtankProfiles` composed by the App and Headless hosts (`ACDREAM_VTANK_PROFILE_DIR`), real `mineOnly` + `.cdf` + `.ast`, restored tests + disabled-rule refusal, all fifteen `tDouble` fields widened so the compare is exact, nits. MossTank suite 562 → 585. Contract correction: `bore_quest.af` is hand-edited (space instead of tab after `IF:`) like `bore_enhanced.af` and cannot byte-match any writer — excluded with the reason, not a defect. Round 2 (store cutover: `.usd`/`.af` primary, one-time JSON migration, Profiles tab, `/vt` verbs) dispatched.
- 2026-09-07 Part A round 2 (store cutover) landed: `479495ecc` settings → real `.usd` (load/save/create/clear through the serializer, listing + `.cdf` binding through `VtankProfileDirectory`, `SetOptionInAll` patches every `.usd`), `a5d52bfb0` metas and routes → `.af` (sole storage; `WriteLegacyExport` deleted; named routes carry the owner's own `nav_` prefix), `e5c4912b6` migration pins, `8a146aa8c` Profiles-tab Delete through the markup contract. One-time JSON migration per store (idempotent, counterpart-exists tests); disabled-rule saves refused with a notice. MossTank suite 585 → 595. Left in JSON as MossTank-only state: item/consumable name lists, no-buff names, attack power, scan intervals, meta state, dynamic settings, the monster-rule and loot-rule tables, the buff category bools, vitals enabled, the loot classifier id, and a `MineOnly` preference. Flagged: `MyMonsters``MonsterRule` not parsed (slice 3/7 scope); `.utl` loot profile store still JSON-indexed with its own legacy export (round-2 scope question for the re-review). Narrow re-reviews (both lenses) dispatched.
- 2026-09-07 Part A narrow re-reviews at `8a146aa8c`: architecture lens 13/15 closed, NOT merge-ready — the JSON side-car still duplicates `EnableLooting` and overwrites the `.usd` on load (a drop-in profile loads with looting off), the legacy roster is orphaned (only the selected profile migrates), `MyMonsters` divergence unrecorded, stale references, Delete only for settings, KB 07 row 6 stale. Fidelity lens 19/22 closed, NOT merge-ready — `nav_--Name_Server.af` defeats the `--` hidden filter (a private route leaks into every picker), meta/route pickers still enumerate the same `.af` set, the 2000 ms jump clamp was applied at file load instead of execution (rewrites authored values), `SetOptionInAll` skips absent rows where `bk.a` appends, `.cdf` names `.af` files a real VTank cannot load (register row owed), nav byte identity covers one fixture. Round 3 dispatched with all of it plus the `.utl` store cutover and Delete for the other three families. Then one final narrow re-check on both lists, merge, suites, hand over.
- 2026-09-07 Part A round 3 landed all twelve items (`b6d642c9b``f58e997b1`; MossTank suite 595 → 623): side-car overlap removed with an empty-intersection pin and a drop-in regression test, full legacy-roster sweep, `--nav_Name_Server.af` (hidden prefix first) with disjoint pickers, jump ceiling moved to execution, `SetOptionInAll` appends like `bk.a` with VTank's texts, register row for `.af` names in `.cdf` + launch-options warning, nav/`.ast` byte identity widened, loot store cut over to real `.utl` (a MossTank-owned unknown block preserves rule expressions VTClassic would drop), Delete for all four families, `MyMonsters` gap recorded as TS-86, cleanup. Final narrow re-check (both lists in one pass) dispatched.
- 2026-09-07 Part A final re-check: all twelve round-3 items CLOSED with failable tests, VTClassic confirmed to skip the MossTank expression block by length, byte-identity theories run with zero normalization, plugin has no file access outside embedded resources — MERGE-READY. Three non-blocking findings: #488 (latent newline/length mismatch in the `.utl` expression block), dead `remaining` branches in two sweeps, the meta Delete notice text. **Merged into the campaign branch at `f680bf234`.** Merged-branch gates: build 0/0, MossTank 625/625, Core plugin 50/50, App plugin+markup 238/238; full App suite run separately (known env-gated lanes only).
## Closeout
Slice 1 is COMPLETE on `claude/latest-main-sync-497549` (Part B `da0fcb374`, Part A `f680bf234`). Nothing merged to main, nothing pushed. Owner items: (1) RESOLVED 2026-09-07 — the binary `.met`/`.nav` readers stay for one-shot import (convert on import, save as `.af`), writers stay deleted; (2) `bore_quest.af`/`bore_enhanced.af` are hand-edited and excluded from byte identity; (3) a check column shorter than its list draws inert lamps; (4) pointing `ACDREAM_VTANK_PROFILE_DIR` at a real VirindiTank folder writes `.cdf` lines naming `.af` files that real VTank cannot load (AD-122). Next slice per the VT2 order and the owner's "looks like VTank" priority: slice 7 (tabs on the new columns; visual gate), then slice 2 (scheduler).

View file

@ -0,0 +1,58 @@
# Campaign VT — VT2 gap audit and implementation order
Date: 2026-09-06
Status: FINAL 2026-09-06 (VT1 citation pass folded for all nine docs).
Inputs: the nine "MossTank gap" sections in `docs/research/vtank-kb/`,
the review of `c406942ef` recorded in
`docs/plans/2026-09-06-mosstank-mode-arbitration.md`, and the owner decisions
in `docs/plans/2026-09-06-campaign-vt-vtank-oracle.md`.
## Headline
MossTank already carries VTank's 137 setting names, all documented `/vt`
verbs, working `.nav`/`.utl`/`.met` readers (the meta/nav ones now superseded
by the `.af` decision), the exact loot-requirement
vocabulary, faithful ownership/blacklist/stop-range numbers, and a shell with
the nine tabs. The gaps are concentrated in five places: **(1) no `.usd`
profile file at all** and a lossy JSON persistence record, **(2) the
scheduler model** (an imperative owns-action chain instead of VTank's
single-winner priority list, which is where every ordering bug lives), **(3)
combat policy details** (debuff order, target selection, arc/bolt), **(4) the
UI tables** (no multi-column list, so four tabs are select-then-edit
approximations), and **(5) the plugin-to-plugin API** (no sibling access at
all).
## Ordered slices
Sizes: S ≈ one implementer-day, M ≈ two to three, L ≈ a week. "Gate" says who
accepts: T = tests only, O = owner connected/visual gate.
| # | Slice | Contents (doc §) | Depends on | Size | Gate |
|---|---|---|---|---|---|
| 1 | **File compatibility + multi-column list** (VT3 slice 1, in the current goal) | 1a `.usd` reader/writer with the self-describing table grammar, profile directory discovery and selection (`--`/`[By char]` naming), `RechargeHandlerSet` table parse, and the ten fields `BuffProfileDocument` drops (01 §1,§3,§5). 1b **metaf `.af` reader/writer for metas and nav routes** (owner amendment 2026-09-06: no `.met`, no authored `.nav`) proven against the 148 real files in the owner's metas repo and against the metaf tool's own conversions of `met/` and `nav/`; the `.utl` `BuffedInt/Double` base-key gate (05 gap 4); the jump-charge clamp to 2000 ms at load (06 gap 5) applied to `.af` `jmp` nodes. Loading real files from a configured directory (Linux-clean). 1c `<list>` gains `<column type="text\|check\|icon">` children with header, widths, per-cell click and per-row check state (08 §2-3). | — | M+M (parallel: plugin vs App) | T |
| 2 | **Scheduler parity** | Replace the owns-action chain with a declarative single-winner rule list in VTank's order incl. sentinels, `Running=false` teardown signal, 293 ms heartbeat + event poke, IdlePeace as terminal rule AND pre-chain on idle loot/approach/nav, the `fd` tight-waypoint Magic override; fold in every deferred finding on `c406942ef` (no-caster stops the macro; retry budget in ticks with wand-use recovery; "buff due" suppresses idle; Items-page order for the fallback wand; equip cadence/budget; `Unknown` mode hands-off; single caster predicate; notice text). (02 §1-5). **Plus (owner, 2026-09-07): headless operation.** (a) A MossTank section in the headless session config (settings/meta/nav/loot profile names + `startMacro`) applied on login, so an unattended bot loads its profiles and starts the macro without a UI; (b) a headless end-to-end smoke test: plugin under `AcDream.Headless`, real ACE connect, a real `.af` meta loaded and ticked, assertions on the event stream — the automated proof for every later slice. Owner's model: one plugin, two hosts — configure and play in the graphical client, run unattended in headless (no renderer, far cheaper; Slice K ran 30 sessions in one process). | 1 (profile order, settings) | L | O (macro start from peace; idle peace) |
| 3 | **Combat policy corrections** | Hardcoded 12-step debuff order and first-due-wins; target selection with `DebuffEachFirst` + urgency score, `TargetLock`/sticky as low tiebreaks; quality before `UseArcs`; unknown-monster → no auto-element; wield-match tiebreak. (03 §8) | 2 | M | O (live fight) |
| 4 | **Buffs and vitals** | Per-tick plan re-evaluation (mid-pass tier cascade); profile-item → default enchant rows on Add; helper pool incl. non-fellow tracked players (decide); worn-item charge ORDER only (oldest-queued vs lowest-%, Low — the pool is global on both sides); keep the RandomHelper improvement (decide, document). (04 §5) | 2 | M | O (buff pass) |
| 5 | **Looting** | `EarlyMatch`/`NeedsID` identify-avoidance; rare-first corpse selection; ownership-denial chat listener (10 s skip); corpse age clock from first sighting; explicit re-close. (05 §4) | 1 | M | O (loot field) |
| 6 | **Navigation** | Creep band + `FaceHeading` snap-turn inside 1/160 with the forced-Magic case; OpenVendor fire-and-forget vs wait (decide, document as a divergence if kept); lockpick strategy (decide); recall stationary gate; Portal2/UseNPC candidate filter; chat-color gate on UseNPC. (06 §6) | 2 | M | O (route) |
| 7 | **UI transcription** | Monsters/Items/Consumables/Buffs/Route/Meta tabs rebuilt as VTank's grids on the multi-column list; window geometry from `mainView.xml`; Advanced Options and Loot Editor stay in-panel by decision. (08 §1,§5) | 1c | M | O (visual) | ← PULLED FORWARD to run right after slice 1 (owner: "looks basically the same" is the visible win); plan `2026-09-07-campaign-vt-slice7-tabs.md`
| 8 | **Meta and expressions** | Doc 07's five semantic gaps: `CreateView` markup translation (VTank's two-tag Button/Layout dialect → our panel markup; 6-view cap), nested `All` actions must run every child (no short-circuit), case-sensitive monster-name regex, the `;` operator decision (retail returns the FIRST operand, UtilityBelt/MossTank the last — owner decides, document as a divergence either way), watchdog ring pre-fill with the far sentinel; plus the per-state-entry firing rule, the two "seconds in state" clocks, and the call/return stack semantics of 07 §2. Unregistered retail condition id 27 stays unregistered. | 1b (`.af`) | M | T + O (a live meta run) |
| ~~9~~ | ~~**Plugin interop API**~~ | **DROPPED by the owner 2026-09-06: no interop.** All plugins are rewritten for acdream against its own API; no VTank export surface, no Decal-shaped relay. (09 §5 is historical context.) | — | — | — |
Slices 37 all need the owner's eyes and are outside the current goal; the
goal ends after slice 1's review. Slice 9 is dropped (owner: no interop).
## `c406942ef` re-judged against doc 02
Keep the two-owner shape (it matches VTank's `ga.a` + terminal `cm` rule)
but its ten findings belong to slice 2, where the scheduler rewrite makes
most of them fall out naturally (priority-ordered rules give "buff due
suppresses idle" and "no-caster stops the macro" for free). No fix lands
before slice 2; the commit stays as is on the branch.
## Ledger
- 2026-09-06 draft written from docs 01-06, 08, 09; 07 and the citation pass pending.
- 2026-09-06 doc 07 landed and its gaps placed in slice 8; owner amendment: metas and navs are metaf `.af` (slice 1b rewritten). Citation pass still pending before FINAL.
- 2026-09-06 citation pass folded for docs 02-09 (commits `1b8183674`, `887242cd1`, `2e36ea826`, `642eacc13`, `ee901b7f2`, `69bb16484`, `d8f85848b`, `caf730752`); slice rows re-judged where a finding moved (slice 4 G4 → ordering only; slice 5 gains "MossTank's pickup-priority sort has no live VTank counterpart" as a documented addition, not a gap; slice 6's OpenVendor row: retail stays busy on missing/non-vendor objects, fire-and-forget only on the resolving path; slice 8's nav-node candidate filter = free-standing objects only). Doc 01 verifier pending; FINAL after it lands.
- 2026-09-06 owner: no interop — slice 9 dropped; every plugin is rewritten against acdream's own API.
- 2026-09-06 doc 01 verifier folded (`tString` rows are not in the Advanced Options list; the template ships 8 tables with an empty `Settings` table; `bk.a` rewrites every `.usd` on disk). All nine docs verified → status FINAL. Slice 1 row: Part B merged (`da0fcb374`); Part A in its store-cutover round.

View file

@ -0,0 +1,88 @@
# Campaign VT — VTank oracle: knowledge base first, then MossTank parity
Date: 2026-09-06
Status: ACTIVE — VT0, VT1, VT2 complete; VT3 slice 1 in review
Owner direction (2026-09-06): "decompile everything, get a knowledge base and
after that we should start implementing"; must work on Linux.
Supersedes the wiki-derived requirements in
`docs/research/2026-07-29-vtank-plugin-automation-requirements.md` and
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` wherever
they disagree with the decompiled source. `docs/plans/2026-08-26-mosstank-parity-campaign.md`
remains the feature ledger; its slices are re-judged against this campaign's
catalog.
## Decisions (owner, 2026-09-06)
| Question | Decision |
|---|---|
| Decompile set | VTank (`utank2-i.dll`), VirindiViewService, VTank Classic Looter (`VTClassic.dll`). UtilityBelt and Mag-Tools are read from their open source, not decompiled. No Decal core. |
| UI | Our own native retained UI (retail chrome, DAT fonts). VVS is NOT ported. VTank's own view XML is the layout truth (control inventory, grouping, density per tab); pixel-matching the VVS theme is not required. **Sharpened by the owner 2026-09-06: "I don't care if the UI is constructed the same way, just as long as it looks basically the same" — the acceptance bar for every tab is visual resemblance plus the same interactions from the player's seat, never VVS control internals (padding constants, hit-test rules, scrollbar mechanics).** |
| Files | Drop-in compatibility for `.usd` profiles and `.utl` loot profiles (load unchanged; settings keep VTank's names). **Metas and navs use metaf `.af` (owner, 2026-09-06 amendment):** MossTank reads and writes the human-readable metaf format for both metas and nav routes and does NOT implement binary `.met` or author `.nav`; MossTank keeps the byte-exact binary `.met`/`.nav` READERS for one-shot import only (owner confirmed 2026-09-07: "keep them for consistency and just convert them if the user imports in the plugin") — an imported binary is parsed once, saved as `.af`, and never rewritten; the binary writers are deleted and nothing in the plugin can produce a `.met` or `.nav`. Batch conversion outside the client is the metaf tool's job. **Folder layout (owner, 2026-09-07): metas and routes live in two subfolders of the profile directory, `metas/` and `navs/`, with no name prefix** (the `nav_` prefix was only how the owner named files in the metas repo); per-character auto files are `--Name_Server.af` inside each folder. Reference parser: `C:\Users\erikn\source\repos\metas\metaf_monolithic.py` (port of `metaf.cs`, GPLv3 — read, never paste), with 148 real `.af`, 25 `.met`, 130 `.nav` files beside it. Memory: `claude-memory/reference_metaf_af_format.md`. |
| Knowledge base | Full catalog with decompile citations BEFORE implementation. |
| Platform | Linux is a first-class target: no `System.Drawing`, no GDI+/Windows fonts, no Windows-only paths or registry, no COM. The plugin stays BCL-only per `AcDream.Plugin.Abstractions`. |
| Interop | **NONE (owner, 2026-09-06): "We should not do any interops. All plugins will have to get rewritten for acdream." VTank relies on Decal hooking/injecting the client; acdream controls the client through its own API.** No VTank export-API surface, no Decal-shaped relay, no compatibility layer for existing Decal plugins (MosswartMassacre, UtilityBelt, Mag-Tools are rewritten as acdream plugins when wanted). KB doc 09's interop sections stay as historical context only. VTank has no real API — what KB 09 calls its "export API" is a reflection relay behind a signed handshake that every real consumer bypassed. If acdream plugins ever need to talk to each other, acdream creates its own plugin API later, on its own terms ("we can of course create one later on"). |
| Hosts | **One plugin, two hosts (owner, 2026-09-07).** Plugins work in the graphical client (configure, play) AND in the headless bot (unattended: on login load the named profiles and start the macro). Headless = no renderer, cheap. Slice 2 adds the session-config autostart and a headless MossTank smoke test. |
## The oracle (local, never committed)
`refs/` is gitignored (same posture as `refs/acclient.pdb`). Decompiled with
ILSpy 10.1 (`ilspycmd -p`); embedded resources extracted with 32-bit
PowerShell reflection.
| Path | Content |
|---|---|
| `refs/vtank/decompiled/` | 458 `.cs`, 1.5 MB. Identifiers obfuscated (`ch.cs`, `ga.cs`…) but every string, setting name, chat message and the logic are intact. `PluginCore.cs` (207 KB) is the hub; `ExpressionEvaluator.cs`, `cLogic.cs` keep names. |
| `refs/vtank/uTank2.ViewXML.*.xml` | VTank's own views: `mainView.xml` (856×210, nine tabs, 156 controls), `AdvancedOptionsView.xml`, `SelfBuffChoiceView.xml`, `FriendlyLootEditorView.xml`. |
| `refs/vtank/uTank2.Resources.defaultsettings.usd` (+ `defaultsettingstemplate.usd`, `defaultinfodb.ugd`, `defaultitemagedb.ugd`, `CustomIcons.RES`, `dc.xml`) | Default profile (the 137 options), templates, databases. |
| `refs/vvs/decompiled/` + `refs/vvs/resources/` | VirindiViewService: control semantics (`HudList`, `HudCombo`, `HudTabView`…), themes (`Float_Theme.cs` is the default when the registry names none; `Decal_Theme.cs` is the retail-textured one), theme images. Reference only. |
| `refs/vtank-classiclooter/decompiled/` | 53 `.cs`, real names: `LootCore`, `cLootItemRule`, `UTLBlock_*`, `UTLFileExtraBlockManager`, `ComputedItemInfo`, `GameInfo`. |
| metaf (owner-supplied 2026-09-06) | `C:\Users\erikn\source\repos\metas` (`ssh://git@git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas.git`): `metaf_monolithic.py` (the `.met/.nav``.af` converter), `af/` (148 real metas and `nav_*.af` nav-only files), `met/`, `nav/`, `profiles/`. The `.af` grammar oracle and the fixture source for slice 1b. |
| Open source (owner-supplied references, 2026-09-06) | UtilityBelt: `git@gitlab.com:utilitybelt/utilitybelt.gitlab.io.git` — holds the full plugin source (`UtilityBelt.sln`, `UtilityBelt/Lib/Expressions/` incl. the `MetaExpressions.g4` grammar); cloned to `C:\Users\erikn\source\repos\utilitybelt.gitlab.io` (older clone `utilitybelt.service`). Mag-Tools: `https://github.com/Mag-nus/Mag-Plugins/tree/master/Mag-Tools`, local `C:\Users\erikn\source\repos\Mag-Plugins`. Read as source, never decompiled. |
Rules for using it: cite `file:line` in the catalog and in code comments; never
paste decompiled code into the repo; behavior is re-implemented in our own
code from the catalog. The decompile is the oracle the same way the retail
pseudo-C is: when the wiki and the source disagree, the source wins.
## Phases
### VT0 — Oracle (COMPLETE 2026-09-06)
VTank, VVS, Classic Looter decompiled and resource-extracted into `refs/`.
### VT1 — Knowledge base (COMPLETE 2026-09-06)
One document per subsystem under `docs/research/vtank-kb/`, each written from
the decompile with citations, each ending with a "MossTank gap" section
against the current plugin (`src/AcDream.Plugins.MossTank/`). Index:
`docs/research/vtank-kb/README.md`.
| # | Doc | Scope |
|---|---|---|
| 01 | `01-settings-and-profiles.md` | Every setting (name, type, default, UI control, consumer), the `.usd` format read/write, profile selection per character, `/vt opt` handling. |
| 02 | `02-scheduler-and-actions.md` | The macro loop: action classes, their priority order and suppressors, run/stop, death, IdlePeace, combat-mode transitions, equipment/wand selection and the peace→wield→mode sequencing, timeouts and retries. |
| 03 | `03-combat.md` | Target acquisition and selection methods, monster rules (`DEFAULT`, priorities, per-rule actions), weapon/damage/ammo choice, debuff scheduling, rings/arcs/streaks, ghost/blacklist logic, pets, attack power/height. |
| 04 | `04-buffs-and-vitals.md` | Buff plan construction, rebuff timing, item enchantments and "no buffs", helper buffs, vitals healing (spells, kits, potions, `GoToPeaceModeToUseKits`), dispel, mana. |
| 05 | `05-looting-and-utl.md` | VTank's loot flow + Classic Looter: `.utl` blocks, rule evaluation order, salvage combine, corpse handling, priorities. |
| 06 | `06-navigation-and-nav.md` | `.nav` format and every node type, route types, follow/stick, portal/door/lockpick, recall, movement and stuck handling. |
| 07 | `07-meta-and-expressions.md` | `.met` format, states, conditions, actions, the expression language (`ExpressionEvaluator.cs`), views created by metas. |
| 08 | `08-ui-views.md` | Per-tab control inventory from `mainView.xml` (type, name, geometry, bound setting), the secondary views, and the VVS control semantics our markup must offer (multi-column lists with text/check/icon columns, combo, notebook). |
| 09 | `09-commands-and-interop.md` | `/vt` commands, chat output, the exported plugin API other plugins call (what MosswartMassacre's `vTank.cs`/`VtankControl.cs` use), Classic Looter integration points. |
Acceptance for VT1: every doc cites file:line for each behavioral claim, lists
what could not be determined, and has its gap section. An Opus pass checks a
sample of citations per doc.
### VT2 — Gap audit and implementation order
Consolidate the nine gap sections into one ordered slice list with sizes and
dependencies (file compatibility and the multi-column list markup first, since
everything else lands on them). Re-judge the 2026-09-06 arbitration commit
(`c406942ef`) against 02.
### VT3+ — Implementation slices
Each slice: plan (Fable) → implement (**Opus for behavior ports from the decompile/catalog; Sonnet for plumbing** — process revised with the owner 2026-09-07 after slice 1 needed 3 fix rounds and slice 7 seven, half of them Sonnet fidelity misreads) → **owner live look first for anything visible****one Opus review carrying both lenses** (two separate lenses only for a port slice's first review) → fix round (Sonnet) → merge → owner gate.
Linux-clean by construction; the Ubuntu CI closure runs the MossTank tests.
## Ledger
- 2026-09-06 VT0 complete. VT1 docs 01/02/03/08 dispatched first, 0407/09 next.
- 2026-09-06 VT1: all nine catalog docs landed; index written; Opus citation pass in flight.
- 2026-09-06 Files decision amended by the owner: metas and navs are metaf `.af`, not `.met`/`.nav` (see Decisions). Docs 06 §1 and 07 §1 stay as the binary-format record (they describe what the metaf tool converts from); the semantics sections (06 §2-5, 07 §2-4) remain the behavior oracle. VT2 doc: `2026-09-06-campaign-vt-vt2-gap-audit.md`; slice-1 contract: `2026-09-06-campaign-vt-slice1-files-and-columns.md`.
- 2026-09-07 owner asked whether every VTank format should get a human-readable twin like `.af`. Lead's answer, recorded as a post-campaign idea: VTank's `.usd`/`.ugd`/`.utl`/`.met` are already text (the one-value-per-line table grammar) and `.nav` is plain lines; only metas/navs had a community-authored readable format (metaf) with a converter both ways, which is why `.af` is safe as storage. For settings/loot a second format would be our own invention with two sources of truth. Instead: a one-way readable EXPORT + a profile DIFF command (`/vt settings export`, `key = value` lines) for inspection and version control, VTank's format staying the only storage. Revisit only if hand-editing loot rules becomes routine.

View file

@ -0,0 +1,217 @@
# MossTank — combat-mode and caster arbitration for buffing and idle
Date: 2026-09-06
Status: ACTIVE (owner request after the plugin-UI acceptance the same day)
Parent: `docs/plans/2026-08-26-mosstank-parity-campaign.md` (MT3 buff parity /
MT1 autocombat); this is the missing arbiter between the two.
Owner direction: plan (Fable) → implement (Sonnet) → review (Opus, two lenses).
## Owner request (verbatim intent)
1. When the macro is running and needs to buff or fight, it must use the
**Items** page (the profile of items the macro may equip). With buffing and
fighting enabled and the character in peace mode when the macro starts, the
macro must enter the right combat mode by itself and fight or buff as needed.
2. With **Peace Mode When Idle** on, the macro must return to peace mode when it
has no action (no buff, no fight) to perform.
## Verified current state (HEAD `e61edd946`)
| Piece | Where | Fact |
|---|---|---|
| Items profile | `CombatSettings.CombatItemNames` (durable exact names) + `CombatItemObjectIds` (session ids); `_noBuffItemNames` in `MossTankPanel` | Items page `Add` / `Add (no buffs)` / `Remove` mutate these. `no buffs` = VTank's "do not cast item enchantments on this weapon"; it does NOT mean "not a buffing caster". |
| Combat side | `CombatController.OnTick` (`:185-345`) | With a target: `TickEquipment` picks the rule weapon from the profile (`SelectAutomaticWeapon` filters by `CombatItemNames`/`ObjectIds`), `TryEquipIfNeeded` drops to Peace to wield (retry budget `VitalSettings.DropToPeaceModeRetryCount`, then the wand-recovery path), then `EnterDefaultMode`. With no target and `CombatSettings.IdlePeaceMode`: `EnterMode(Peace)` — but ONLY while `_settings.Enabled` (combat enabled); with combat disabled the tick returns at "Combat disabled" and nobody drops to peace. |
| Buff side | `MossTankPanel.StartBuffPass` / the pass tick (`~3905-3975`) / `TryCast` | Builds the queue and casts through `Magic.EvaluateGate` + `Magic.Cast`. **No combat-mode and no caster handling at all.** The combat controller is `SetPaused` while `_running` (pass active). |
| Host gates | `AppAutomationSurface.EvaluateGate` | Knows / busy / target-compatibility only; it does not check combat mode. |
| Server truth | ACE `Player_Magic.cs:84-95, 275-286` | A cast arriving with `CombatMode != Magic` is DROPPED (`SendUseDoneEvent`) unless `LastCombatMode == Magic`. So a buff pass started from peace or melee mode silently casts nothing. |
| Magic mode entry | ACE `Player_Combat.cs:778+` (`GetEquippedWand`), runtime `RuntimeCombatModeState.Request(Magic)` | Magic mode requires a wielded caster; a request without one is denied server-side. Wielding requires Peace mode (retail rule already modelled by `TryEquipIfNeeded`). |
| Caster selection precedents | `VitalRecharge.cs:495-540` (`CasterItemType = 0x8000`, profile membership), `CombatController.SelectRecoveryCaster` | Both already pick a profiled caster. |
| Mode requests | `ICombatAutomation.EnterMode(mode)``ModeChangeSent`; `Snapshot.Mode` updates when the server confirms | Asynchronous; callers must wait on the snapshot. |
## Design
Two small, testable owners inside the plugin. No host/API changes; the
plugin-facing surface already has everything needed.
### A. `BuffCasterPreparer` — get the character ready to cast
A state machine the buff pass runs BEFORE its first `TryCast`, ticked every
frame while the pass is running, with `Ready` gating the queue. It is the
single owner of "which caster do we buff with and how do we get into Magic
mode". States:
1. **Resolve caster.** Snapshot `Equipment.CaptureOwnedEquipment()`. The
buffing caster is, in order: the currently wielded caster
(`ItemType & 0x8000`, `IsEquipped`); else the first profiled caster
(membership by `CombatItemObjectIds`/`CombatItemNames`, same predicate as
`VitalRecharge`), deterministic order (name, then object id). `no buffs`
membership is irrelevant here. No candidate at all → the pass STOPS with
VTank's own error text, announced once through `Chat.PostSystemMessage`:
`"You must add at least one wand to your Items profile."` (the string
`CombatController` already uses), and the automatic pass does not retry
for `BuffSettings`' next scan interval.
2. **Wield if needed.** If the caster is not wielded: if mode ≠ Peace →
`EnterMode(Peace)` and wait; when in Peace → `Equipment.Equip(caster)`;
while `Equipment.IsBusy` wait; if `Refused` → stop the pass with the
refusal in the status line.
3. **Enter Magic.** If mode ≠ Magic → `EnterMode(Magic)`; wait until
`Snapshot.Mode == Magic`.
4. **Ready** → the existing queue/`TryCast` loop runs unchanged.
Waits carry a bounded budget: each mode request may be re-issued after
`ModeRetrySeconds = 2.0` and at most `VitalSettings.DropToPeaceModeRetryCount`
times, mirroring `TryEquipIfNeeded`; exhausting it stops the pass with a
status naming the stage ("could not enter magic mode"). `Magic.IsCasting`
true at any stage → wait. The preparer is reset on `Stop`, on session end, and
when the macro stops.
After the pass ends nothing is restored here: the combat controller resumes
(it is un-paused) and, with a target, `TickEquipment` swaps to the rule
weapon exactly as today; with no target the idle owner below decides.
### B. `MacroIdleModeArbiter` — one owner for "peace when idle"
Move the idle-peace decision OUT of `CombatController.OnTick` (delete its
`_settings.IdlePeaceMode` branch at `:274-282`; keep "Waiting for a target")
into one arbiter ticked from `MossTankPanel.OnTick` after every controller has
ticked, so it also works with combat disabled. It requests
`EnterMode(Peace)` when ALL hold:
- the macro is running;
- `CombatSettings.IdlePeaceMode` is on;
- no buff pass is running (`!_running`) and `!Magic.IsCasting`;
- no controller owned an action this tick (the existing `*OwnsAction` set,
plus `_combat.HasTarget` false and `Equipment.IsBusy` false);
- `Snapshot.Mode` is not Peace/Unknown.
It re-issues at most once per `IdleRetrySeconds = 1.0` while the condition
holds and writes the status line "Entering peace mode" / the refusal notice.
With `IdlePeaceMode` off it never touches the mode (VTank stays in combat
mode when idle).
### C. Macro start from peace mode (owner scenario)
Nothing new is needed beyond A and B: on `Run Macro` the combat controller
already scans for hostiles and, on a target, wields the profiled weapon and
enters the default mode; the automatic buff scan (`TickAutomaticBuffing`)
starts a pass which now prepares the caster and enters Magic. Pin the whole
scenario in a panel-level test (see Tests, item 6).
### Non-goals
- No new VTank options. `IdlePeaceMode`, the Items profile, and the buff
settings are the only inputs.
- No host/API change. Do not add combat-mode logic to `AppAutomationSurface`.
- Do not change `TickEquipment`'s weapon policy or the recovery-caster path.
## Files
- New: `src/AcDream.Plugins.MossTank/BuffCasterPreparer.cs`,
`src/AcDream.Plugins.MossTank/MacroIdleModeArbiter.cs`.
- `src/AcDream.Plugins.MossTank/MossTankPanel.cs`: construct both; run the
preparer at the head of the pass tick and gate `TryCast` on `Ready`;
reset on `Stop`/session end/macro stop; tick the arbiter at the end of
`OnTick` with the owns-action set already computed there.
- `src/AcDream.Plugins.MossTank/CombatController.cs`: delete the idle-peace
branch (single owner), keep the rest.
- Tests in `tests/AcDream.Plugins.MossTank.Tests/` (reuse the fake automation
surface in `CombatControllerTests.cs`; extend it if it lacks equipment or
mode-change simulation).
## Tests (each shown to fail before its change)
1. Preparer, wielded caster + Peace mode: requests Magic, not Ready until the
snapshot reports Magic, then Ready; exactly one `EnterMode` call before the
snapshot flips.
2. Preparer, caster in profile but not wielded, mode Melee: requests Peace →
equips the caster (only after the snapshot reports Peace) → requests
Magic → Ready; order pinned via a recorded call list.
3. Preparer, no caster anywhere: stops the pass, posts the VTank error once,
does not re-post on the next tick.
4. Preparer, mode request never confirmed: after the retry budget the pass
stops with the stage named; the number of `EnterMode` calls equals the
budget.
5. Arbiter: idle + IdlePeaceMode + Melee → one Peace request; second tick
within 1 s → no second request; a running buff pass, a hostile target, a
busy equipment swap, or `IsCasting` each suppress it; IdlePeaceMode off →
never requests; combat DISABLED but macro running still requests (the
case the old code missed).
6. Panel scenario: macro started in Peace with buffing + combat enabled, a
profiled wand and a profiled melee weapon, one buff due, one hostile in
range → observed order: Peace(already) → Equip wand → Magic → cast → (pass
ends) → Peace → Equip weapon → default mode → attack. Then, with the
hostile gone and IdlePeaceMode on → Peace.
7. `CombatControllerTests`: the moved idle-peace expectation is deleted there
and re-pinned on the arbiter; every other test stays green.
## Acceptance
`dotnet build AcDream.slnx -c Release`; `dotnet test
tests/AcDream.Plugins.MossTank.Tests -c Release` all green (337 + new);
`dotnet test tests/AcDream.App.Tests -c Release` unchanged failure set. Then
the owner's connected gate: with a wand and a weapon on the Items page, start
the macro in peace mode with buffing and combat enabled; watch it wield the
wand, enter magic mode, buff, swap to the weapon and fight when a creature
appears, and drop to peace when nothing is left and Peace Mode When Idle is on.
## Review ledger
- Implementation commit: `c406942ef9c72dbf6744b30d46c0acf70537e298`
`feat(mosstank): buff-caster preparer and single idle-peace arbiter —
wield from the Items profile, enter magic/peace mode by itself`.
Design A (`BuffCasterPreparer`), Design B (`MacroIdleModeArbiter`),
the `MossTankPanel`/`CombatController` wiring, and Tests 1-7 landed.
`AcDream.Plugins.MossTank.Tests` 336 -> 345 (336 baseline sans the
moved `IdlePeaceIsTheNoTargetFallback`, plus 9 new); `AcDream.App.Tests`
MossTank/Plugin filter (79 tests, incl. `MossTankMarkupContractTests`)
stays green with no markup changes. Owner review still owed (Opus,
two lenses) plus the connected gate in Acceptance.
### Opus review of `c406942ef` against VTank's decompiled source (2026-09-06)
Verdict PASS-WITH-FIXES; owner direction is knowledge base first, so the
fixes are DEFERRED to Campaign VT (VT2 re-judges this commit against
`docs/research/vtank-kb/02-scheduler-and-actions.md`). Oracle citations are
into `refs/vtank/decompiled/`.
VTank facts established (cite these, do not re-derive): IdlePeace is one rule
type `cm` registered LAST after the `"END"` sentinel (`cLogic.cs:576-577`); the
engine runs strictly the first valid rule per tick (`cLogic.cs:220-253`); the
buff rule `fz` (`cLogic.cs:466`) is valid whenever a buff is DUE
(`fz.cs:69-86`), so a due buff suppresses idle-peace even between passes;
`cm(0)` is also a pre-chain on idle loot, corpse approach, target approach and
nav route (`cLogic.cs:530-570`, `LogicRulePreChain.cs:52-62`) — VTank drops to
Peace BEFORE those; `fd.cs:129-133` forces Magic in the tight-waypoint case.
Caster/mode sequencing is one idempotent per-tick function `ga.a`
(`ga.cs:1432-1565`): preferred item → wielded item if its class maps to the
wanted mode → profile scan in Items-page insertion order (`ga.cs:1400-1408`,
`eq.cs:85-93`; the "no buffs" spell field is ignored); no caster → one error
`"You must add at least one wand to your profile."` + `StopMacro()`
(`ga.cs:1469-1472`); retry counter per tick, exhaustion runs the bugged-
combat-state wand-use recovery, never a give-up (`ga.cs:1502-1524`); nothing
is restored after buffing — combat re-wields through the same function.
Findings to fix in VT3 (numbers from the review):
1. BLOCKING — no-caster path re-posts the notice ~1 Hz forever (`Stop` clears
the latch, the auto scan restarts the pass); VTank posts once and stops the
macro. Fix: stop the macro (as `CombatController.Disable` does).
2. BLOCKING — 2.0 s × `DropToPeaceModeRetryCount`(34) = 68 s > the pass's
30 s `StallTimeoutSeconds`, so the exhausted-budget stop is unreachable;
and VTank does not stop there anyway — it runs the wand-use recovery.
3. SHOULD-FIX — arbiter drops to peace while a buff is due (1 s scan gap);
add "a buff is due" to the idle set.
4. SHOULD-FIX — `MacroIdleModeArbiter.Status` is never displayed.
5. SHOULD-FIX — profiled-caster order must be Items-page insertion order,
not name order (needs an ordered profile list; `CombatItemNames` is a set).
6. SHOULD-FIX — equip stage re-issues `Equip` every frame with no cadence or
budget and no wieldability check (`ga.cs:1456-1460`).
7. NIT — `Mode == Unknown` spins the preparer (arbiter treats it as hands-off).
8. NIT — two caster predicates (`& 0x8000` vs exact); share one helper.
9. NIT — the notice text differs from VTank's ("…to your profile.").
10. CARRIED — the `cm(0)` pre-chains and the `fd.cs:131` Magic case are
unported; ours suppresses idle-peace on nav/loot instead.
Also: `CombatController.EnterDebuffMode` (`:1373`) and the meta
`setcombatstate` expression can fight the arbiter (no owns-action flag) —
add `HasPendingItemDebuff` and meta activity to the idle set.
Tests T3/T4/T5b/T5d are vacuous as written (pass with the defect present);
T1/T2/T5a/T5c/T6 are real pins.

View file

@ -0,0 +1,316 @@
# Plugin shelf mobility + DAT icons in plugin markup
Date: 2026-09-06
Status: ACTIVE — prerequisite for the MossTank plugin work that follows
Owner direction: plan (Fable) → implement (Sonnet) → review (Opus, two lenses)
## Why
Two owner requests before MossTank feature work resumes:
1. The plugin shelf (`PluginSidePanel`, the right-edge strip of plugin-window
buttons) must be **movable** and **easy to hide**. Today it is pinned to the
right screen edge every tick, is not draggable, has no hide affordance, and
its position is never persisted.
2. Plugin panels must be able to **embed icons from the game's DAT files**, the
way Decal/VirindiViewService plugins do. The owner's own Decal plugin,
MosswartMassacre (`C:\Users\erikn\source\repos\MosswartMassacre`), is the
reference usage: `DecalControls.IconColumn` list columns fed by
`HudPictureBox.Image = iconId`, icon ids resolved from Decal's
`FileService.SpellTable` (spell art) and `SkillTable` (skill art), raw
portal.dat indices normalized by adding `0x06000000`, and a `<view icon=...>`
window icon. See `MosswartMassacre/Views/FlagTrackerView.cs`
(`SafeSetListImage`) and `MosswartMassacre/FlagTrackerData.cs`
(`GetSpellIcon`, `GetSkillIconId`).
## Non-negotiables
- Plugins stay **BCL-only**. Nothing in `AcDream.Plugin.Abstractions` may
reference App/UI types. Plugins hand the host DAT ids; the host owns every
texture.
- API-v1 source/binary compatibility: every abstraction change is **additive**
(new `init` properties with defaults, new default-interface members).
- No new default-on behavior, no env-var probes. Persisted state goes through
the existing `SettingsStore` / `RetailWindowLayoutPersistence` path only.
- The shelf must never disable a plugin or dispose a window. Hiding is
presentation only (existing invariant, keep the existing tests green).
- Retail's UI lock (`UiRoot.UiLocked`, retail `@lockui`) must also lock the
shelf's drag, exactly like every other retained window.
## Current state (verified 2026-09-06, HEAD `0275b4ca5`)
| Thing | Where | Fact |
|---|---|---|
| Shelf | `src/AcDream.App/UI/PluginSidePanel.cs` | `UiPanel` subclass; `Draggable=false`; `OnTick` forces `Left = parent.Width - Width - 4`; `Top = 116`; `Visible = EntryCount > 0`; not registered with `RetailWindowManager`; disposed at `RetailUiRuntime.cs:5583`. |
| Shelf construction | `RetailUiRuntime.MountPlugins` (`RetailUiRuntime.cs:4637-4710`) | Built lazily on the first `ShowInSidePanel` plugin window; `Host.Root.AddChild(_pluginSidePanel)`. |
| Plugin windows | same method | `MarkupDocument.Build(xml, binding, Assets.ResolveSprite, Assets.Controls, Assets.DefaultFont)`; registered as `plugin:{pluginId}:{windowId}` with `Host.WindowManager.Register`; geometry/visibility persist via `RetailWindowLayoutPersistence` (attaches on `WindowRegistered`). |
| Window layout schema | `src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs` | `X, Y, Width, Height, Visible, Collapsed, Maximized, AuthoredGeometryRevision``Collapsed` already exists and is captured/restored through `IRetainedWindowStateController` (`RetailWindowLayoutPersistence.cs:249,296`). |
| Retail plugin-manager chord | `InputAction.TogglePluginManager` (Shift+Ctrl+F1, retail id `0x7C`) | Handler at `RetailUiRuntime.cs:1044` only prints "The retail plugin manager is not available in acdream." |
| Markup vocabulary | `src/AcDream.App/UI/MarkupDocument.cs` | `panel` root; `group, meter, label, button, tab, toggle, slider, field, menu, list`. Bindings are `{PropertyName}` reflection reads per frame. `resolve: Func<uint,(tex,w,h)>` is `TextureCache.GetOrUploadRenderSurface` (any `0x06xxxxxx` RenderSurface DID). |
| List widget | `src/AcDream.App/UI/UiMarkupList.cs` | text rows + optional per-row colors; no icon column. |
| Button widget | `UiSimpleButton` in `src/AcDream.App/UI/UiPanel.cs:161` | rect + text; no icon. |
| Descriptor icon | `PluginPanelDescriptor.IconSurfaceId` | already drawn on the shelf button (`PluginShelfButton.OnDraw`); zero → initials. No normalization: a Decal-style bare index (`7735`) resolves to nothing. |
| Composited icons | `src/AcDream.App/UI/IconComposer.cs` | `GetSpellIcon(spellId)` = retail `CompositeSpellIcon` (power backing + art + tint + self/fellow overlay); `GetIcon(itemType, iconId, underlay, overlay, effects)` = retail item icon. Available to the UI runtime as `RetailUiAssets.Icons`. |
| Plugin-visible icon data | `src/AcDream.Plugin.Abstractions/Automation.cs` | `PluginSpellComponentInfo.IconId` exists. `PluginSpellInfo`, `PluginSkillInfo`, `PluginInventoryItem`, `PluginWorldObject` carry **no** icon id. `ClientObject` has `IconId/IconUnderlayId/IconOverlayId` (`src/AcDream.Core/Items/ClientObject.cs:201`). `AppAutomationSurface` implements `ISpellCatalog` and reads the DAT SpellTable. |
| Tests | `tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs`, `MarkupDocumentTests.cs`, `MarkupPanelClickTests.cs` | harness pattern: `new UiRoot{Width,Height}`, `root.WindowManager.Register`, fake `resolve` (`_ => (1u,32,32)`), `root.Tick(...)`, `OnEvent(new UiEvent{...})`. |
| Smoke plugin | `src/AcDream.Plugins.Smoke/SmokePlugin.cs` | no UI; copied into `plugins/AcDream.Plugins.Smoke/` by `AcDream.App.csproj`. User plugins load from `{ConfigDirectory}/plugins` (`Program.cs:186`). |
## Slice A — movable, hideable plugin shelf
### Behavior
1. **The shelf is a retained window.** Register it with
`Host.WindowManager.Register("plugin-shelf", shelf, shelf, controller)` the
moment it is created in `MountPlugins`. That gives it drag, z-order,
`RetailWindowLayoutPersistence` (position + visibility + collapsed, per
character and resolution) and the UI lock for free. Name it exactly
`plugin-shelf` (stable persisted key; document it).
2. **Default dock is unchanged.** With no saved layout, the shelf sits where it
sits today (right edge, top 116). The edge dock is computed once when the
parent has a real size (first tick with `parent.Width > 0`) and **only while
the user has never moved it** — after a drag, or after a restored layout,
the shelf stays where it was put. Reflow on entry add/remove keeps the
shelf's **top-right corner** fixed when it is docked and its **top-left**
fixed otherwise, so growth never pushes it off screen. Keep the existing
`KeepWindowReachable` clamp for the shelf itself on display resize.
3. **Drag grip.** A grip strip across the top of the shelf (≈10 px tall, full
width, drawn with a subtle three-line glyph or dotted texture in the
existing border color) is the drag handle. Set `Draggable = true` on the
shelf; verify in `UiRoot` that the shelf buttons (which handle clicks)
do not promote to a window drag and that the grip does. Respect
`ConstrainDragToParent = true`.
4. **Collapse toggle.** The grip carries a small toggle at its right end
(`»` when expanded, `«` when collapsed; use the DAT font). Collapsed =
only the grip (with the toggle) remains, buttons hidden, width shrinks to
the grip's minimum. Collapsed state persists through `UiWindowLayout.Collapsed`
by implementing `IRetainedWindowStateController` on the shelf (capture/
restore). A collapsed shelf still reflows its hidden button set so
expanding is instant.
5. **Hide/show.** Re-purpose `InputAction.TogglePluginManager`
(Shift+Ctrl+F1, retail's plugin-manager chord — acdream has no other
plugin manager, so this is the honest home). Handler in `RetailUiRuntime`:
- shelf exists and visible → `Hide()`;
- shelf exists and hidden → `Show()` (and expand if collapsed);
- no shelf (no plugin windows registered) → keep today's system message
but reword to "No plugin windows are registered." Update the
Configure Keyboard caption for the action if a caption table names it
("Plugin Manager" → "Plugin Shelf"); check `RetailActionIdentityTable`
and the keymap caption source before renaming anything retail-derived
and cite what you found in the commit message.
- Hidden state persists (window layout `Visible`). New plugin windows
registering while the shelf is hidden do **not** show it.
- Also add a `/plugins` chat verb? **No.** Out of scope.
6. **Minimize buttons on plugin windows** stay as they are.
### Files
- `src/AcDream.App/UI/PluginSidePanel.cs` — grip, toggle, docking rule,
`IRetainedWindowStateController`, remove the per-tick right-edge pin.
- `src/AcDream.App/UI/RetailUiRuntime.cs` — register the shelf window in
`MountPlugins`; `TogglePluginManager` handler; shutdown path unchanged
(unregister happens through the window manager's own disposal — verify).
- Tests in `tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs` (extend; keep
every existing test green):
- default dock when no layout, top-right anchored growth;
- drag via grip moves the shelf and the position survives a reflow;
- drag refused while `UiLocked`;
- collapse hides buttons, shrinks width, `CaptureWindowState().Collapsed`
round-trips through `RestoreWindowState`;
- toggle action: visible→hidden→visible; hidden shelf stays hidden when a
new plugin window registers;
- persistence: `RetailWindowLayoutPersistence` round-trips X/Y/Visible/
Collapsed for `plugin-shelf` (use the existing persistence test harness
if one exists; otherwise a focused test with a temp `SettingsStore`).
### Acceptance
`dotnet build` green; `dotnet test tests/AcDream.App.Tests --filter
"FullyQualifiedName~PluginSidePanel|FullyQualifiedName~RetailWindowLayout"`
green; then the full App suite green. Connected visual gate by the owner
(after Slice B): drag the shelf, collapse, hide with Shift+Ctrl+F1, relog,
confirm it comes back where it was.
## Slice B — DAT icons in plugin markup
### Plugin-facing contract (BCL-only, additive)
1. **Icon id grammar (`PluginIcons`).** New static class
`AcDream.Plugin.Abstractions.PluginIcons` with
`static uint Normalize(uint idOrIndex)`: `0 → 0`; values below
`0x01000000` are Decal/VVS-style bare portal indices and become
`0x06000000 + value`; anything else is returned unchanged. The host applies
`Normalize` at **every** icon sink (descriptor `IconSurfaceId`, `<icon
did>`, `<button icon>`, list icons). Document the grammar on the class.
2. **Icon ids on existing records** (all `init` properties defaulting to 0,
so API-v1 plugins compile unchanged):
- `PluginSpellInfo.IconId` — raw SpellTable `spell.Icon` DID (Decal's
`SpellTable.GetById(id).Icon`).
- `PluginSkillInfo.IconId` — SkillTable icon DID. Verify the
Chorizite.DatReaderWriter `SkillBase` member name from its XML docs
(`~/.nuget/packages/chorizite.datreaderwriter/1.0.0/lib/net8.0/DatReaderWriter.xml`)
and from how the character panel already draws skill icons; cite it.
- `PluginInventoryItem.IconId`, `PluginWorldObject.IconId` — the object's
`ClientObject.IconId`.
Fill them in `AppAutomationSurface` (spells/skills) and the existing
item/world-object snapshot builders. Where a builder is shared with
headless hosts, keep it BCL-only.
### Markup
3. **`<icon>` element.** Attributes: `x y w h` (w/h default 32), exactly one
source: `did="0x06001234"` | `did="7735"` | `did="{IconDid}"` (uint
property, re-read every frame) | `spell="{SpellId}"` (retail composited
spell icon via `IconComposer.GetSpellIcon`) | `item="{ObjectId}"` (retail
composited item icon via `IconComposer.GetIcon` from the live
`ClientObject`). Plus the common `name/visible/enabled/tooltip`.
Zero/unresolvable → draws nothing (never throws at draw time; a malformed
literal throws at `Build`, like the other elements). Aspect is preserved,
sprite centered in the box, nearest filtering for 32×32 art.
4. **`<button icon="...">`** — same `did` grammar/binding; icon drawn at the
left inside the button with the text shifted right; text may be empty
(icon-only button). Optional `iconkind="did|spell|item"` (default `did`).
5. **`<list icons="{IconDids}" iconkind="did|spell|item">`** — `IEnumerable<uint>`
parallel to `items`, rendered as a leading square column of
`RowHeight - 2` px per row (Decal's `IconColumn`); text shifts right by the
column width when `icons` is present. Missing entries draw no icon.
6. **Descriptor** — apply `PluginIcons.Normalize` to
`PluginPanelDescriptor.IconSurfaceId` in `PluginShelfButton`.
### Host plumbing
7. Introduce `IMarkupIconResolver` in `AcDream.App.UI`:
`(uint tex,int w,int h) ResolveDid(uint did)`, `ResolveSpell(uint spellId)`,
`ResolveItem(uint objectId)`. `MarkupDocument.Build` gains an optional
`IMarkupIconResolver? icons = null` parameter (tests pass a fake). When
null, `spell`/`item` sources resolve to nothing and `did` falls back to the
existing `resolve` func. `MountPlugins` passes a real resolver built from
`RetailUiAssets.Icons` (+ `ResolveSprite`) and the live object table the
inventory UI already uses — find the existing item-icon lookup path in
`src/AcDream.App/UI` (search for `IconComposer`/`UiItemSlot`) and reuse it;
do not add a second object lookup.
8. New widget `UiMarkupIcon : UiElement` (draw-only, `ClickThrough = true`
unless a tooltip is set). Button icon and list icon column draw through the
same resolver; cache nothing beyond what `TextureCache`/`IconComposer`
already cache.
### Proof and docs
9. **Smoke plugin panel.** Give `AcDream.Plugins.Smoke` a small panel
(`RegisterPanelContent`, in-memory KSML) that exercises every new surface:
`<icon did="7735">` (bare index), `<icon did="0x06002D14">`, `<icon
spell="{SpellId}">` bound to a spell the character knows (fall back to
spell 1 — Strength Other I is fine for art), `<button icon=...>`, and a
`<list icons=... iconkind="spell">` of the first five spellbook entries
with `PluginSpellInfo.IconId` also shown as text. Descriptor
`IconSurfaceId = 7735` to prove normalization. This is the owner's visual
gate.
10. **Author docs.** New `docs/plugin-ui-markup.md`: every element, every
attribute, binding rules (`{Prop}`, types, per-frame reads), the icon id
grammar, the three icon sources, the shelf (`plugin-shelf`, drag,
collapse, Shift+Ctrl+F1), and a MosswartMassacre-style example (list with
icon column fed from `PluginSpellInfo.IconId`). Link it from
`docs/README.md` and from the "Plugin UI API" section of
`docs/plans/2026-04-24-ui-framework.md` (update that section's vocabulary
sentence).
11. Tests (`MarkupDocumentTests` + new `MarkupIconTests`):
- `PluginIcons.Normalize` table (0, 7735, 0x00FFFFFF, 0x06002D14,
0x0600FFFF);
- `<icon>` builds with each source; literal `did` variants; bound `did`
re-reads; `spell`/`item` route to the fake resolver with the bound id;
unresolvable draws nothing; two sources on one element → `FormatException`;
- `<button icon>` shifts text and draws through the resolver;
- `<list icons>` reserves the column and draws per row; missing entries
skip; `iconkind` selects the resolver method;
- `AppAutomationSurface` fills `PluginSpellInfo.IconId` from the installed
DAT (use the existing InstalledDat test category/fixture conventions;
skip cleanly when the DAT is absent, as sibling tests do);
- API-v1 compatibility: an existing test binding record without the new
properties still constructs (compile-level).
### Acceptance
`dotnet build` green; App suite green; `AcDream.Plugins.Smoke` builds and is
copied by the App csproj. Owner visual gate: Smoke panel shows real DAT art in
all four places, list icon column aligned with rows.
## Execution
- **Order:** Slice A, Opus review, fix round, commit → Slice B, Opus review,
fix round, commit → connected visual gate (owner) → memory + doc updates.
Both slices edit `RetailUiRuntime.MountPlugins`, so they are sequential
(see `feedback_dont_parallelize_coupled_plan_slices`).
- **Implementer (Sonnet):** one agent per slice, this document is the contract.
Commit style: `feat(plugin-ui): <slice> — <what>` with the why; do not
touch files outside the slice's list without saying so in the report.
- **Review (Opus):** two independent lenses per slice — (1) architecture:
BCL boundary, ownership, no duplicate lookups, no workaround shapes, tests
prove behavior not shape; (2) plugin-author/retail lens: does the surface
match Decal/VVS usage in MosswartMassacre, is the icon grammar unambiguous,
does the shelf behave like a retail window under `@lockui`, persistence
keys stable. Each code finding gets a bounded fix and a narrow re-review;
documentation findings the lead fixes directly.
- **Out of scope (file, do not build):** a `/plugins` chat verb, a plugin
manager panel, icon support in `menu`/`tab`, per-plugin shelves, item icon
drag from plugin panels.
## Review ledger
- Slice A commits: `01b98ca30` (feat: movable, collapsible plugin shelf),
`4fada238e` (review fixes: real grip/toggle children, dialog z-order,
persisted collapse/hide intent), `718005b21` (residuals: padding-drag pin,
live dock detection, single clamper, chord text, register row), `2ebcc0164`
(grip sized from the DAT font, collapsed tab findable).
- Slice B commit: `8217a349e` (feat: DAT icons in plugin markup — icon
element, button/list icons, plugin icon ids).
- Slice A review verdict: two independent Opus lenses (architecture;
plugin-author/retail) found the shelf's shape sound; fix round landed the
three commits above and closed clean.
- Slice B review verdict: two independent Opus lenses found 15 findings (1
BLOCKING — an unresolvable `did` painted a scaled-up magenta placeholder;
14 SHOULD-FIX spanning the normalize threshold, per-attribute binding
leniency, sampler-keying, column-reservation semantics, and doc accuracy).
All 15 fixed in one review fix round (this commit); see its message for
the per-finding breakdown.
- **Deferred to the MossTank plugin work** (explicitly out of scope for this
fix round, carried forward rather than built here):
- Multi-column `<list>` — today's list is one text column plus the
optional Slice B icon column; real tabular rows are MossTank's problem.
- Root `<panel visible>` binding-only literal support — the root element
currently accepts only `{Binding}` for `visible`, not a literal
`visible="true"/"false"` the way every child element does.
- Residual round (2026-09-06, commit following `466272ec5`): shelf icon
resolve re-pointed from `ResolveSprite`/`ResolveChrome` (magenta on miss)
to the shared `IMarkupIconResolver.ResolveDid` (draws nothing on miss) so
the initials fallback can actually fire in production; `icon="{Typo}"`/
`icons="notabinding"` now throw at `Build` on a resolver-less host too;
negative/overflow icon ids (scalar and list) map to `0u` instead of
throwing `OverflowException` at draw or wrapping to `0xFFFFFFFF`;
`RetailMarkupIconResolver.ResolveDid` memoizes per DID (including the
miss) instead of re-probing the DAT every frame; `<icon iconkind="...">`
now throws at `Build` instead of silently ignoring the attribute; stale
`SampleData.cs:64` citations corrected to `:69` (Melee Defense's real
line).
- **Owner acceptance (2026-09-06):** the owner visually accepted all three
connected gates — the plugin shelf (Slice A), the DAT icon markup
(Slice B), and the retained-UI outline-order fix (`761a7519f`, landed
alongside this plan's residual round). With the visual gate passed, the
Smoke plugin's job as the gate artifact is done. Per owner direction,
`src/AcDream.Plugins.Smoke/` (including its "Icon Smoke" proof panel,
`SmokeIconPanel.cs`) was deleted outright rather than merely hidden —
the shipped-in-the-release-zip risk this ledger flagged above is now
moot because the plugin no longer exists to ship. Every build/CI/test/doc
reference to it was removed or repointed at the MossTank plugin in the
same commit; see `chore(plugins): remove the Smoke gate plugin; MossTank
shelf icon 0x06002C41`. MossTank's own panels defaulting to
`StartVisible = true` remains open — same look, still out of scope here.
- **Fix-round commits + closing cleanup (2026-09-06):** `47eb2d575` (shelf
children must not anchor — PASS, connected gate confirmed the `<`/`>`
toggle survives collapse/reflow), `761a7519f` (retained-UI rect outlines
composite in painter order — PASS, connected gate confirmed the MossTank
border no longer draws over the inventory paperdoll), `ce05c4fb0` (Slice B
residuals — shelf icon sink without magenta, validated icon bindings,
negative ids, memoized DID resolves — PASS; its one SHOULD-FIX carryover,
the memoization tests sitting in a `Lane=InstalledDat` class where CI never
ran them, is this session's cleanup item 1), and the cleanup commit
`ece210418` (Smoke gate plugin removal, referenced by message above).
Latent anchor-pass owners filed by this cleanup pass: #486, #487.

View file

@ -0,0 +1,157 @@
# Campaign VT — slice 7: the nine tabs, looking like VTank's
Date: 2026-09-07
Status: PLANNED — implementation starts once slice 1c (the `metas/`/`navs/`
folder follow-up) has merged; it shares `mosstank.xml` and `MossTankPanel.cs`.
Owner's bar (2026-09-06): "I don't care if the UI is constructed the same
way, just as long as it looks basically the same" — visual resemblance and
the same player-facing interactions, judged side by side with a running
VTank. Never VVS internals. Pulled forward from the VT2 order because it
only depends on slice 1's column markup and gives the owner the visible
result early. Owner gate: VISUAL (the first stop point of the campaign).
## Oracle
`docs/research/vtank-kb/08-ui-views.md` §0 (856×210, nine tabs, icon
`0x060029AB`), §1 (every control of every tab with `L,T,W,H`, text and the
bound setting), §5 (what `mosstank.xml` does today), plus VTank's own layout
file `C:\Users\erikn\source\repos\acdream\refs\vtank\uTank2.ViewXML.mainView.xml`
(read-only, the geometry truth). Behavior per cell click: KB 08 §2/§3 and
`refs/vtank/decompiled/uTank2/PluginCore.cs:7904-8089` (Monsters cell
handlers), `:8529-8562` (Items), `:2219-2247` (Meta), `:3576-3599` (Route),
`:7323-7355` (Buffs), `:7683-7776` (Consumables). Markup reference:
`docs/plugin-ui-markup.md` (columns, `width="*"`, text `onclick`).
## Scope
S7.1 **Window.** `mosstank.xml` becomes 856×210 content (VTank's proportions;
our chrome adds its own title bar), nine tabs in VTank's order and widths,
each tab's content area 856×(210tab strip). The Advanced Options page and
the Loot Editor page stay in-panel (decision, KB 08 §5) but move to VTank's
own popup geometry as separate groups (392×300 and 268×300) opened from the
same checkboxes VTank uses (`cShowAdvanced`, `cShowLootEditor`).
S7.2 **Options / Profiles / Vitals** transcribed control-for-control from KB
08 §1 at VTank's `L,T,W,H` and captions (our labels/fields/toggles/buttons/
sliders; the nine Vitals sliders read 0100 with the "NN%" readout labels —
add optional `min`/`max` literal attributes to `<slider>` so the plugin
binds the percent directly, KB 08 §3 gap). Profiles: the four rows
(Macro Settings / Auto-Navigation / Looting / Meta) with their combo,
CopyTo/New, Clear, Mine-only and Show-Editor controls; the combos list the
real files from slice 1's stores. Add optional `scroll="true"` on `<menu>`
(one column, scrollbar) for the 27-entry recall list and long profile lists.
S7.3 **Monsters** — one 23-column list on `<column>`: 14 check (F B G I Y V
A R S WC FC Cp DC Cs, 16 px each with the header letters as `<label>`s
above and the tooltips), 7 text (Name 120, P 20, Dmg type 56, Ex. Vuln 56,
Weapon 80, Offhand 80, PetDmg 56) with VTank's per-cell clicks (Name click
deletes the monster; P cycles 1→0…4; Dmg type / Ex. Vuln / Weapon /
Offhand / PetDmg cycle their value lists exactly as `PluginCore.cs:7948-8089`),
2 icon (move up / move down, DAT ids `0x060028FC`/`0x060028FD`), plus the
Add / Add Sel row. Bound to the existing `CombatSettings.Rules` model
(`MyMonsters` parse is slice 3 / TS-86).
S7.4 **Items / Consumables / Buffs** — the five lists at VTank's geometry
(Items: name 320 + hands 80, click name deletes, click hands cycles;
Consumables: name list + the excluded-scarab list with the component icon
column; Buffs: extra buffs + blacklisted families; every "Add..." opens the
same in-panel picker shape as `SelfBuffChoiceView`, 268×236, search field
filtering a single-column list).
S7.5 **Route** — the waypoint list (text 330 + count 30, click deletes), the
nested bottom row (nav-type and insert-mode menus, the three image buttons
with DAT ids `0x060028FD`/`0x060028FC`/`0x060011F7` and their tooltips), and
the eight Add buttons with their fields (pause seconds default "5", chat
default "/ls", the 27-entry recall menu).
S7.6 **Meta** — the six-column rules grid (delete / up / down icons, State
150, Condition `*`, Action `*`; any text-cell click opens the rule editor
as today), Create, "Current State:" bold label and the state menu.
S7.7 **Gate script** `docs/research/2026-09-07-campaign-vt-slice7-test-script.md`:
one row per tab — what to open in VTank, what to open in acdream, what
must look the same (arrangement, density, captions, list shapes) and the
one interaction to try per tab (toggle a monster flag, cycle a priority,
add a waypoint, delete an item). Launch line and the profile-dir setup
(`ACDREAM_VTANK_PROFILE_DIR` on a COPY of the VirindiTank folder).
## Out of scope
Any behavior under the tabs (slices 26, 8); pixel-matching VVS
(padding constants, hit-test mechanics); the `MyMonsters` parse.
## Process
One Sonnet implementer per sub-slice group in one worktree (S7.1+S7.2, then
S7.3, then S7.4S7.6, then S7.7), each with build + `dotnet test
tests/AcDream.App.Tests --filter Markup` + `tests/AcDream.Plugins.MossTank.Tests`
green and the markup contract test updated; every new pin shown to fail;
UI-probe screenshots (`ACDREAM_UI_PROBE_SCRIPT`) of every tab attached to the
closeout so the owner can pre-screen before the live gate. Two Opus lenses
(architecture; resemblance against `mainView.xml` — the reviewer judges
arrangement/density/captions, NOT VVS mechanics), fix round, narrow
re-review, merge to the campaign branch, then the owner's visual gate.
## Ledger
- 2026-09-07 planned.
- 2026-09-07 S7.1/S7.2 implemented in worktree `agent-a46673911c3cc2a31`
(branch `worktree-agent-a46673911c3cc2a31`, base `7cfe57827`, current
`fbdbbc7f9`). Three commits: `e72a64a31` (`<slider min max>` +
`<menu scroll>` markup additions), `f6eebf8c5` (unrelated slice-1c
review fix, `TryLoadNav` refuses `STATE:`), `a7b132282` (the S7.1/S7.2
window + Options/Profiles/Vitals rework). 856x350 window (350, not
VTank's 210, only because the Advanced Options/Loot Editor popups
moved to VTank's own 392x300/268x300 geometry as separate groups);
Options/Profiles/Vitals transcribed control-for-control from KB 08 §1.
Full suite green (645/645 MossTank, 183/183 App markup/menu/slider,
3 pre-existing skips). S7.3-S7.6 (Monsters/Items/Consumables/Buffs/
Route/Meta) and S7.7 (gate script) are NOT done — out of this
sub-slice's scope, group width widened only so they aren't clipped.
Screenshots NOT captured — ACE (127.0.0.1:9000) was not listening at
implementation time, so the live-client UI-probe recipe was skipped
per the owning instruction rather than looping on a stuck connect.
Owner's visual gate against a running VTank is still owed.
- 2026-09-07 S7.3 implemented in worktree `agent-a46673911c3cc2a31`
(branch `worktree-agent-a46673911c3cc2a31`, on top of `89a49836c`).
One commit `57ced0aff`: the Monsters tab body is now VTank's own
23-column HudList grid (14 check + 7 text + 2 icon columns, 15 header
`<label>`s carrying the decompile's own tooltip text) transcribed
from `docs/research/vtank-kb/08-ui-views.md` §1 and
`PluginCore.cs:7885-8105`'s per-cell handler, replacing the old
select-then-edit adaptation entirely. Deviation: Weapon/Offhand
columns cycle the registered weapon roster (Items tab) rather than
VTank's own opaque weapon-TYPE id list, since MossTank models
equipment as concrete owned items, not types — see the commit body
for the full reasoning. Every new/changed pin shown to fail against a
targeted mutation first. Full suite green (651/651 MossTank — net +6
over S7.1/S7.2's 645, 109/109 App markup-filtered). S7.4-S7.6 (Items/
Consumables/Buffs/Route/Meta) and S7.7 (gate script) remain out of
this sub-slice's scope. Screenshots NOT captured (same ACE-down
reason as S7.1/S7.2). Owner's visual gate against a running VTank is
still owed for both S7.1/S7.2 and S7.3.
- 2026-09-07 07:55 live screenshots of `89a49836c` captured (gate worktree, ACE up — the earlier "ACE down" was a TCP probe against a UDP server) → `docs/research/2026-09-07-slice7-screenshots/` (cropped to the panel, 864×360). Lead's read for the review + fix round: (1) Vitals reads like VTank; (2) Options has the right arrangement but VTank's literal `L,T,W,H` do not fit our DAT font — captions overlap the next row ("Follow/Nav Min Distance" over "Enable Navigation"): scale VTank's geometry to our font (row pitch from the DAT font's line height instead of VVS's 16 px; widths likewise), keep the proportions, never pixel-match; (3) Profiles still shows legacy controls (three stacked "New" buttons, the Loot engine row, the macro-profile path string drawn twice) — delete them; (4) the window is 856×350 with ~180 px of dead space below the content: bring the main panel back to VTank's 210-tall proportion (scaled) and make Advanced Options / Loot Editor their own plugin panels (separate windows, like VTank's popups) instead of in-panel groups; (5) the Route, Meta and Loot-editor pages show blank trailing button slots.
- 2026-09-07 08:05 owner looked at the live gate build: (a) "The Options tab labels overlap, fix that"; (b) "Those BIG gold/yellow buttons HAS to go. That is not how vtank looks." — the plugin `<menu>` renders retail's gold pushbutton art; VTank's combos are plain (flat dark box, thin border, value text, small arrow at right — the same look as its lists). Fix round: plugin-markup `<menu>` draws the plain combo by default (a `style="retail"` opt-in keeps the gold art for anyone who wants it), plus the geometry scaling, the Profiles leftovers, the 210-tall main window and the two popups as separate panels.
- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651) — same commits as the entry above; recorded again here because the plain-menu-style branch (`cfa703065`) merged into the panel worktree afterward to pick up fix (b) before fix round A started. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4S7.6 follow.
- 2026-09-07 fix round A landed on the panel worktree, four commits: `045cd0a19` (merge `claude/latest-main-sync-497549`, bringing the plain-`<menu>`-style fix (b) in — resolved the ledger/markup/test conflicts by keeping both sides' content), `565a33d78` (grid scaling + Profiles cleanup + popups split into their own panels), `e414b2f56` (AcDream.App.csproj's CopyMossTankPlugin* targets hardcoded mosstank.xml as the only file to copy into `plugins/AcDream.Plugins.MossTank/` — the two new popup markup files silently landed in the App's own bin root instead and would have thrown `FileNotFoundException` on load; caught before any screenshot by inspecting the build output layout, not by a test), `78b42a519` (StartVisible=true fix for both popups — `StartVisible=false` left `PluginWindowVisibilityController`'s "requested visible" axis permanently false with no shelf entry to ever call `OnShown()`, so neither popup ever rendered despite a checked/green checkbox; plus dropped each popup's now-redundant in-content title label, and repositioned both away from the overlapping (440,60) placeholder). Real DAT-font measurements (`AcDream.Cli dump-font-atlas` against the installed DAT: font 0x40000000 MaxCharHeight=16, matching VVS's own assumed row height exactly) replaced the "sy row-pitch" theory in the 07:55 lead's read — the actual fix is a translation of the columns after each overflowing caption (Options +62px, Profiles +16px), not a font-driven vertical scale. Fresh screenshots recaptured end-to-end against a live local ACE with an isolated `ACDREAM_CONFIG_DIR` (stale persisted popup window positions from earlier probe runs would otherwise have overridden the new authored defaults forever — `RetailWindowLayoutPersistence` has no revision bump wired for plugin windows). All six requested screenshots (Options/Profiles/Vitals/Monsters/both popups) confirm: no overlap, no gold buttons, no stacked New/Loot-engine/path-string leftovers, both popups open as genuinely separate windows with clean titles, and the Route/Meta/Loot-editor move-up/move-down slots render real DAT icons instead of blank buttons. MossTank suite 651 → 654 (three new pins: the two-file `SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves` theory cases + `NoButtonAnywhereUsesTheUnrenderableArrowGlyphs`); App markup/plugin/menu filter holds 237/237. Deviation carried forward: Macro/Nav CopyTo lost their only in-UI target-name entry (the deleted 3-row block was their sole source; Meta already has one on its own tab) — matches VTank's own Profiles table having no name-draft control at all, but is a real, accepted capability regression pending a future naming-UX slice. Owner's connected visual gate is the next step.
- 2026-09-07 S7.4S7.6 implemented on the panel worktree (base `66b070def`), three commits: `f5409530f` (S7.4 — Items' 2-column name/hands grid, Consumables' "Excluded Scarab Types" icon+text grid and "Add Selected" button, Buffs' Extra Buff Spells / Blacklisted Buff Families lists plus a shared `mosstank-buffpicker.xml` SelfBuffChoiceView-style picker popup registered the same way fix round A's two popups are), `6118062a7` (S7.5 — Route's clWP/clWPc 2-column waypoint grid, the "Follow" nav-mode display remap, `scroll="true"` on the recall menu, and a third nav image button for "Select Nearest Point"), `cc323f6a5` (S7.6 — Meta's 6-column lstMetaRules grid: delete/move-up/move-down cells plus State/Condition/Action text cells opening the existing rule editor). Deviations documented at their own binding site: Items' Hands column is session-local only (no backing wieldable-handedness data anywhere in the plugin surface); Consumables' "Add Selected" accepts any selected owned item rather than requiring VTank's own SpellComponent object-class check (no classifier surface exists for plugins); ExtraBuffSpellNames/BlacklistedBuffFamilyNames (BuffPlan.cs) add storage + UI only, not wired into `BuffPlan.Build`'s cast selection (real casting-algorithm behavior, owned by a future Campaign VT behavior slice); Route's recall menu keeps its real 4 kinds rather than VTank's 27 named recalls (needs real per-recall spell-id data); Route's "Select Nearest Point" moves the tab's own edit selection rather than VTank's live navigation cursor (no mutable cursor exposed to a plugin); Meta's delete cell is a text "X" rather than an icon (no retail DAT delete-glyph id confirmed anywhere in this codebase, unlike the established move-up/move-down `0x060028FC`/`0x060028FD` pair). Every new/changed pin (contract control count 167→177→180→186, the new `mosstank-buffpicker.xml` popup pin, six new `MossTankPanelTests` interaction tests) was shown to fail against a targeted mutation before being confirmed green. MossTank suite 654 → 660; App markup/plugin filter holds 192/192; full solution builds clean in Release. Fresh live screenshots recaptured against the same local ACE recipe fix round A established (isolated `ACDREAM_CONFIG_DIR`/`ACDREAM_DATA_DIR`, an `ACDREAM_UI_PROBE_SCRIPT` route through the five changed tabs plus the new buff picker popup) → `docs/research/2026-09-07-slice7-screenshots/` (`tab-items.png`, `tab-consumables.png`, `tab-buffs.png`, `tab-route.png`, `tab-meta.png` recaptured at 900×300; `popup-buffpicker.png` added at 940×715). All six confirm: plain (non-gold) controls throughout, no overlapping captions, the two new Consumables/Buffs grids and the buff picker popup render correctly, and the Route/Meta move icons render real DAT art. No crashes or ungraceful exits across the probe runs. S7.7 (the gate script) and the owner's connected visual gate remain.
- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651): the 23-column grid with VTank's exact cycle lists (P 1…4; Dmg type 14 values; Ex. Vuln 9; PetDmg 10; name click deletes; arrows reorder with DEFAULT pinned). Implementer deviations for the review: Weapon/Offhand cycle MossTank's registered item roster instead of VTank's opaque weapon-type ids (MossTank models concrete owned items); the move-up/down DEFAULT guard is symmetric. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4S7.6 follow.
- 2026-09-07 10:10 fix round A landed on the panel worktree (`045cd0a19` merge of the plain menu, `565a33d78` column shifts + Profiles cleanup + 236-tall window + popup panel files, `e414b2f56` csproj plugin-copy fix, `78b42a519` popups actually render (`StartVisible` gotcha) + fresh screenshots, `66b070def` ledger; MossTank suite 651 → 654). Owner's two complaints verified fixed on the new screenshots. Deviation for the review: Macro/Nav CopyTo lost their in-UI target-name field with the deleted block (VTank has none either). S7.4S7.6 dispatched on the same worktree.
- 2026-09-07 10:20 owner, live: "Drop down menus look horrible, there is also a checkmark on the text there." — the OPEN popup still draws retail art (tan gradient panel, ornate gold scrollbar, checkmark on the selected row). Plain open state (dark list rows, selected fill, plain scrollbar, no checkmark) dispatched on the plain-menu worktree; merges to the campaign branch, then into the panel worktree at fix round B.
- 2026-09-07 11:20 S7.4S7.6 landed on the panel worktree (`f5409530f` Items/Consumables/Buffs + buff picker popup, `6118062a7` Route grid, `cc323f6a5` Meta grid, `41fc1d88d` screenshots; MossTank suite 654 → 660); the plain popup merged in at `dbdde0783`. Both Opus lenses dispatched on the whole slice. Lead's read of the new Route and Meta screenshots for fix round B: VTank's Route tab is the waypoint list + one small row under it (nav-type menu, insert-mode menu, three icon buttons) + the right-hand Add grid — ours still carries MossTank-only controls on the tab (Checkpoint / Jump / Remove / Set Follow Target / Follow target / Nav Priority / Follow Corners / Open Doors / Use NPC / Follow-Nav Min Distance spinners; "Add to End" rendered as a button instead of the insert-mode menu) that VTank does not show there — move them out (Advanced Options popup, or drop what VTank has no equivalent for). VTank's Meta tab is the six-column grid + Create + "Current State:" menu — ours adds a profile row (By char / New / CopyTo / Clear / Delete / Enable Meta / State) that belongs on Profiles, and an INLINE rule editor (state, condition/action menus, three fields, N/N2 spinners, Apply/Add/Remove, arrows) where VTank opens a separate rule-editor view on click — make it a popup panel like the buff picker. Implementer deviations to judge: Hands column session-local; extra/blacklisted buff names stored but not consumed by the plan; recall menu 4 kinds vs VTank's 27; "Select Nearest Point" moves the edit selection; Meta delete cell is a text "X".
- 2026-09-07 12:00 both Opus lenses on `dbdde0783`. Resemblance: APPROVE-WITH-FIXES — Options, Vitals and the buff picker read as VTank; Monsters one header fix away; Buffs (VTank's two lists shrunk for a MossTank block, a new "+"/"Add…" overlap) and Meta (grid buried under a toolbar and an inline editor; "Current State:" not settable) do not read as VTank; clipped status lines on three tabs + the Loot Editor; "CopyTo" overflowing its button; Route's grid re-flowed 3-across with "Add NPC Talk" renamed and the nav icons moved; the gold slider track; the 4-vs-27 recall menu is a data hole (slice 6). Owner complaints 1 and 2 closed on the screenshots; 3 closed in code but needs a dropdown-open re-capture. Architecture: APPROVE-WITH-FIXES — one RED App test at HEAD (the `<menu scroll>` sprite pin invalidated by the plain popup merge; the ledger's 192/192 was stale), `ExcludedComponentIcons` captures the whole inventory per row per frame, derived columns allocate per frame, the markup file list hardcoded in four places, the buff lists look live but are neither consumed nor persisted, two host defects found live (filed as #490), the `MarkupDocument.Build`-over-real-files seam untested. Lead decisions for round B: VTank's own controls never shrunk/moved for MossTank extras (extras → Advanced Options popup or dropped); plain sliders; no grid selection band; the Meta rule editor as its own popup; the four Deletes stay, aligned. #491 filed for the buff-list wiring (slice 4). Fix round B (18 items) dispatched.
- 2026-09-07 14:50 fix round B landed 16/18 on the panel worktree (`040d5f3d8``8d3c6ad7c`; MossTank suite → 676, App markup/plugin filter 285/3 skipped); the agent was stopped after three hours failing the screenshot step (malformed session config, disk-wide search) — nothing lost, tree clean. Item 16 was the dedupe, tab-switch popup cleanup, and slider validation. Architecture narrow re-check at `8d3c6ad7c`: NOT merge-ready — D1 the Advanced Options popup re-materializes the 163-entry catalog per frame (the class item 12 killed elsewhere), D2 `/mt refresh` mutates Rules without `RefreshMonsterEditor()`, D3 the bottom-band pin skips unsized children; carried nits (hands column per frame, popup-size theory hardcoded, #491 not cited, mutable bool[]). Fix round C dispatched. Screenshots: replaced by the owner testing LIVE from the gate build at `8d3c6ad7c` (all five plugin windows load); the owner's findings feed round C/D; the resemblance re-check runs on the final state.
- 2026-09-07 15:20 OWNER LIVE LOOK at `8d3c6ad7c` (gate build): "Looks mostly fine, a lot of polish can be done though. But we do that later." Findings: (1) Advanced Options' category filter shows the raw bitmask values (0x1, 0x2, …) with lamps instead of the category NAMES (Misc, Recharge, MeleeCombat, SpellCombat, Ranges, Navigation, Buffing, Crafting, Looting); (2) Advanced Options must follow VTank's model (owner's screenshots 2/3): name + value columns, clicking a bool/enum VALUE flips it in place, only NUMBERS open the edit field at the bottom (Enter applies) with the description box under it, no Apply/Back buttons; (3) scrollable dropdowns and the popup windows use the SAME scrollbar/window assets as the chat and inventory windows (not the flat scrollbar); (4) the whole window: larger default size AND resizable (window-manager resize like chat, lists stretching); (5) Route's Add Recall is missing most of VTank's recalls and recalls do not work in routes yet. Dispatched now (App side, own worktrees): retail scrollbar assets for plain popups/lists; resizable plugin panels + `anchor` markup. Queued on the panel worktree after fix round C: Advanced Options model + category names; the full VTank recall table with spell ids (metaf `NRecall`, py:10981-11008 — 26 spells: Primary Portal Recall 48, Secondary 2647, Lifestone 1635, Lifestone Sending 1636, Portal Recall 2645, Aphus 2931, Sanctuary 2023, Singularity Caul 2943, Glenden Wood 3865, Aerlinthe 2041, Mount Lethe 2813, Ulgrim's 2941, Bur 4084, PtOIA 4198, Mhoire Forge 4128, Colosseum 4213, Facility Hub 5175, Gear Knight Camp 5330, Neftet 5541, Return to the Keep 4214, Rynthid 6150, Viridian Rise 6321, Viridian Rise Great Tree 6322, Celestial Hand 6325, Radiant Blood 6327, Eldrytch Web 6326) wired to the existing recall execution; adopt anchors + the larger default.
- 2026-09-07 16:10 App-side pieces merged into the campaign branch: `b71a8ea37` retail scrollbar chrome on plain `<menu>` popups and overflowing `<list>`s (chat/inventory skin ids `0x06004C5F`/`6C`/`69`/`60`/`63`/`66`; markup menus are one-column scrollable), `2e63391cc` `<panel resizable minw minh>` + `anchor="left top right bottom"` on every element (window-manager resize + persistence already generic). App markup/menu/list/window/anchor filter 294/1 skipped. Panel round D (after fix round C on the panel worktree): merge the campaign branch in, Advanced Options model + category names, VTank's 26-recall table wired, `resizable="true"` + anchors on the main panel with a larger default.
- 2026-09-07 16:30 fix round C (architecture re-check of fix round B `8d3c6ad7c`) landed on the panel worktree, seven commits: `466fac426` (D1 — Advanced Options popup's four bindings, previously re-filtering the 163-entry VtankOptionCatalog and re-running GetMetaOption/ToDisplayString per row on every draw, materialized once by a new RefreshAdvancedOptions() called from category toggle/edit-apply/selection-change/profile-load), `23d4376cc` (D4 — AdvancedOptionCategoryEnabled no longer hands out the mutable backing bool[] directly; a ReadOnlyCollection view built once over the same array), `f15667db5` (D2 — `/mt refresh` now calls RefreshMonsterEditor after EnsureDefaultMonsterRule, the one gap found auditing every `_combatSettings.Rules` mutation site in the plugin), `3b8d02194` (D3 — AssertWithinParent's bottom-band check now counts each widget kind's own implicit default height when `h` is absent, label/field 16 toggle 20 button 16, instead of reading an absent `h` as zero), `37055bddc` (F7+F12 — ItemHandsColumn materialized once in RefreshItemEditors from the SAME base-name array that builds the decorated `_itemRows`, replacing a per-frame `.Select().ToArray()` plus a suffix-reparsing BaseItemName helper with one forward-only decoration; CycleItemHandsAtCore now refreshes the cache it mutates), `0724761ad` (F3 — SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves discovers popup files with the same glob the sibling-overlap and arrow-glyph tests already use instead of a hand-maintained InlineData list), `d20ad47c5` (F4 — the ExtraBuffSpellNames/BlacklistedBuffFamilyNames field comments and their two Buffs-tab tooltips now cite issue #491). D3's grep audit of every real `mosstank*.xml` file found no label/toggle/field/button missing `h`, so the stricter bottom-band check introduced zero new failures against shipped markup. Every new/changed pin was shown to fail against a targeted mutation (property/getter reverted, or the assertion target broken) before the fix was restored; D1's own fix broke one PRE-EXISTING test (`AdvancedOptionValueColumnMirrorsTheLiveSettingValue`, which expected a setting toggled OUTSIDE the popup's own mutators to appear on the very next read) — updated it to exercise the "selection change" mutator afterward, with a comment explaining the narrowed contract. MossTank suite 677 (D1) → 678 (D4) → 679 (D2) → 680 (D3) → 681 (F7+F12) → 678 (F3, four InlineData cases collapsed into one Fact, no coverage loss) → 678 (F4, comment/tooltip only) → 678 (F13, no new test, an assertion removed). Full solution build green after every commit; App markup/plugin filter held 203/203 throughout.
- 2026-09-07 fix round C item F13 folded the `EveryInteractiveControlDeclaresARealHandlerBinding` test's exact-control-count history here, in the same commit that removed it from the test file: Round 3 item 10 added +3 for the Route/Loot/Meta Delete buttons (Settings already had one from round 2 step 5). Campaign VT slice 7 S7.1/S7.2 transcribed Options/Profiles/Vitals control-for-control from VTank's own tabs (docs/research/vtank-kb/08-ui-views.md §1) and repositioned the Advanced Options/Loot Editor popups to VTank's exact 392x300/268x300 geometry — net 194 → 205. The Options tab gained four direct checkboxes for settings that already existed but were only reachable through the generic Advanced Options key-value editor (Enable Autostack, Fastcast Buffs, Don't Shoot at Walls, Fallback Debuffs if Blocked); the Profiles tab gained a real Meta-profile combo/CopyTo row (previously a stub label) and per-type name fields/New buttons, and lost the Loot Priority Boost toggle as a duplicate of the Options-tab control of the same name; the Loot Editor popup lost LootEditorNotice (a status label, not an action). S7.3 replaced the whole Monsters tab body with VTank's own 23-column grid: the select-then-edit surface (1 list + 1 field + 8 buttons + 3 menus + 14 toggles + 3 equip buttons = 30 controls) is gone, replaced by 1 list + 23 columns + 1 field + 2 buttons (27) — net 205 → 202. Fix round A (2026-09-07) moved the Advanced Options and Loot Editor groups OUT of mosstank.xml entirely into their own plugin panels and deleted the Profiles tab's three stacked name-draft field+"New"-button rows and the acdream-only "Loot engine" classifier menu — net 202 → 167. S7.4 replaced the Items tab's single-column list with VTank's own clWeaponName/clHandedness 2-column grid (+2), added the Consumables tab's "Excluded Scarab Types" 2-column grid plus its "Add Selected" button (+4), and added the Buffs tab's Extra Buff Spells / Blacklisted Buff Families lists plus their two "Add..." buttons (+4) — net 167 → 177. S7.5 replaced the Route tab's single-column waypoint list with VTank's own clWP/clWPc 2-column grid (+2) and added the "Select Nearest Point" button (+1) — net 177 → 180. S7.6 replaced the Meta tab's single-column rules list with VTank's own 6-column lstMetaRules grid (+6) — net 180 → 186. Fix round B item 5: VTank's real Meta tab is only 5 controls (the grid, "Create", a caption, and the settable current-state menu). Removed entirely: the profile toolbar (menu/name-draft/New/CopyTo/Clear/Delete, 6) and "Enable Meta" (1) — 7 controls gone for good. Moved into mosstank-metaeditor.xml (a separate file this test doesn't scan): the State/Condition/Action editor (2 fields + 2 menus + 1 field for state + 1 secondary-text field + 4 numeric-stepper buttons + Apply/Remove/MoveUp/MoveDown, 14). Added: the settable cmbMetaCurrentState menu (+1). Net 186 → 166 (-7 -14 +1). Fix round B item 8: the Route tab collapses to VTank's own 2-across button grid + bottom nav-control row, 28 → 18 interactive controls within mosstank.xml. Checkpoint/Jump/Remove/Set Follow Target/Follow Corners/Open Doors/Nav Priority/the Follow-Nav-Min-Distance stepper moved to mosstank-advanced.xml's own new "MossTank Extras" section. The 2-state ToggleRouteAddPosition button became the real 3-option cmbNavInsertMode `<menu>`, and the pause "-"/"+" stepper became one editable field. Net 166 → 156 (the count the test asserted immediately before this fix round removed the assertion). The test's own per-control handler/enabled loop remains the live guarantee; a bare `Assert.NotEmpty(controls)` guards the selector itself. Round D dispatched on the same worktree (merges the campaign branch first): category names + VTank's Advanced Options interaction model, the 26-recall table wired to route execution, resizable main panel at a ~15% larger default with anchored lists.
- 2026-09-07 round D landed on the panel worktree (four commits, this session, base `6b42fd68e`): merged `claude/latest-main-sync-497549` first (`4ba0a557f`, bringing in `b71a8ea37` retail scrollbar chrome and `2e63391cc` resizable/anchor markup — resolved the ledger/plugin-ui-markup/UiMarkupList/MarkupDocument/MarkupDocumentTests conflicts keeping both sides' intent, one Advanced Options popup design collision found and resolved in the merge itself: the sync branch's always-scrollable `<menu>` model won over this branch's opt-in `scroll=` attribute, since it is the owner-driven shipped design). Item 1 (`ebe670adf`): Advanced Options' category filter shows VTank's real names (Misc/Recharge/MeleeCombat/SpellCombat/Ranges/Navigation/Buffing/Crafting/Looting) instead of raw bitmask hex, derived from real single-category KB rows rather than a typed-in literal. Item 2 (`435ced86f`): VTank's click model — a `tBool` value flips in place, a `tEnum` value cycles to the next label (VtankDefaultSettingsDatabase.SettingEnumValues, parsed from the embedded .usd's own SettingsEnumInfo table), everything else selects into the edit field; Apply/Back buttons removed (retail has neither); panel height 476->450. Item 3 (`5318adbb3`): RouteRecallKind replaced with VTank's real 26-recall cmbRecallType table (metaf's NRecall) plus Marketplace kept as its own slash-command entry (27 total) — deviation: the old Lifestone slash-command member is dropped rather than duplicating the new spell-based LifestoneRecall's "Lifestone Recall" label; Primary/SecondaryPortal upgraded from runtime spell-name lookup to their real hardcoded ids (48/2647); AddRouteRecallCore now writes RecallSpellId+RecallSpellName onto the waypoint so a route-tab-added recall casts exactly like a metaf-imported one. Item 4 (`2d626971d`): main panel `resizable="true"` floored at the pre-round-D authored size (856x236 minw/minh), default enlarged ~15% to 984x271; all nine tab groups anchor="left top right bottom" and grow by the same 128x35 delta; Monsters/Meta's lists get full left/right/top/bottom stretch (nothing beside them); Items/Consumables/Buffs/Route's lists grow height-only, pinned away from a sibling list/button column; a right-pinned sibling list (Consumables' Excluded Scarab, Buffs' Blacklisted Buff Families) repositions +128 so it already sits flush-right at the enlarged default. Every new/changed pin shown to fail against a targeted mutation first (category-name projection reverted, ClickAdvancedOptionValue reduced to a bare select, SubmitRecall reduced to `return false`, enum-order swap, Monsters' anchor attribute removed) and restored green. MossTank suite 678 -> 682 (item 2) -> 713 (item 3) -> 715 (item 4); App markup/plugin filter 242 -> 243 (item 4's real-file re-layout test); full solution builds clean in Release throughout. Owner's connected visual gate against these five fixes remains the next step; #491 (buff-list wiring) and the pre-cutover-JSON RouteRecallKind-ordinal migration risk (documented at its own binding site in MossTankRouteProfileStore.cs) are the carried, accepted deviations from this round. Final narrow re-checks (architecture over C+D; resemblance over the final markup) dispatched; the gate build at `2b79ca325` launched for the owner's look.
- 2026-09-07 18:20 architecture re-check of rounds C+D at `2b79ca325`: round C fully closed; round D sound but NOT merge-ready on two items — D-1 pre-cutover JSON routes silently remap recalls (old ordinals 0..3 now name different kinds; the `Enum.IsDefined` guard never fires), D-2 the recall combo shows C# identifiers instead of the metaf-exact names; follow-ups D-3 (Advanced Options popup claims resizable but is not), D-4 (unknown `.af` recall name falls back to Primary Portal Recall), D-5 (values stale on popup open), D-6 (no resolved-geometry pin at min/enlarged sizes). Category names, the enum table and the click model verified data-driven and single-path; zero App-side production change in round D. Round E dispatched with all six.
- 2026-09-07 18:40 resemblance re-check at `2b79ca325`: all 13 earlier items CLOSED; owner asks 13 CLOSED, 45 PARTIAL. NOT ready for the gate on: (1) the enlarged default never reaches a machine with a stored layout — plugin windows register with `authoredGeometryRevision = 0`, so the saved 856×236 always wins (App-side fix dispatched on its own worktree: derive the revision from the authored geometry; #490 part 2); (2) the recall combo's full spell names overflow the 120 px box on most of the 27 entries — VTank's `cmbRecallType` uses terse captions ("Primary", "LS Sending", "PtOIA", "Fac. Hub"…): show those, keep the full name in the waypoint and `.af`; (3) Monsters' move-down arrow drifted ~67 px from move-up because the last (auto) column absorbed the new width — add a trailing filler column; (4) Advanced Options popup not resizable (round E D-3). Nits for the same round: single-column `items=` lists still draw the selection band while grids do not; Buffs' 136 px middle gap and the orphaned Coverage label; the Loot Editor's leftover Back button; Route's count column squeezing to 17 px when the list scrolls. Six of nine tabs keep dead margin at 984×271 (Route 366 px right, Profiles 244) — accepted for now under "polish later", noted for the owner. Round F queued after round E.
- 2026-09-07 19:10 App-side merges: `7b7e5e579` plugin `<list>` draws no selection band by default (`selectionband="true"` opts in); `3d7065ebc` #490 part 2 fixed — plugin windows carry a geometry-hash `authoredGeometryRevision`, so a changed authored size replaces a stored size once (position kept, clamped); App window/markup/list filter 254/254. Round F (after round E on the panel worktree): merge the campaign branch in; VTank's terse `cmbRecallType` captions in the recall combo (full spell name stays in the waypoint and `.af`); Monsters trailing filler column so the arrows stay adjacent; Buffs middle gap + Coverage label; Loot Editor's leftover Back; Route count column min width. Then one combined final re-check, merge, gate build.
- 2026-09-07 18:15 OWNER LIVE LOOK at `2b79ca325`: (1) Advanced Options — the whole bottom block ("Editing X." notice + the MossTank Extras section with the Route extras) must go; (2) Advanced Options — the list scrollbar shows but clicks/drags work only sometimes (App-side hit-testing bug; dispatched); (3) `BuffProfile-Prots`/`-Banes` appear "duplicated" beside `BuffProfile_Prots`/`_Banes` and only the enum ones respond — the hyphen ones are `tString`, which VTank's Advanced Options never lists (KB 01, `db.cs:132-166`): hide `tString` settings from the list; (4) Items tab — remove the MossTank block right of the list (Weapon/Offhand readout, Refill Worn Mana slider, status, hint); (5) Meta "Create" should open a VTank-style rule editor — deferred by the owner, filed as #493 (with the loot-rule editor). Items 1, 3, 4 join round F on the panel worktree.
- 2026-09-07 19:40 round E landed on the panel worktree (`3178d9202` unresolved recall refused, `a9d1d0a82` legacy JSON recall ordinals mapped explicitly, `3fe304359` combo shows VTank names, `64b62fea3` Advanced Options popup really resizable, `1a0b0e014` refresh on open, `7badbee88` resolved-geometry pin at 856×236 and 1100×320; MossTank suite 715 → 722, App filter 243 → 246). Scrollbar snap-back fixed App-side and merged (`b4fe18007`: the list re-centred on its selected row every frame). Round F dispatched (merges the campaign branch; the owner's second-look items 1/3/4 + VTank's terse recall captions at 120 wide + Monsters filler column + Buffs/Loot Editor/Route nits). After F: one combined final re-check, merge, gate build.
- 2026-09-07 20:50 round F landed on the panel worktree (`279de7c2d` merge of the campaign branch; `ad3f4df57` Advanced Options bottom block gone, popup 392×300 resizable; `9e23f0acb` `tString` hidden; `179e6f339` Items tab VTank-only (`RefillWornMana*` stay catalog settings); `6ef6eb751` recall combo 120 wide with VTank's 27 terse captions; `bae34aaa8` Monsters filler column; `837610593` Buffs strip / Loot Editor Back removed / Route count column 30 px; `13a304a92` ledger; MossTank suite 722 → 726, App filter 258 → 262). Deviation: the Buffs extras strip is centred at the 856 floor, not at the 984 default (a fixed strip can be centred at one width only; accepted under "polish later"). Final combined re-check (both lenses, E+F) dispatched; then merge + gate build.
- 2026-09-07 round E (architecture re-check of round D) landed on the same worktree, six commits, base `2b79ca325`: `3178d9202` (D-4 — an .af "rcl" node whose spell name fails to resolve leaves RecallSpellId at 0 and Recall at its default ordinal, PrimaryPortalRecall; SubmitRecall's own Recall-based fallback used to cast spell 48 for it regardless, since MetafSerializer.ReadNavNode never touches the enum on that path — TickRecall now refuses and skips instead, naming the unresolved spell in the status notice; the dead fallback branch is removed). `a9d1d0a82` (D-1 — the pre-cutover RouteRecallKind was {Lifestone=0, Marketplace=1, PrimaryPortal=2, SecondaryPortal=3}, completely different kinds at the SAME ordinals as the round-D enum, and Enum.IsDefined never caught the silent remap since 0..3 are all still valid new members; LegacyWaypointDocument.Recall is now a bare int translated through an explicit MapLegacyRecall table that also derives RecallSpellId/RecallSpellName). `3fe304359` (D-2 — RouteRecallNames/SelectedRouteRecall showed Enum.GetNames/ToString()'s bare C# identifiers; both now route through RouteWaypoint.RecallDisplayName like RouteInsertModeNames/RouteModeNames already do, SelectRouteRecall parses the same display string back, and the combo widened 120->300 to fit VTank's own full metaf-sourced names, e.g. "Paradox-touched Olthoi Infested Area Recall" — VTank's real combo is also 120 wide only because ITS captions are hand-abbreviated). `64b62fea3` (D-3 — mosstank.xml's own top comment already claimed the Advanced Options popup got resizable+anchored treatment; the popup file itself still carried the dead resize="none", never actually resizable; now resizable="true" minw/minh floored at the pre-fix authored 392x450, lOptionList grows WIDTH ONLY since the value field/description/notice sit directly below with zero vertical slack and none of them anchor to shift down, lFilterList tracks the growing right edge in lockstep so the widening list never walks into it; AdvancedOptionDescription is declared anchor="left top right" for consistency but documented as a real no-op — UiLabel.OnDraw always overwrites Width/Height to the measured text extent every draw, so nothing a label's anchor computes is ever visible). `1a0b0e014` (D-5 — RefreshAdvancedOptions' own doc comment lists every mutation site that must call it, but ShowAdvancedOptions itself was missing one, so a setting changed via /vt opt set or an Options-tab checkbox while the popup was closed stayed stale until some in-popup interaction refreshed it; ShowAdvancedOptions now calls RefreshAdvancedOptions). `7badbee88` (D-6 — a new whole-tree resolved-geometry pin at the main panel's minw/minh floor (856x236) and one enlarged size (1100x320), adapting AssertNoSiblingOverlap/AssertWithinParent's rectangle-overlap algorithm from the authored XElement tree to the resolved UiElement tree for each of the nine tabs in turn; building the test surfaced and fixed two real test-methodology bugs along the way — reusing one built tree across tabs let an earlier tab's resize corrupt a later tab's anchor-capture baseline, and walking into an invisible tab group's never-anchored-for-this-size descendants misreported stale geometry as broken — plus one genuine test-harness false positive (UiLabel's font-dependent measured box, excluded from the size checks for the same reason D-3's own commit documents). Every new/changed test shown to fail first via a targeted revert-then-reapply (git checkout + saved patch) or a deliberate markup mutation (D-6's Monsters-anchor removal), each restored green afterward. MossTank suite 715 (pre-round-E baseline, matching round D's close) -> 716 (D-4) -> 720 (D-1) -> 721 (D-2) -> 721 (D-3, App-side only) -> 722 (D-5); App markup/plugin filter 243 -> 243 (D-2 widened menu, no new pin) -> 244 (D-3) -> 246 (D-6, two theory cases). Full solution builds clean in Release throughout. Carried, accepted deviations unchanged from round D: #491 (buff-list wiring) and (now closed by D-1) the former pre-cutover-JSON RouteRecallKind-ordinal migration risk. Scrollbar snap-back fixed App-side and merged (`b4fe18007`: the list re-centred on its selected row every frame). Round F dispatched (merges the campaign branch; the owner's second-look items 1/3/4 + VTank's terse recall captions at 120 wide + Monsters filler column + Buffs/Loot Editor/Route nits). After F: one combined final re-check, merge, gate build.
- 2026-09-07 round F landed on the panel worktree, six commits, base `279de7c2d` (merge of `claude/latest-main-sync-497549``7b7e5e579` no selection band by default on plugin lists, `3d7065ebc` geometry-hash window revision, `b4fe18007` scrollbar snap-back fix; resolved the UiMarkupList/plugin-ui-markup/ledger conflicts keeping both sides' intent, including retiring the now-dead opt-in `<menu scroll="true">` doc section in favor of "menus always scroll"). Item 1 (`ad3f4df57`): the whole Advanced Options bottom block — the "Editing X." notice and the "MossTank Extras" section (Checkpoint/Jump/Remove/Set Follow Target/Follow Corners/Open Doors/Nav Priority/Follow-Nav min distance) — is gone; popup back to VTank's 392x300. Every removed control had a real path already: Checkpoint/Jump via `/mt addnavcheckpoint`/`/mt addnavjump`, Remove via the Route tab's own click-to-delete grid (DeleteRouteWaypointAt — identical logic to the removed RemoveRouteWaypointCore), Set Follow Target via re-selecting "Follow" in the Route tab's own nav-mode menu (already calls CaptureFollowTarget), and the four toggles/stepper via their own real VtankOptionCatalog entries (FollowAroundCorners/OpenDoors/NavPriorityBoost/NavCloseStopRange), still listed in THIS SAME shrunk popup. Item 2 (`9e23f0acb`): `FilteredAdvancedOptionNames` now excludes every `tString` row (BuffProfile-Prots/-Banes, BlacklistedSpellComps) — VTank's own Advanced Options list has no case for tString at all (KB 01, db.cs:132-166/:201-203) — closing the owner's "duplicated" BuffProfile report; the enum counterparts stay listed. Item 3 (`179e6f339`): Items tab's right-of-list MossTank block (Weapon/Offhand readout, Refill Worn Mana toggle/slider/status, the notice hint) is gone; RefillWornMana/RefillWornMana-Item-ManaPercent remain real catalog settings, editable in Advanced Options. The pre-campaign "Remove" button (traced to the plugin's very first commit, `4e6e9bc9d`) stays as the accepted slice-1 exception. Item 4 (`6ef6eb751`): the recall combo shows VTank's own terse cmbRecallType captions (RecallShortCaption, transcribed from the KB's mainView.xml citation since this worktree has no refs/vtank/ checkout) instead of round E's full spell names, back at VTank's real 120px width; the full name still lands on the waypoint/grid row/.af via RecallDisplayName, untouched. Item 5 (`bae34aaa8`): a 24th, genuinely-handled trailing filler column after Monsters' MoveDownIcons so it is no longer the grid's LAST column (docs/plugin-ui-markup.md's "last column always auto" rule) and keeps its declared 23px pitch — the arrows now stay a fixed ~23px apart at both 984 and 1100 wide, proven wrong first (89.5px/147.5px gaps against the unmodified file). Item 6 (`837610593`, three nits): Loot Editor's leftover "Back" button is gone (the title bar closes it, matching every other popup since round D); Route's count column narrows the text column 337->324 AND adds a trailing filler so 324+30 exactly matches the scrollbar-reduced 354px width (a filler alone was proved insufficient by mutation — still clamped to 17px); Buffs' Coverage label moved from the group's anchored bottom edge (a 55px hole) to sit right under the Buff button row. Buffs' "136px gap" turned out geometrically unclosable at the 984 default without breaking the D-6 floor-safety invariant (Extra Buff Spells never moves, Blacklisted Buff Families anchors to the growing right edge, a 128px swing) — discovered by writing x=397 first and watching the existing whole-tree overlap pin catch it; the strip is now centered at the panel's own tested FLOOR instead (x=333, ~1px from where fix round B item 2 already had it), with the residual 984-default asymmetry accepted as the same "dead margin, polish later" trade already applied to six other tabs. Every new/changed pin shown to fail against a targeted mutation first (temporarily re-adding removed markup, or reverting a property to its pre-fix form, then restoring). MossTank suite 722 (round F start, matching round E's close) -> 722 (item 1, existing pins updated in place, no new Fact) -> 723 (item 2, +1) -> 724 (item 3, +1) -> 724 (item 4, one Fact replaced/renamed not added) -> 724 (item 5, the 23->24 column-count update lives in an EXISTING Fact — item 5's own new pin is App-side) -> 726 (item 6, +2: LootEditorHasNoLeftoverBackButton, BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow). App markup/plugin filter 258 (round F start — already ahead of the other worktree's last-recorded 254 by the time this branch merged the sync branch) -> 258 (items 1-4, no App-side pin) -> 260 (item 5's two-width Theory) -> 262 (item 6, +2: RouteShapedGrid_CountColumnStaysThirtyPxWhenTheListScrolls, BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor). Full solution builds clean in Release throughout. Deviations carried forward, none new this round. Next: one combined final re-check, merge, gate build.
## Closeout
- 2026-09-07 21:10 final combined re-check (E+F, both lenses): all twelve items CLOSED with failable tests, no per-frame allocation regression, the three recall tables agree pairwise, plugin references BCL + Abstractions only — MERGE-READY. **Merged into the campaign branch at `4a825c23c`.** Owner's second-look items all closed (Advanced Options bottom block gone; string settings hidden; Items tab VTank-only). Accepted, documented deviations: the Buffs extras strip centres at the 856 floor only (anchors have no "centre"); the recall combo lists "Marketplace" in place of VTank's duplicated "Fac. Hub"; dead margins on six tabs at the 984 default. Carried polish (owner: "we do that later"): strip the retired `scroll="true"` from five markup sites and make its test failable or document that unknown attributes are ignored; delete the ~20 bindings round F orphaned; cache the two Buffs row sources like every other column; two comment corrections; Meta's delete cell as an icon. Open issues from this slice: #490 part 1 (host: `StartVisible=false` + `ShowInSidePanel=false` unshowable), #491 (buff lists not consumed by `BuffPlan.Build`), #492 (Vulkan device-loss crash + shutdown re-throw), #493 (in-game meta/loot rule editors, deferred). Owner's visual gate: the merged build launched from the gate worktree for the final look.
- 2026-09-07 21:30 OWNER THIRD LIVE LOOK at the merged build (`4a825c23c`): (1) the Buffs tab's middle block (seven category toggles, difficulty/rebuff spinners, Stop/Force buffing + status, the counts line) is "made up — remove them from here": VTank's Buffs tab is exactly two lists + two Add… buttons; the real settings behind two of them (`SpellDiffExcessThreshold-Buff`, `RebuffTimeRemainingSeconds`) stay editable in Advanced Options, Force Buff stays on Options; the MossTank-only category bools stay in the model only. (2) Advanced Options still shows tooltips — remove them (VTank's popup has none; the description box is the info channel). Round G dispatched on a fresh worktree off `a2df6efae`; merge + relaunch follow.
- 2026-09-07 owner's third live look, post-closeout: (1) "Screenshot shows options under the buffing tab circled in red. Those are made up, remove them from here" — the whole MossTank-extras middle strip fix round B item 2 added between the Buffs tab's two lists (7 category toggles, difficulty/rebuff spinners, a second Force/Stop buffing button + status, the coverage counts) is gone outright; the Buffs tab is now VTank's own exact six controls (2 labels, 2 lists, 2 "Add..." buttons), matching the Items-tab precedent from round F item 3. The underlying BuffSettings fields (seven category bools, SkillExcessOverDifficulty, RebuffWhenUnderSeconds) stay in the model/side-car and remain editable through Advanced Options (SpellDiffExcessThreshold-Buff / RebuffTimeRemainingSeconds); Force Buff / Cancel Force Buff stay on the Options tab. Twenty-two now-orphaned XML-only bindings deleted from MossTankPanel.cs (the seven Enabled/Toggle pairs, DifficultyText/Up/Down, RebuffText/Up/Down, BuffButtonText, the Buff action) — this is the "delete the ~20 bindings round F orphaned" carried-polish item from the closeout, done as part of this fix rather than separately; BuffStatus/Coverage stay since MossTankPanelTests still exercises both directly. Commit `6be555ad4`. (2) "Also still have tooltips in the advanced options, remove please" — the three tooltip= attributes mosstank-advanced.xml had grown (option list, category checklist, value field) are removed; VTank's real AdvancedOptionsView has no tooltip mechanism, only its own description readout. New whole-tree test AdvancedOptionsPopupHasNoElementWithATooltip guards against any future re-addition. Commit `617a9c221`. Both fixes' new/changed tests shown to fail first against targeted mutations (a stray toggle re-added to the Buffs group; a tooltip= reintroduced on the option list), then reverted and restored green. MossTank suite 726 -> 726 (one Fact replaced); App markup/plugin filter 262 -> 261 (item 1, one Fact removed) -> 262 (item 2, one Fact added). Full solution Release build green throughout.

View file

@ -0,0 +1,224 @@
# Headless console — an interactive CLI for the bot host
Date: 2026-09-07
Status: CLOSED 2026-09-07 — merged `8cb284d6f`, connected proof passed (owner direction 2026-09-07: "the headless client should have
a CLI as well. Like we have the chat loaded in headless so we can see what it
does and we can talk via it if we want and control plugins like /moss bla or
/say hello")
Belongs with the "one plugin, two hosts" model recorded in
`docs/plans/2026-09-06-campaign-vt-vtank-oracle.md`. Independent of Campaign
VT's slices (host side only) — runs in parallel.
## What it is
When the headless host runs with a terminal attached (or `--console` /
`ACDREAM_HEADLESS_CONSOLE=1`, documented in `docs/launch-options.md`), it
becomes a chat-shaped REPL for the session:
- **Output.** The ordered event stream rendered one line per event, chat
first-class: `[Tell] Bob: hi`, `[Fellowship] …`, `[Local] …`, system text,
plugin output (everything a plugin writes through the host log/chat sink),
and lifecycle/command outcomes (`connected`, `entered world`, `portal →
Holtburg`, `command rejected: …`). Same channel names and ordering the
graphical SpewBox shows; no colors required (ANSI channel colors only when
the terminal supports them, off when piped).
- **Input.** Each typed line goes through the SAME command pipeline the
graphical chat box uses: a `/`-prefixed line is dispatched to the plugin
command registry first (`/vt …`, any plugin verb) and otherwise to the
client/server slash commands (`/say`, `/tell`, `/t`, `/f`, `/a`, emotes,
`/help`), a plain line is `/say`. No second parser, no bot-only shortcuts:
it must reuse the exact seam the retained chat box calls (find it — the
CH-campaign command registry and the chat-send route), through the
Runtime command surface Slice K already exposes ("chat and slash/server
commands").
- **Control.** `/quit` (graceful logout, exit 0), `/status` (session
generation, position, macro state if the plugin reports one). Ctrl+C keeps
today's graceful behavior.
- **Multi-session.** Out of scope for the first cut: the console attaches to
the single-session host (K1's production mode). A multi-session prefix
(`@name …`) is a later addition; leave the seam shaped for it (input routed
through a session-scoped adapter, not a static).
## Rules
- Headless stays Runtime-only: no App references, no rendering types. If the
chat-send or command-dispatch seam currently lives in App, lift the
presentation-free part into Runtime/Core with a dependency guard, and leave
the graphical client calling the same lifted code (behavior-preserving).
- Reads of stdin never block the scheduler: a reader thread feeding a
thread-safe queue drained on the session tick (Slice K's monotonic
scheduler contract).
- Every line of output goes through one renderer over the typed event stream
— the same events the K2 bot API emits — so tests assert on the rendered
text without a terminal.
- Portable: Windows and Linux; no ANSI when `Console.IsOutputRedirected`.
## Tests (Headless test project, no live server)
- Scripted stdin → rendered stdout: chat lines, a plugin command reaching
the plugin's registered verb, `/say` producing the same outbound message
the graphical route produces (assert on the command the fake Runtime
received), plain text → say, `/quit` → graceful teardown, unknown verb →
the same error text the chat box prints.
- Reader-thread/scheduler test: input queued while the tick is busy is
drained in order on the next tick, never on the reader thread.
- Launch-options doc row + the documentation test.
One connected proof at closeout (real ACE, `testaccount`, type `/say hello`
and see it echo; type `/vt start` with MossTank loaded) — the owner may run
it or the lead may, it is not a visual gate.
## Ledger
- 2026-09-07 planned; implementer dispatched.
- 2026-09-07 IMPLEMENTED. The dispatch seam already existed:
`AcDream.Runtime.Chat.ChatCommandRouter.Submit` is the SAME presentation-
free pipeline `LoginCommandSequence` (headless) and every graphical chat
window (`ChatWindowController`, `FloatingChatWindowController`,
`RetailUiRuntime`) already call — no lift was needed. Added
`HeadlessSessionHost.SubmitConsoleLine` (`Hosting/HeadlessSessionHost.cs`)
as the one new call site, reusing the host's own retained
`LiveChatCommandSurface`/plugin registry (now promoted from ctor locals to
fields) instead of a second parser.
New files: `Configuration/HeadlessConsoleOptions.cs` (typed `--console` /
`ACDREAM_HEADLESS_CONSOLE=1` / terminal-default resolution),
`Hosting/HeadlessConsoleInputReader.cs` (background stdin thread → FIFO
queue, never executes handler code), `Hosting/HeadlessConsoleController.cs`
(drains the queue on the session tick via a new `HeadlessSessionHost.
ConsolePump` hook; owns `/quit`/`/status`), `Hosting/
HeadlessConsoleChatFormatter.cs` + `Hosting/HeadlessConsoleRenderer.cs`
(renders the K2 bot event stream — `IRuntimeEventObserver`, the same
interface a bot policy subscribes — as bracket-labelled lines:
`[Tell] Bob: hi`, `[Fellowship] …`, `[Local] …`), `Hosting/
HeadlessConsoleChatFeedback.cs` (decorates `RuntimeChatCommandFeedback` so
retail's transient SpewBox/`ClientLocal` interface text — which never
touches `ChatLog`, so it never reaches the K2 event stream — also reaches
the console). `/quit` cancels a `CancellationTokenSource` linked into the
scheduler's run token in `HeadlessProcessHost` (the SAME graceful-exit
path an external Ctrl+C/SIGTERM already takes); `/status` reports
generation, position (or "unknown" without a live movement controller),
and loaded-plugin count (no plugin today reports a richer macro-state
string). Console only attaches for a single-session `run` (per the plan's
"out of scope for the first cut" multi-session note); constructed AFTER
every session's own credential resolution so the reader thread never
races a `StandardInput`-provider password prompt on the same stream.
Chosen console default: on when `!Console.IsInputRedirected` (a real
operator at a terminal), off when redirected (scripts/CI/piped fixtures,
where a blocked `ReadLine` on a background thread would just sit idle) —
resolved once in `Program.cs`, the only place that can see the real
`Console`.
Deviation from the plan's illustrative example: retail's own transcript
never prefixes Tell/Local lines with a bracket (`ChatVM.FormatEntry`
renders "Bob tells you, ..."/"Bob says, ..." with no label) — Headless
cannot reference `AcDream.UI.Abstractions` (the dependency-boundary
test), so `HeadlessConsoleChatFormatter` is a deliberately DIFFERENT,
terminal-shaped "[Label] Sender: text" rendering using the SAME channel-
name strings (matching the plan's literal `[Tell] Bob: hi` example), not
a byte-for-byte port of the graphical prose.
Tests: `tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs` (20 new
tests — options resolution, command-line flag parsing, reader-thread
ordering/never-on-reader-thread, controller drain/quit/status, chat
formatting, and full `/say`/plain-text/plugin-verb/unknown-verb dispatch
against a real `HeadlessSessionHost` + `FixtureSessionOperations`, no live
server) plus the existing `LaunchOptionsDocumentationTests` (4/4 green)
and `HeadlessDependencyBoundaryTests` (3/3 green, unchanged — Headless
still references only `AcDream.Runtime`). Every test in this batch was
mutation-checked to fail before the corresponding production line existed
(see the implementer's final report for the specific mutations run:
skipping the interface-text callback, skipping `_quitRequested.Cancel()`,
forcing `TryHandlePluginCommand` to always return false, dropping the
reader thread's `Enqueue`, and swapping `ChatChannelKind.Say` for `.Tell`
in `SubmitConsoleLine`).
Suites: `dotnet test tests/AcDream.Headless.Tests -c Release` → 193
passed / 1 pre-existing failure (`LinuxRejectsGroupOrOtherCredentialPermissions`,
a Linux-only lane test that cannot run on this Windows host — unrelated
to this change) / 194 total. `dotnet test tests/AcDream.Runtime.Tests -c
Release` → 1891/1891 passed. `dotnet test tests/AcDream.App.Tests -c
Release --filter "FullyQualifiedName~Chat|FullyQualifiedName~Command|
FullyQualifiedName~LaunchOptions"` → 414 passed / 2 pre-existing failures
(`ChatIndicatorButtonLiveMountProbeTests`/`OptionsPanelLiveMountProbeTests`
— both gated on `ACDREAM_PROBE_LIVE_MOUNT=1`, a manual live-DAT probe lane,
unrelated to this change) / 3 skipped / 419 total. `dotnet build
AcDream.slnx -c Release` green throughout.
- 2026-09-07 FIX ROUND (Opus review, APPROVE-WITH-FIXES). S1: `ACDREAM_
HEADLESS_CONSOLE=0` now disables the console even when stdin is a real
terminal — the prior `== "1"` test let `"0"` silently fall through to the
terminal-shaped default; the flag is now the sixth entry in
`LaunchOptionsDocumentationTests.DefaultOnBehaviorFlags` (a default-on
behavior with an A/B off-switch, once set at all, like
`ACDREAM_RETAIL_CHASE`). S2: the reader-thread pin is now falsifiable — a
fixture `TextReader` records the actual thread id `ReadLine` ran on, and a
new test asserts the controller's submit callback runs on neither that
thread nor any other unexpected one, only the `DrainDue` caller's. S3: one
`HeadlessProcessHost` end-to-end test proves a console line reaches the
session's real `SubmitConsoleLine` pipeline and `/quit` returns
`HeadlessExitCode.Success`. S4: `HeadlessConsoleController.Handle` now
wraps `_submit` in try/catch (mirroring `LoginCommandSequence.DrainDue`)
and prints a line for `UnknownCommand`/`Dropped`, so a console typo can
never escape into the scheduler's per-session quarantine catch. S5:
deleted the per-call `HeadlessConsoleChatFeedback` decorator — it only
ever saw text produced by the console's OWN `SubmitConsoleLine` calls.
The new `HeadlessConsoleSpewBoxPump` polls the shared `SpewBoxState` on
the console's own per-tick pump instead, the SAME seam the graphical
overlay's `SpewBoxController.Tick` reads, so server- and plugin-driven
`ClientLocal` interface text prints too. S6: `Program.cs` now resolves
`standardOutputIsTerminal` next to the stdin probe and threads it through
`HeadlessEntryPoint.Run``HeadlessProcessHost`, which no longer reads
`System.Console.IsOutputRedirected` itself. S7: a multi-session process
launched with `--console` now reports `_diagnostics.Message("console",
"single-session only")` instead of silently skipping console attachment.
N1: corrected two stale dispatch-order doc comments
(`HeadlessSessionHost.SubmitConsoleLine`, `HeadlessConsoleController`'s
class remarks) to the real `ChatCommandRouter.Submit` order: retail's
client-command catalog, local `/help`, plugin verbs, the unregistered-
channel-tag fallback, an explicit server command, then plain chat. N2:
`HeadlessCommandLine.Console` renamed to `ConsoleEnabled`. N3: `validate`
mode now rejects `--console` outright rather than silently ignoring it.
N4: **`/status` and `/quit` are console-intercepted verbs — they never
reach `ChatCommandRouter`, unlike `@status`, which is a real server
command and still passes through untouched.** N5: `HeadlessConsoleRenderer`
now dims only lifecycle/command/portal lines; chat and interface text
print at the terminal's default weight.
Every new/changed test was shown to fail first against a targeted
mutation of the corresponding production code (see each commit's own
body for the specific mutation) before the fix landed; one commit per
item, all with `Co-Authored-By: Claude Fable 5.1`.
Suites (Release): `dotnet test tests/AcDream.Headless.Tests` → 207
passed / 1 pre-existing Linux-lane failure
(`LinuxRejectsGroupOrOtherCredentialPermissions`) / 208 total (up from
193/1/194 before this round — 14 new/changed tests). `dotnet test
tests/AcDream.App.Tests --filter "FullyQualifiedName~LaunchOptions"` →
4/4 passed, including the corrected `OnlyTheSixProductBehaviorFlagsDefaultOn`
(renamed from Five). `dotnet build AcDream.slnx -c Release` green
throughout.
### Connected proof recipe (owner runs; NOT run by the implementer)
Against a running local ACE at `127.0.0.1:9000` with MossTank loaded for
the second half:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
dotnet run --project src\AcDream.Headless\AcDream.Headless.csproj --no-build -c Release -- `
run --config <path-to-a-one-session-config.json> `
-user testaccount -password testpassword --console
```
The referenced config's one session should target character `+Acdream`
(server guid `0x5000000A`) against `127.0.0.1:9000`, an `idle` bot policy,
and (for the second half) the MossTank plugin id under `plugins`. Once the
console prints `entered world`:
1. Type `/say hello` and press Enter — expect the SAME line ACE echoes back
to any other observer (a retail client or a second acdream session
watching `+Acdream`) to also print `[Local] You: hello` in this console
(the server's own HearSpeech echo, rendered through the normal chat
event stream).
2. Type `/status` — expect a line with `generation=`, `position=` (a real
cell/local-frame triple once in world), and `plugins=N loaded`.
3. With MossTank loaded, type `/vt start` (or whatever verb MossTank
registers) — expect MossTank's own handler to run (check its own
status/log output) and confirm NOTHING was sent to the wire for that
line (no `@vt` server command).
4. Type `/quit` — expect a graceful ACE logout (same as the existing
Ctrl+C behavior) and the process to exit 0.
This is not a visual gate; the owner (or the lead) runs it opportunistically
before considering the plan CLOSED.
- 2026-09-07 narrow re-check: all twelve fix items CLOSED; MERGE-READY. Merged into the campaign branch at `8cb284d6f`; the unknown-verb pin re-targeted to the chat scroll after AD-124 (`074a1561b`). **Connected proof PASSED (lead, 2026-09-07):** `acdream-headless run --config <one idle session, +Acdream> --console` with scripted stdin — `/say hello` → the server's echo printed as `[Local] You: hello`; `/status``generation=1 position=unknown plugins=0 loaded` (idle policy has no movement controller); `/quit``[session] graceful logout confirmed`, exit 0. The MossTank half (`/vt start`) is owed with slice 2's autostart work. Follow-ups filed as #489 (SpewBox growth without a console; polish; and the JSON diagnostics stream interleaving with chat lines in console mode — the console should quiet or redirect it). Status: CLOSED.

700
docs/plugin-ui-markup.md Normal file
View file

@ -0,0 +1,700 @@
# Plugin UI markup
SSOT for `AcDream.Plugin.Abstractions.IUiRegistry`'s markup vocabulary — every
element and attribute a plugin can put in the KSML-style XML it hands the host
via `AddPanel`/`RegisterPanel`/`RegisterPanelContent`, the `{Binding}` rules
those attributes follow, the DAT-icon grammar (Slice B), and the movable
plugin shelf (Slice A). Both slices are recorded in
[`plans/2026-09-06-plugin-shelf-and-dat-icons.md`](plans/2026-09-06-plugin-shelf-and-dat-icons.md);
this page is the day-to-day reference for writing a panel, that plan is the
design record.
Plugins stay BCL-only: nothing in `AcDream.Plugin.Abstractions` references
App/UI or Core.Items types. A plugin hands the host raw ids (spell ids,
object guids, DAT indices); the host owns every texture, every composited
icon, and the parser that turns markup into a live `UiElement` tree
(`AcDream.App.UI.MarkupDocument`).
## Registering a panel
```csharp
host.Ui.AddPanel(
new PluginPanelDescriptor("main", "MossTank")
{
IconText = "MT", // fallback initials if IconSurfaceId is 0
IconSurfaceId = 0x06002C41, // Decal-style bare index OR a full DID — both normalize
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(pluginDirectory, "mosstank.xml"),
binding);
```
`RegisterPanel` (same signature, returns `IDisposable`) removes the window
independently of the plugin's own lifetime. `RegisterPanelContent` takes an
in-memory KSML string instead of a file path — the route to reach for when a
panel is small enough not to need its own shipped `.xml` asset.
Every registered window gets a stable persisted key
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in —
`<panel resizable="true">`, see "Resizable panels and anchors" below), the
global UI lock, and a button in the shared plugin shelf
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
disables the plugin or pauses its `Tick`.
## The `{Binding}` rule
Every attribute that isn't a plain literal is either:
- a **literal** — a number, color, or string typed directly in the markup, or
- a **binding**`{PropertyName}`, resolved once at `Build` time against the
binding object's public properties/`Action`/`Action<T>` members via
reflection, then **re-read every frame** through a `Func<T>` (or invoked
live for actions). A plugin updates its panel by assigning a property; it
never touches `UiElement` objects directly, and never from a thread other
than the one that calls `Tick`.
A binding failure's severity is per-attribute, not one blanket rule — see the
table below. An unrecognized `{Prop}` that resolves loudly (any row marked
"Throws") throws `FormatException` **at `Build`**, the same moment any other
malformed attribute throws, never silently at draw time. The attributes
marked "Silent" instead fall back to something visible-but-harmless at
*runtime* (the literal text, `null`, or `0`) — a plugin author who typos one
of those sees a wrong-looking value on screen rather than a crash, so double
check those four against the markup by eye.
| Attribute(s) | On a missing/mistyped `{Prop}` | Bound CLR type |
|---|---|---|
| `label text`, `field text`, `menu selected`, `tooltip` (any element) | **Silent**`BindString` falls back to the literal attribute text itself (a typo'd `{Typo}` renders as the literal string `{Typo}`) | `string` (via `.ToString()`) |
| `meter cur`, `meter max` | **Silent**`BindUint` returns `null` (the meter shows no cur/max) | `uint?` (accepts any integral type) |
| `meter fill`, `slider value` | **Silent**`BindFloat` returns `0` | `float?`/`float` |
| `list items`, `menu items` | Throws | `IEnumerable<string>` |
| `list colors` | **Silent** if omitted (no color override); throws if present but mistyped | `IEnumerable<uint>` **or** `IEnumerable<int>` (shared `BindUintList`) |
| `list icons` (Slice B) | Throws if present but mistyped; omitting it entirely means no icon column at all. A negative `int` element is **silent**: it maps to `0u` (no icon for that row), matching the scalar `did`/`spell`/`item` row above | `IEnumerable<uint>` **or** `IEnumerable<int>` |
| `<icon>`/`<button icon>` `did`/`spell`/`item` bindings (Slice B) | **Build-time:** throws only for a missing bound property (or, for a literal, one that isn't valid hex/decimal) — the binder never checks the property's static CLR type. **Draw-time:** a resolved value that is negative or above `uint.MaxValue` is **silent** (maps to `0u`, draws nothing); a resolved value that cannot convert to a number at all throws `InvalidCastException`/`FormatException` from the draw, not from `Build` | any integral type (`uint`, `int`, `long`, `ushort`, a nullable of one, …) via `Convert.ToUInt32` |
| `list selected` | Throws (required int reader) | `int` |
| `tab selected`, `toggle checked` | Throws (required bool reader) | `bool` |
| root `panel visible` | Throws (required bool reader; see the root-only note below) | `bool` |
| `onclick` (button/tab/toggle) | Throws | `Action` |
| `slider onchange` | Throws | `Action<float>` |
| `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action<string>` |
| `list onchange` | Throws | `Action<int>` |
| `column items` (`type="text"`) | Throws — REQUIRED, unlike the single-column list's own `items` sugar it mirrors | `IReadOnlyList<string>` |
| `column colors` (`type="text"`) | **Silent** if omitted (no per-row override, same rule as `list colors`); throws if present but mistyped | `IReadOnlyList<uint>` **or** `IReadOnlyList<int>` |
| `column onclick` (`type="text"`) | **Silent** if omitted (keeps the original select-the-row behavior); throws if present but mistyped | `Action<int>` (row index) |
| `column values` (`type="check"`) | Throws — REQUIRED (there is no "no check column" fallback the way `list icons` has "no icon column") | `IReadOnlyList<bool>` |
| `column values` (`type="icon"`) | Throws — REQUIRED | `IReadOnlyList<uint>` **or** `IReadOnlyList<int>` |
| `column onchange` (`type="check"`) | Throws — REQUIRED (unlike the list's own optional `onchange`) | `Action<int>` (row index) |
| `column onclick` (`type="icon"`) | Throws — REQUIRED | `Action<int>` (row index) |
| `column width` (any type, NOT the list's last column) | Throws if missing, unparseable, or `<= 0` — UNLESS it is the literal `"*"` | `float`, or the literal `"*"` for auto |
| `column width` (the list's LAST column) | **Silent** — never validated, never used for layout (it always absorbs the remainder), UNLESS it is the literal `"*"` (then it joins the auto-sharing group instead of taking 100% of the remainder alone) | `float`, or the literal `"*"` |
The icon-id row is the one binding here whose failure mode depends on WHEN you look: a typo'd property name is caught immediately at `Build`, but a property that exists yet holds the wrong kind of value at runtime is only ever discovered later, from inside a live draw.
## Elements
Every element name is validated at `Build`: an unknown or miscased tag (e.g.
`<Icon>`, `<butotn>`) throws `FormatException` rather than silently
vanishing from the built tree.
| Element | Purpose | Key attributes |
|---|---|---|
| `panel` (root) | The window itself | `x y w h title resize resizable minw minh visible` |
| `group` | Transparent layout container | `x y w h background border visible anchor` |
| `label` | Static or bound text | `x y text color anchor` |
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind anchor` |
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip anchor` |
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
| `tab` | Selectable tab button | `x y w h text selected onclick anchor` |
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color anchor` |
| `slider` | Horizontal scalar | `x y w h value onchange min max style anchor` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background anchor` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style anchor` |
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight selectionband anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
art read as an out-of-place "big yellow button" next to a plugin's own dark
list boxes (owner live-client report, 2026-09-07), so a plugin `<menu>` now
draws the flat VTank/Decal `HudCombo` box (list-matching fill/border, a
left-aligned value, and a small ▾) by default; `style="retail"` opts back
into the gold face for a panel that genuinely wants it. Any other value
throws `FormatException` at `Build`. The plain style covers the WHOLE menu,
closed and open: a follow-up owner report (still 2026-09-07 — "Drop down
menus look horrible, there is also a checkmark on the text there") found the
OPEN popup still drew retail's tan/orange gradient panel, its ornate gold
scrollbar, and a baked checkmark glyph on the current entry even with
`style="plain"`. The open popup now matches `<list>`'s own chrome too: a
flat fill + 1px border, one row per entry in the list text color, the
current entry filled like a list selection, the hovered entry a slightly
lighter fill, and no checkmark. A `<menu>` popup always scrolls a single
column (rather than wrapping into more grid columns) once its item count
exceeds `rows`; a further owner directive (still 2026-09-07 — "For
scrollable dropdown or the meta window we use the same assets as we do in
for example chat or inventory window") moved that overflow scrollbar to
retail's own chrome — the exact sprites the chat window's transcript and
the inventory list already use — while the rows themselves stay the flat
style described above; a menu with too few items to overflow shows no bar
at all. `style="retail"` keeps the sprite popup rows (gradient panel,
checkmark-bearing row art) exactly as before, unchanged — only the
scrollbar chrome is shared between the two styles.
`slider style` is the same `plain`/`retail` grammar (fix round B item 11,
2026-09-07): `plain` (the default) draws a flat dark track, a 1px border,
and a small flat nub — no DAT scrollbar art, no `SpriteResolve` dependency
at all — via `UiScrollbar.RetailArt=false`'s `DrawPlainScalar`; `style=
"retail"` keeps `RetailScrollbarChrome.ApplyHorizontal`'s sprite track/thumb
exactly as before. Any other value throws `FormatException` at `Build`,
same rule as `menu style`.
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
name), `visible` (literal `true`/`false` or a bound `bool` property),
`enabled` (same rule), `tooltip` (a literal string or `{Binding}` shown
through retail's own runtime tooltip popup, empty/whitespace treated as no
tooltip), and `anchor` (which edges of the element's PARENT it keeps a fixed
margin to on resize — see "Resizable panels and anchors" below). The root
`<panel>` is the one exception: it does **not** go through `ApplyCommon` (no
`name`/`enabled`/`tooltip`/`anchor` — a top-level window is never anchored to
its own parent, only dragged/resized directly), and its `visible` attribute
accepts a `{Binding}` only — a literal `visible="true"` on the root is not
parsed (unlike every child element, where a literal is fine).
Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
with `<column>` children is no longer limited to one padded text column. A
`<list>` with no `<column>` children stays exactly the older single-column
form (`items`/`colors`/`icons`/`iconkind` on the element itself); the two
forms are mutually exclusive on one element.
`list colors`' values are `0xRRGGBB` (opaque, no alpha channel), while every
`color=`/`background=`/`border=` attribute elsewhere is `#AARRGGBB` (alpha
first) — the two grammars look similar but are not interchangeable.
Every hex literal (`did`, `0x` id bindings, `list colors` entries) requires
the `0x` prefix to parse as hex; an all-digit string with no prefix
(`did="165"`) parses as **decimal**, not hex — `did="165"` and `did="0x165"`
are different ids.
## Resizable panels and anchors
A plugin panel is **fixed-size by default** — this matches every panel
shipped before 2026-09-07 (e.g. `mosstank.xml`'s `resize="none"`). A window
opts into real user drag-resize with `<panel resizable="true">`, and every
non-root element opts its OWN geometry into following that resize with
`anchor="..."`. The two attributes are independent: a resizable panel whose
children have no `anchor` just gets bigger/smaller with empty space at the
bottom-right (today's default placement, `Left|Top`); a panel with anchored
children but `resizable` left at its default `false` never actually resizes,
so the anchors never have anything to react to.
| Attribute | Element | Meaning |
|---|---|---|
| `resizable` | `panel` (root) | `"true"` arms the window for user drag-resize on both axes (edges + corners, same mechanism chat windows use); default `false` — fixed size, exactly as before this attribute existed |
| `minw` / `minh` | `panel` (root) | The floor a drag-resize (and a persisted-layout restore) will not shrink below. Default: the panel's own authored `w`/`h` — a resizable panel never shrinks past the layout its author actually tested. Only meaningful when `resizable="true"` |
| `resize` | `panel` (root) | Pre-existing per-axis lock (`x`/`y`/`both`/`none`) that narrows `resizable="true"` to one axis; has no effect on its own now that `resizable` (default `false`) is the master switch |
| `anchor` | `group` `list` `menu` `field` `label` `button` `icon` (and `meter`/`tab`/`toggle`/`slider`) | Space-separated subset of `left top right bottom` (case-insensitive), naming which edges of the element's **direct parent** it keeps a fixed margin to as that parent resizes. Default (attribute absent) is `left top` — today's fixed placement, unchanged |
`anchor` semantics are exactly `AcDream.App.UI.UiElement.Anchors`/
`AnchorEdges`/`ApplyAnchor` (already used by every retail-imported window):
- `left top` (the default) — pinned top-left at a fixed size; never stretches.
- `left right` — stretches WIDTH to track the parent (both side margins stay
fixed).
- `top bottom` — stretches HEIGHT the same way, vertically.
- `left top right bottom` — stretches on both axes.
- `right` alone (no `left`) — pins to the parent's right edge at a FIXED
width, moving as the parent resizes rather than stretching. `bottom` alone
is the same, vertically.
An element's parent is whatever markup element directly contains it — for a
`<group>`'s children, that is the GROUP, not the panel. This is how a group
propagates resize to its own contents: give the group
`anchor="left top right bottom"` so it stretches with the panel, and give a
`<list>` inside it `anchor="left right"` so the list stretches with the
GROUP's width in turn. An unrecognized token (a typo like
`anchor="left rihgt"`) throws `FormatException` at `Build`, naming the
offending element by its `name`/`id` — the same "malformed markup throws"
rule every other attribute in this grammar follows.
No other markup or host wiring is needed to make a panel resizable: once
`resizable="true"` sets the window's `Resizable`/`ResizeX`/`ResizeY`/
`MinWidth`/`MinHeight`, the SAME drag-resize, persistence (save/restore
across sessions, clamped to `minw`/`minh`), and UI-lock behavior every other
retained window already has just applies. Changing a panel's authored `w`/
`h`/`minw`/`minh`/`resizable` in a later plugin update resets every user's
stored SIZE to the new authored default exactly once (their saved position
is kept and re-clamped to the screen) — the host derives a stable revision
from that tuple automatically, so a plugin author never needs to bump one
by hand (#490 part 2).
```xml
<panel x="0" y="0" w="420" h="320" title="MossTank" resizable="true" minw="360" minh="260">
<group anchor="left top right bottom" x="8" y="8" w="404" h="304" border="#FF4A3A14">
<label x="4" y="4" text="Monsters"/>
<list anchor="left right top bottom" x="4" y="24" w="396" h="276"
items="{MonsterNames}" selected="{SelectedMonster}" onchange="{SelectMonster}"/>
</group>
</panel>
```
Here the outer `<group>` stretches with the panel on every edge, and the
`<list>` inside it stretches with the GROUP on every edge in turn — dragging
the window's corner grows the whole list, not just empty panel background.
## The icon-id grammar (Slice B)
Decal/VirindiViewService plugins (the reference usage this ported:
MosswartMassacre's `HudPictureBox.Image` assignments, fed from Decal's
`FileService.SpellTable`/`SkillTable` icon columns) hand out **bare portal.dat
indices** — small integers, not full `0x06xxxxxx` RenderSurface DIDs. acdream's
host normalizes every icon id through one function so both styles work
everywhere an icon id is accepted:
```csharp
// AcDream.Plugin.Abstractions.PluginIcons
static uint Normalize(uint idOrIndex);
// 0 -> 0 (no icon)
// 7735 -> 0x06001E37 (bare index -> RenderSurface DID)
// 0x00FFFFFF -> 0x06FFFFFF (largest bare index, just below the boundary)
// 0x01000000 -> 0x01000000 (AT the boundary -> already a DID, unchanged)
// 0x06002D14 -> 0x06002D14 (already a DID, unchanged)
```
The boundary is `0x01000000`: any value below it is treated as a bare
Decal-style index and gets the `0x06000000` RenderSurface block prefix added;
any value at or above it (including `0x01000000` itself) is assumed to
already be a resolvable DID and passes through unchanged.
The host applies `Normalize` at **every** `did`-shaped sink: the descriptor's
`IconSurfaceId` (drawn on the plugin shelf button), and every `<icon did>` /
`<button icon>` (`iconkind="did"`) / `<list icons>` (`iconkind="did"`) value —
literal or bound, re-normalized every frame for a bound value. A plugin never
needs to call `Normalize` itself; handing the host either a Decal-style index
or a full DID produces the same drawn icon.
Plugin-facing records that already carry full retail RenderSurface DIDs
(`PluginSpellInfo.IconId`, `PluginSkillInfo.IconId`,
`PluginInventoryItem.IconId`, `PluginWorldObject.IconId`) are **not**
re-normalized — they are already in DID space, read straight from the
client's SpellTable/SkillTable/object state. `Normalize` only matters at a
markup `did` sink, where a plugin author might type a bare index by hand.
**Do NOT add `0x06000000` to any of the four `IconId` records above by
hand** — they are already full DIDs, not bare indices. `PluginSkillInfo.IconId`
in particular comes straight from `SkillBase.IconId`, and retail's own
`UIRegion::SetImageByDID(SkillBase._iconID)` (`@0x004f150e`) draws that field
directly as a DID with no `+0x06000000` step of its own — adding the block
prefix again would double-normalize it and resolve nothing (see
`src/AcDream.App/UI/Layout/SampleData.cs:69-83` for the real values: Melee
Defense is `0x06000165`, never `7735`/`0x165`, in that field).
**API-v1 note:** `IconId` is a positional/`init` member on each of the four
records above, so it participates in record equality (`Equals`/`GetHashCode`)
along with every other field. Plugin code that compares two
`PluginSpellInfo`/`PluginSkillInfo`/`PluginInventoryItem`/`PluginWorldObject`
values for equality now also compares their `IconId` — harmless for code
built against the new host (both sides fill it identically), but worth
knowing if you see an equality check that used to succeed start failing
against a host that populates `IconId` where an older one left it `0`.
## The three icon sources
Every icon-bearing attribute (`<icon>`'s `did`/`spell`/`item`, `<button icon>`,
`<list icons>`) resolves through one of three sources, selected by which
attribute is set (`<icon>`) or by `iconkind` (`<button>`/`<list>`, default
`"did"`):
| Source | What it draws | Backing API |
|---|---|---|
| `did` | The raw RenderSurface art at that DID, nothing composited on top | `IMarkupIconResolver.ResolveDid` (a plain sprite resolve, after `PluginIcons.Normalize`) |
| `spell` | Retail's **composited** spell icon: power-level backing + spell art + reversed/normal tint + self/fellow-targeted overlay | `IconComposer.GetSpellIcon` (retail `ClientMagicSystem::CompositeSpellIcon`) |
| `item` | Retail's **composited** item icon for a *live* object id: type-default underlay + custom underlay + base icon + custom overlay + effect recolor | `IconComposer.GetIcon`, reading the id's fields from the same `ClientObjectTable` the inventory UI already uses |
`did` accepts a literal (`did="7735"` decimal, or `did="0x06002D14"` hex) or a
binding (`did="{IconDid}"`, a `uint` property re-read every frame). `spell`
and `item` are almost always bindings (`spell="{SpellId}"`,
`item="{ObjectId}"`) but accept the same literal grammar. Any of the three
resolving to 0, or the resolver returning no texture, draws nothing — never a
placeholder, never a throw.
A `did` icon is **not blitted raw**. It is drawn the way retail draws every
icon it composites (`IconData::RenderIcons` with no overlay and no effects):
the art's pure-white pixels are the DAT's "recolor me" key and are replaced
with the solid-black fallback tile, exactly as a mundane item in the inventory.
Raw art shows a white ring around the icon (Decal's `HudPictureBox` draws it
that way); acdream does not. Art without any pure-white pixel is unaffected.
### `<icon>`
```xml
<icon x="8" y="8" w="32" h="32" did="7735" tooltip="Decal-style index"/>
<icon x="48" y="8" w="32" h="32" did="0x06002D14"/>
<icon x="88" y="8" w="32" h="32" spell="{SpellId}" tooltip="{SpellName}"/>
```
Exactly one of `did`/`spell`/`item` must be present — two sources on one
`<icon>` throws `FormatException` at `Build`. `w`/`h` default to 32 (retail's
standard icon size) when omitted. The sprite is drawn nearest-filtered,
aspect-preserved, and centered inside the `w`×`h` box — a non-square source
never stretches. A `tooltip` attribute makes the icon a real hit-test target
(it is click-through otherwise, so it never steals clicks meant for something
underneath it).
`<icon>` derives its kind from WHICH of `did`/`spell`/`item` is set — unlike
`<button>`/`<list>`, it has no per-element `iconkind` to disambiguate.
Putting `iconkind` on an `<icon>` throws `FormatException` at `Build`
("`iconkind applies to button and list; icon derives its kind from
did/spell/item`") rather than silently ignoring it.
### `<button icon="..." iconkind="did|spell|item">`
```xml
<button x="12" y="68" w="120" h="24" text="Report"
icon="0x06002D14" onclick="{Report}"/>
```
The icon draws flush left inside the button; the caption's centering region
shifts right to make room. `text` may be empty for an icon-only button.
`iconkind` defaults to `"did"`.
### `<list icons="{IconIds}" iconkind="did|spell|item">`
```xml
<list x="12" y="100" w="256" h="108"
items="{SpellRows}" icons="{SpellIds}" iconkind="spell"
selected="{SelectedIndex}"/>
```
`icons` is an `IEnumerable<uint>` (or `IEnumerable<int>`) binding parallel to
`items` — Decal's `IconColumn` convention: a leading square column,
`RowHeight - 2` pixels wide, one icon per row. A row past the end of the
icons list, or an id that resolves to nothing, draws no icon for that row
(the text still draws, just without an icon). Omitting `icons` entirely
keeps the list exactly as it was before Slice B (full-width text, no
column).
`iconkind` is **per-`<list>`**, not per-row: every id in one list's `icons`
binding is resolved the same way (all `did`, all `spell`, or all `item`).
There is no way to mix kinds within a single list. Two consequences:
- If a plugin's data genuinely mixes id spaces (some rows are raw DIDs, some
are spell ids needing a composited badge), it must pre-normalize/pre-resolve
outside the markup and expose ONE consistent `IEnumerable<uint>` of
`did`-space ids — `iconkind="did"` on the list.
- retail's composited spell badge (power-level backing + tint + self/fellow
overlay) can **only** be reached through `iconkind="spell"` with real spell
ids — there is no DID that already IS the composited result, so a list
that wants the badge look has no `did`-space escape hatch.
MosswartMassacre-style example — a list column composited from spell ids,
with the spell's own raw art DID printed alongside the name for comparison:
```csharp
// iconkind="spell": the values MUST be spell ids (what ResolveSpell composites
// a badge from), NOT the spell's raw IconId — those are different id spaces.
public IEnumerable<uint> SpellIds =>
host.Automation.Spells.KnownSelfBuffs.Select(s => s.SpellId);
public IEnumerable<string> SpellRows =>
host.Automation.Spells.KnownSelfBuffs.Select(
s => $"{s.Name} (icon 0x{s.IconId:X8})");
```
```xml
<list items="{SpellRows}" icons="{SpellIds}" iconkind="spell" .../>
```
(A list backed by `PluginSpellInfo.IconId` directly — the spell's own raw art
tile, no composited badge — uses `iconkind="did"` instead, with `icons`
yielding `IconId` rather than `SpellId`.)
## Columns (Campaign VT slice 1 Part B)
VVS's `HudList` (the VirindiViewService list control VTank's own `mainView.xml`
uses) supports N independently-typed columns per row — text, checkbox, and
icon cells side by side in one scrolling grid, each with its own `Click(row,
col)`. acdream's `<list>` matches this by letting a `<list>` declare
`<column>` children instead of the single-column `items`/`colors`/`icons`
attributes:
```xml
<list x="8" y="24" w="256" h="120" rowheight="18"
selected="{SelectedMonster}" onchange="{SelectMonster}">
<column type="check" width="20" values="{MonsterFester}" onchange="{ToggleFester}"/>
<column type="text" width="127" items="{MonsterNames}" onclick="{PingMonster}"/>
<column type="icon" width="*" iconkind="did" values="{MonsterIcons}" onclick="{MoveMonsterUp}"/>
</list>
```
VVS lists draw no persistent row-selection fill, so `<list>` matches that by
default in both the single-column and `<column>` forms — a plugin that wants
a visible band anyway sets `selectionband="true"` (row selection itself,
including scroll-into-view, is unaffected either way).
This mirrors VTank's own Monsters tab (several boolean flag columns, a name
column, and icon-button columns) — see
`docs/research/vtank-kb/08-ui-views.md` §3's "Multi-column lists with typed
columns" gap and its proposed extension, which this implements verbatim. The
`rowheight="18"` above is the widget's own default (`UiMarkupList.RowHeight`),
chosen to match VVS's own row pitch exactly: `Padding*2 + ControlHeight` =
`1*2 + 16` = `18` (`docs/research/vtank-kb/08-ui-views.md`'s `HudList` row:
`Padding=1px`, `ControlHeight=16px`) — an author who omits `rowheight`
entirely already gets VVS's pitch for free.
### `<column>` attribute grammar
| Attribute | Applies to | Required | Meaning |
|---|---|---|---|
| `type` | every column | yes | `text`, `check`, or `icon` — any other value throws `FormatException` at `Build` |
| `width` | every column | see below | Column width in px, or `"*"` for auto. See "Width semantics" below — the rules differ for the LAST column in a `<list>` vs. every other column |
| `items` | `type="text"` | yes | `{IReadOnlyList<string>}` — one row of text per index |
| `colors` | `type="text"` | no | `{IReadOnlyList<uint>}`, `0xRRGGBB` per row (same grammar as the single-column list's own `colors`); omitted rows (or the whole attribute) fall back to the list's `TextColor` |
| `onclick` | `type="text"` | no | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell INSTEAD of selecting the row. Omitted (the default) keeps the original select-the-row behavior; present but malformed throws `FormatException` at `Build`. None of VTank's eight lists actually relies on row selection — every real text cell in `mainView.xml` is wired as an action target — so a new column is usually written WITH an `onclick` |
| `values` | `type="check"` | yes | `{IReadOnlyList<bool>}` — the checked state per row |
| `onchange` | `type="check"` | yes | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell; the plugin flips its own bool, the column never mutates `values`' backing collection itself |
| `values` | `type="icon"` | yes | `{IReadOnlyList<uint>}` (or `IReadOnlyList<int>`) — one icon id per row, same id-space rules as `list icons` |
| `iconkind` | `type="icon"` | no (defaults `"did"`) | `did`/`spell`/`item`, same three-source dispatch as `<list icons iconkind>` above — one kind per column, not per row |
| `onclick` | `type="icon"` | yes | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell |
Unlike the single-column list's optional `icons`/`onchange`, a `check`/`icon`
column's own `values` and `onchange`/`onclick` are **required** — a column
that can never fire anything, or has nothing to draw, is a Build-time author
error, not a silently-inert control. A `<column>` with an unrecognized `type`,
a missing required binding for its type, or any `<list>` child element that
isn't `<column>` at all, throws `FormatException` at `Build`. A `<list>` with
`<column>` children cannot ALSO use the single-column `items`/`colors`/`icons`
attributes on the `<list>` element itself — pick one form per list. Every
column-attribute throw message identifies the offending column by position
and declared type — `column[2] type="check" values`, not just `"column
values"` — so a list with several columns of the same type still points at
the right one.
### Width semantics
`width="*"` means AUTO: this column shares the list's remaining width
EQUALLY with every other auto column, VVS's own "0-width column auto-sizes"
rule (`docs/research/vtank-kb/08-ui-views.md`'s `HudList` row: "a 0-width
text/button/edit/list/fixedlayout/notebook column auto-sizes... share the
remaining width equally"). `width="*"` is legal on ANY column, including the
last.
The LAST column in a `<list>` is special: it is ALWAYS treated as auto —
sharing the remaining width like every other auto column when one or more
earlier columns also declare `width="*"`, or absorbing 100% of the remainder
by itself when no other column does (the original, still-default behavior).
Its own declared `width` (or omitting `width` entirely) is never validated
and never used for layout — only an explicit `width="*"` on the last column
actually changes anything (it makes the last column share evenly with
earlier auto columns instead of taking the whole remainder alone). When two
or more columns end up sharing, integer-division remainder goes to the LAST
one — e.g. three columns sharing 100px split 33/33/34, not 33/33/33 with 1px
unaccounted for.
Every OTHER (non-last) column's declared `width` is validated at `Build`: **a
missing, unparseable, or non-positive `width` throws `FormatException`**
naming the column's index and declared type (`column[0] type="text" width
must be a positive number or "*", got (missing)`) — UNLESS it is `width="*"`.
At layout time (recomputed every frame off the list's live width, so a
resizable list re-flows like every other retained widget), a declared width
that would overflow the list's total width is CLAMPED to whatever room is
actually left, walked left to right — every column after the overflow point
gets `0` width and draws nothing (a `<=0`-width cell is skipped entirely, the
same as today).
### Row count, selection, and clicks
Row count is the longest bound column (a text column with 20 rows next to a
check column with only 5 simply draws 15 rows past its own data — see "Short
columns past their own row count" below for what each column kind does
there). The list's own `selected`/`onchange` attributes keep exactly their
single-column meaning: a click in a **text** column without its own
`onclick` selects that row (and fires the list's `onchange` with the row
index, same as today). A click in a **check** or **icon** column, or a
**text** column that DOES declare its own `onclick`, instead fires that
column's own `onchange`/`onclick` and does **not** change the list's
selection — VVS's per-cell `Click(row, col)` folded into a per-column
callback, since acdream's binding model is per-attribute rather than
per-cell. A click landing on a row past that SPECIFIC column's own bound
data (even though the row is valid for the list overall, because some OTHER
column has more rows) fires nothing — no callback, no crash. Scrolling works
exactly as the single-column list already does.
### No header row
VVS's `HudList` has no built-in header row either — the column-caption
glyphs seen in VTank's own `mainView.xml` (e.g. the Monsters tab's single-letter
"F"/"B"/"G"/"I"/… flag headers) are ordinary `StaticText` controls placed
manually above the list. acdream matches this for free: put a `<label>` (or
several, one per column, hand-positioned) directly above the `<list>` — there
is no dedicated header markup to learn.
### Check-column glyph
A `type="check"` cell draws with the exact same five-band lamp glyph as
`<toggle>` (both now share one `UiCheckLamp` primitive — its checked/
unchecked colors and `Draw` method), so a column checkbox reads identically
to every other checkbox in the client rather than a bespoke box-and-tick.
The glyph is centered horizontally in its cell (matching how an icon cell
already centers its sprite) — a check column is routinely declared wider
than the glyph itself under the PITCH convention below.
### Short columns past their own row count
A **text** or **icon** cell past its own column's row count draws nothing —
there is no sensible default string or icon to show. A **check** cell past
its own column's row count still draws the lamp, UNCHECKED — VVS
materializes every cell in a row regardless of which columns actually have
data for it, and there is always a sensible default for a boolean (false).
### The PITCH convention for transcribing a VTank column table
VVS's own `HudList` reserves geometry acdream's column model doesn't have a
separate concept for: `WPaddingOuter=3px` (the list's own left/right
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
also forces every `CheckColumn` to a fixed 13px regardless of its declared
`fixedwidth`. acdream's column model has no separate gap/forced-width
concept — every column's declared `width` is its full cell width, columns
sit directly adjacent with no gap, and a check column uses whatever `width`
it's given like any other column. The 16px scrollbar column IS now
automatic (owner directive, 2026-09-07 — see "Scrollbar" below): a plugin
author never reserves it by hand.
To transcribe a real VTank column table (as in
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
**PITCH** instead of its raw `fixedwidth`: `pitch = fixedwidth + 7` (baking
VVS's inter-column `WPadding` into the cell width itself, since acdream has
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Do NOT also fold VVS's 16px
scrollbar width into the last column's pitch or the list's total `w` — the
list reserves that width itself, automatically, only while its rows
actually overflow (see "Scrollbar" below); doing both would double-reserve
it and starve the last column once the list has few enough rows to hide
the bar.
### Scrollbar
Once a `<list>`'s rows overflow its own height (either the single-column or
the `<column>` form), it reserves a 16px column at its right edge — VVS's
own `VScrollBarButtonSize` placement — and draws retail's scrollbar chrome
there: the same sprite ids the chat window's transcript and the inventory
list already draw through (owner live-client report 2026-09-07: "For
scrollable dropdown or the meta window we use the same assets as we do in
for example chat or inventory window"). Mouse wheel keeps working as
before; the bar itself is also fully interactive (up/down arrow clicks,
track paging, and thumb drag). A list whose rows all fit reserves no width
and draws no bar at all — the reservation and the chrome both come and go
together with actual overflow, never present "just in case."
### Backward compatibility
A `<list>` with no `<column>` children is byte-for-byte the original
single-text-column widget — every existing panel (including every current
MossTank tab) keeps working unchanged; `<column>` is additive, not a
migration.
## Slider range and scrollable menus (Campaign VT slice 7)
Two small `<slider>`/`<menu>` attributes, both closing gaps identified in
`docs/research/vtank-kb/08-ui-views.md` §3 while porting VTank's own Vitals
and Profiles tabs.
### `<slider min max>`
VVS's `HudHSlider`/`LinearPositionControl` expose an arbitrary `Min`/`Max`
range (VTank's own nine Vitals sliders are `minimum="0" maximum="100"`).
acdream's `<slider>` always bound a fixed 0.01.0 scalar; `min`/`max` are
now optional literal attributes that declare the range the BOUND `value`/
`onchange` speak in, while the widget itself keeps working internally in
0.01.0 (drag math, click-to-jump, mouse-wheel are all unchanged):
```xml
<slider x="96" y="0" w="144" h="16" min="0" max="100"
value="{HealPercent}" onchange="{SetHealPercent}"/>
```
`HealPercent`/`SetHealPercent` read and write a plain `0..100` value — no
`/100f` scaling shim in the plugin's own ViewModel. Omitting both attributes
(every `<slider>` written before this slice) keeps the exact historical
0.01.0 identity range — `min`/`max` default to `0`/`1`, so `(value-0)/(1-0)`
and `0+t*(1-0)` are both no-ops. A declared `min == max` falls back to a
range of `1` rather than dividing by zero.
### `<menu>` is always a single scrolling column
VVS's `HudCombo` popup is always exactly one scrolling column, at most 10
rows visible before a scrollbar appears (`HudCombo.cs:35,102-146`). An
earlier revision of this slice gated that behind an opt-in `scroll="true"`
attribute; the owner's live-client report on 2026-09-07 ("scrollable
dropdowns... use the SAME scrollbar/window assets as we do in for example
chat or inventory window") made the scrolling, chrome-matching popup the
one and only shape a plugin `<menu>` draws — `MarkupDocument` now sets
`UiMenu.Scrollable = true` and applies `RetailScrollbarChrome` for every
`<menu>` unconditionally, so a VTank `Choice` with many entries (the
27-option recall menu, a long named-profile list) always keeps VVS's
one-column look instead of ever fanning out sideways:
```xml
<menu x="188" y="64" w="120" h="22" items="{RouteProfileNames}"
selected="{SelectedRouteProfile}" onchange="{SelectRouteProfile}"
rows="7"/>
```
There is no `scroll` attribute to set or omit — every `<menu>`, old and
new, gets the scrolling popup.
## The plugin shelf (Slice A)
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of
per-plugin-window buttons. It is a real retained window
(`RetailWindowManager` key `plugin-shelf`), so it gets drag, the global UI
lock, and persisted position/visibility/collapsed state for free, exactly
like every other window.
- **Drag**: a grip strip across its top (three short dashes) is the move
handle. Dragging elsewhere on the shelf (the buttons themselves, the
padding between them) does not move the window.
- **Collapse**: a small `>`/`<` toggle at the grip's right end (expanded
shows `>`, collapsed shows `<`) shrinks the shelf to a 28px-tall,
button-sized tab (deliberately findable-sized, not a thin sliver) rather
than just the grip band; button entries stay laid out underneath so
expanding is instant. Persists through the same window-state channel as
position/visibility.
- **Hide/show**: `Shift+Ctrl+F1` (retail's plugin-manager chord,
`InputAction.TogglePluginManager` — acdream has no separate plugin manager,
so this is its honest home). Hiding the shelf never disables a plugin or
touches any individual plugin window's own visibility; a new plugin window
registering while the shelf is hidden does not un-hide it. If no plugin has
registered a shelf entry yet, the chord reports "No plugin windows are
registered." instead.
- **Default dock**: with no saved layout, the shelf sits at the right screen
edge, top 116px — until the user drags it (or a saved layout restores a
different position), after which it stays put and growth preserves whatever
corner it's anchored from.
## Testing conventions
`MarkupDocumentTests`/`MarkupIconTests` build panels with a fake
`resolve`/`IMarkupIconResolver` (`_ => (1u, 32, 32)` for sprites; a small
in-test class recording which id/kind it was asked to resolve) rather than a
live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises
the shelf's drag/collapse/hide/persistence behavior against a bare `UiRoot`.
`MarkupListColumnsTests` covers the Columns extension above: per-column
binding-type validation (every throw naming its column by index and type),
width semantics (`"*"` sharing, the last-column-always-auto rule, the
overflow clamp), the per-column row-bound click guard, draw-level
column-offset/clipping/check-glyph pins against the same recording-renderer
apparatus, hit-test routing (text selects unless it has its own `onclick`;
check/icon/onclick-text fire their own callback and never touch selection),
a backward-compatibility proof that a column-less `<list>` is unaffected,
and two full `MarkupDocument.Build` end-to-end tests transcribing VTank's
real Monsters- and Meta-tab column shapes. `MarkupResizableAnchorTests`
covers `resizable`/`minw`/`minh` parsing, the `anchor` grammar (default,
every token combination, the unknown-token throw) across every element
listed above, live re-layout against the same recording-renderer apparatus
(a stretching list, a right-anchored button that moves, a group whose resize
propagates to its own anchored children), and a golden proving a panel with
none of these attributes draws byte-identically to itself across repeated
builds. `RetailWindowManagerTests`/`RetailWindowLayoutPersistenceTests`
cover a resizable markup panel through the real `ResizeTo`/save-restore
paths (accepts within `minw`/`minh`, a fixed panel refuses, a restored size
below the CURRENT floor clamps up to it).

View file

@ -0,0 +1,39 @@
# Campaign VT slice 7 — visual gate script (the nine tabs)
Owner's bar: "looks basically the same" as VTank and behaves the same from
the player's seat. Judge arrangement, density, captions, list shapes and the
one interaction per tab. Do NOT judge padding pixels, fonts or chrome (ours),
or VVS internals.
## Setup
1. Copy `C:\Games\VirindiPlugins\VirindiTank\` to a scratch folder (our
character-binding file would name `.af` files real VTank cannot load —
register row AD-122). Point acdream at the copy:
`$env:ACDREAM_VTANK_PROFILE_DIR = "<scratch copy>"`.
2. Launch acdream as usual (retail UI, MossTank enabled). Open the MossTank
panel from the plugin shelf (the `0x06002C41` icon).
3. Have retail VTank open on the same character on a second monitor or
window for the side-by-side. Its window is 856×210 with nine tabs.
## Per tab
For each row: open the tab in both, compare, try the interaction, mark
PASS / FAIL with a one-line note. Screenshots from the closeout are in
`docs/research/2026-09-07-slice7-screenshots/` (UI-probe captures) if you
want to pre-screen before launching.
| Tab | Must look the same | Interaction to try |
|---|---|---|
| Options | Four columns of controls: range fields at left (Monster / Ring / Approach / Follow-Nav), the Force Buff / Cancel buttons and five Enable toggles, the autostack/autocram/dispel/fellow/mana-charge column, the boost/peace/rebuff/fastcast/walls/fallback column, the pets group with the two fields, Advanced Options and Run Macro at bottom right | Toggle "Peace Mode When Idle"; it flips in the profile and survives a reopen |
| Profiles | Four rows (Macro Settings / Auto-Navigation / Looting / Meta): label, profile menu, CopyTo/New, Clear, Mine-only, Show Editor; the menus list YOUR real profiles by VTank's names, `[Default]`/`[By char]`/`[None]` first | Select a different settings profile; the Options tab reflects it |
| Vitals | Three groups of three sliders (self / top-off / others) with the "NN%" readouts and captions | Drag "Heal at:" and watch the percent readout follow |
| Monsters | One wide grid: 14 single-letter flag columns (F B G I Y V A R S WC FC Cp DC Cs) with hover tooltips, then Name, P, Dmg type, Ex. Vuln, Weapon, Offhand, PetDmg, and the two move arrows; Add / Add Sel row below | Click a flag to toggle it; click the P cell to cycle priority; click a Name cell to delete that monster (as VTank does) |
| Items | Two-column list (item, hands) with Add / Add (no buffs) | Click the hands cell to cycle handedness |
| Consumables | Left list of gems/food/kits with Add and Add All Peas; right list of excluded scarab types with their icons and Add Selected | Add a selected component to the excluded list |
| Buffs | Left: Extra Buff Spells with Add…; right: Blacklisted Buff Families with Add…; the Add… picker is a searchable single-column list | Type in the picker's search and pick one |
| Route | Waypoint list (text + count) with the nav-type and insert-mode menus and the three small arrow/reset buttons under it; the Add / Open Vendor / Add Portal-NPC / Add NPC Talk / Add Recall (+ recall menu) / Add Pause (+ seconds) / Add Chat (+ text) grid at right | Add a pause waypoint; advance the current point with the arrow button |
| Meta | Six-column rules grid (delete / up / down icons, State, Condition, Action), Create, "Current State:" and the state menu | Click a rule's Condition to open the editor; move a rule with the arrow |
## Verdict
Tab-by-tab PASS/FAIL. Anything FAIL: what differs, in one sentence. Layout
fixes ride a fix round; behavior findings go to the slice that owns them
(26, 8).

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

View file

@ -0,0 +1,592 @@
# VTank knowledge base 01 — settings and profiles
Campaign VT, phase VT1. Oracle: `refs/vtank/decompiled/` (ILSpy output of
`utank2-i.dll`, obfuscated identifiers, real strings) plus
`refs/vtank/uTank2.Resources.defaultsettings.usd`,
`uTank2.Resources.defaultsettingstemplate.usd`, `uTank2.ViewXML.mainView.xml`,
`uTank2.ViewXML.AdvancedOptionsView.xml`. All paths in this document are
relative to the repo root unless stated otherwise; none of the cited code is
reproduced verbatim beyond short identifiers and strings, per the campaign
rules in `docs/plans/2026-09-06-campaign-vt-vtank-oracle.md`.
No code changes are part of this document. Every claim below cites a
`file:line` in the decompile; a companion Python parser used to extract the
`.usd` grammar and the settings/UI/consumer correlation lives only in the
session scratchpad (not committed) and is described in enough detail here to
be reproduced.
## 1. How settings are stored and read (architecture)
VTank settings are not a flat key/value file. They are rows in a small
generic in-memory relational database (`y` = a named collection of tables,
`bd` = one table, `cw` = one row, `gy` = one self-describing typed cell) that
gets serialized to a `.usd` file. The same database format is reused for
`.ast` per-character caches (section 3) and — per the embedded resource names
— shares a family with `.ugd` template/database resources.
- **`gy`** (`refs/vtank/decompiled/gy.cs:20-56`) is a self-typing cell.
`gy.a(TextReader)` reads one line for a *type tag*, then, depending on the
tag, one more line for the value: `d`=double, `i`=int, `u`=uint, `f`=float,
`s`=string (the raw next line, possibly empty), `b`=bool
(`gy.cs:25-49`). Any other tag string is looked up in a type registry
(`y.a(string)`, `refs/vtank/decompiled/y.cs:28-43`) and the matching custom
type's own multi-line reader runs recursively — two such types are
registered at startup: `bd` itself (tag `"TABLE"`,
`refs/vtank/decompiled/bd.cs:422-426`, used when a setting's *value* is a
nested table) and a length-prefixed raw-text blob class (tag `"ba"`,
`refs/vtank/decompiled/f6.cs:9-30`, `int` length line then that many raw
characters — not lines — allowing embedded newlines). If the tag is unknown
to the registry, `y.a(text)` returns null and `gy.a` consumes **exactly one
line** (the tag) and reads no value line (`gy.cs:50-55`) — the parse
silently desynchronizes from that point rather than erroring. A `.usd`
writer must never emit a tag the reader does not register (citation pass
2026-09-06).
- **`cw`** (`refs/vtank/decompiled/cw.cs:9-40`) is one row: a fixed-size list
of `gy` cells, one per column of its owning table.
- **`bd`** (`refs/vtank/decompiled/bd.cs`) is one table. Its text format
(`bd.a(TextReader)`, `bd.cs:364-395`) is: column count, then that many raw
column-name lines, then that many `"y"`/`"n"` index-flag lines (whether
that column is hash-indexed for fast lookup), then a row count, then that
many rows (each row is `columnCount` `gy` cells in order — **not** always 2
lines each; a `tCustom` cell recurses into a full nested table or blob, so
row length is variable and must be parsed, never assumed).
- **`y`** (`refs/vtank/decompiled/y.cs`) is the whole database: a table
count line, then that many `(tableName, bd)` pairs
(`y.a(TextReader)`, `y.cs:87-97`). `y.d(path)` reads a plain file
(`y.cs:99-104`); `y.c(resourceName)` reads an embedded assembly resource
(`y.cs:131-139`, used only for the two shipped defaults).
- **`f3`** (`refs/vtank/decompiled/f3.cs`) is the accessor facade all other
code calls: `f3.k(name)`→bool (`f3.cs:52-61`), `f3.j(name)`→int
(`f3.cs:63-72`), `f3.i(name)`→double (`f3.cs:74-83`), `f3.h(name)`→float
(`f3.cs:85-94`), `f3.g(name)`→string (`f3.cs:96-105`),
`f3.e(name)`→raw `gy` (`f3.cs:129-147`), `f3.b(name, gy)` sets + persists +
refreshes dependent UI (`f3.cs:150-174`), `f3.a(name, gy)` sets without the
refresh (`f3.cs:176-198`), `f3.d(name)`→description string
(`f3.cs:200-215`), `f3.b(name)``eSettingValueType`
(`f3.cs:223-238`), `f3.a(eSettingCategories)`→list of setting names in any
of the given category flags (`f3.cs:339-352`, `[Flags]` OR test). Every
read-through accessor caches by name in a `static Dictionary`
(`f3.cs:8-20`) that `f3.b()` (the no-arg cache-clear, `f3.cs:118-125`)
flushes on every write.
- **Two databases, one fallback chain.** `f3.e(name)` (`f3.cs:129-147`)
first looks in `PluginCore.dz.m.c["Settings"]` (the *current*, loaded
profile) and only if the key is missing there falls back to
`PluginCore.dz.m.b["Settings"]` (the *built-in* defaults, seeded once from
the embedded `defaultsettings.usd` resource, `refs/vtank/decompiled/da.cs:21`
field `d`, loaded in the `da()` constructor at `da.cs:55-58`). **This is
VTank's unknown/missing-key handling**: a `.usd` saved by an older build
that lacks a setting the current build added silently reads that setting's
shipped default; there is no error, no migration write-back for scalar
settings. (Grid-shaped settings such as `MyMonsters` get an *explicit*
column-count migration instead — see section 3.)
- **`da`** (`refs/vtank/decompiled/da.cs`) is the profile manager,
reachable as `PluginCore.dz.m` (`dz` is `PluginCore`'s singleton hub, of
type `s`; `s.m` is declared and constructed at
`refs/vtank/decompiled/s.cs:33,152`). `da.b` is the built-in-defaults `y`;
`da.c` is the loaded/current `y`.
- **`db`** (`refs/vtank/decompiled/db.cs`) is the *Advanced Options* window
— a single generic property-grid editor that is the **primary UI for the
great majority of settings** (see section 4). It is reachable as
`PluginCore.dz.ad` (field declared and constructed as type `db` at
`refs/vtank/decompiled/s.cs:67,186`) and toggled by the "Advanced Options"
checkbox (`cShowAdvanced` in `mainView.xml`, bound to field `x` at
`refs/vtank/decompiled/uTank2/PluginCore.cs:1627`, toggle call at
`uTank2/PluginCore.cs:6731`).
## 2. Complete settings table (137 rows)
Generated by parsing `uTank2.Resources.defaultsettings.usd`'s `Settings`
table (4 columns: `Setting`, `Value`, `Description`, `SettingType`; 137 rows,
confirmed against `SettingsCategories` (136 rows — every setting except
`RechargeHandlerSet`, which has no category) and `SettingsEnumInfo` (33 rows,
the `tEnum` display-label mappings)) with a from-scratch grammar-accurate
parser (implementing the exact `gy`/`bd`/`cw`/`y` recursive format above),
then cross-referencing each name against:
- every `f3.<accessor>("Name")` call site across all 344 decompiled `.cs`
files (a same-line regex match against `X.Checked`/`X.Text`/`.SelectedIndex`/
`.Value` was used to find a bound UI field), and
- the `this.<field> = (ICheckBox|ITextBox|...)this.k["<ControlName>"]`
binding table built from every such assignment in
`refs/vtank/decompiled/uTank2/PluginCore.cs` (115 bindings extracted, one
per named view control PluginCore holds a reference to), joined against
every `<control name="...">` element in `mainView.xml` /
`AdvancedOptionsView.xml` (189 controls, walked with page/tab tracking).
`eSettingValueType` (`refs/vtank/decompiled/uTank2/eSettingValueType.cs`) and
`eSettingCategories` (`refs/vtank/decompiled/uTank2/eSettingCategories.cs`,
a `[Flags]` enum) supply the Type and Category columns. All 137 settings are
global to the loaded profile (`da.c`) — VTank's per-character/per-profile
scoping happens at the **file** level (section 3: which `.usd` file is
loaded for a character), not at the individual-setting level; there is no
concept of a setting being "per-character" independent of which profile file
is currently selected.
| # | Setting | Type | Default | Category | UI | Primary consumer (decompiled) |
|---|---|---|---|---|---|---|
| 1 | `EnableLooting` | bool | False | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/a1.cs:63 [k]`; `refs/vtank/decompiled/ar.cs:63 [k]`; `refs/vtank/decompiled/b7.cs:109 [k]` |
| 2 | `EnableNav` | bool | False | Navigation | Advanced Options list only (`lOptionList`, filter category Navigation) | `refs/vtank/decompiled/b7.cs:92 [k]`; `refs/vtank/decompiled/da.cs:708 [a]`; `refs/vtank/decompiled/g8.cs:87 [k]` |
| 3 | `EnableBuffing` | bool | True | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/fz.cs:76 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1513 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1514 [b]` |
| 4 | `EnableCombat` | bool | True | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/b4.cs:69 [k]`; `refs/vtank/decompiled/dq.cs:42 [k]`; `refs/vtank/decompiled/h1.cs:32 [k]` |
| 5 | `SpellDiffExcessThreshold-Hunt` | int | 25 | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/da.cs:64 [j]`; `refs/vtank/decompiled/da.cs:711 [a]` |
| 6 | `SpellDiffExcessThreshold-Buff` | int | 5 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/da.cs:66 [j]`; `refs/vtank/decompiled/da.cs:712 [a]` |
| 7 | `ArrowheadFletchDiffExcessThreshold` | int | 10 | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) | `refs/vtank/decompiled/da.cs:713 [a]` |
| 8 | `Recharge-Norm-HitP` | int | 75 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:714 [a]`; `refs/vtank/decompiled/ei.cs:54 [j]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4421 [a]` |
| 9 | `Recharge-Norm-Stam` | int | 50 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:715 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4408 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4412 [b]` |
| 10 | `Recharge-Norm-Mana` | int | 50 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:716 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4395 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4399 [b]` |
| 11 | `Recharge-NoTarg-HitP` | int | 1 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:717 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4343 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4347 [b]` |
| 12 | `Recharge-NoTarg-Stam` | int | 1 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:718 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4330 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4334 [b]` |
| 13 | `Recharge-NoTarg-Mana` | int | 1 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:719 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4317 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4321 [b]` |
| 14 | `Recharge-Helper-HitP` | int | 20 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:720 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4382 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4386 [b]` |
| 15 | `Recharge-Helper-Stam` | int | 1 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:721 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4369 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4373 [b]` |
| 16 | `Recharge-Helper-Mana` | int | 1 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:722 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4356 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:4360 [b]` |
| 17 | `DoHelp` | bool | True | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/da.cs:723 [a]` |
| 18 | `AttackDistance` | double | 0.0208333333333333 | Ranges | Advanced Options list only (`lOptionList`, filter category Ranges) | `refs/vtank/decompiled/da.cs:724 [a]`; `refs/vtank/decompiled/dz.cs:666 [i]`; `refs/vtank/decompiled/ga.cs:1084 [i]` |
| 19 | `AttackMinimumDistance` | double | 0 | Ranges | Advanced Options list only (`lOptionList`, filter category Ranges) | `refs/vtank/decompiled/dz.cs:716 [i]`; `refs/vtank/decompiled/dz.cs:952 [i]`; `refs/vtank/decompiled/dz.cs:966 [i]` |
| 20 | `ApproachDistance` | double | 0 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/da.cs:725 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6502 [i]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6504 [b]` |
| 21 | `RingDistance` | double | 0.0208333333333333 | SpellCombat\|Ranges | `txtRingRange` (Edit, tab "Options"): "" | `refs/vtank/decompiled/da.cs:726 [a]`; `refs/vtank/decompiled/dz.cs:721 [i]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6522 [i]` |
| 22 | `CorpseApproachRange-Max` | double | 0 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/uTank2/PluginCore.cs:6506 [i]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6508 [b]`; `refs/vtank/decompiled/uTank2.Logic/SettingDelegate_SetWaitingOnCorpseId.cs:27 [i]` |
| 23 | `CorpseApproachRange-Min` | double | 0.014 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 24 | `NavCloseStopRange` | double | 0.00833333333333333 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/ca.cs:199 [i]`; `refs/vtank/decompiled/ca.cs:256 [i]`; `refs/vtank/decompiled/ca.cs:317 [i]` |
| 25 | `NavFarStopRange` | double | 999999 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/da.cs:710 [a]` |
| 26 | `UsePortalDistance` | double | 0.0166666666666667 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/fq.cs:100 [i]`; `refs/vtank/decompiled/fq.cs:109 [i]` |
| 27 | `HelperDistanceHitP` | double | 0.310416666666667 | Recharge\|Ranges | Advanced Options list only (`lOptionList`, filter category Recharge\|Ranges) | `refs/vtank/decompiled/ai.cs:290 [i]`; `refs/vtank/decompiled/ai.cs:354 [i]`; `refs/vtank/decompiled/da.cs:727 [a]` |
| 28 | `HelperDistanceStam` | double | 0.310416666666667 | Recharge\|Ranges | Advanced Options list only (`lOptionList`, filter category Recharge\|Ranges) | `refs/vtank/decompiled/ai.cs:296 [i]`; `refs/vtank/decompiled/ai.cs:360 [i]`; `refs/vtank/decompiled/da.cs:728 [a]` |
| 29 | `HelperDistanceMana` | double | 0.166666666666667 | Recharge\|Ranges | Advanced Options list only (`lOptionList`, filter category Recharge\|Ranges) | `refs/vtank/decompiled/ai.cs:302 [i]`; `refs/vtank/decompiled/ai.cs:366 [i]`; `refs/vtank/decompiled/da.cs:729 [a]` |
| 30 | `MinimumRingTargets` | int | 4 | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/da.cs:730 [a]`; `refs/vtank/decompiled/hi.cs:222 [j]` |
| 31 | `DefaultMeleeAttackHeight` | int | 2 | MeleeCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat) | `refs/vtank/decompiled/bo.cs:300 [j]`; `refs/vtank/decompiled/f7.cs:119 [j]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1528 [b]` |
| 32 | `CastDispelSelf` | bool | False | Recharge | `cDispel` (Checkbox, tab "Options"): "Cast Dispel Self" | `refs/vtank/decompiled/c8.cs:68 [k]`; `refs/vtank/decompiled/da.cs:731 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6567 [b]` |
| 33 | `UseDispelItems` | bool | False | Recharge | `cGems` (Checkbox, tab "Options"): "Use Dispel Items" | `refs/vtank/decompiled/cx.cs:66 [k]`; `refs/vtank/decompiled/da.cs:732 [a]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6579 [b]` |
| 34 | `AutoCram` | bool | False | Misc | `cEnableAutoCram` (Checkbox, tab "Options"): "Enable Autocram" | `refs/vtank/decompiled/el.cs:129 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:3273 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:3276 [b]` |
| 35 | `AutoStack` | bool | True | Misc | `cEnableAutoStack` (Checkbox, tab "Options"): "Enable Autostack" | `refs/vtank/decompiled/el.cs:81 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:3272 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:3275 [b]` |
| 36 | `ReadUnknownScrolls` | bool | True | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/hv.cs:479 [k]` |
| 37 | `UseDispelDrum` | bool | False | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/af.cs:75 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:3046 [b]` |
| 38 | `SwitchWandsToDebuff` | bool | False | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/dz.cs:488 [k]` |
| 39 | `AutoCraftItems` | bool | True | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) | `refs/vtank/decompiled/a9.cs:111 [k]` |
| 40 | `UseHealersHeart` | bool | True | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/fb.cs:71 [k]` |
| 41 | `JumpOutWandCasting` | bool | False | Recharge\|SpellCombat | Advanced Options list only (`lOptionList`, filter category Recharge\|SpellCombat) | `refs/vtank/decompiled/gs.cs:238 [k]`; `refs/vtank/decompiled/gs.cs:304 [k]` |
| 42 | `LootAllCorpses` | bool | False | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/fo.cs:431 [k]` |
| 43 | `LootFellowCorpses` | bool | False | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/fo.cs:426 [k]` |
| 44 | `DoJiggle` | bool | False | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/gj.cs:165 [k]`; `refs/vtank/decompiled/gs.cs:119 [k]` |
| 45 | `RandomHelperBuffs` | bool | False | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/ba.cs:85 [k]` |
| 46 | `RandomHelperIntervalSeconds` | double | 5 | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/ba.cs:154 [i]` |
| 47 | `IdlePeaceMode` | bool | False | Misc | `cIdlePeace` (Checkbox, tab "Options"): "Peace Mode When Idle" | `refs/vtank/decompiled/cm.cs:66 [k]`; `refs/vtank/decompiled/fd.cs:131 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6744 [b]` |
| 48 | `TargetLock` | bool | False | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/dz.cs:717 [k]`; `refs/vtank/decompiled/dz.cs:952 [k]`; `refs/vtank/decompiled/dz.cs:967 [k]` |
| 49 | `StopMacroOnDeath` | bool | True | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/uTank2/PluginCore.cs:4155 [k]` |
| 50 | `UseArcs` | enum | 1 | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) Enum: 1=No, 2=At Range, 3=Yes | `refs/vtank/decompiled/hi.cs:515 [f]` |
| 51 | `ArcRange` | double | 0.0208333333333333 | SpellCombat\|Ranges | Advanced Options list only (`lOptionList`, filter category SpellCombat\|Ranges) | `refs/vtank/decompiled/hi.cs:522 [i]` |
| 52 | `TargetSelectMethod` | enum | 3 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) Enum: 1=By range, 2=By angle, 3=Both | `refs/vtank/decompiled/dz.cs:722 [f]` |
| 53 | `TargetSelectAngleRange` | double | 0.0208333333333333 | MeleeCombat\|SpellCombat\|Ranges | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat\|Ranges) | `refs/vtank/decompiled/dz.cs:726 [i]` |
| 54 | `IdleBuffTopoff` | bool | False | Buffing | `cTopoffBuffs` (Checkbox, tab "Options"): "Rebuff When Idle" | `refs/vtank/decompiled/uTank2/PluginCore.cs:6675 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7278 [k]` |
| 55 | `IdleBuffTopoffTimeSeconds` | int | 1200 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 56 | `RebuffTimeRemainingSeconds` | int | 300 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/gw.cs:14 [j]` |
| 57 | `RefillWornMana` | bool | True | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/ev.cs:108 [k]` |
| 58 | `RefillWornMana-Item-ManaPercent` | int | 33 | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/ev.cs:113 [j]` |
| 59 | `BuffProfile-Prots` | string | "ALFCBPS" | Buffing | **Not exposed**`tString` has no case in the Advanced Options list switch (`db.cs:132-166`) and the click handler no-ops for it (`db.cs:201-203`); no control binds it and no code writes it. Settable only via `/vt opt set`, read only when `BuffProfile_Prots == 1` (Custom). | `refs/vtank/decompiled/eq.cs:161 [g]` |
| 60 | `BuffProfile-Banes` | string | "ALFCBPS" | Buffing | **Not exposed**`tString` has no Advanced Options case (`db.cs:132-166`, `:201-203`); `/vt opt set` only, read only when `BuffProfile_Banes == 1` (Custom). | `refs/vtank/decompiled/eq.cs:248 [g]` |
| 61 | `BuffProfile_Prots` | enum | 2 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) Enum: 1=Custom, 2=All, 3=None, 4=B, 5=BPS, 6=BPSA, 7=ALFC, 8=BPSAC | `refs/vtank/decompiled/eq.cs:160 [f]`; `refs/vtank/decompiled/eq.cs:161 [b]` |
| 62 | `BuffProfile_Banes` | enum | 2 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) Enum: 1=Custom, 2=All, 3=None, 4=B, 5=BPS, 6=BPSA, 7=BPSAC, 8=ALFC | `refs/vtank/decompiled/eq.cs:247 [f]`; `refs/vtank/decompiled/eq.cs:248 [b]` |
| 63 | `DebuffEachFirst` | enum | 1 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) Enum: 1=One, 2=Priority, 3=All | `refs/vtank/decompiled/dz.cs:712 [c]`; `refs/vtank/decompiled/dz.cs:712 [c]`; `refs/vtank/decompiled/dz.cs:713 [c]` |
| 64 | `AutoAttackPower` | bool | True | MeleeCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat) | `refs/vtank/decompiled/bo.cs:301 [k]` |
| 65 | `LootPriorityBoost` | bool | False | Looting | `cLootPriorityBoost` (Checkbox, tab "Options"): "Boost Loot Priority" | `refs/vtank/decompiled/uTank2/PluginCore.cs:1574 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1575 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1583 [b]` |
| 66 | `CorpseCacheTimeoutMinutes` | double | 60 | Navigation | Advanced Options list only (`lOptionList`, filter category Navigation) | `refs/vtank/decompiled/fo.cs:138 [i]` |
| 67 | `CorpseItemAppearanceTimeoutSeconds` | double | 6 | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/hv.cs:318 [i]` |
| 68 | `CorpseItemIDTimeoutSeconds` | double | 60 | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/hv.cs:336 [i]` |
| 69 | `DebuffSelectionMethod` | enum | 2 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) Enum: 1=SpellLevel, 2=Skill | `refs/vtank/decompiled/dz.cs:38 [c]`; `refs/vtank/decompiled/dz.cs:57 [c]` |
| 70 | `ManaStoneLootCount` | int | 4 | Misc\|Looting | Advanced Options list only (`lOptionList`, filter category Misc\|Looting) | `refs/vtank/decompiled/dy.cs:276 [j]` |
| 71 | `ManaTankMinimumMana` | int | 1000 | Misc\|Looting | Advanced Options list only (`lOptionList`, filter category Misc\|Looting) | `refs/vtank/decompiled/dy.cs:132 [j]` |
| 72 | `SplitPeas` | bool | True | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) | `refs/vtank/decompiled/as.cs:76 [k]` |
| 73 | `SpellCompMin-Critical` | int | 4 | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 74 | `SpellCompMin-Normal` | int | 20 | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 75 | `SpellCompMin-Idle` | int | 20 | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 76 | `RechargeBoostTimeSeconds` | double | 5 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/be.cs:142 [i]`; `refs/vtank/decompiled/be.cs:149 [i]`; `refs/vtank/decompiled/be.cs:154 [i]` |
| 77 | `RechargeBoostAmount` | int | 40 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/cg.cs:44 [j]`; `refs/vtank/decompiled/cg.cs:65 [j]`; `refs/vtank/decompiled/cg.cs:86 [j]` |
| 78 | `UseSpecialAmmo` | enum | 0 | Crafting | Advanced Options list only (`lOptionList`, filter category Crafting) Enum: 0=None, 1=Raider, 2=SCurrency, 3=Both | `refs/vtank/decompiled/bv.cs:88 [f]` |
| 79 | `OpenDoors` | bool | False | Navigation | Advanced Options list only (`lOptionList`, filter category Navigation) | `refs/vtank/decompiled/b7.cs:88 [k]` |
| 80 | `DoorIDRange` | double | 0.0833333333333333 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/b7.cs:99 [i]` |
| 81 | `DoorOpenRange` | double | 0.0166666666666667 | Ranges\|Navigation | Advanced Options list only (`lOptionList`, filter category Ranges\|Navigation) | `refs/vtank/decompiled/b7.cs:138 [i]` |
| 82 | `DoorLockpickDiffExcessThreshold` | int | -50 | Navigation\|Crafting | Advanced Options list only (`lOptionList`, filter category Navigation\|Crafting) | `refs/vtank/decompiled/b7.cs:149 [j]` |
| 83 | `ManaChargesWhenOff` | bool | True | Misc | `cMChargesWhenOff` (Checkbox, tab "Options"): "M. Charges When Off" | `refs/vtank/decompiled/uTank2/PluginCore.cs:6768 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7284 [k]` |
| 84 | `AutoFellowManagement` | bool | True | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/bf.cs:224 [k]`; `refs/vtank/decompiled/bf.cs:476 [k]`; `refs/vtank/decompiled/bf.cs:604 [k]` |
| 85 | `MinimumHealKitSuccessChance` | int | 95 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/a5.cs:84 [j]` |
| 86 | `UseKitsInMagicMode` | bool | True | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/a5.cs:61 [k]` |
| 87 | `StaminaToHealthMultiplier` | double | 1.9 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/dg.cs:104 [i]` |
| 88 | `ManaToHealthMultiplier` | double | 2.8 | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/dg.cs:108 [i]` |
| 89 | `NavPriorityBoost` | bool | False | Navigation | `cNavPriorityBoost` (Checkbox, tab "Options"): "Boost Nav. Priority" | `refs/vtank/decompiled/uTank2/PluginCore.cs:1567 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1568 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:1581 [k]` |
| 90 | `DeleteGhostMonsters` | bool | True | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/b8.cs:114 [k]` |
| 91 | `GhostMonsterSpellAttemptCount` | int | 200 | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/b8.cs:111 [j]` |
| 92 | `WhoYouGonnaCall` | bool | True | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 93 | `BlacklistMonsterAttemptCount` | int | 4 | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/fp.cs:79 [j]` |
| 94 | `BlacklistMonsterTimeoutSeconds` | int | 120 | Misc | Advanced Options list only (`lOptionList`, filter category Misc) | `refs/vtank/decompiled/fp.cs:96 [j]` |
| 95 | `CombineSalvage` | bool | True | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/c7.cs:207 [k]` |
| 96 | `LootOnlyRareCorpses` | bool | False | Looting | `cLootOnlyRareCorpses` (Checkbox, tab "Options"): "Loot Only Rare Corpses" | `refs/vtank/decompiled/fo.cs:403 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6860 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7289 [k]` |
| 97 | `DeleteGhostMonstersByHPTracker` | bool | True | Misc\|MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category Misc\|MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/b8.cs:79 [k]` |
| 98 | `GhostDeleteHPTrackerSeconds` | int | 30 | Misc\|MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category Misc\|MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/b8.cs:89 [j]`; `refs/vtank/decompiled/b8.cs:89 [j]` |
| 99 | `GoToPeaceModeToUseKits` | bool | False | Misc\|Recharge | Advanced Options list only (`lOptionList`, filter category Misc\|Recharge) | `refs/vtank/decompiled/a5.cs:121 [k]` |
| 100 | `UseRecklessness` | bool | True | MeleeCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat) | `refs/vtank/decompiled/hi.cs:663 [k]` |
| 101 | `DebuffPrecastSeconds` | int | 5 | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/dz.cs:579 [j]`; `refs/vtank/decompiled/hi.cs:111 [j]` |
| 102 | `ClearLevelBoostFlagOnCast` | bool | True | Recharge | Advanced Options list only (`lOptionList`, filter category Recharge) | `refs/vtank/decompiled/be.cs:168 [k]`; `refs/vtank/decompiled/be.cs:178 [k]`; `refs/vtank/decompiled/be.cs:188 [k]` |
| 103 | `IdleCraftCount_HealthKits` | int | 2 | Recharge\|Crafting | Advanced Options list only (`lOptionList`, filter category Recharge\|Crafting) | `refs/vtank/decompiled/a9.cs:88 [j]` |
| 104 | `IdleCraftCount_StamKits` | int | 2 | Recharge\|Crafting | Advanced Options list only (`lOptionList`, filter category Recharge\|Crafting) | `refs/vtank/decompiled/a9.cs:89 [j]` |
| 105 | `IdleCraftCount_ManaKits` | int | 2 | Recharge\|Crafting | Advanced Options list only (`lOptionList`, filter category Recharge\|Crafting) | `refs/vtank/decompiled/a9.cs:90 [j]` |
| 106 | `IdleCraftCount_HealthFood` | int | 15 | Recharge\|Crafting | Advanced Options list only (`lOptionList`, filter category Recharge\|Crafting) | `refs/vtank/decompiled/a9.cs:91 [j]` |
| 107 | `IdleCraftCount_StamFood` | int | 15 | Recharge\|Crafting | Advanced Options list only (`lOptionList`, filter category Recharge\|Crafting) | `refs/vtank/decompiled/a9.cs:92 [j]` |
| 108 | `IdleCraftCount_ManaFood` | int | 15 | Recharge\|Crafting | Advanced Options list only (`lOptionList`, filter category Recharge\|Crafting) | `refs/vtank/decompiled/a9.cs:93 [j]` |
| 109 | `BuffCastRecast_Seconds` | int | 30 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/fz.cs:83 [j]`; `refs/vtank/decompiled/gw.cs:17 [j]` |
| 110 | `BuffCastRecastReset_Seconds` | int | 30 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/fz.cs:118 [j]` |
| 111 | `EnableMeta` | bool | False | Misc | `c1EnableMeta` (Checkbox, tab "Options"): "Enable Meta" | `refs/vtank/decompiled/a7.cs:91 [k]`; `refs/vtank/decompiled/ao.cs:79 [b]`; `refs/vtank/decompiled/cp.cs:148 [b]` |
| 112 | `BlacklistedSpellComps` | string | "" | Recharge\|SpellCombat\|Buffing | **Not exposed** by the Advanced Options list (`tString` has no case, `db.cs:132-166`); settable via `/vt opt set` and by `PluginCore.cs:7749 [b]`. | `refs/vtank/decompiled/uTank2/MySpell.cs:403 [g]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7734 [g]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7749 [b]` |
| 113 | `DropToPeaceModeRetryCount` | int | 34 | Misc\|MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category Misc\|MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/ga.cs:1511 [j]` |
| 114 | `FollowAroundCorners` | bool | True | Navigation | Advanced Options list only (`lOptionList`, filter category Navigation) | `refs/vtank/decompiled/gl.cs:83 [k]`; `refs/vtank/decompiled/gl.cs:121 [k]`; `refs/vtank/decompiled/gl.cs:142 [k]` |
| 115 | `BlacklistCorpseOpenAttemptCount` | int | 30 | Misc\|Looting | Advanced Options list only (`lOptionList`, filter category Misc\|Looting) | `refs/vtank/decompiled/fo.cs:339 [j]` |
| 116 | `BlacklistCorpseOpenTimeoutSeconds` | int | 200 | Misc\|Looting | Advanced Options list only (`lOptionList`, filter category Misc\|Looting) | `refs/vtank/decompiled/fo.cs:349 [j]`; `refs/vtank/decompiled/fo.cs:393 [j]` |
| 117 | `SummonPets` | bool | True | MeleeCombat\|SpellCombat | `cSummonPets` (Checkbox, tab "Options"): "Summon Pets" | `refs/vtank/decompiled/h1.cs:36 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6848 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7290 [k]` |
| 118 | `PetRangeMode` | enum | 0 | MeleeCombat\|SpellCombat\|Ranges | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat\|Ranges) Enum: 0=AttackDistance, 1=PetCustomRange | `refs/vtank/decompiled/ga.cs:1081 [f]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6831 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6835 [b]` |
| 119 | `PetCustomRange` | double | 0.0208333333333333 | MeleeCombat\|SpellCombat\|Ranges | `txtCustomPetRange` (Edit, tab "Options"): "" | `refs/vtank/decompiled/ga.cs:1083 [i]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6814 [i]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6816 [b]` |
| 120 | `PetRefillCount-Idle` | int | 3 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 121 | `PetRefillCount-Normal` | int | 1 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | *(no direct f3 accessor call found — see notes / doc 02)* |
| 122 | `CorpseOpenTimeoutSeconds` | double | 1.5 | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/bj.cs:109 [i]` |
| 123 | `PetMonsterDensity` | int | 1 | MeleeCombat\|SpellCombat | `txtPetDensity` (Edit, tab "Options"): "" | `refs/vtank/decompiled/ga.cs:1086 [j]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6799 [j]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6801 [b]` |
| 124 | `CorpseLootItemMaxAttempts` | int | 20 | Looting | Advanced Options list only (`lOptionList`, filter category Looting) | `refs/vtank/decompiled/hv.cs:379 [j]` |
| 125 | `FastCastBuffs` | bool | False | Buffing | `cFastCast` (Checkbox, tab "Options"): "Fastcast Buffs" | `refs/vtank/decompiled/gj.cs:171 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6687 [b]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:7279 [k]` |
| 126 | `UseBreakableTurnTo` | bool | True | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/gj.cs:505 [k]` |
| 127 | `UseProjectileAwareness` | bool | True | MeleeCombat\|SpellCombat | `cCollisionChecks` (Checkbox, tab "Options"): "Don't Shoot at Walls" | `refs/vtank/decompiled/cz.cs:122 [k]`; `refs/vtank/decompiled/dz.cs:676 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6699 [b]` |
| 128 | `CollisionProjectileRadius` | double | 0.4 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/cz.cs:164 [i]` |
| 129 | `CollisionStepDistance` | double | 0.7 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/cz.cs:165 [i]` |
| 130 | `ShowCollisionDebug` | bool | False | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/cz.cs:166 [k]` |
| 131 | `MaximumCollisionChecksPerTick` | int | 500 | MeleeCombat\|SpellCombat | Advanced Options list only (`lOptionList`, filter category MeleeCombat\|SpellCombat) | `refs/vtank/decompiled/dz.cs:676 [j]` |
| 132 | `SpellRangeFudge` | double | 1 | SpellCombat | Advanced Options list only (`lOptionList`, filter category SpellCombat) | `refs/vtank/decompiled/uTank2/MySpell.cs:351 [i]` |
| 133 | `BuffWithUntrained-Item` | int | 80 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/eq.cs:199 [j]`; `refs/vtank/decompiled/eq.cs:242 [j]` |
| 134 | `BuffWithUntrained-Creature` | int | 80 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/eq.cs:211 [j]` |
| 135 | `BuffWithUntrained-Life` | int | 80 | Buffing | Advanced Options list only (`lOptionList`, filter category Buffing) | `refs/vtank/decompiled/eq.cs:223 [j]` |
| 136 | `AllowDebuffFallback` | bool | False | MeleeCombat\|SpellCombat | `cDebuffFallback` (Checkbox, tab "Options"): "Fallback Debuffs if Blocked" | `refs/vtank/decompiled/dz.cs:234 [k]`; `refs/vtank/decompiled/hi.cs:369 [k]`; `refs/vtank/decompiled/uTank2/PluginCore.cs:6711 [b]` |
| 137 | `RechargeHandlerSet` | custom(table) | (nested table — 5 columns `Vital`/`HandlerString`/`MinPercent`/`MaxPercent`/`Stance`, 26 seed rows (`defaultsettings.usd:1247`; the first draft said 24), see prose) | *(none)* | **Not exposed** by the generic Advanced Options editor — `tCustom` has no case in its display/edit switch (`refs/vtank/decompiled/db.cs:118-165`) and no case in `/vt opt set` either (`refs/vtank/decompiled/uTank2/PluginCore.cs:5567-5610`). Owned by a dedicated `cRechargeManager` object (`refs/vtank/decompiled/s.cs:83`), not by any control found in `mainView.xml`/`AdvancedOptionsView.xml`. See "Could not determine". | `refs/vtank/decompiled/da.cs:659 [a]`; `refs/vtank/decompiled/da.cs:685 [e]` |
Where "Primary consumer" shows *(no direct f3 accessor call found)*, the
setting's only reference found was inside `cLogic.cs`'s scheduler-rule
registration (`AddLogicRule(new <RuleClass>(priority, "SettingName", ...))`)
— the rule class itself reads the setting through its own indirection at
runtime. That indirection is scheduler-and-actions territory (doc 02), not
this settings doc; `WhoYouGonnaCall` is the one name with **no** reference
anywhere outside the `.usd` files themselves — see "Could not determine".
## 3. The `.usd`/`.ast` file format, locations, naming, and versioning
**Format**: exactly the `y` database grammar from section 1 — plain text,
CRLF line endings (confirmed on-disk: `refs/vtank` sources and the live
`.usd`/`.ast` files under `C:\Games\VirindiPlugins\VirindiTank\` both use
`\r\n`), no compression, no checksum, no version header of its own (the
version discipline lives inside individual tables — see below).
**Directory and path source**: the profile folder (referenced everywhere as
`PluginCore.dq`, declared at `refs/vtank/decompiled/uTank2/PluginCore.cs:921`)
is **not hardcoded** — it is read once from the Windows registry:
`Registry.LocalMachine.OpenSubKey("Software\Decal\Plugins\{642F1F48-16BE-48BF-B1D4-286652C4533E}").GetValue("ProfilePath")`
(`uTank2/PluginCore.cs:4052`). On the user's machine this currently resolves
to `C:\Games\VirindiPlugins\VirindiTank\`, confirmed by directory listing
(read-only inspection performed for this doc). **Linux-portability note**:
this registry dependency cannot be ported as-is (no Windows registry, no
Decal); MossTank must choose its own directory-discovery mechanism (an env
var, an XDG-style config dir, or a user-set path) while keeping the `.usd`
**file format** byte-for-byte compatible so existing profiles remain
loadable. This is an explicit design decision doc 02+/VT2 needs to make, not
something the decompile answers.
**Per-character binding file — the `.cdf`**: one file per character,
`Path.Combine(dq, Server + "_" + CharacterName + ".cdf")`
(`refs/vtank/decompiled/da.cs:110`, read in `da.e()` at `da.cs:105-154`,
written in `da.q()` at `da.cs:156-164`). Format: line 1 is the literal
version header `"uTank2 CDF 1.0"` (`da.cs:15`); if that doesn't match, the
`.cdf` is treated as absent/corrupt and every profile resets to that
character's auto-created per-character defaults (`da.cs:113-121`). Lines
24 are the three profile filenames currently assigned to this character:
settings (`.usd`), loot (extension varies — see below), nav (`.nav`); an
optional line 5 (present only if `!streamReader.EndOfStream`,
`da.cs:125-128`) carries the meta (`.met`) filename, handled by a different
owner (`PluginCore.dz.at`, not `da` itself). **Backward-compat rewrite**: if
the settings filename read from line 2 ends in the legacy extension `.uts`,
`da.e()` silently rewrites it in memory to the same basename with `.usd`
(`da.cs:130-141`) before use — see the `.uts``.usd` one-time file migration
below.
**Auto per-character profile names** (the "[By char]" entries in every
profile combo box): built as `"--" + CharacterName + "_" + Server + ".usd"`
/ `.nav` / `.met` (`refs/vtank/decompiled/uTank2/PluginCore.cs:3863-3865`),
stored on `da.o`/`da.p`/`da.q` (`da.cs:43`, `da.cs:45`, `da.cs:47` — the
loot default has no equivalent auto-name; loot profiles default to none).
Confirmed against the live directory: files named exactly
`--<Name>_<Server>.usd/.met/.nav` exist (read-only inspection).
**The `--` prefix — reserved, per-character**: any profile filename whose
first two characters are `"--"` is hidden from the normal (cross-character)
profile picker (`uTank2/PluginCore.cs:7020,7072,7144`, etc.) *unless* it
additionally starts with that character's own prefix
`"--" + Name + "_" + Server + "_"` (trailing underscore — a **different**,
longer prefix than the single auto-file name above, stored in the field
`dw`, `uTank2/PluginCore.cs:933,3866`), in which case it is shown specially
as `"[Char] <suffix>"` with the `--Name_Server_` prefix and the extension
stripped (`uTank2/PluginCore.cs:7016-7046` builds the display name;
`uTank2/PluginCore.cs:7060-7112` is the equivalent list-population pass).
This is confirmed live: `--Barris_Coldeve_Base.usd`,
`--Barris_Coldeve_Blank.usd`, `--Barris_Coldeve_viridian.usd` etc. are all
named per-character sub-profiles for the character "Barris" on server
"Coldeve", distinct from that character's single default
`--Barris_Coldeve.usd`. A profile whose name starts with `"--"` but does
**not** match the current character's own `dw` prefix belongs to a
*different* character and is invisible to this one.
Nav profiles additionally hide any file starting with `"~~"`
(`uTank2/PluginCore.cs:7182`) — a third reserved prefix whose purpose was
not determined from the settings/profile code path examined (see "Could not
determine").
**Profile listing and selection UI** (`refs/vtank/decompiled/uTank2/PluginCore.cs`):
four near-identical list-builder methods populate the four combo boxes —
`a0()` (settings, `cmbSettingsSet`, lines 7054-7128) always seeds
`"[Default]"` and `"[By char]"` first, then every non-`--` `*.usd` file
(filtered to only the currently-selected one when the "Mine only" checkbox
`cSettingsShowAll`/field `a9` is checked, `7072-7078`; the same test appears in the
display-name helper `h()` at `7020-7024`) plus every
`dw`-matching `"[Char] "`-labeled file; `aa()` (loot, `cmbLootSet`,
lines 7130-7166) seeds only `"[None]"` and lists **every** non-`--` file
across all of `dz.ah.a()`'s loot-profile extensions with no "mine only"
filter and no `"[By char]"` option; `l()` (nav, `cmbNavSet`, lines
7168-7207) seeds `"[None]"`/`"[By char]"` and filters out both `--` and
`~~`; `ac()` (meta, `cmbMetaSet`, lines 7209-7248) seeds `"[None]"`/`"[By char]"`
and filters only `--`. **Loot profiles are structurally the odd one out**:
no auto per-character file, no "mine only" filtering, and (per Classic
Looter's own file format — out of scope for this doc, see doc 05) a
different file extension family, not `.usd`.
**`.uts``.usd` one-time migration** (`da.g()`, `refs/vtank/decompiled/da.cs:688-780`):
on every profile-load pass, VTank scans the profile directory for `*.uts`
files (the pre-0.2.1 flat line-by-line settings format, header
`"uTank2 SCF 1.06"`, `da.cs:690`), and for each one: loads defaults from the
**template** resource (`da.c.c(this.e)`, i.e. `defaultsettingstemplate.usd`,
not `defaultsettings.usd`), reads roughly 25 fixed fields by position
(`Convert.ToBoolean`/`ToDouble`/`ToInt32`/`ToSingle` in a fixed order,
`da.cs:707-732`), then a variable-length gem/food and assist-items block,
then hands off to another owner for spell-book-derived data
(`PluginCore.dz.e`, `da.cs:749-759`), writes the result as `<basename>.usd`
(`da.cs:763`), and renames the old `.uts` to `<name>.uts.bak`
(`da.cs:765`). This is legacy/historical and is unlikely to matter for
drop-in compatibility today (no live `.uts` files were found in the
inspected directory) — noted for completeness since the task calls for the
format "exactly as VTank reads and writes it," including old formats it
still tolerates.
**Grid-shaped setting schema migration** (`da.k()`,
`refs/vtank/decompiled/da.cs:259-367` — the settings-profile loader):
after loading a `.usd` file's `Settings`/`MyMonsters`/etc. tables, VTank
checks the **column count** of the loaded `MyMonsters` table
(`this.c["MyMonsters"].d()`) against eleven hardcoded thresholds
(`da.cs:280-323`) and appends a new column with a hardcoded default value
for every column added by a newer build than the one that saved the file
(e.g. `if (...).d() <= 16) { ...a("Corrosion", gy.a(false)); }` at
`da.cs:304-307`) — this is VTank's explicit unknown-schema/versioning
strategy for **table**-shaped settings (as opposed to the silent
default-database fallback used for scalar settings in section 1). The same
method also adds three whole tables outright if missing
(`ExtraBuffSpells`, `AntiExtraBuffSpells`, `ItemUseSpecifiers`,
`da.cs:324-336`) — i.e., **whole-table addition uses `ContainsKey`, per-column
addition uses a row-count-like column-count threshold; neither uses an
explicit schema-version field.** If the `.usd` file fails to parse at all,
`da.k()` falls back to the **template** resource and immediately overwrites
the file on disk with defaults (`da.cs:347-352`; `da.cs:354-359` is the
separate file-missing branch with the identical body) — a corrupt/foreign `.usd`
is silently replaced, not preserved or backed up.
**`defaultsettings.usd` vs `defaultsettingstemplate.usd`**: they are NOT the
same schema (corrected by the citation pass 2026-09-06). `defaultsettings.usd`
(2270 lines) carries **10 tables**, including `Settings` (4 columns × 137
rows), `SettingsCategories` (136 rows) and `SettingsEnumInfo` (33 rows).
`defaultsettingstemplate.usd` (141 lines) carries only **8 tables**: it drops
`SettingsCategories` and `SettingsEnumInfo` entirely, and its `Settings` table
has just **2 columns (`Setting`, `Value`) and zero rows**. The grid tables are
identical in both files — `GemFoodItems` has the same 2 rows ("Asheron's
Benediction"→3810, "Blackmoor's Favor"→3811) and `MyMonsters` the same
single 21-column `<DEFAULT>` row; the whole 2129-line delta is the three
settings tables. Consequence: a template-seeded profile (`da.f()`) starts
with an **empty** `Settings` table, so every scalar read falls through
`f3.e()`'s defaults fallback to `da.b` — an independent confirmation of the
fallback chain in section 1.
`defaultsettings.usd` is the built-in fallback database (`da.b`, never
written to disk on its own); `defaultsettingstemplate.usd` is what a brand
new (or unparseable) profile is seeded from (`da.f()`,
`refs/vtank/decompiled/da.cs:592-610`, and `da.k()`'s failure path above).
**`.ast` — a related but distinct per-character cache**: same `y` database
grammar, but no `"--"` prefix and not user-selectable — always exactly
`CharacterName + "_" + Server + ".ast"`
(`refs/vtank/decompiled/dm.cs:391`, class `dm`). Live inspection of
`+Horan_sawato.ast` (65 bytes) shows one table, `Spells`, four columns
(`SpellID`, `EndTime`, `Target`, `CastTime`), zero rows in the sample
inspected. `dm.g()` (`dm.cs:389-414`) reseeds from the
embedded `defaultitemagedb.ugd` resource on two triggers — clock-rollback
detection (`dm.cs:396-401`) and the catch for a missing/corrupt file
(`dm.cs:408-413`) — i.e. if the file is missing or
corrupt. This looks like a per-character cast/assist-tracking cache
(tracking spell durations placed on other players) rather than a settings
profile; its exact runtime semantics were not traced further as out of
scope for this document.
## 4. `/vt opt` and other settings commands
`refs/vtank/decompiled/uTank2/PluginCore.cs:5446-5610` (inside the `/vt `
chat-command dispatcher — the surrounding `if` chain is a long
`text.CompareTo(...)`/`text.StartsWith(...)` ladder over the text after the
`/vt ` prefix):
| Command | Grammar | Behavior | Error text |
|---|---|---|---|
| `/vt opt` (no args) | `opt` | prints usage | `"Usage: /vt opt [list/get/set/setinall]"` (`:5450`) |
| `/vt opt list` | `opt list` | prints `"Available options: (" + <count> + ")"` then every `Settings` row's name from the **built-in defaults** table (`dz.m.b["Settings"]`, not the loaded profile), four per line, `" "`-indented (`:5518-5538`) | `"Usage: /vt opt list"` if extra args (`:5514`) |
| `/vt opt get <name>` | `opt get X` | prints `"Option " + name + " = " + f3.e(name)"` via the raw `gy.ToString()` (`:5539-5556`) | `"Option get: Invalid option specified."` if the name isn't found (`:5553`); `"Usage: /vt opt get [option name]"` for wrong arg count (`:5543`) |
| `/vt opt set <name> <value>` | `opt set X V` | looks up the name's declared type in the **built-in defaults** table, converts `V` to that CLR type (`double`/`int`/`float`/`string`/`bool` — note **no case for a `tCustom` value**, so `RechargeHandlerSet` cannot be set this way), then calls `f3.b(name, gy)` (persists + refreshes) (`:5566-5615`; type ladder `:5588-5607`) | `"Option set: Invalid option specified."` if name not found; `"Option set: Invalid value specified. Proper type of " + name + " is " + type + "."` on conversion failure or unrecognized type; `"Usage: /vt opt set [option name] [option value]"` for wrong arg count |
| `/vt opt setinall <name> <value>` | `opt setinall X V` | same type resolution as `set`, but writes through `global::bk.a(name, gy)` instead of `f3.b` (case `:5457-5511`, call at `:5504`). `bk.a` (`bk.cs:6-35`) enumerates **every `*.usd` file in `PluginCore.dq`** with no `--` filtering (other characters' profiles included): the currently-loaded file (`dz.m.m`) goes through `f3.b`, every other file is loaded into a fresh `y`, has its `Settings` row inserted-or-overwritten, and is written straight back to disk (`y2.b(text)`); it reports `"Done saving setting X to all profiles. (Changed N profiles)"` and writes the canonical name from the defaults row (`gy.b(cw3[0])`), not the typed casing. Quirk: the arg-count guard at `:5459-5462` prints usage but does not `break` — it falls through and still attempts the lookup | `"Usage: /vt opt setinall [option name] [option value]"` if arg count != 4 (`:5462`) |
| *(default)* | any other `opt <word>` | | `"Usage: /vt opt [list/get/set]"` (`:5617`) — note this usage string omits `setinall` even though it is a real, dispatched subcommand |
The exact same `/vt` ladder also contains many non-settings debug commands
(`dumptracker`, `deletemonster`, `getdb`, `obtest`, `nav save/load`, …,
`uTank2/PluginCore.cs:5401-5445,5610+`) that are out of scope for this
document (see doc 09 for the full command catalogue).
## 5. MossTank gap analysis
`src/AcDream.Plugins.MossTank/` already has a **large amount of real parity**
with VTank's setting *names* and *defaults* — this is a materially different
starting point than "MossTank has nothing." Evidence:
- `VtankOptionCatalog.Names` (`src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs:9-`)
is an exact, order-preserved copy of all 137 `.usd` `Settings` row names.
- `MossTankPanel.SetMetaOption` (`src/AcDream.Plugins.MossTank/MossTankPanel.cs:2788-3372`)
has a `case` for the **lowercased form of every one of the 137 VTank
setting names**, confirmed by an exhaustive diff (137/137 matched; 0
missing) run for this document.
- `CombatSettings`/`BuffSettings` (`BuffPlan.cs`)/`VitalSettings` (`VitalPlan.cs`)/
`InventoryProfileDocument` (`MossTankProfileStore.cs`) carry live fields for
the great majority of those names, frequently with the exact VTank default
value and a doc comment citing the VTank setting name.
That said, real gaps and divergences were found:
1. **No `.usd` file compatibility at all.** MossTank persists profiles as
JSON via `_host.Storage` (`MossTankProfileStore.cs:279-311`), not as the
`y`/`bd`/`cw`/`gy` grammar in section 1. `MossTankCommands.cs:236`
strips a `".usd"`/`".settings"` suffix from a **profile name argument**
purely for display/matching — no code anywhere in
`src/AcDream.Plugins.MossTank/` opens or parses an actual `.usd` file
(confirmed by grep: every `.usd` reference in the plugin's source is a
code comment citing VTank's own defaults file as the source of a ported
constant, e.g. `InventoryMaintenance.cs:8`, `Looting.cs:81`,
`VitalPlan.cs:28`, `VtankOptionCatalog.cs:5`). **A real, unmodified
VTank `.usd` profile cannot be loaded by MossTank today.** This is the
headline gap the campaign plan (VT2/VT3) already flags as the
first-priority slice.
2. **`BuffProfileDocument` (the JSON persistence record,
`MossTankProfileStore.cs:807-856`) does not round-trip several fields
that the live `BuffSettings` model (`BuffPlan.cs`) actually has and that
`SetMetaOption` actually writes.** Confirmed missing from both
`Capture()` and `Apply()`: `BuffCastRecastSeconds`,
`BuffCastRecastResetSeconds`, `FastCastBuffs`, `RandomHelperBuffs`,
`RandomHelperIntervalSeconds`, `BlacklistedSpellComponents`,
`ProtectionElements`, `ProtectionProfileMode`, `BaneElements`,
`BaneProfileMode` — i.e. VTank's `BuffCastRecast_Seconds`,
`BuffCastRecastReset_Seconds`, `FastCastBuffs`, `RandomHelperBuffs`,
`RandomHelperIntervalSeconds`, `BlacklistedSpellComps`,
`BuffProfile-Prots`, `BuffProfile_Prots`, `BuffProfile-Banes`,
`BuffProfile_Banes` are all settable at runtime
(`MossTankPanel.cs:2942-2973,3297-3317`) but **silently reset to the
class defaults on the next save/load round-trip** because the
persistence record never captures them. This reads as an oversight (the
fields were added to `BuffSettings` and to `SetMetaOption` but the
corresponding `BuffProfileDocument` fields were never added), not an
intentional design choice.
3. **`RechargeHandlerSet` is accepted only as an opaque string.**
`MossTankPanel.cs:3368-3370` stores whatever text is given verbatim into
`_vitalSettings.RechargeHandlerSet` with no parsing into VTank's real
5-column `Vital`/`HandlerString`/`MinPercent`/`MaxPercent`/`Stance`
structure (section 2, row 137), and the actual recharge-priority
*behavior* MossTank runs is a hand-ported, hardcoded replica of VTank's
**default** table baked into `VitalRechargePlanner`
(`VitalRecharge.cs:33-` — its own doc comment says exactly this: "VTank's
default `RechargeHandlerSet`, including its stance- and
current-percentage-dependent order"). A user who customized their
RechargeHandlerSet table in real VTank has no way to bring that
customization into MossTank today; `_vitalSettings.RechargeHandlerSet` IS
read back (`VitalRecharge.cs:59-63``Handlers(...)``TryParseHandlerSet`,
`VitalRecharge.cs:308-340`): the sentinel default `"RechargeHandlerSet"`
(`VitalPlan.cs:48`) and blank mean "no override" and fall through to the
hardcoded table, while a non-sentinel string parses as a `key=value`
segmented form keyed by `{stance}-{vital}-{band}` (`VitalRecharge.cs:305`)
with a `-normal` fallback. So a customization channel exists — it is just
not VTank's 5-column table (citation pass 2026-09-06; slice 1 replaces it
with the parsed table).
4. **Buff classification model is architecturally different, not just
incomplete.** VTank's `BuffProfile_Prots`/`BuffProfile_Banes` are an
8-way named enum (`Custom`/`All`/`None`/`B`/`BPS`/`BPSA`/`ALFC`/`BPSAC`,
section 2 rows 61-62) whose letters index into **fixed, hardcoded lists
of specific spell families** (not decompiled further in this pass —
doc 04's scope). MossTank instead classifies buffs at runtime from each
spell's own English description text (`BuffProfile.cs:6-140`, incl. `Unknown = 0`, matching
phrases like "Increases the caster's ... skill by"), producing a
`BuffTargetKind` (`Skill`/`Attribute`/`Protection`/`Aura`/`Bane`/
`Regeneration`/`Other`) and exposing coarser boolean toggles
(`BuffProtections`, `BuffBanes`, etc., `MossTankProfileStore.cs:807-815`)
rather than VTank's letter-coded profile modes. MossTank *does* also
carry the exact VTank enum fields (`ProtectionProfileMode`/
`BaneProfileMode`, gap #2 above) alongside the coarse booleans, so the
two models currently coexist rather than one replacing the other — this
needs an explicit design decision (which model is authoritative) before
a `.usd`-compatible profile with a non-default `BuffProfile_Prots` value
can be faithfully honored.
5. **No character/profile-selection machinery matching VTank's file-naming
scheme.** `MossTankProfileStore.ProfileIndex`/`ProfileEntry`
(`MossTankProfileStore.cs:320-333`) has its own `Owner` and
`SelectedByCharacter` fields — a real analogue exists — but it has
no equivalent to VTank's `"--"`-prefix / `"[By char]"` /
`dw`-prefixed-named-sub-profile scheme (section 3). Because MossTank
doesn't read the filesystem for profiles at all (gap #1), there is
currently no way for it to see, list, or select among a real character's
existing `--Name_Server*.usd` files from `C:\Games\VirindiPlugins\VirindiTank\`.
6. **Minor naming difference, not a functional gap**: VTank's setting is
`BlacklistedSpellComps`; `CombatSettings.BlacklistedSpellComponents`
(`CombatSettings.cs:113`) and `BuffSettings.BlacklistedSpellComponents`
(`BuffPlan.cs:26`) spell the field out in full. `SetMetaOption`'s case
label is still the exact VTank string (`"blacklistedspellcomps"`,
`MossTankPanel.cs:3305`), so external-facing compatibility (`/vt`-style
commands, once implemented) is unaffected — only the internal C# field
name differs.
No settings were found present in the `.usd` schema but entirely absent from
MossTank's `SetMetaOption` table (0/137 missing by name, confirmed by
programmatic diff) — the gaps here are all about **persistence fidelity**,
**file-format compatibility**, and **one architecturally-coarser model**
(buff profiles), not missing setting coverage.
## 6. Could not determine
- **The `"~~"` filename prefix — RESOLVED as far as VTank goes** (citation
pass 2026-09-06): `uTank2/PluginCore.cs:7182` filters it but VTank never
produces it — `grep -rn '~~'` over the decompile returns only that line
and an unrelated string delimiter (`hw.cs:127`), and no `~~*` file exists in
the live profile directory. It is a filter for files written by a different
Virindi assembly, not by `utank2-i.dll`.
- **Whether `RechargeHandlerSet` has *any* live UI editor at all.** No
control in `mainView.xml` or `AdvancedOptionsView.xml` matches (no
`lstRecharge`-style list), and both `/vt opt set` and the generic
Advanced Options editor explicitly exclude `tCustom` values (section 2,
row 137). It's owned by a dedicated `cRechargeManager` object
(`refs/vtank/decompiled/s.cs:83`) which may have its own popup view not
captured among the four `.xml` resources extracted at VT0, or may simply
be defaults-only in the shipped build. Not resolved in this pass.
- **Whether `MossTankProfileStore`'s `ProfileIndex.SelectedByCharacter`
already covers the "[By char]" semantics conceptually** (as opposed to
file-naming) — a fuller read of `MossTankProfileStore.cs`'s selection
logic (lines outside the ranges read for this document) would be needed
to say whether gap #5 is "needs new code" or "needs a new file-backed
adapter over existing logic."
- **Whether `_vitalSettings.RechargeHandlerSet` (the opaque string,
MossTank gap #3) is read by anything at all**, or is a write-only field
today. `VitalRecharge.cs` was read only through its top ~60 lines for
this document.

View file

@ -0,0 +1,857 @@
# VTank KB 02 — Scheduler and Actions
Oracle: `refs/vtank/decompiled/` (obfuscated VTank 2.x decompile via ILSpy;
identifiers are single/double-letter, strings and method *shapes* are
intact). All citations are `file:line` against that tree. No decompiled
source is pasted verbatim below — every claim is paraphrased and cited so
it can be checked against the file directly.
Comparison target: `src/AcDream.Plugins.MossTank/` at the tree checked out
in worktree `eloquent-hugle-42119e` (commit `c406942ef` and prior).
---
## 0. Class-name key (obfuscated → role)
Established once here; used by name for the rest of the doc.
| Obfuscated | Role | Notes |
|---|---|---|
| `PluginCore.dz` (type `s`, `s.cs`) | The "god object" — one field per subsystem manager | `s.cs:9-90` lists ~50 fields `a`..`ay` |
| `dz.d` | `cLogic` — the scheduler itself | field `d`, `s.cs:14` |
| `dz.o` | `ga` — combat-mode/wield/equip manager, action-lock table, log sink | field `o`, `s.cs:34`; `ga.cs` |
| `dz.p` | `dz` (yes, class `dz.cs` reused as a field type) — target lock / attack-plan selector | field `p`, `s.cs:40`; `dz.cs` |
| `dz.q` | `g6` — WorldFilter/object-classification cache | field `q`, `s.cs:42` |
| `dz.k` | `eq` — a tracked-item-id list (populated elsewhere; consumed by wand selection) | field `k`, `s.cs:22` |
| `dz.m` | `da` — profile data (buff list, craft list, nav route) | field `m`, `s.cs:24` |
| `dz.r` | `fo` — corpse/loot cursor | field `r`, `s.cs:44` |
| `dz.s` | `hv` — loot-item picker | field `s`, `s.cs:46` |
| `dz.l` | `ai` — helper-buff/recharge-other spell cache | field `l`, `s.cs:26` |
| `dz.al` | `cRechargeManager` — self mana/stam/health-stone recharge executor | field `al`, `s.cs:64` |
| `dz.ak` | `bf` — fellowship auto-follow manager | field `ak`, `s.cs:66` |
| `dz.an` (via `h1`) | `fp` | pet-count / summon helper |
| `dz.v` | `f0` — waiting-lock table keyed by object (corpse/door) | field `v`, `s.cs:52` |
| `dz.n` | `hr` — key-press (forward/turn) driver | field `n`, `s.cs:30` |
`o.n` (an `ActionLockType`-keyed timer table inside `ga`) is the shared
per-action cooldown/mutex mechanism — almost every rule's `ValidNow` checks
`o.n.b(ActionLockType.X)` (locked → not valid) and every rule's `Running`
body that fires a request calls `o.n.a(ActionLockType.X, TimeSpan...)` to
arm a cooldown. This is VTank's equivalent of MossTank's per-controller
`canAct`/cooldown fields.
---
## 1. The macro loop
### 1.1 What drives a tick
The scheduler `cLogic` (`uTank2/cLogic.cs`) is **not** driven by a fixed
per-frame `Tick()` call from the host. It is driven by two independent
`System.Windows.Forms.Timer` wrappers (class `ey`, `ey.cs`), whose common
tick source is Decal's `RenderPreUI` hook incrementing a static frame
counter (`ey.c()`/`ey.b()`, `ey.cs:70-79`); each `ey` instance also
de-duplicates so its own handler fires **at most once per distinct render
frame** (`ey.cs:172-177`).
| Timer | Interval | Armed | Handler | Purpose |
|---|---|---|---|---|
| `f` | 293 ms, re-armed every fire | Only while macro running (`StartMacro`, `cLogic.cs:327-328`) | `b()``f.a(293); TryPokeMacro();` (`cLogic.cs:141-152`) | Heartbeat: re-evaluate rules |
| `g` | 3203 ms, fixed | Always (constructed once, `cLogic.cs:61-63`) | `a(object,EventArgs)` → if macro **not** running, evaluate the `m` list (`cLogic.cs:175-181`) | Background pass while macro is stopped |
`TryPokeMacro()` (`cLogic.cs:163-173`) only actually re-runs the rule pass
if the macro is running (`dz.o.c`), a start is not already in progress
(`!k`), and the frame-counter has advanced since the last run (`j !=
ey.b()`) — i.e. it coalesces multiple timer/event firings within the same
render frame into one pass.
`SchedulePoke()` (`cLogic.cs:154-161`) is the fast-wake path: two Decal
game-state trackers, `gj` (the SpellCaster cast-completion tracker, `gj.cs:269,326,442`) and `gs`
(the WandCaster cast-completion tracker, `gs.cs:178,194,233,442` — NOT attack/missile completion; citation pass 2026-09-06), each fire a `b`-enum "completed/idle"
event that `cLogic` subscribes to (`cLogic.cs:52-59, 125-139`); on that
event `SchedulePoke()` stamps the dedup counter to the *current* frame and
re-arms `f` for **1 ms**, forcing a near-immediate re-poke on the very
next frame rather than waiting up to 293 ms. In effect: state changes
(a cast finishes, an attack resolves) wake the scheduler immediately; the
293 ms timer is the fallback heartbeat for everything else (vitals
ticking down, a target coming into range, a corpse timing out).
### 1.2 The rule pass itself
`a(MyList<ILogicRule> A_0, MyList<ILogicRule> A_1)` (`cLogic.cs:183-279`)
is the actual pass, called as `a(l, n)` from `TryPokeMacro` and as
`a(m, null)` from the 3203 ms background timer. Per pass:
1. Clear two per-tick flags (`dz.o.s`, `dz.o.t` — a "did-nav-already-decide-
not-to-move" flag and the corpse-wait latch) — `cLogic.cs:289-293`.
2. If Meta is enabled and the macro isn't stopped (`!dz.o.f()`), evaluate
the Meta FSM (`dz.at.h()`, `a7.e()`) — `cLogic.cs:188-196`. (Meta is a
separate subsystem from the action list below; not covered by this
doc.)
3. If `A_1` (the **independent** list, normally `n` = `{ h1 }`, see §2.1)
is non-null, set `Running = ValidNow` on **every** item in it,
unconditionally — `cLogic.cs:198-213`. These do **not** compete for the
single winner slot below; they run in parallel with whatever the main
loop picks.
4. If the macro is stopped, return (the log line, the priority-list scan,
and the "clear rules"/"activate rule" steps below are all skipped) —
`cLogic.cs:214-217`.
5. Walk `A_0` (the main list `l`) top to bottom in **insertion order**
(the `int Priority` on each rule is a monotonically increasing debug id,
never sorted on) and take the **first** rule whose `ValidNow` is true —
`cLogic.cs:218-239`. This is a strict "exactly one winner" scan: no
further items are even evaluated once one is picked (`goto IL_0323`).
6. Every rule in `A_0` that is not the winner gets `Running = false`; the
winner gets `Running = true``cLogic.cs:243-257`. Setting `Running`
is how a rule actually *does* its work (see §2's `Running` column);
`ValidNow` is pure precondition-checking with no side effects other than
caching the choice it made for `Running` to re-use (most classes stash
the choice — a target id, an item id, a spell — in a private field
during `ValidNow` and consume it in `Running`).
7. Every 30 s (wall clock, guarded by a `DateTimeOffset` stamp), force a
GC — `cLogic.cs:268-277`. Not scheduler logic, but explains an
occasional frame hitch on a live VTank session.
### 1.3 Run macro / Stop macro / death
`StartMacro()` (`cLogic.cs:295-338`):
- Refuses if the extension failed to initialize (`PluginCore.ds` false),
or the character isn't fully logged in (`PluginCore.dn == 0` or
`!dz.a0.dt`) — posts "Please wait until you are fully logged in." and
(in the second case) immediately calls `StopMacro()` on itself.
- Sets `dz.o.c = true` (the single "is the macro running" flag almost
every `ValidNow`/`Running` body reads), clears the loot-wait table
(`dz.o.l()` and `dz.o.k()`, `cLogic.cs:319,321` — the field letter `j` in the first draft was wrong), resets the equip-retry counter (`dz.o.r = 0`), clears every
`ActionLockType` cooldown (`dz.o.n.b()`), resets Meta, arms the 293 ms
timer and starts it, disables every item in the macro-disabled Always
Rules list `m` (they're only meaningful while stopped — see §1.1), and
does one immediate `TryPokeMacro()`.
`StopMacro()` (`cLogic.cs:340-365`), wrapped in a bare `try/catch` that
**silently swallows any exception** — a stop can partially fail with no
visible error:
- Sets every rule in `l` to `Running = false`, clears `dz.o.c`, stops the
293 ms timer, unchecks the UI "Run Macro" box, and if a spread-lock
target list was active, clears it and re-sends an empty target list to
the host (`PluginCore.PC.a(new MyList<int>(100))`).
- Does **not** reset the equip-retry counter or Meta state itself (those
are handled by `StartMacro` on the next run) and does **not** stop or
reset navigation, crafting, dispel, or loot — the rule instances are
simply never evaluated again until `StartMacro` re-enables `dz.o.c`.
Death: `StopMacroOnDeath` is read elsewhere in `PluginCore.cs` (not in
`cLogic`) — `PluginCore.cs:4155` gates a `StopMacro()` call on
`dz.o.c && f3.k("StopMacroOnDeath")`. This doc's oracle is the scheduler;
the death-detection trigger itself lives in the main plugin class, not
`cLogic`.
### 1.4 Pause conditions
There is no single "pause" flag distinct from "stopped." The closest
equivalent is the per-`ActionLockType` cooldown table (`dz.o.n`), which
individual rules use to make themselves temporarily invalid (busy-state,
navigation lock, item-use lock, door-opening lock, salvage lock, spread-
lock-target-requested, corpse-open-attempt, recharge-level-boost per
vital, random-helper-buff-lock, buff-cast-recast) — see the "Suppressors"
column in §2. `ga.a(CombatState,...)` (the shared wield/mode gate, §3)
also returns `false` outright whenever `Actions.BusyState != 0`
(`ga.cs:1448-1451`), which starves every rule that routes through it
(BuffSelf, DispelSelf, DispelAllies, RechargeOther, UseHealersHeart,
RandomHelper, Attack's own wield step) for that tick without any explicit
"paused" bookkeeping.
---
## 2. The complete action list
### 2.1 Two tracks
VTank schedules rules on two separate tracks:
- **Main track (`l`)** — the priority list below; exactly one rule's
`Running` fires per tick (§1.2 step 5-6).
- **Independent track (`n`)** — evaluated every tick *in addition to*
whichever main-track rule wins, with no competition at all
(`cLogic.cs:198-213`). By default `n` holds exactly one rule:
**SummonPet** (`h1`, `h1.cs`) — valid when combat and pet-summoning are
both enabled, the Summoning skill is trained, the class is not on its
own internal 32555ms cooldown gate (`an.a(-32555)`, `h1.cs:44-47`), a
summonable pet item is present (`dz.o.j()`), and the pet-count check
`bm.a()` passes; `Running(true)` uses the found item
(`f9.p(this.m_a)`). SummonPet requires **no target at all** — it can
fire purely because combat is enabled, independent of whatever the main
track is doing that tick.
- There is also a **macro-disabled Always Rules list (`m`)**, evaluated
only by the slow 3203 ms timer while the macro is **stopped**
(`cLogic.cs:175-181`, §1.1). By default it holds one gated rule:
`RefillWieldedMana` (`a0`, §2.3) behind the `ManaChargesWhenOff` setting
— VTank keeps a worn mana stone charging on its own cadence even with
the macro off.
### 2.2 Main-track priority order
All 66 entries of `l`, in the exact order `InitializeDefaultLogicRules`
(`cLogic.cs:433-578`) adds them. Sentinel rows are markers only (their
`ValidNow` is hard-coded `false`, `LogicRuleSentinel.cs:11`); they never
win, but their names are the section headers VTank's own debug UI/log
uses and are reproduced here for that reason. "Gate" is the
`ISettingDelegate[]` requirement wrapper (`LogicRulePreChain`) that must
*all* be true before the wrapped rule's own `ValidNow` is even consulted
(`LogicRulePreChain.cs:18-32`); "Fallback" is a `cm(0)` (IdlePeace) or
similar rule wired as the `LogicRulePreChain`'s own sub-action, which
`Running(true)` on the wrapper will start automatically if the wrapped
rule/its own reqs fail but the fallback's reqs pass
(`LogicRulePreChain.cs:34-75` — a wrapper's `Running=true` setter tries
each fallback in order first, then the primary).
| # | Rule (log name) | Class | Gate | Fallback | Cite |
|---|---|---|---|---|---|
| 1 | Sentinel START | — | — | — | `cLogic.cs:459` |
| 2 | SplitPeas (SpellCompMin-Critical) | `as` | — | — | `cLogic.cs:460` |
| 3 | CraftFood (kit/food counts, non-idle) | `a9` | — | — | `cLogic.cs:461-469` |
| 4 | RechargeSelf2 (Recharge-Norm-*) | `cr` | — | — | `cLogic.cs:470` |
| 5 | RefillWieldedMana | `a0` | — | — | `cLogic.cs:471` |
| 6 | BuffSelf (RebuffTimeRemainingSeconds) | `fz` | — | — | `cLogic.cs:472` |
| 7 | SplitPeas (SpellCompMin-Normal) | `as` | — | — | `cLogic.cs:473` |
| 8 | Sentinel POSTBUFF | — | — | — | `cLogic.cs:474` |
| 9 | DispelSelf | `c8` | — | — | `cLogic.cs:475` |
| 10 | UseDispelItem | `cx` | — | — | `cLogic.cs:476` |
| 11 | UseHealersHeart | `fb` | — | — | `cLogic.cs:477` |
| 12 | RechargeOther | `gu` | — | — | `cLogic.cs:478` |
| 13 | DispelAllies | `af` | — | — | `cLogic.cs:479` |
| 14 | CraftFood (all recipes/spells, count=1) | `a9` | — | — | `cLogic.cs:480` |
| 15 | RefillPetCharges (PetRefillCount-Normal) | `dq` | — | — | `cLogic.cs:481` |
| 16 | Sentinel POSTHELPER | — | — | — | `cLogic.cs:482` |
| 17 | FellowshipManager | `g5` | — | — | `cLogic.cs:483` |
| 18 | Sentinel POSTAUTOFELLOW | — | — | — | `cLogic.cs:484` |
| 19 | OpenDoor | `b7` | — | — | `cLogic.cs:485` |
| 20 | Sentinel PREPRIORITYLOOTACTIONS | — | — | — | `cLogic.cs:486` |
| 21 | ReadScroll (priority) | `er` | EnableLooting, LootPriorityBoost | — | `cLogic.cs:487` |
| 22 | StackCram (priority) | `aj` | same | — | `cLogic.cs:488` |
| 23 | SalvageItems (priority) | `ar` | same | — | `cLogic.cs:489` |
| 24 | Sentinel POSTPRIORITYLOOTACTIONS | — | — | — | `cLogic.cs:490` |
| 25 | Sentinel PREPRIORITYLOOT | — | — | — | `cLogic.cs:491` |
| 26 | Navigate → corpse (priority) | `g8`+`fg` | EnableLooting, LootPriorityBoost, SetWaitingOnCorpseId | — | `cLogic.cs:492-497` |
| 27 | OpenCorpse (priority) | `bj` | LootPriorityBoost, SetWaitingOnCorpseId | — | `cLogic.cs:498-502` |
| 28 | LootCorpse (priority) | `d0` | EnableLooting, LootPriorityBoost | — | `cLogic.cs:503` |
| 29 | CorpseWait (priority) | `a1` | same | — | `cLogic.cs:504` |
| 30 | Sentinel POSTPRIORITYLOOT | — | — | — | `cLogic.cs:505` |
| 31 | Sentinel PREPRIORITYNAV | — | — | — | `cLogic.cs:506` |
| 32 | Navigate → nav route (priority) | `g8`+`ca` | NavPriorityBoost, SetWaitingOnCorpseId | — | `cLogic.cs:507-512` |
| 33 | Sentinel POSTPRIORITYNAV | — | — | — | `cLogic.cs:513` |
| 34 | Sentinel PREATTACK | — | — | — | `cLogic.cs:514` |
| 35 | **Attack** | `b4` | — | — | `cLogic.cs:515` |
| 36 | Sentinel POSTATTACK | — | — | — | `cLogic.cs:516` |
| 37 | Sentinel PREIDLESTATUS | — | — | — | `cLogic.cs:517` |
| 38 | SplitPeas (SpellCompMin-Idle) | `as` | — | — | `cLogic.cs:518` |
| 39 | CraftFood (idle counts) | `a9` | — | — | `cLogic.cs:519-527` |
| 40 | RefillPetCharges (PetRefillCount-Idle) | `dq` | — | — | `cLogic.cs:528` |
| 41 | Sentinel PREIDLELOOTACTIONS | — | — | — | `cLogic.cs:529` |
| 42 | ReadScroll (idle) | `er` | (none) | **IdlePeace** | `cLogic.cs:530` |
| 43 | StackCram (idle) | `aj` | (none) | **IdlePeace** | `cLogic.cs:531` |
| 44 | SalvageItems (idle) | `ar` | (none) | **IdlePeace** | `cLogic.cs:532` |
| 45 | Sentinel POSTIDLELOOTACTIONS | — | — | — | `cLogic.cs:533` |
| 46 | Sentinel PREIDLELOOT | — | — | — | `cLogic.cs:534` |
| 47 | Navigate → corpse (idle) | `g8`+`fg` | EnableLooting, SetWaitingOnCorpseId | **IdlePeace** (gated: not yet close) | `cLogic.cs:535-543` |
| 48 | OpenCorpse (idle) | `bj` | EnableLooting, SetWaitingOnCorpseId | **IdlePeace** | `cLogic.cs:544-548` |
| 49 | LootCorpse (idle) | `d0` | (none — own `EnableLooting` check) | — | `cLogic.cs:549` |
| 50 | CorpseWait (idle) | `a1` | (none) | — | `cLogic.cs:550` |
| 51 | Sentinel POSTIDLELOOT | — | — | — | `cLogic.cs:551` |
| 52 | Sentinel PREIDLEBUFF | — | — | — | `cLogic.cs:552` |
| 53 | BuffSelf (IdleBuffTopoffTimeSeconds) | `fz` | IdleBuffTopoff | — | `cLogic.cs:553-556` |
| 54 | Sentinel POSTIDLEBUFF | — | — | — | `cLogic.cs:557` |
| 55 | Sentinel PRETARGETAPPROACH | — | — | — | `cLogic.cs:558` |
| 56 | Navigate → nearest monster | `g8`+`eb` | EnableCombat | **IdlePeace** (gated: not yet close) | `cLogic.cs:559-563` |
| 57 | Sentinel POSTTARGETAPPROACH | — | — | — | `cLogic.cs:564` |
| 58 | Sentinel PREIDLERECHARGE | — | — | — | `cLogic.cs:565` |
| 59 | RechargeSelf2 (Recharge-NoTarg-*) | `cr` | — | — | `cLogic.cs:566` |
| 60 | Sentinel POSTIDLERECHARGE | — | — | — | `cLogic.cs:567` |
| 61 | Sentinel PRENAVROUTE | — | — | — | `cLogic.cs:568` |
| 62 | Navigate → nav route (idle) | `g8`+`ca` | (none) | **IdlePeace** (gated: not close+not recalling) | `cLogic.cs:569-573` |
| 63 | Sentinel POSTNAVROUTE | — | — | — | `cLogic.cs:574` |
| 64 | RandomHelper | `ba` | — | — | `cLogic.cs:575` |
| 65 | Sentinel END | — | — | — | `cLogic.cs:576` |
| 66 | **IdlePeace** (terminal catch-all) | `cm` | — | — | `cLogic.cs:577` |
`IdlePeace` (`cm`) is instantiated **8 times total** — the terminal
catch-all plus 7 fallback children wired directly into other rules'
`LogicRulePreChain` — see §4.
### 2.3 Per-action detail
Each entry: precondition (`ValidNow`), what `Running(true)` does, and
timeouts/retries/suppressors. "Suppressor" = an `ActionLockType` (or
equivalent) whose lock, once armed, makes `ValidNow` false until it
expires.
**SplitPeas — `as` (`as.cs`)**
Precondition: not `ItemUse`-locked, in Peace mode already
(`dz.z.d() == o.b.a`, `as.cs:72`), `SplitPeas` setting on, and a
craftable split found under the given component-minimum setting
(`SpellCompMin-Critical`/`-Normal`/`-Idle`) either for the special
`[All Peas]` group or a specific reagent — `as.cs:66-131`. `Running(true)`:
if not already in Peace mode, request Peace (`f9.a((CombatState)1)`);
else perform the split — `as.cs:153-168`. No explicit timeout; re-attempts
every tick it's the winner. Warns once per scan if no splitting tool is
present (`as.cs:94, :120`).
**CraftFood — `a9` (`a9.cs`)**
Precondition: not `ItemUse`-locked, in Peace mode, `AutoCraftItems` on,
and a recipe/spell in the profile craft list (`dz.m.h`/`dz.m.g`) resolves
to an executable craft — `a9.cs:101-140`. The idle-tier instance passes an
explicit `fs[6]` type filter and `A_2:true`, which switches the per-type
count from a flat `1` to the matching `IdleCraftCount_*` setting
(kits/food × health/stamina/mana) — `a9.cs:82-98`. `Running(true)`: same
Peace-mode-first gate as SplitPeas, then execute the craft —
`a9.cs:160-176`.
**RechargeSelf2 — `cr` (`cr.cs`)**
Precondition: not `ItemUse`-locked; `cg.c/b/a` (external helper, not this
file) report a vital below its Norm-tier or NoTarg-tier threshold setting
`cr.cs:72-92`. On an exception in that check, sets an internal
error-state flag and still reports valid (`cr.cs:86-90`). `Running(true)`:
logs `(RechargeSelf2) Running (errorstate ...)`, then calls
`dz.al.Recharge(...)` for whichever vital tripped — `cr.cs:112-135`. **No
target requirement of its own** — the Norm/NoTarg split is entirely a
function of *where in the list* it's placed (before vs. after the Attack
rule), not a parameter difference beyond which three setting keys it
reads.
**RefillWieldedMana — `a0` (`a0.cs`)**
Precondition: not `ItemUse`-locked; delegates to `dz.ac` (an item-mana
tracker) — `a0.cs:63-77`. `Running(true)`: recharges the wielded item if
`dz.ac.h()`, else (if not `ManaStoneUse`-locked) uses a mana stone via
`dz.ab.e()``a0.cs:97-111`.
**BuffSelf — `fz` (`fz.cs`)**
Precondition: not `ItemUse`-locked, `EnableBuffing` on, and
`dz.k.a(seconds, isIdleTopoff, out shouldForce)` (the buff-plan builder)
reports work — `fz.cs:69-90`. The `RebuffTimeRemainingSeconds` normal-tier
call passes `A_2:true`("idle" flag inverted per-arg — see cite) while the
idle-tier call (`IdleBuffTopoffTimeSeconds`) passes `A_2:false`; both add
`BuffCastRecast_Seconds` on top of the raw threshold if the
`BuffCastRecast` lock is armed — `fz.cs:80-89`. `Running(true)`: routes
through `ga.a((CombatState)8, 0, true)` (the shared wield/mode gate, §3)
and, once ready, arms `BuffCastRecast` and asks the buff-plan builder to
actually cast (`dz.k.a()`) — `fz.cs:113-121`.
**DispelSelf — `c8` (`c8.cs`)**
Precondition: not `ItemUse`-locked, `CastDispelSelf` on, "Eradicate Life
Magic Self" known and its difficulty passes `c4.a(...)``c8.cs:64-85`.
`Running(true)`: through the shared wield/mode gate, cast the spell on
self — `c8.cs:113-121`.
**UseDispelItem — `cx` (`cx.cs`)**
Precondition: `UseDispelItems` on, not `ItemUse`-locked, and — gated by
two difficulty tiers (`c4.a(400)`, `c4.a(350)`) — one of a fixed item list
is in inventory: Rune of Dispel, Society/Black Market Gem of Dispelling
(tier 400), or Rune of Dispel, Chocolate Gromnie, Condensed Dispel
Potion, Gem of Stillness (tier 350) — `cx.cs:65-116`. `Running(true)`:
uses the found item and arms `ItemUse` for `dz.o.k` (a configured
duration) — `cx.cs:139-145`.
**UseHealersHeart — `fb` (`fb.cs`)**
Precondition: `UseHealersHeart` on, not `ItemUse`-locked, "The Healer's
Heart" or "Legendary Seed of Mornings" present and off cooldown
(`dz.aa.a`), Healing/Arcane-Lore-equivalent skills above fixed thresholds
(245/105), and a helper-buff spell resolves — `fb.cs:69-115`.
`Running(true)`: through the wield/mode gate (target = the item's owner,
not self — `A_2:false`), use the item then apply the resolved buff —
`fb.cs:140-146`.
**RechargeOther — `gu` (`gu.cs`)**
Precondition: not `ItemUse`-locked; a helper-recharge request (`gh`, built
from three `Recharge-Helper-*` settings clamped to 100) resolves via
`dz.l.b(...)``gu.cs:71-82`. `Running(true)`: through the wield/mode
gate, execute via `dz.l.a(...)``gu.cs:109-129`.
**DispelAllies — `af` (`af.cs`)**
Precondition: `UseDispelDrum` on, not `ItemUse`-locked, an Awakener or
Attenuated Awakener drum present and off cooldown with matching
skill/training thresholds, and a fellow member is found carrying
qualifying debuffs of quality > 250 and difficulty ≤ 350 within a 5-hour
window — `af.cs:75-158`. `Running(true)`: through the wield/mode gate
(target = the drum's owner), use the drum on the chosen ally —
`af.cs:184-189`.
**CraftFood (default) — `a9` again**, same class as above, constructed
with no type filter (`m_c == null`), which additionally lets it craft
*any* known helper-buff spell in the profile if no recipe matched
(`a9.cs:127-138`) — this instance runs *after* DispelAllies, before
RefillPetCharges (Normal).
**RefillPetCharges — `dq` (`dq.cs`)**
Precondition: not `ItemUse`-locked, `EnableCombat` on, a wielded-pet-
device candidate under the given `PetRefillCount-*` threshold found via
`ga.e(...)` classification, and "Encapsulated Spirit" present —
`dq.cs:38-75`. `Running(true)`: if not in Peace mode, request Peace
first; else select the device, use the spirit item on it, restore
selection — `dq.cs:99-113`.
**FellowshipManager — `g5` (`g5.cs`)**
Precondition/Running: entirely delegated — `ValidNow` is `dz.ak.h()`,
`Running(true)` calls `dz.ak.g()``g5.cs:28-58`. `dz.ak` (`bf.cs`) is
out of scope for this doc (fellowship auto-follow subsystem).
**OpenDoor — `b7` (`b7.cs`)**
Precondition: `OpenDoors` and `EnableNav` on; scans all doors, adds
in-range ones to a navigation wait-set (`dz.v`), defers if navigation is
already pending; then, among doors within `DoorOpenRange`, requires (for
a locked door) `Lockpick` known and skill above the difficulty +
`DoorLockpickDiffExcessThreshold`, and a usable lockpick found via a
by-`ObjectClass.Door`-filtered inventory scan (`a()`, `b7.cs:52-81`) —
`b7.cs:83-172`. `Running(true)`: for a locked door, use the lockpick on it
(`ItemUse`/`Navigation`/`DoorOpening` locks armed for 0.5s/5s/5s); for an
unlocked door, walk up (`f9.p`) then, once adjacent, `ApplyItem` (the
door itself) — `b7.cs:206-226`.
**ReadScroll / StackCram / SalvageItems — `er`, `aj`, `ar`**
`ReadScroll` (`er.cs:63-90`): not `ItemUse`-locked; scans a cached
scroll-item dictionary (`dz.o.i`), drops entries no longer in the
worldfilter classification set (`dz.q.y`), and picks the first remaining
one the loot-item picker (`dz.s`) accepts. `Running(true)` re-uses the
scroll (`f9.p`) and arms `ItemUse` for `dz.o.k``er.cs:118-137`.
`StackCram` (`aj.cs`): delegates entirely to `dz.x` (`el.cs`, out of
scope) — `ValidNow = dz.x.c()`, `Running(true) → dz.x.d()`. `SalvageItems`
(`ar.cs`): `EnableLooting` on; if `Salvage` lock already armed, valid
immediately (continuing a multi-tick salvage); else requires a "Ust"
(salvage tool) present and `dz.u.j()` (has salvageable items) —
`ar.cs:63-81`; `Running(true)` re-uses the Ust and calls `dz.u.i()`
`ar.cs:105-111`.
**Navigate (generic) — `g8` (`g8.cs`) + a goal provider (`bz` interface)**
`g8` is a thin wrapper around `fd` (`fd.cs`), parameterized with two
setting-key names (a close-stop and a far-stop range) and a goal object.
Precondition: not already decided-no-move this tick (`dz.o.s`), `EnableNav`
on, not `Navigation`/`SpreadLockTargetRequested`/`DoorOpening`-locked, and
the wrapped `fd.e()` (goal non-null and not "reached", `fd.cs:274-281`)
`g8.cs:80-104`. `Running`: delegates start/stop to `fd.c(bool)`
(`g8.cs:124-138`); the real per-tick walking logic (face-heading, forward-
press via key-driver `dz.n`, autorun toggle, a periodic ~0.7s repress) is
in `fd.a()` (`fd.cs:311-386`) — described fully in §4 because it is also
where the IdlePeace / nav-min-distance interaction lives. Five goal
providers are wired in: `fg` (`CorpseApproach`, corpse-priority and
corpse-idle tiers), `eb` (`MonsterApproach`, target-approach tier), and
`ca` (nav-route follow, nav-priority and nav-route tiers — its own
`FriendlyName` is literally the string `"?????"`,
`ca.cs:415-417` — a cosmetic bug shipped in retail VTank, not a decompiler
artifact).
**LootCorpse / CorpseWait / OpenCorpse — `d0`, `a1`, `bj`**
`OpenCorpse` (`bj.cs`): `EnableLooting` on; valid immediately if
`ItemUse`-locked (so the pass "continues" while an open is in flight);
else requires the corpse cursor (`dz.r`) to accept the given max-distance
and not already be a "waiting" corpse (`dz.r.e()`) — `bj.cs:63-79`.
`Running(true)`: opens the corpse and arms `ItemUse`/`Navigation`/
`CorpseOpenAttempt` for `CorpseOpenTimeoutSeconds``bj.cs:104-113`.
`LootCorpse` (`d0.cs`): `EnableLooting` on, corpse cursor's item index at
its item count (`m == j` — fully enumerated), corpse marked "waiting"
(`dz.r.e()`), and the loot-item picker (`dz.s`) has a decision
(`dz.s.e()`) — `d0.cs:63-79`. `Running(true)`: executes the loot decision,
arms `ItemUse`/`Navigation` for 0.75s — `d0.cs:105-111`. `CorpseWait`
(`a1.cs`): `EnableLooting` on, not `ItemUse`-locked, corpse cursor
"waiting" (`dz.r.e()`) — `a1.cs:63-71`; `Running(true)`: if the loot
picker's own wait-check (`dz.s.f()`) also agrees, closes the corpse
(`dz.r.b()`) — `a1.cs:95-101`.
**Attack — `b4` (`b4.cs`)**
Precondition: not `ItemUse`-locked, `EnableCombat` on, and the target-lock
class `dz.p.c()` finds an in-range target and picks a spell/attack for it
(§3 describes `dz.p.c()`/`dz.p.a()` at a summary level — the target-
selection and damage-formula internals are out of scope for this
scheduler doc) — `b4.cs:63-76`. `Running(true)`: if neither casting
(`dz.h.e()`) nor busy on an item (`dz.aa.e()`), execute the chosen attack
via `dz.p.a()``b4.cs:99-105`; on `Running(false)` (losing the winner
slot, e.g. to a higher-priority rule), it explicitly tears down door/
salvage cursors (`dz.ar.d()`, `dz.w.c()`) and clears any spread-lock
target list — `b4.cs:108-114`.
**RandomHelper — `ba` (`ba.cs`)**
Precondition: `RandomHelperBuffs` on, not `ItemUse`-locked, not
`RandomHelperBuffLock`-locked, not currently casting (`dz.h.e()`), a
fellow player within **0.075 landblock units (≈18 m)**, and — trying up
to 100 random `(target, spell)` draws from a fixed 11-spell Tier-I list
(Endurance/Regeneration/Rejuvenation/Armor/five elemental protections/
Acid Protection, all literally "... Other I") — an uncast combination
found — `ba.cs:25-40, 82-125`. `Running(true)`: through the wield/mode
gate, cast the chosen spell on the chosen target, arm
`RandomHelperBuffLock` for `RandomHelperIntervalSeconds``ba.cs:146-157`.
Both target and spell are chosen with `System.Random`, not a
deterministic scan — VTank's RandomHelper is genuinely random by design.
**SummonPet — `h1`** — see §2.1 (independent track).
**Dead code — `be` (`be.cs`)**: an unreferenced class named
`"RechargeSelf"` (not `"RechargeSelf2"`), same constructor shape and
near-identical body to `cr`, including its own copy of the stuck-combat-
state handling for level-boost spells. No `new be(` appears anywhere in
the decompiled tree — it is superseded, unreachable code, not a rule any
current VTank build ever runs. Flagged here so it is not mistaken for a
25th live rule.
---
## 3. Combat-mode and equipment sequencing
### 3.1 The shared gate: `ga.a(CombatState, int, bool, eDamageElement,
ePrismaticDamageBehavior, int)` (`ga.cs:1433-1573`)
Every rule that needs to cast a buff/heal/recharge/dispel spell — BuffSelf,
DispelSelf, DispelAllies, RechargeOther, UseHealersHeart, RandomHelper —
calls through this **one** subroutine (via an overload chain,
`ga.cs:1415-1431`) instead of switching mode itself. Per call:
1. **Busy check.** If `Actions.BusyState != 0`, return `false`
immediately — `ga.cs:1448-1451`.
2. **Resolve the primary item to wield.**
- Start from the explicit override `A_1` (an item id), if the caller
passed one, and validate it's owned by the player and currently
wieldable (`b(WorldFilter[num])`); if invalid, fall back to `0` and
log a warning — `ga.cs:1452-1461`.
- **Prefer whatever is already wielded**: if auto-select is on (`A_2`)
or no override was given, *and* something is already wielded whose
weapon-class already maps to the requested `CombatState` (via
`a(ObjectClass)`, `ga.cs:1573-1607`), keep using that item instead of
switching — `ga.cs:1462-1465`.
- Otherwise, call the private `a()` helper (`ga.cs:1398-1411`): scan
`dz.k.j()` (a tracked item-id list — see §0; **its population order
is not established from this file alone**, see §6) for the first
owned item classified `fi.b` ("is a wand"); if none, post **"You
must add at least one wand to your profile."**, `StopMacro()`, return
`false``ga.cs:1466-1475` (message at 1471).
3. **Resolve a secondary/held item** the same way (`A_5`), and whether a
spell/element re-selection is needed (`bv.a(...)`, an
element/prismatic-compatibility check) — `ga.cs:1482-1499`.
4. **If anything needs to change** (wrong item wielded, wrong secondary
held, or a spell re-selection is due):
- Refuse if a shared busy-lock (`dz.w.c()`) fails — `ga.cs:1502-1505`.
- **If not already in Peace mode**, this is where the stuck-state
recovery lives: increment a retry counter `r`; once `r` reaches
`DropToPeaceModeRetryCount`, lock `ItemUse`, re-resolve *any* wand
from the profile, force-equip it, log **"Warning: Macro detected
bugged combat state. Attempting to wield an item to clear it."**,
reset `r`, and return `false``ga.cs:1506-1523`. Otherwise, just
request Peace mode (`f9.a((CombatState)1)`) and return `false`,
incrementing nothing further this tick — `ga.cs:1524-1526`.
- Once in Peace mode (`r` reset to 0, `ga.cs:1527`): if the target item
is a wand type (`fi.h`) *and* a secondary must also change, unequip
the current primary first if one is worn, else equip the secondary
`ga.cs:1530-1539`; else if the primary itself needs to change,
equip it — `ga.cs:1540-1544`; else if only the spell/element
selection needs updating, do that (`bv.a()`) — `ga.cs:1552-1554`.
- Any of the above branches returns `false` for this tick — the wield
step always costs at least one extra tick before it can report ready.
5. **Once everything matches**, recompute the `CombatState` implied by
whatever is now wielded and, if it differs from `Actions.CombatMode`,
request that mode (again gated by the same busy-lock) and return
`false``ga.cs:1556-1564`.
6. **Only when wield + secondary + spell + mode all already match** does
the gate return `true``ga.cs:1565`. Callers only execute the actual
cast/use inside their own `if (ga.a(...)) { ... }` branch — see e.g.
`c8.cs:114-121`, `af.cs:185-189`, `ba.cs:152-156`.
`ga.a(ObjectClass)` (`ga.cs:1581-1613`) is the class→mode map:
`MeleeWeapon → Melee(2)`, class `9` (missile launcher) `→ Missile(4)`,
class `31` and everything else `→ Magic(8)` (the default).
### 3.2 (a) Buffing with a wand — which wand?
**Wielded first.** If auto-select is on (or no explicit item was
requested) and the currently-wielded item is already the right *class*
for Magic mode, VTank keeps using it rather than re-equipping every tick
(`ga.cs:1471-1475`). Only when nothing suitable is wielded does it fall
back to the first `fi.b`-classified item found while scanning `dz.k.j()`
(`ga.cs:1398-1411`). **`dz.k.j()` is Items-page insertion order** (lead
verification 2026-09-06): `eq.j()` (`eq.cs:83-94`) walks `m_e` in list
order and emits each distinct item id (skipping `-1` and self); `m_e` is
appended by `eq.a(c)` (`eq.cs:54-57`, a plain `List.Add`), whose only
producers are the Items-tab Add buttons — `PluginCore.cs:8348-8434` (five switch cases)
(`dz.k.a(new eq.c(item, spell))`, one row per element for "Add", a single
`-1`-spell row for "Add (no buffs)") — and the profile loader (`da.cs:628`). So the
first wand a player added is the fallback caster.
### 3.2 (b) Fighting with the rule weapon
Handled separately from the buff/heal gate above: `Attack` (`b4`)
delegates target selection **and** weapon/mode resolution together to
`dz.p` (`dz.cs`) — `dz.p.c()` (`ValidNow`, picks a target + attack plan
within `AttackDistance`, `dz.cs:664-667`) and `dz.p.a()` (`Running`,
executes the chosen plan, `dz.cs:201-206`). The internal weapon-choice/
damage-formula logic inside `dz.cs`'s target-sort and `hi` attack-plan
classes is out of scope for this scheduler doc.
### 3.2 (c) Kits/food — `GoToPeaceModeToUseKits`
`GoToPeaceModeToUseKits` **is a real VTank setting** (lead verification
2026-09-06; an earlier draft of this section wrongly reported it absent):
`a5.cs:121` reads `f3.k("GoToPeaceModeToUseKits")` and, when it is on and
the character is not in Peace, the kit-use rule (`a5`) requests Peace at
`a5.cs:123` and does nothing else that tick; the setting is declared in
`refs/vtank/uTank2.Resources.defaultsettings.usd:931` (and repeated at
`:1913`). It is the same class-local "Peace first, then act" pattern that
CraftFood inlines at `a9.cs:168-171` and SplitPeas at `as.cs:160-164`,
not a shared subroutine — eight such open-coded drop-to-peace sites exist
(`cm.cs:101`, `a5.cs:123`, `a9.cs:170`, `as.cs:162`, `dq.cs:106`,
`bv.cs:196`, `dz.cs:514`, `ga.cs:1526`); the only call into the client's
`SetCombatMode` is `f9.cs:378`. Full trace:
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` (local).
### 3.2 (d) IdlePeace
See §4 in full.
### 3.3 Stuck-combat-state recovery
One mechanism, `ga.cs:1506-1523` (§3.1 step 4), reused by every rule that
routes through `ga.a(...)`: after `DropToPeaceModeRetryCount` consecutive
ticks stuck outside Peace mode while trying to wield something, VTank
stops trying the *specific* requested item and instead force-equips
*any* valid wand from the profile, with the exact chat line **"Warning:
Macro detected bugged combat state. Attempting to wield an item to clear
it."** This is the only explicit "timeout" in the wield/mode path — there
is no separate wall-clock timeout, only a per-`ga.a(...)`-call tick
counter (`r`, reset to 0 the moment Peace mode is reached).
---
## 4. IdlePeace (`cm`, `cm.cs`) in full
**Precondition** (`cm.cs:62-75`): `IdlePeaceMode` setting is on, **and**
`Actions.CombatMode != Peace`. That's the entire `ValidNow` — no busy
check, no cast check, no target check of its own.
**Running(true)** (`cm.cs:96-103`): logs `(IdlePeace) Running` and calls
`f9.a((CombatState)1)` directly — **not** through the shared `ga.a(...)`
gate. This means IdlePeace does not go through the wand-preference or
stuck-state-recovery logic in §3.1 at all; it just asks for Peace mode
every tick it wins.
**Position in the list.** IdlePeace is instantiated 8 times
(`cLogic.cs:530-577`): once as the true terminal catch-all (last item in
`l`, after even RandomHelper), and 7 more times as the `LogicRulePreChain`
*fallback* wired onto other rules — ReadScroll/StackCram/SalvageItems
(idle tier), OpenCorpse (idle tier), the corpse-approach Navigate (idle
tier), the target-approach Navigate, and the nav-route Navigate. Because
`LogicRulePreChain.Running=true` tries its fallback children *before* its
primary action (`LogicRulePreChain.cs:53-67`), this means: while
approaching a corpse, a monster, or a nav waypoint, if the *approach
itself* isn't valid this tick but IdlePeace's own gated sub-condition is
(see below), the character drops to Peace mode as part of that specific
rule's turn — long before the terminal catch-all at the very end of the
list is ever reached.
**Interaction with nav minimum distance — the actual mechanism lives
outside `cm.cs`, in `fd.a(bool, double)` (`fd.cs:112-177`)**, which is the
per-tick movement driver for every `g8`-wrapped Navigate rule (§2.3). Read
carefully:
- `fd` computes whether the character is "close enough" to stop pressing
forward (`a(double)` — true when distance < `1.0/160.0` landblock units,
`fd.cs:103-110`, the `nav minimum distance` referenced in the campaign
brief).
- If that "stop" condition is true **and** `Actions.CombatMode == Peace`
(`fd.cs:129`): if `IdlePeaceMode` is on (`fd.cs:131`) log **"Warning: Idle peace
selected with low waypoint minimum distance. Will switch to magic
mode."** (`fd.cs:133`) — the warning is the ONLY thing that setting gates —
then, regardless of the setting, attempt to force Magic mode via
`dz.o.a((CombatState)8, 0, true)` (the shared gate, §3.1) — and if that
fails, cancel the "stop" decision for this tick, i.e. keep walking
instead (`fd.cs:135-138`).
- In other words: **`fd`, not `cm`, is what refuses to let the character
sit in Peace mode with an extremely tight waypoint-stop distance** — it
actively pushes into Magic mode instead, once per approach, with a
one-line warning. `cm`'s own `ValidNow`/`Running` never reference nav
distance at all; this override only fires while a `g8`/`fd` Navigate
rule is actually driving movement, and IdlePeace being wired as that
same rule's fallback (§ above) is *not* the same code path — the
fallback IdlePeace still just asks for Peace via `f9.a((CombatState)1)`,
unaware of `fd`'s own override, which runs on a completely separate
tick of `fd.a()` inside the wrapping `g8`'s own `Running` handling.
**Cadence.** No dedicated cooldown of its own; it re-requests Peace mode
every tick it wins (bounded only by the scheduler's own 293 ms heartbeat /
event-driven fast-poke, §1.1).
---
## 5. The MossTank gap
Files read: `MossTankPanel.cs` (`OnTick`, `TickAutomaticBuffing`,
`TickRandomHelper`), `CombatController.cs` (`OnTick`, `TickEquipment`,
`TryEquipIfNeeded`), `BuffCasterPreparer.cs`, `MacroIdleModeArbiter.cs`,
`DispelController.cs`, `VitalRecharge.cs`/`VitalPlan.cs`, `Navigation.cs`.
### 5.1 Structural model difference
VTank's `l` is a declarative list of independent `ILogicRule` objects; one
pass picks the first whose `ValidNow` is true and marks it `Running`, and
every other rule is explicitly told `Running = false` (§1.2). MossTank's
`OnTick` (`MossTankPanel.cs:3732-4022`) is an imperative chain of ~16
named controllers, each called unconditionally every tick with a `canAct`
argument that is the logical AND of "not owned by any higher controller
so far." The net *suppression* behavior is similar (a higher controller
owning the tick prevents a lower one from acting), but it is a different
mechanism: VTank rules that lose the pick are actively silenced
(`Running=false`, which several rules use to do cleanup —
e.g. `b4.cs:108-114` tears down cursors on losing the slot); MossTank
controllers that are not granted `canAct` simply never got permission to
begin with; there is no equivalent "you just lost the turn, clean up"
signal threaded through the chain.
### 5.2 IdlePeace / MacroIdleModeArbiter: covered, but not the nav-distance
override
`MacroIdleModeArbiter` (full file read) is a faithful, deliberately
improved single-owner port of the *terminal* IdlePeace idea — its own doc
comment explains it fixed a real gap where `CombatController`'s no-target
branch could never reach idle-peace once combat policy was disabled
(`MacroIdleModeArbiter.cs:5-13`, `CombatController.cs:270-280`). But
**there is no port of `fd.cs:129-138`'s nav-minimum-distance override**:
`grep` across `Navigation.cs`, `MacroIdleModeArbiter.cs`, and
`CombatController.cs` for "IdlePeace"/"low waypoint"/"minimum distance"/
"NavMinDistance" finds nothing outside the arbiter's own setting name.
A MossTank user with `IdlePeaceMode` on and a very tight waypoint stop
distance would, on retail VTank, see a one-time warning and an automatic
push into Magic mode near that waypoint; MossTank has no equivalent — it
would presumably just alternate between Peace (arbiter) and whatever mode
navigation itself wants, with no warning and no forced-Magic override.
### 5.3 RandomHelper: round-robin + best-known-tier vs. random + fixed
Tier-I
VTank's `ba` (`ba.cs:114-125`) makes a **random** `(target, spell)` draw
(`System.Random`, up to 100 tries) from a **hardcoded Tier-I** spell list
(literally "... Other I" for all 11 buffs — it never casts a higher known
tier even if the caster knows one). MossTank's `TickRandomHelper`
(`MossTankPanel.cs:4047-4122`) instead does a **deterministic round-robin
scan** (`_randomHelperCursor`) and picks the caster's **best known tier**
of each buff family (`.OrderByDescending(Quality).ThenByDescending(Tier)`,
line 4095-4097). Both are defensible design choices, but they are
observably different: retail VTank always buffs allies with the weakest
version of Tier-I self-buffs, cycling randomly; MossTank always buffs
with the strongest known tier, cycling in a fixed order. The 18 m range
constant (VTank's hardcoded `0.075` landblock units) is correctly ported
(`< 18d`, line 4071; no comment ties it to the conversion).
### 5.4 BuffCasterPreparer vs. `ga.a(...)`: same shape, one real
difference
`BuffCasterPreparer` (full file read) is a close, well-documented port of
§3.1's shared gate: prefer the already-wielded caster
(`TryResolveCaster`, `BuffCasterPreparer.cs:158-169`, matching
`ga.cs:1471-1475`), else the first profiled caster by a **deterministic
name-then-object-id order** (`BuffCasterPreparer.cs:171-188`) rather than
VTank's `dz.k.j()` scan order (whose actual ordering semantics are
unestablished, §6 — so this may or may not be a behavioral difference;
it is at minimum a *documented, reproducible* order where VTank's is not).
It reproduces the exact chat line "You must add at least one wand to
your \[Items\] profile." (`BuffCasterPreparer.cs:201`, cf.
`ga.cs:1471, 1481, 1517` verbatim except for the bracketed word), and
`CombatController.TryEquipIfNeeded` (`CombatController.cs:820-847`)
reproduces the stuck-combat-state recovery with the same
`DropToPeaceModeRetryCount` semantics and an equivalent warning message
(`CombatController.cs:841-843`). The one structural difference: VTank's
gate is retried every scheduler tick (bounded only by the 293 ms
heartbeat, §1.1); `BuffCasterPreparer`'s mode-request retry is explicitly
throttled to a fixed 2-second wall-clock cadence
(`BuffCasterPreparer.cs:33, 229-255`) with its own doc comment explaining
why ("a buff pass is not scanning for targets every 0.25 s"). This is a
deliberate, documented pacing change, not an oversight — flagged here
because it means the *retry-count* semantics (`DropToPeaceModeRetryCount`)
are shared, but the *wall-clock time* to exhaust them is not: VTank can
exhaust its retry budget in under a second at a 293 ms cadence, while
`BuffCasterPreparer` takes `DropToPeaceModeRetryCount × 2` seconds.
### 5.5 Two-tier vital recharge: present, but merged differently
VTank re-checks self-vital recharge at two separate priority tiers
(`cr` "Recharge-Norm-*" before Attack, "Recharge-NoTarg-*" after target-
approach fails, §2.2 rows 4 and 59) — two independent settings, gated
purely by *list position* relative to Attack. MossTank's `VitalPlan.
Threshold` (`VitalPlan.cs:79-91`) keeps both `Normal*` and `NoTarget*`
settings and evaluates `noTarget ? Math.Max(Normal, NoTarget) : Normal`
with a target present the NoTarget setting is ignored entirely, and only
in the no-target branch does the Max apply (the `noTarget` bool is
`VitalRechargeController.Tick`'s third parameter, `VitalRecharge.cs:887,
933-939`). This
converges on similar behavior in the common case (both thresholds usually
agree in intent — top off more eagerly while idle) but is not a literal
port: VTank's two rules can disagree in *which* vitals they check (a
Norm-tier check only fires while attack is even reachable in the list;
the NoTarg-tier check is a wholly separate, later evaluation with its
own three setting keys) whereas MossTank always evaluates one combined
threshold per vital per tick.
### 5.6 SummonPet's independent scheduling: not reproduced
VTank's `n` list (§2.1) runs SummonPet (`h1`) on every tick regardless of
what the main list is doing, and its own precondition needs no target —
only `EnableCombat` + `SummonPets` + skill + item + its own cooldown
gate. MossTank's pet automation (`PetAutomation.Tick`, called from
`CombatController.OnTick` only after `_targetId != 0`,
`CombatController.cs:270-280, 298-308`) is reachable **only once a
hostile target is already selected**. A player relying on VTank's
"summon before you need it" behavior (summon while wandering, combat
enabled, no target yet) would not see the same behavior in MossTank —
the pet is only summoned once combat has already found something to
fight.
### 5.7 Order differences that are cosmetic, not behavioral gaps
- OpenDoor **is** ported (`Navigation.cs:412+`, `TickDoor`), including the
lockpick-vs-open branch and range/threshold settings — not a gap.
- The stuck-combat-state recovery message and mechanism **are** faithfully
ported (§5.4) — not a gap.
- The overall high-level ordering (critical-craft-ish → vital → buff →
dispel → mana-recharge → crafting/idle-crafting → loot → inventory →
navigation → random-helper → combat, with idle-peace last) tracks
VTank's real priority reasonably closely; the one clear ordering
inversion found is RefillWieldedMana (VTank: before BuffSelf,
`cLogic.cs:471-472`) vs. mana-recharge (MossTank: after
`TickAutomaticBuffing`, `MossTankPanel.cs:3792-3814`) — a player would
notice this only in the edge case where both a buff and a wielded-item
mana-recharge are simultaneously due.
### Five most important differences a player would notice
1. **No nav-minimum-distance → forced-Magic-mode override** (§5.2): VTank
warns and forces Magic mode near a very tight waypoint stop distance
when Idle Peace is on; MossTank has no such override.
2. **RandomHelper is deterministic + best-known-tier, not random +
fixed Tier-I** (§5.3): visibly different buff choices and cadence
pattern on fellow players over a long session.
3. **SummonPet requires a target in MossTank; VTank does not** (§5.6): a
MossTank character will not pre-summon a pet while wandering with no
enemy yet found.
4. **BuffCasterPreparer's mode-retry cadence is a fixed 2 s wall-clock
pace vs. VTank's ~293 ms scheduler-tick pace** (§5.4): the *number* of
retries before the stuck-state recovery fires is the same setting, but
MossTank takes several times longer to reach it.
5. **RefillWieldedMana runs after buffing in MossTank, before it in
VTank** (§5.7): only visible when both are simultaneously due, but is
a genuine, checkable ordering inversion.
---
## 6. Could not determine
- ~~`dz.k.j()` ordering~~ — RESOLVED (lead, 2026-09-06): Items-page
insertion order; producers are `PluginCore.cs:8422-8434` and the profile
loader. See §3.2(a).
- ~~`GoToPeaceModeToUseKits` absent~~ — RESOLVED: it exists at `a5.cs:121`
and `defaultsettings.usd:931`. See §3.2(c).
- **`dz.p` / `hi` (the Attack rule's target-selection and attack-plan
execution internals)** were read only at the level needed to describe
scheduling (`dz.cs:664-667, 201-206`); the damage-formula, spell-choice,
and melee/missile-vs-magic decision logic inside `hi`/`dz.cs`'s target
sort were not traced — out of scope for a scheduler/action-list doc,
likely covered by a combat-math KB doc.
- **`gj`/`gs` (the two Decal state trackers that drive `SchedulePoke`'s
fast-wake path)** were read only enough to confirm what they represent
in shape (resolved by the citation pass: `gj` = SpellCaster, `gs` =
WandCaster, both cast-completion trackers); their own internal state
machines were not traced.
- **Meta FSM** (`dz.at.h()`, `a7.e()`, `cLogic.cs:191-196`) is explicitly
out of scope — it is evaluated once per pass alongside the main rule
list but is its own subsystem (VTank's expression-language meta layer),
not one of the 24 action classes catalogued here.

View file

@ -0,0 +1,596 @@
# VTank knowledge-base 03 — Combat
Research-only. Oracle: `refs/vtank/decompiled/` (obfuscated VTank 2.x
source; class/field names below are the decompiler's raw identifiers —
see the class map). Cross-checked against
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` §2.1
(marked disagreements are called out explicitly; everywhere else the
two sources agree). No code was changed to produce this document.
## Class map (for anyone grepping the decompiled tree later)
VTank's god object is `PluginCore.dz`, an instance of class `s`
(`refs/vtank/decompiled/s.cs:7-114`). Its combat-relevant children:
| Field (`dz.X`) | Type / file | Role |
|---|---|---|
| `p` | `dz` (`dz.cs`) | Target scan + candidate ranking, debuff-spell/item resolution |
| `e` | `d1` (`d1.cs`) | Monster Rules table (`MyMonsters`) + per-creature rule cache |
| `o` | `ga` (`ga.cs`) | Creature-info dict, current/last target id, blacklist list, wield-item info, pet pick, `CombatState` classifier |
| `w` | `bo` (`bo.cs`) | Melee/missile attack executor (swing timing, hit/miss chat parsing) |
| `h` | `gj` (`gj.cs`) | Spell-cast state machine ("SpellCaster": gesture echo → result text) |
| `y` | `e0` (`e0.cs`) | GameInfoDB client: monster/species auto-damage tables, heal-kit/grenade dbs |
| `an` | `fp` (`fp.cs`) | Blacklist-attempt-count manager (temporary skip) |
| `am` | `b8` (`b8.cs`) | Ghost-monster detector (permanent client-side deletion) |
| `f` | `ak` | Spell name → `MySpell` lookup |
| `i` | `fk` | Wand/spell-equivalent + element→spell-name resolution (War/Arc/Ring/Streak/Vuln) |
| `j` | `dm` | Per-target recast-timer tracker |
| `d` | `cLogic` (`cLogic.cs`) | Primary rule scheduler (293 ms tick) |
| `m` | `da` (`da.cs`) | Character profile loader; owns the settings DB (`.c["MyMonsters"]`) |
`f7` (`f7.cs`) is not a `dz` field — it is the transient
"attack candidate" record built fresh for every monster on every scan.
`hi` (`hi.cs`) is the transient "what to do this tick" decision object,
recreated each attack tick inside `dz.b(double)`.
---
## 1. Target acquisition
**Scan cadence.** The primary macro loop (`cLogic`) arms a 293 ms
repeating timer (`m_a = 293`) at construction and starts it from
`StartMacro` (`refs/vtank/decompiled/uTank2/cLogic.cs:11,52-65,327-328`).
Every tick, if not already mid-poke, `TryPokeMacro` walks the ordered
rule list and re-evaluates `ValidNow` on each rule top to bottom,
running the first one whose `ValidNow` is true
(`cLogic.cs:163-173,183-260`). The "Attack" rule (`b4`) is one of
those rules: its `ValidNow` calls `dz.p.c()` — the full target scan —
on **every** 293 ms tick, gated only by `EnableCombat` and the
`ItemUse` action lock (`refs/vtank/decompiled/b4.cs:62-76`). There is
no separate, slower "target scan" timer; scanning and rule evaluation
share the same 293 ms cadence. A second `cLogic` timer at 3203 ms
(`m_b`) exists but only forces a GC pass and is unrelated to combat
(`cLogic.cs:37,61-70,266-277`).
**What counts as a target.** `dz.p.c()` calls `dz.p.b(AttackDistance)`,
which enumerates every object of `ObjectClass.Monster` known to the
world filter (`dz.q.a(ObjectClass.Monster)`) and builds one `f7`
candidate per object via `f7.a(fu, maxDist, minDist, targetLockArg)`
(`refs/vtank/decompiled/dz.cs:664-704,706-730`; `refs/vtank/decompiled/f7.cs:247-297`).
A candidate is rejected (`f7.n = false`) at the first failing gate, in
this order (`f7.cs:254-296`):
1. No cached creature-info record for the guid (`CreatureInfoMissing`).
2. The creature-info record is itself invalid, i.e. `hj.a()` true (`CIInvalid`).
3. Its matched Monster Rule priority is negative (`NegativePriority` — see §3).
4. Distance from the player exceeds the scan's max (`DistanceTooFar` — normally `AttackDistance`, but the pet-pick and "target lock follow" call sites pass their own max, `dz.cs:952,971,1092-1093`).
5. Distance is below the scan's min (`DistanceTooNear` — normally `AttackMinimumDistance`).
6. It needs a debuff but the monster's rule forbids attacking without one first (`DebuffPassWithNoAttack` — the two-arg `dz.a(int,f7)`/`dz.a(int,bool,d1.a)` gate at `dz.cs:561-607`; only reached when the rule's own "needs a specific debuff and it's not up" test at `dz.cs:561-585` is true **and** the rule is a pure-debuff row with no independent Attack/Ring flag).
A candidate that passes fills in distance (`f7.e`), heading delta to
the target (`f7.f`), the monster-rule debuff-urgency score (`f7.g`,
see §2), whether it *is* the last-attacked target (`f7.l`, guid equals
`ga.e`), whether it *is* the currently-attacked target (`f7.k`, guid
equals `ga.d`), whether it is the in-game selection **and**
`TargetLock` is on (`f7.j`), and whether it is blacklisted
(`f7.m`, guid is in `ga.f`) (`f7.cs:283-296`; `ga.cs:56-64` for the
field types).
**Ranges.** All distance-shaped settings (`AttackDistance`,
`AttackMinimumDistance`, `RingDistance`, `ApproachDistance`,
`ArcRange`, `TargetSelectAngleRange`, `PetCustomRange`) are compared
directly, unconverted, against `f7.e`
(`dz.cs:666,716-726`; `ga.cs:1081-1086`). The only place a `*240`
conversion appears is the monster-rule expression keyword `range`,
which exists purely for user-facing text
(`refs/vtank/decompiled/cl.cs:159-163`) — this confirms the raw
setting values and `f7.e` share one internal unit where 240 units ≈ 1
meter, but exact numeric defaults could not be reliably decoded from
`uTank2.Resources.defaultsettings.usd` (see §9).
**Ghost detection — two independent, differently-scoped mechanisms.**
VTank does not have one "ghost" concept; it has two counters fed by
different signals with different consequences:
| | Blacklist (`dz.an` = `fp`) | Ghost delete (`dz.am` = `b8`) |
|---|---|---|
| Increment trigger | A physical **missile** attack reports "hit the environment" (`refs/vtank/decompiled/bo.cs:110-116`) | (a) repeated retries of the cast gesture before the "You say" echo ever arrives (`refs/vtank/decompiled/gj.cs:319-334`); (b) a cast enters its result-wait state and **times out with no fail/success/kill text at all**, single-target spells only (`gj.cs:468,477-485`) |
| Force-trip (bypasses count) | An explicit "permanent fail" cast-result text (spell inapplicable to the creature) calls `fp.a` directly (`gj.cs:401-413`) | — |
| Reset | Any confirmed melee/missile hit-message (`bo.cs:117-120`); a spell "kill" or "success" result text (`gj.cs:391-398,442-452`); entering the cast-result-wait state resets the ghost counter unconditionally (`gj.cs:222-223`) | separately, an HP-tracker path (below) |
| Threshold | `BlacklistMonsterAttemptCount` (`fp.cs:79`) | `GhostMonsterSpellAttemptCount` (`b8.cs:111`) |
| Consequence | Marks the creature-info record blacklisted for `BlacklistMonsterTimeoutSeconds` — a **temporary** skip in future scans; the object is not touched (`fp.cs:86-100`) | Deletes the client-side world object outright (`f9.f(guid)`), only if it is still `ObjectClass.Monster` and `DeleteGhostMonsters` is on — **permanent** for that object (`b8.cs:111-120`) |
A **second, independent** ghost path exists purely from HP-bar
staleness: if `DeleteGhostMonstersByHPTracker` is on, the macro is
running, and there is a currently-HP-tracked monster
(`dz.ao.b() != 0`) whose species is recognized in the damage db, then
once **both** "time since the tracker last updated" and a second
tracker timestamp exceed `GhostDeleteHPTrackerSeconds`, that object is
deleted the same way, independent of any spell attempts
(`b8.cs:77-99`). This ticks on its own ~6.3 s timer (`b8.cs:16-19`),
not the 293 ms combat tick.
**Target lock.** `TargetLock` only changes whether "is the in-game
selection" (`f7.j`) counts as true when filling a candidate
(`f7.cs:293`, third argument threaded from `dz.cs:717,730`). It is
**not** a first-refusal filter at the candidate-building stage — its
effect on final target choice is a low-priority tie-breaker inside
selection (§2).
## 2. Target selection
After building candidates, `dz.p.b` picks one target via a single
linear pass that keeps a running "best so far" (`this.a`), never a
full sort (`dz.cs:706-730` header, comparison chain `dz.cs:740-920`).
The comparison chain, evaluated **in this exact order** for every
candidate against the current best:
1. **Priority** (the matched Monster Rule's priority, §3) always wins
first — *unless* `DebuffEachFirst` is `"All"`, in which case a
candidate that still needs a debuff outranks one that doesn't
**even across a priority difference**, and priority is only
compared once both share the same debuff-need state
(`dz.cs:740-751`, flag2 gate).
2. Within a priority tie: if `DebuffEachFirst` is `"All"` **or**
`"Priority"`, a candidate needing a debuff outright beats one that
doesn't (`dz.cs:770-781`, `flag` gate). `DebuffEachFirst == "One"`
skips this step entirely — VTank's own §2.1 phrasing ("one target,
priority group, or all targets before attack") maps to these three
values.
3. **Debuff-urgency score** (`f7.g`, from `dz.p.b(guid,f7)`
`dz.cs:163-199`): a small 03 integer — +1 if a Vuln debuff
matching the *currently chosen attack element* is not yet applied
(recast timer still running), **and** +2 if either Imperil (when
fighting with a melee/missile weapon) or Magic Yield (when fighting
bare-handed/with magic, i.e. `CombatState == Magic`) is not yet
applied. Higher score wins outright; this is evaluated for **every**
candidate regardless of `DebuffEachFirst`, and only checks these
two/three specific debuffs, not the full monster-rule debuff set.
4. **Target lock** (`f7.j`): if one candidate is the in-game selection
under `TargetLock` and the other isn't, it wins (`dz.cs:788-793`).
5. **Wield-match** (only when *both* candidates are within
`TargetSelectAngleRange` distance of the player): counts how many
of {weapon, offhand} would need to change to engage each candidate
and prefers fewer changes — i.e. avoid re-wielding between two
nearby targets (`dz.cs:794-824`).
6. **Sticky last target** (`f7.l`, guid equals the previously-attacked
guid `ga.e`): prefer the target already being attacked over a new
one (`dz.cs:825-830`).
7. Only now does `TargetSelectMethod` (§ below) decide.
**MossTank disagreement:** `CombatController.cs:1707-1798` clusters
candidates by max priority, then gives `TargetLock` (line 1740) and
the previously-attacked target (line 1757, explicitly commented as
modelling `ga.e`) **unconditional first refusal** ahead of any
ranking — i.e. steps 4 and 6 above are promoted ahead of steps 23
instead of following them. `DebuffEachFirst` IS implemented as a debuff *scope* filter
(`CombatController.DebuffScope()`, `CombatController.cs:1618-1658`) but is
never consulted during target selection; only the debuff-urgency score
(`f7.g`) has no equivalent anywhere (citation pass 2026-09-06). See §8 gap #1.
**The three `TargetSelectMethod` values**, read once per scan as
`f3.f("TargetSelectMethod")` (`dz.cs:722`), decide only the final tie
(step 7) — for the vast majority of contested scans with more than one
same-priority monster this is reached only after every prior step
above ties:
| Value | Name (VTank UI) | Rule (`dz.cs:831-915`) |
|---|---|---|
| 1 | Distance | Sort by distance (`f7.e`) first, heading delta (`f7.f`) as the tiebreak |
| 2 | Angle | Sort by heading delta first, distance as the tiebreak |
| 3 | Both (hybrid) | If **both** compared candidates are within `TargetSelectAngleRange` distance of the player, sort by angle first (distance tiebreak); if **both** are beyond it, sort by distance first (angle tiebreak); a candidate within the cutoff always beats one beyond it, regardless of angle/distance values |
Despite its name, `TargetSelectAngleRange` is compared directly
against the **distance** field `f7.e`, never against `f7.f` (the
angle) — this is a real quirk of the retail setting, not a
misreading; MossTank's `CombatController.cs:1766-1767` reproduces this
correctly (`candidate.Target.Distance <= _settings.TargetSelectAngleRange`).
## 3. Monster rules
The `MyMonsters` table backs `d1` (`d1.cs`). Its 21 columns, recovered
from the schema listed in
`refs/vtank/decompiled/uTank2.Resources.defaultsettings.usd:48-70` and
cross-checked against the column→field wiring in
`d1.a.a(cw)`/`a(a)` (`d1.cs:58-119`) and the incremental-migration
names in `refs/vtank/decompiled/da.cs:280-322`:
| # | Column name | `d1.a` field | Type | Meaning |
|---|---|---|---|---|
| 0 | MonsterName | `u` | string | Rule name; `"<DEFAULT>"` is the fallback row |
| 1 | AttackPriority | `a` | int | -1 (never attack/never targeted) .. 4 |
| 2 | DamageType | `b` | `eDamageElement` | Primary attack element, or `Auto`/`Harm` |
| 3 | WeaponToUse | `f` | int | Wielded-item object id override (0 = auto) |
| 4 | Imperil | `g` | bool | Cast Imperil Other I |
| 5 | Vuln | `h` | bool | Cast Vuln matching the *attack* element |
| 6 | Yield | `i` | bool | Cast Magic Yield Other I |
| 7 | GravityW | `k` | bool | Cast Gravity Well |
| 8 | Attack | `!t` | bool (stored inverted) | Attack the monster at all |
| 9 | Ring | `j` | bool | Use ring/void-curse attack magic |
| 10 | Broadside | `l` | bool | Cast Broadside of a Barn |
| 11 | Fester | `m` | bool | Cast Fester Other I |
| 12 | WeakeningCurse | `n` | bool | Cast Weakening Curse I |
| 13 | FesteringCurse | `o` | bool | Cast Festering Curse I |
| 14 | Corruption | `p` | bool | Cast Corruption I |
| 15 | DestructiveCurse | `q` | bool | Cast Destructive Curse I |
| 16 | Corrosion | `r` | bool | Cast Corrosion I |
| 17 | Streak | `s` | bool | Prefer streak-shape attack spells |
| 18 | SecondaryVuln ("Ex. Vuln") | `c` | `eDamageElement` | Extra Vuln element beyond the natural one |
| 19 | SecondaryEquip ("Offhand") | `e` | `eSecondaryEquipTypeOrObjectID` | Offhand item selection mode/id; members `Auto, AutoShield, AutoWeapon, None, LISTEDTYPES_END` (`uTank2/eSecondaryEquipTypeOrObjectID.cs:3-10`) |
| 20 | PetDamageType | `d` | `eDamageElement` (default `PAuto`) | Preferred pet damage element for this monster |
**Matching semantics.** `d1.a(fu)` walks the table top-to-bottom,
skipping the `"<DEFAULT>"` row, and returns the **first** row whose
expression matches; the default row is the fallback only when nothing
else matched (`d1.cs:415-448`). A per-row/per-target match result is
cached for the session unless the expression used a *volatile* token
(see below), in which case it's re-evaluated every call
(`d1.cs:389-405`; the cacheability flag comes from `cl.a`'s `out bool`).
**Expression grammar** (`cl.cs`), a small infix language with a
shunting-yard evaluator over doubles and strings:
- Literals: numbers, and quoted/bare strings.
- Operators: `&& || == < > >= <= != #` (regex match, string only)
`+ - * / %`, with `(` `)` grouping (`cl.cs:279-304` precedence table,
`cl.cs:306-446` evaluator).
- Built-in identifiers, each a function of the candidate monster
(`cl.cs:87-182`): `true`, `false`, `name`, `typeid`
(`PropertyInt` `bc.cp`), `species` (species-table name via
`dz.y.b`), `maxhp` (`dz.y.c`, from the damage/species db), `range`
(distance × 240, **volatile**), `hasshield` (any armor-class item on
the target, **volatile**), `metastate` (**volatile**).
- `setting_<Name>` reads any VTank setting by name at evaluation time
(**volatile**) (`cl.cs:184-216`).
- If the final expression value is numeric, non-zero means match; if
it resolves to a string, VTank instead compares that string
case-insensitively to the monster's own name (`cl.cs:247-262`) — so
a bare string literal like `"Drudge"` is itself a valid "expression."
- Parse or evaluation errors are caught, logged, and treated as
cacheable non-matches (`cl.cs:263-274`).
**MossTank comparison:** `MonsterExpression.cs` and `MonsterRules.cs`
are a faithful, well-cited re-derivation of this exact grammar,
identifier set, and volatility/caching model (`MonsterExpression.cs:69-117,151,214-229`;
`MonsterRules.cs:104-150`) — no material gap found here.
## 4. Weapon and damage choice
**Auto damage database.** `dz.y` (`e0`) wraps a community
`gameinfodb.ugd` file (falling back to an embedded
`defaultinfodb.ugd`), auto-updated from
`auth.virindi.net/plugins/gamedb/get2.php`
(`e0.cs:53-79,435-441`). `e0.d(monsterName)` resolves the auto-damage
element preference list with a two-step fallback: an explicit
per-monster row in `MonsterDamageOverrides`, else the monster's
species row in `SpeciesDamages` (via `SpeciesMembers` for the
name→species id lookup); if neither exists it returns an **empty**
list, not a guessed default (`e0.cs:327-349`). When a Monster Rule's
`DamageType` is `Auto`, `f7.a()`'s private element resolver takes the
list's **first** entry as the debuff/attack element, or leaves it
`None` if the list is empty (`f7.cs:200-227`).
**MossTank disagreement:** `VtankDamageDatabase.cs:12-21,28-44` falls
back to a hardcoded 7-element guess order
(`Pierce, Bludgeon, Slash, Acid, Electric, Cold, Fire`) when a monster
is in neither local table, where retail simply has no auto-element
for that monster (no Vuln cast, `f7.h == None`). See §8 gap #4.
**Ammo/prismatic.** For bow-class weapons, `ga.a(fi,eDamageElement)`
checks whether ammunition of the requested element is actually in
inventory via `bv.b(fi, element, 1, ePrismaticDamageBehavior.Any)`
(a small per-tick cache keyed by launcher-shape) before allowing that
element to be used, logging a warning and refusing otherwise
(`ga.cs:1211-1241`); a mirrored `b(fi,eDamageElement)` exists
immediately after (only partially read; not confirmed identical).
Launcher/projectile shape is carried as a six-value enum `l` (`l.a``l.f`,
declared at `l.cs:1-8`) attached to debuff items and spells (`dz.cs:250,264,296`;
`f7.cs:122-146`); the member names are obfuscated, so the semantic mapping
is unrecovered (§9).
**Offhand / re-wield.** The Monster Rule's `SecondaryEquip` column
(`eSecondaryEquipTypeOrObjectID`) selects the offhand item; the target
selector's wield-match tie-break (§2 step 5) actively tries to avoid
re-wielding weapon or offhand between two nearby targets, but does not
prevent it outright — a genuine priority/urgency difference always
forces a re-wield.
**MossTank comparison:** `VtankAmmunitionDatabase.cs` was not read in
detail for this pass; flagged for a follow-up doc rather than guessed
here.
## 5. Attack execution
**Melee/missile timing (`bo`, `refs/vtank/decompiled/bo.cs`).**
`bo.a(guid,power,spell)` (called only for physical attacks — `spell`
is always `null` here) selects the target in-game if not already
selected and arms the attack (`bo.cs:326-348`). A 263 ms timer
(`bo.cs:22,46-48`) drives the swing loop: while waiting to confirm the
in-game selection actually changed, it re-issues `SelectItem`; once
selected, the private swing method fires (`bo.cs:238-268`). That
method reads `DefaultMeleeAttackHeight`, and if `AutoAttackPower` is
on, applies the computed power (§ below) via `f9.a`
(`bo.cs:296-324`); it then sends the height-mapped key down+up pair
(`ha.a/b/c → br.aq/af/ae`, `bo.cs:172-181`) and locks
`ActionLockType.MeleeAttackShot` for 0.75 s (`bo.cs:322`). Hit/miss is
read back out of chat: a missed missile shot ("hit the environment")
feeds the blacklist counter, a matching damage-report line
(`^(Critical hit!)? ... for ... point(s) of ...!$`) resets it
(`bo.cs:110-120`, §1 table).
**Power/height and Recklessness (`hi.c`, called only for
`CombatState.Melee`/`Missile`, i.e. `val == 2`/`4`;
`hi.cs:650-680`).** Missile attacks always use power `1f`. Melee power
is a fixed decision table over: whether the chosen weapon is a
single-hand slash/pierce hybrid without an offhand melee weapon,
whether the weapon has a triple-slash attack type, and whether the
offhand is another melee weapon or a shield — producing one of
`{0f, 0.2f, 0.49f, 0.5f, 1f}` (`hi.cs:657-661`). If `UseRecklessness`
is on and the Recklessness skill is trained, the result is clamped to
`[0.11, 0.9]` (`hi.cs:663-673`; `bo.cs` applies the same clamp to the
`AutoAttackPower`-computed value). **MossTank's `AutoAttackPower.cs`
is a faithful, explicitly-cited port of this exact table and clamp**
(`AutoAttackPower.cs:55-83`, header comment names `hi.cs` directly) —
no material gap found.
**Magic attack selection (`hi.a`, the per-tick decision object,
`hi.cs:66-327`).** `CombatState` is derived from the *player's chosen
weapon's* `ObjectClass`: `MeleeWeapon → Melee(2)`,
`MissileWeapon → Missile(4)`, anything else (including bare hands and
wands/orbs) → `Magic(8)` (`ga.cs:1581-1616`; the numeric tags are
inferred from the `(CombatState)N` casts used at every call site, not
from an explicit enum declaration — see §9). When `CombatState ==
Magic`, `hi` first runs the fixed 12-step debuff-priority chain (§6),
then, only if none is due, the attack-spell branch:
- **Ring** is used when the monster rule's Ring flag is set **and**
either the nearby-monster count (`dz.p.c`, tallied during the scan
as "candidates within `RingDistance`" — `dz.cs:735-739`) meets
`MinimumRingTargets`, or the rule has no independent Attack flag at
all (`hi.cs:220-240`). If the ring spell needs scarab components and
they're in inventory, casts it directly; otherwise falls through to
bolt/arc.
- **Streak** is tried next when the rule's Streak flag is set and a
usable streak spell of the requested element exists; if none is
usable it logs a warning and falls back to bolt/arc
(`hi.cs:258-307`).
- **Bolt vs Arc** (`hi.a(eDamageElement,f7)`, `hi.cs:471-542`): looks
up a War-school bolt spell and an Arc spell for the element; if only
one exists, use it; **otherwise the higher-`Quality` spell wins
outright** — `UseArcs` is consulted **only when both spells tie in
Quality**, where `1` = prefer bolt, `2` = prefer arc only if
`f7.e >= ArcRange`, `3` = always prefer arc, default = prefer bolt.
**MossTank disagreement:** `AttackSpellCatalog.cs`'s `Preference()`
(lines 140-204) buckets candidates by `UseArcs`/`ArcRange`/streak
**before** ever comparing spell quality — Tier/Difficulty only break
ties *within* a bucket (`Compare`, lines 93-138). This means MossTank
will follow the `UseArcs`/range rule even when the character actually
knows a strictly higher-tier spell of the other shape, where retail
picks the higher-tier spell outright and only falls back to
`UseArcs` on an exact tie. See §8 gap #2.
**Spell fizzle / result handling (`gj`, the cast state machine,
`gj.cs:1-149,341-466`).** States: idle → waiting for the "You say ..."
gesture echo → waiting for a result chat line. Result-line
classification against four regex families (`refs/vtank/decompiled`
list source not fully traced, referenced as `l.g.{a,b,c,d}`):
"kill" (`d`, ends the target and clears the blacklist counter),
"permanent fail" (`b`, e.g. immune — force-trips the blacklist
immediately), "fail"/resist (`a`, plain reset, no penalty), "success"
(`c`, matched by spell name + optional target name — clears the
blacklist counter). A silent timeout with **no** result line at all
increments the ghost counter instead (§1).
## 6. Debuffs
**Fixed check order.** Debuff *choice* is not a sorted/scored list —
`hi`'s private decision method (`hi.cs:66-327`) tests exactly twelve
debuffs in this **hardcoded** order and dispatches the first one whose
recast timer has elapsed (within `DebuffPrecastSeconds` of expiring,
except Corruption/DestructiveCurse/Corrosion which require the timer
to have fully reached zero):
1. Magic Yield Other I (`hi.cs:123-129`)
2. Weakening Curse I (`130-136`)
3. Festering Curse I (`137-143`)
4. Corruption I (`144-150`, zero-tolerance)
5. Destructive Curse I (`151-157`, zero-tolerance)
6. Corrosion I (`158-164`, zero-tolerance)
7. Imperil Other I (`165-171`)
8. Vuln matching the **current attack element** (`172-178`)
9. Vuln matching the rule's **`SecondaryVuln`/"Ex. Vuln"** column, via
`f7.h` (`179-185`)
10. Gravity Well (`186-192`)
11. Broadside of a Barn (`193-199`)
12. Fester Other I (`200-206`)
Once a debuff is chosen, the actual spell/wand/item used to cast it is
resolved separately by `dz.a(MySpell,f7)` (`dz.cs:219-393`), which
picks among: the equivalent spell known by the character, a wielded
wand of matching family/quality, or a thrown "grenade" item — ranked
by the `dz.b` comparer using `DebuffSelectionMethod` (`"SpellLevel"`
compares item Quality first then item count/priority, `"Skill"` swaps
that order — `dz.cs:11-91`), with wand fallback and
`AllowDebuffFallback` gating whether a mismatched projectile-type item
may substitute at all (`dz.cs:233-393`). This per-debuff *item* choice
comparer is a completely separate mechanism from the fixed
*debuff-kind* order above.
**`DebuffEachFirst` reaches into target selection**, not just
scheduling — see §2 steps 12. `"One"` leaves target choice alone;
`"Priority"` makes debuff-need a tiebreak within a priority tier;
`"All"` makes it override priority itself until the need is resolved.
**Wand switching.** When the chosen debuff must be cast via a wand and
`SwitchWandsToDebuff` is on, VTank actually re-wields to a
matching-element wand for the cast (comparing the *target's* current
wielded-item CombatState against the *player's own* prospective
change) before casting, then restores afterward
(`dz.cs:486-508`).
**MossTank disagreement (highest-impact finding in this document):**
`DebuffScheduler.cs` (class `DebuffSpellCatalog`)'s `OrderedFlags` (lines 21-34) declares the
order `Fester, Broadside, GravityWell, Imperil, Yield, Vulnerability,
WeakeningCurse, FesteringCurse, Corruption, DestructiveCurse,
Corrosion` — almost the **reverse** of retail's real order above
(retail's *last*-checked debuff, Fester, is MossTank's *first*).
Worse, MossTank does not implement retail's "check exactly one fixed
kind per tick, first due wins" model at all: it gathers **every** due
debuff into a candidate set and sorts it by `DebuffSelectionMethod`
(Skill/SpellLevel) then spell Tier/Difficulty, with `ActionOrder`
(the wrong-order array above) only as the final tiebreak
(`DebuffScheduler.cs:93-118`). Retail's `DebuffSelectionMethod`
comparer (`dz.b`, §6 above) is a *per-kind item/spell choice*
mechanism in the real client, never a *cross-kind debuff-choice*
ranking — MossTank has repurposed it for a role retail never gives it.
Also, retail's natural-element Vuln (step 8) and the rule's own
`SecondaryVuln` Vuln (step 9) are two sequential, separately-ordered
checks; MossTank's `Required()` (lines 125-146) does add both as
distinct `DebuffIdentity` values, but assigns them the *same*
`ActionOrder` (5), so their relative order falls to
Tier/Difficulty/SpellId instead of retail's guaranteed
natural-before-extra sequence. See §8 gap #1.
## 7. Pets
`ga.j()` (`ga.cs:1076-1209`) is the pet-selection algorithm, run from
the always-on `h1` "SummonPet" logic rule
(`refs/vtank/decompiled/h1.cs:30-53`, gated on `EnableCombat`,
`SummonPets`, the Summoning skill being trained, and a
spell-cooldown/readiness check via `an.a(-32555)`/`bm.a()` whose exact
semantics were not traced further):
1. Pick a scan range: `PetCustomRange` if `PetRangeMode == 1`, else
`AttackDistance` (`ga.cs:1081-1085`).
2. Build `f7` candidates for every monster in range (bypassing the
min-distance/target-lock/priority-reject gates used for normal
attack scanning — only the base validity check `f7.o` is used);
among those whose matched Monster Rule has a `PetDamageType` other
than `None`, track the count and the single best one under a
**combined** "nearer distance AND higher rule priority" predicate
(`ga.cs:1090-1110`).
3. Refuse to summon (return 0) if no eligible monster exists, or if
the eligible count is below `PetMonsterDensity`
(`ga.cs:1111-1118`).
4. Among the player's own pet-capable items (`PluginCore.PC.ec`,
filtered to ones actually owned/wieldable with a known damage
element), score each by how well its element matches the target:
exact match to the rule's `PetDamageType` scores best, then a match
to the target's *actual chosen attack element*, then a match
anywhere in the target's auto-damage preference list (indexed, so
earlier list entries score better); ties prefer the pet with the
higher `a12.m` stat (not identified further) (`ga.cs:1119-1206`).
5. Return the winning pet's object id, or 0 for none.
The `h1` rule's `Running(true)` handler simply calls `f9.p(petId)`
use/summon that item (`h1.cs:74-81`). No MossTank pet-selection code
exists — `PetAutomation.cs` (`PetAutomationChoice`: device/target/element pick)
and `PetDeviceCatalog.cs` — but was not compared step-by-step against
`ga.j()`; that comparison is owed (§9).
## 8. MossTank gap ranking (highest player impact first)
1. **Debuff-kind ordering and selection model is structurally
different, not just re-ordered.** `DebuffSpellCatalog.OrderedFlags`
(`DebuffScheduler.cs:21-34`) is close to the reverse of retail's
real fixed 12-step order (`hi.cs:123-206`), and MossTank scores
across debuff *kinds* using a comparer retail only ever uses to
choose *within* one kind (`DebuffScheduler.cs:93-118` vs.
`dz.b`, `dz.cs:11-91`). Effect: a MossTank character debuffs
targets in a different sequence than retail VTank ever would,
which changes which debuff is up when an attack lands and can
waste casts on lower-value debuffs first.
2. **Target selection omits `DebuffEachFirst` and the debuff-urgency
score entirely, and inverts the priority of `TargetLock`/sticky-target.**
Retail interleaves debuff-need into priority/tie-break resolution
(`dz.cs:740-824`) with `TargetLock` and the sticky-last-target as
*low*-priority tiebreaks near the bottom of the chain; MossTank
(`CombatController.cs:1707-1798`) gives `TargetLock` and the
sticky-last-target unconditional first refusal within the top
priority tier; `DebuffEachFirst` exists only as a scope filter
(`DebuffScope()`, :1618-1658) and is never consulted here, and there is
no debuff-urgency signal at all. Effect: MossTank can get "stuck" defending a locked/sticky
target far more rigidly than retail, and never re-prioritizes a
same-priority target that urgently needs a re-debuff.
3. **Arc vs. Bolt is chosen by `UseArcs`/range before spell quality,
not after.** Retail always prefers the higher-`Quality` known spell
and only falls back to the `UseArcs` rule on an exact tie
(`hi.cs:501-540`); MossTank's `Preference()` buckets by
`UseArcs`/`ArcRange` first and only uses Tier/Difficulty to break
ties inside a bucket (`AttackSpellCatalog.cs:140-204`). Effect: a
character who knows a much stronger bolt (or arc) than their
counterpart shape will still be forced into the weaker one whenever
the `UseArcs`/range rule says so.
4. **Unknown-monster auto-damage falls back to a guessed element
order instead of no auto-element.** Retail returns an empty
preference list when a monster is in neither `MonsterDamageOverrides`
nor `SpeciesDamages` (`e0.cs:327-349`), meaning no auto-Vuln is cast
for it; MossTank falls back to a fixed
`Pierce > Bludgeon > Slash > Acid > Electric > Cold > Fire` guess
(`VtankDamageDatabase.cs:12-21`). Effect: only matters for monsters
missing from MossTank's bundled tables, but produces a
confidently-wrong element choice rather than retail's "skip it"
behavior.
5. **No wield-match (weapon/offhand-thrash avoidance) tiebreak.**
Retail actively avoids re-wielding between two nearby same-priority
targets (`dz.cs:794-824`); no equivalent logic was found in
`CombatController.cs`. Effect: minor DPS/time loss from unnecessary
re-wields when several adjacent monsters need different weapons,
lower impact than 13 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
detail and found to be faithful, well-cited ports with no material gap.
## 9. Could not determine
- Exact numeric default values for distance-shaped settings
(`AttackDistance`, `RingDistance`, `ArcRange`,
`TargetSelectAngleRange`, etc.) in
`uTank2.Resources.defaultsettings.usd` — the file appears to hold at
least two differently-typed tables under the same setting names
(one using small `0.02083...`-style doubles, a second using plain
integers like `16`/`48`), and the exact `y`/`bd` deserialization
schema needed to tell them apart was not available in this pass. The
*names* and *semantic meaning* of every setting cited above are
independently confirmed via call sites, not via this file.
- The `l` enum (`l.cs:1-8`, six members `a``f`) is declared but obfuscated;
which member is which launcher/projectile shape is unrecovered.
- `CombatState`'s enum declaration and full name — only inferred
from the numeric casts `(CombatState)2/4/8` at every call site
(`ga.cs:1596-1615` and callers); no explicit `enum CombatState { ... }`
was found in the files searched.
- `ga.a`'s exact per-field semantics beyond what call sites imply
(`j`, `e`, `g`, `l`, `m` — weapon-type flag, slash/pierce-hybrid
flag, triple-slash flag, an unidentified "l" counter used for
critical-hit chat correlation, and an unidentified "m" stat used as
a pet tiebreak).
- `bm.a()` and the `-32555` cooldown check gating the `h1` SummonPet
rule — not traced beyond their call site.
- The four cast-result regex families referenced as `l.g.a/b/c/d` in
`gj.cs` are the `MyList<Regex> a/b/c/d` fields of `d3.cs:5-13` (the only
class holding four regex lists); the `l` holder itself was not located,
so the type link is strong but not proven.
- The step-by-step comparison of MossTank`s `PetAutomation.cs` against
retail`s `ga.j()` (§7) is owed.
- `VtankAmmunitionDatabase.cs` and the full `ga.a`/`ga.b`
ammo-availability pair (`ga.cs` beyond line ~1245) were not compared
against retail in detail.

View file

@ -0,0 +1,718 @@
# VTank knowledge base 04 — buffs and vitals
Research only. Oracle: `refs/vtank/decompiled/` (ILSpy decompile of
`utank2-i.dll`; obfuscated identifiers, intact strings/settings/spell
names). Builds on
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` (rule-engine order,
`ForceCombatMode`, wand/caster selection) — that note's pins are not
re-derived here, only extended. All paths below are relative to
`refs/vtank/decompiled/` unless marked `acdream:`, which is relative to
the repo root. Identifiers are obfuscated single/double letters; this
doc never pastes decompiled source, only short identifiers and the
string literals VTank ships (spell/item/setting names survive
obfuscation intact).
## 0. The moving parts, one paragraph each
- **`fz.cs`** — the `BuffSelf` rule (`ILogicRule`). Registered twice in
`cLogic.cs`: once as the ordinary rebuff rule (threshold setting
`RebuffTimeRemainingSeconds`, considers consumable helper items) and
once as the idle top-off rule (threshold `IdleBuffTopoffTimeSeconds`,
does not consider consumables), see §1.2.
- **`eq.cs`** (`PluginCore.dz.k`, field `s.k` at `s.cs:29`) — the buff
*engine*: builds the self-buff candidate list (`b()`), the
item-enchantment candidate list (`g()`), runs the "is anything due"
scan (`a(out itemId, out consumableItemId)`), and owns the profile
store `m_e : MyList<c>` where `c(int itemId, int spellId)` is one
profile row (ctor `eq.cs:31-35` maps first arg → field `b` = item id,
second arg → field `a` = spell id; `-1` spell id = "no buffs" row).
- **`ga.cs`** — `ForceCombatMode` (wand/shield/caster selection,
already pinned in the trace note) plus the class→`fs` item-type
table and the `fi` object-class-9 sub-tags used by the wand/shield
eligibility checks.
- **`cr.cs`** (`RechargeSelf2`) / **`cg.cs`** — the vitals *trigger*:
compares current Health/Stam/Mana percent against the three
`Recharge-*-*` settings and calls `cRechargeManager.Recharge(vital)`.
- **`cRechargeManager.cs`** — the vitals *dispatch table*:
`RechargeHandlerSet` (a settings TABLE), tried in list order per
(vital, stance) until one's `Activate()` succeeds.
- **`m.cs`, `g4.cs`, `a5.cs`, `dg.cs`** (+ `bn`/`hc`/`c2`/`f2` :
`dg`) — the seven `IRechargeHandler`s: Regular Spell, Recharge With
Food, Kit Recharge, and the four vital-transfer spells.
- **`he.cs`** — shared recharge-cast dispatch: arms the
`RechargeLevelBoost_*` action lock (via `RechargeBoostTimeSeconds`)
when not yet in Magic mode, casts once actually in Magic mode.
Estimates a transfer spell's HP yield by name suffix (§4.4).
- **`ai.cs`** (`PluginCore.dz.l`) — helper-heal target picker: tracks
fellows (server broadcast `0xF7B1`/event 704) and non-fellows
(client/server echo dispatch), casts the fixed Adja's
Gift/Replenish/Gift of Essence family.
- **`gu.cs`** (`RechargeOther`), **`fb.cs`** (`UseHealersHeart`),
**`af.cs`** (`DispelAllies`), **`ba.cs`** (`RandomHelper`) — the
four helper/ally logic rules.
- **`c8.cs`** (`DispelSelf`), **`cx.cs`** (`UseDispelItem`) — self
dispel.
- **`MySpell.HasScarabsInInventory`** (`uTank2/MySpell.cs:398-456`) —
the real blacklisted-component gate.
- **`e7.cs`** / **`hz.cs`** — the two "Choose Extra Buffs" pickers
(same XML, different title/target list): e7 adds to the whitelist
`dz.m.i`, hz adds to the blacklist `dz.m.j`.
acdream counterparts, all under `src/AcDream.Plugins.MossTank/`:
`BuffPlan.cs`, `BuffProfile.cs`, `VitalRecharge.cs`,
`DispelController.cs`, `ItemManaRecharge.cs`, `SpellComponentPolicy.cs`,
and the driver in `MossTankPanel.cs` (`StartBuffPass`, `BuildPlan`,
`TryCast`, `TickAutomaticBuffing`, `TickRandomHelper`).
## 1. Buff plan
### 1.1 Candidate list (`eq.b()`, `eq.cs:134-236`)
Retail's self-buff candidate list is a single **hardcoded, ordered**
sequence, not a generic "everything self-targeted" scan:
| Order | Candidate(s) | Cite |
|---|---|---|
| 1 | `Creature Enchantment Mastery Self I` | `eq.cs:143` |
| 2 | `Focus Self I` | `eq.cs:144` |
| 3 | `Willpower Self I` | `eq.cs:145` |
| 4 | `Mana Conversion Mastery Self I` | `eq.cs:146` |
| 5 | `Life Magic Mastery Self I` | `eq.cs:147` |
| 6 | one self-buff per **trained skill** in `PluginCore.dz.q.x` (skill→spell-name map), only for skill classes 2 or 3 (i.e. an actual skill, not an attribute); unknown skill → warns "Unknown skill detected!" and drops it | `eq.cs:96-110,148-151` |
| 7 | `Strength/Endurance/Coordination/Quickness/Regeneration/Rejuvenation/Mana Renewal Self I` | `eq.cs:152-158` |
| 8 | `Armor Self I` (the physical **prot**, `fk.b`, `fk.cs:424`; `Impenetrability I` is `fk.a` and belongs only to `eq.g()` at `eq.cs:246` — citation pass 2026-09-06) | `eq.cs:159` |
| 9 | the elemental **prots** named by `BuffProfile-Prots`/`BuffProfile_Prots` (letter string `ALFCBPS`, or a numeric legacy mode) | `eq.cs:160-165` |
| 10 | the user's **extra self buffs** list (`PluginCore.dz.m.i`, populated by the `e7.cs` picker) | `eq.cs:166-169` |
| 11 | any **untargeted** profile row (`eq.m_e` rows whose spell has `isUntargeted` true — the code filters on `isUntargeted` alone) | `eq.cs:170-181` |
Then two filters remove entries whose *family* is already covered by
something in the **blacklist** (`PluginCore.dz.m.j`, populated by
`hz.cs`, §1.7) — `eq.cs:182-197` — and two more strip whole
**schools** once the character exceeds `BuffWithUntrained-Item` /
`-Creature` / `-Life` level while untrained in that school
(`f9.c(eGameSkillID.*)` = trained check) — `eq.cs:198-234`.
`eq.g()` (`eq.cs:238-261`) is the **item-enchantment** candidate list:
`Impenetrability I` targeted at self first, then the profile's
**banes** (`BuffProfile-Banes`/`BuffProfile_Banes`, same letter/legacy
scheme), then every **targeted** profile row (item id ≠ irrelevant,
spell not untargeted). It is gated the same way on
`BuffWithUntrained-Item` (`eq.cs:242-245`) — it returns an **empty**
list outright above that level while untrained, rather than filtering
per-row.
**acdream (`acdream:src/AcDream.Plugins.MossTank/BuffProfile.cs`)**
takes a structurally different, description-driven approach: it groups
every known self-buff by spell family and classifies each family from
retail's own spell **description** text (regexes for "Increases the
caster's X", "Reduces damage… from X", the aura sentence, the bane
sentence, the three regen-rate sentences —
`BuffProfile.cs:79-142,204-291`), rather than a hardcoded name list.
This generalizes past retail's fixed candidate set (any self-buff the
character knows gets classified and offered, not just the ~20 hardcoded
lines), and is documented as deliberate (`BuffProfile.cs:47-76`). It
does not reproduce retail's list-*order* semantics directly; instead
`acdream:BuffPlan.cs:337-383` (`CastRank`) encodes school-then-family
ordering (Creature → Item → Life, with Creature sub-order
mastery→Focus→Self→Endurance→rest, Life sub-order
prots/other→regeneration-last) as a *documented reconstruction* of why
retail's literal list is ordered the way it is (§1.6 below covers a gap
in that reconstruction).
### 1.2 The "due" test
`fz.a()` (`ILogicRule.ValidNow`, `fz.cs:70-90`):
1. `false` if the `ItemUse` action lock is held (`fz.cs:72-75`).
2. `false` if `EnableBuffing` is off (`fz.cs:76-79`).
3. `num = f3.j(m_c)` — the configured threshold in **seconds**
(`RebuffTimeRemainingSeconds` = 300 for the main rule,
`IdleBuffTopoffTimeSeconds` = 1200 for the idle rule;
`cLogic.cs:472,553`).
4. If the `BuffCastRecast` lock is held, `num += BuffCastRecast_Seconds`
(default 30) — i.e. right after a successful cast the *effective*
due-threshold is widened by 30s for the reset window
(`fz.cs:80-84`, and see §1.3).
5. Floor `num` at 10 seconds (`fz.cs:85-88`).
6. Delegates to `eq.a(num, m_d, out m_e)``m_d` is the ctor's
"consider consumables" flag: `true` for the main rule, `false` for
idle top-off (`fz.cs:21-27`, `cLogic.cs:472,553`).
`eq.a(int seconds, bool considerConsumables, out bool isConsumable)`
(`eq.cs:573-582`) sets `this.b = seconds`, `this.c = considerConsumables`,
then calls the private `a(out itemId, out consumableItemId)`
(`eq.cs:477-565`) which walks the candidate lists in the exact order of
§1.1 and returns the **first** spell that is either: not currently
tracked as active at ≥ the candidate's quality with ≥ `seconds`
remaining (self-buffs, `eq.cs:481-503`; the "remaining" bookkeeping is
`eq.i()`/`eq.b(ActiveSpellInfo)`/`eq.a(ActiveSpellInfo)` reacting to
the client's tracked active-spell events, `eq.cs:362-475`), or a
targeted item-enchant row whose remaining time (`eq.j.b(itemId,
spell).TotalSeconds`) is under `seconds` (`eq.cs:510-527`), or — only
when `considerConsumables` is true and `ItemUse` is free — a known
**consumable buff item** (`PluginCore.dz.m.g`, populated at
identify-time, `PluginCore.cs:7524-7534,7628-7654`) not on cooldown
and not a fellowship spell while out of fellowship (`eq.cs:528-561`;
sets `m_h = true` to signal "this is a consumable", `eq.cs:554`).
**acdream** (`acdream:BuffPlan.cs:104-213`) is a *snapshot* planner:
`Build()` reads skills/attributes/active enchantments **once**, decides
every wanted line, picks a tier, and returns the whole ordered queue.
`MossTankPanel.StartBuffPass` calls `BuildPlan` (→ `BuffPlan.Build`)
exactly once per pass (`acdream:MossTankPanel.cs:3614-3656,3687-3705`);
`TryCast` (`acdream:MossTankPanel.cs:4317-4354`) then walks the fixed
queue index-by-index and never re-invokes `BuildPlan` mid-pass. Retail
re-evaluates `eq.a()` **every logic tick** (~293 ms,
`uTank2/cLogic.cs:145`) against freshly-read server skill/quality data
— see §5 gap G1.
### 1.3 The `BuffCastRecast` lock, and how a pass "ends"
`fz.a(bool running)` (`ILogicRule.set_Running`, `fz.cs:111-122`) fires
once per successful pick: if the picked spell was a consumable
(`m_e`) **or** the character is already in (or can reach) Magic mode
(`ga.a((CombatState)8, 0, anyItem:true)` — a `ForceCombatMode`
probe/no-op call, not a real state change here), it arms the
`BuffCastRecast` action lock for `BuffCastRecastReset_Seconds` (default
30) and calls `eq.a()` (`eq.cs:584-619`) — which re-derives the pick
and either dispatches the spell cast (`this.m_a.h.a(spell, itemId)`) or
uses the consumable item (`f9.p(consumableItemId)` plus an `ItemUse`
lock of `PluginCore.dz.o.k`, `eq.cs:588-618`).
There is **no explicit "pass" object** in retail: `BuffSelf` is a
single `ILogicRule` that the engine re-evaluates every tick; a "pass"
is just "the rule keeps being the first `ValidNow` rule in the list
until nothing is due any more" (first-match-wins per the trace note).
Nothing marks completion; the rule simply stops returning `true` from
`ValidNow` once every candidate in the ordered list is covered or
exhausted.
**acdream** has an explicit pass object (`_queue`/`_queueIndex`,
`acdream:MossTankPanel.cs:3614-3656`) with its own progress/stall
tracking (`_castThisPass`, `_sinceProgress`) not present in retail —
a deliberate modernization, not a port; see §5.
### 1.4 Force Buff / Cancel Force Buff
`PluginCore.ForceBuff()``dz.k.i()` (`eq.cs:362-372`): for **every**
tracked active-spell entry, sets its "not due again until" timestamp
`a` to `DateTimeOffset.Now`. Since the due test compares
`(entry.a - Now).TotalSeconds >= threshold`, zeroing `a` makes every
currently active buff look like it is about to expire *right now*
so the very next `eq.a()` call treats everything as due, without
touching the real `ExpireTime` (`d`). `CancelForceBuff()``dz.k.e()`
(`eq.cs:374-384`) restores `a = d` for every entry, i.e. undoes the
force by putting the real remaining time back. Both call into
`this.m_a.j.d()`/`.h()` (the buff-list UI refresh) afterward
(`eq.cs:371,383`; `PluginCore.cs:230-239`).
**acdream**: `BuffPlan.Build(force:true, …)` (`acdream:BuffPlan.cs:104,
185-192`) skips the "already covered" `continue` outright when
`force` is set, which is functionally equivalent (recast everything at
best tier regardless of remaining duration) but is a **different
mechanism** — a build-time bypass of one `if`, not a timestamp-zeroing
trick against a live tracked-duration table. No functional gap
observed; noted for completeness since the two codebases reach the
same visible behavior by different means.
### 1.5 Fizzle / failure handling
Not modeled as retry logic anywhere in the traced files: `fz`/`eq`
issue one cast attempt (`m_a.h.a(spell, target)`, field `s.h : gj`)
and rely on the **next tick's** fresh `eq.a()` re-evaluation to notice
the spell is still not active and try again — there is no dedicated
"cast failed, retry N times" state machine in this family (contrast
with the raw drop-to-peace retry counters the trace note found
elsewhere, e.g. `ga.r`). `gj.cs:211/226` (`o.h()`) and `gj.cs:198`
(`o.e()`) are the cast-in-progress suspend counters already pinned in
the trace note; while `ga.f()` (casting-in-progress) is true the whole
logic engine bails (`cLogic.cs:214`), so a fizzle simply means the
suspend clears, the engine resumes, and `eq.a()` runs again from the
top of its candidate list.
### 1.6 Blacklisted components — the *real* mechanism
The obvious-looking gate inside `fk.c()` (`fk.cs:198-236`,
"`Fail_HasBannedComponent`") calls `b(MySpell)` — but `fk.b(MySpell)`
(`fk.cs:95-102`) is:
```
if (A_0 == null || !A_0.isValid) return false;
return false;
```
i.e. it **always** returns `false` regardless of input — the
`HasBannedComponent` diagnostic branch is dead code in this build.
The real blacklist is `MySpell.HasScarabsInInventory`
(`uTank2/MySpell.cs:398-456`): it parses `BlacklistedSpellComps` into a
name dictionary `d` (`MySpell.cs:401-410`), then for each of the
spell's own **scarab-class** components (`d10.e.Contains("Scarab")`,
`MySpell.cs:432`) — if that scarab's name is in the blacklist,
**disable the spell entirely** (`MySpell.cs:443-446`); otherwise if
the character doesn't have enough of that scarab in inventory, disable
it too and warn once (`MySpell.cs:447-452`). This gate is read by
`fk.c()`'s "can I currently cast this" check (`fk.cs:219,189`), so it
applies to **every** spell cast attempt system-wide, not just buffs.
**acdream** (`acdream:SpellComponentPolicy.cs`) checks **all** of a
spell's `FormulaComponentIds` (id or name match) against the setting
— broader in principle than retail's scarab-only filter, but in this
game era all physical spell components *are* scarabs/talismans, so
the practical difference should be negligible; flagged as
"could not fully confirm" in §6.
### 1.7 Extra buffs / blacklist pickers (`e7.cs` / `hz.cs`)
Both use the identical `SelfBuffChoiceView.xml` (list + search box,
`refs/vtank/decompiled/uTank2.ViewXML.SelfBuffChoiceView.xml:1-11`),
built from the same candidate set — every known spell, deduplicated by
family, that is `isUntargeted && !isOffensive && Duration >= 1s`
(`e7.cs:56-76`, `hz.cs:56-76`, identical). Clicking a row: `e7`
(title unset, "Choose Extra Buffs") adds the spell id to
`PluginCore.dz.m.i` — the **whitelist** consumed at `eq.b()` item 10
above (`e7.cs:123-127`); `hz` (title "Choose Blacklisted Buffs") adds
to `PluginCore.dz.m.j` — the **blacklist** consumed by family-removal
at `eq.cs:182-197` (`hz.cs:123-127`).
### 1.8 Fast-cast / movement trick
`MySpell.IsInstantCast` (`uTank2/MySpell.cs:104-129`): `Difficulty <
50` → true; else `isUntargeted && !isFellowship &&
EffectiveDurationMS >= 60000 && School.Id in {2 (LifeMagic), 4
(CreatureEnchantment)}` → true; else `RealFamily in
{243..249, 639}` → true. Gated by setting `FastCastBuffs`
("Attempt to fastcast buff spells. May move the character during
casting.", `uTank2.Resources.defaultsettings.usd:1139-1143`) at
`gj.cs:171,233,237` — movement is only attempted for non-War/non-Void
schools while an instant-cast spell is in flight.
**acdream** (`acdream:MossTankPanel.cs:4400-4412`,
`IsVtankInstantCast`) reproduces this **exactly**: same
difficulty<50 branch, same untargeted/non-fellowship/60s/school-{31
(Creature),33 (Life)} branch (AC skill ids 31/33 = VTank's internal
School.Id 4/2, consistent with the `fk.cs` school-id map), same family
range `243..249,639`. This is a faithful 1:1 port — no gap.
## 2. Item enchantments
`PluginCore.l(int itemId)` (`uTank2/PluginCore.cs:8327-8445`) is what
runs when a wand/shield/weapon/pet is **added to the profile** (the
"Add" / "Add (no buffs)" buttons, `PluginCore.cs:1625`,
`eBuffedItemAddOptions` enum at `PluginCore.cs:613-617`). It reads the
item's `LongValueKey 218103822` mastery-mask value and auto-populates
default enchant rows:
| `LongValueKey 218103822` value | Auto-added spells (StandardBuffs) | "No buffs" fallback | Cite |
|---|---|---|---|
| `1048576` or `33554432` | Aura of Defender Self I, Aura of Blood Drinker Self I, Aura of Swift Killer Self I, Aura of Heart Seeker Self I | `eq.c(itemId, -1)` | `PluginCore.cs:8355-8375` |
| `4194304` (excluded if `dz.o.c(itemId) == fi.h`) | Aura of Defender, Blood Drinker, Swift Killer Self I | `eq.c(itemId, -1)` | `PluginCore.cs:8377-8397` |
| `16777216` | Aura of Defender, Hermetic Link, Spirit Drinker Self I | `eq.c(itemId, -1)` | `PluginCore.cs:8399-8419` |
| `2097152` (shield) | all 7 elemental prots (`eDamageElement 0..6`) + Physical (`Armor Self I`) | `eq.c(itemId, -1)` | `PluginCore.cs:8421-8438` |
A **pet** item (detected via `LongValueKey 36` presence,
`PluginCore.cs:8332-8334`, or `ga.e(WorldObject)`) is always added as
`eq.c(itemId, -1)` — no-buffs — with a mastery-mismatch chat warning if
`ga.f(fu)` fails (`PluginCore.cs:8340-8348`). Only items whose
`LongValueKey 218103822` is one of the five values above, or a pet,
pass the "Add" button's eligibility filter (`PluginCore.l()`'s caller
`PluginCore.b()`, `PluginCore.cs:8448-8477`).
`eq.g()` (§1.1) is the runtime consumer: `Impenetrability I` self-cast
first, then the profile's configured **banes**
(`BuffProfile-Banes`/`_Banes`), then every profile row whose item id
still exists in inventory (`f9.d(itemId)`) and whose spell id is not
`-1` (`eq.cs:510-527`) — a `-1`-spell row (from either the "no buffs"
button or a pet) never contributes a targeted cast; a warning fires if
the item itself has left inventory (`eq.cs:512-515`).
Wielding: item-enchant casting always targets the item itself
(`d(itemId)`, `eq.cs:353-360`, which substitutes the currently-wielded
weapon's id, `this.m_a.ax.g()`, whenever the profile row's item id is
`-1`) — there is no separate "must be wielded first" gate inside `eq`;
wielding for combat is handled by `ForceCombatMode` (§(c) of the trace
note) before any attack, independently of the buff pass.
**acdream**: no equivalent "auto-populate default enchant candidates
from a weapon's mastery mask when it's added to the profile" pipeline
was found among `BuffPlan.cs`/`BuffProfile.cs`/`MossTankPanel.cs`.
`BuffProfile.Build` only classifies the character's own **known
spellbook** self-buffs (attributes/skills/prots/regen/self-auras via
description parsing); it has no notion of a *profile item* or its
`LongValueKey 218103822` mastery mask at all. This is flagged as gap
G2 in §5 — either the mastery-driven default-enchant flow lives
somewhere outside the six files this doc was scoped to (a companion
"item profile" module not yet located), or it genuinely has no acdream
counterpart yet.
## 3. Helper buffs
### 3.1 `RandomHelper` (`ba.cs`)
Gate (`ba.a()`, `ba.cs:83-126`): `RandomHelperBuffs` on, `ItemUse` lock
free, `RandomHelperBuffLock` action lock free (armed for
`RandomHelperIntervalSeconds`, default 5s, on fire —
`ba.cs:154`), not mid-cast (`this.m_a.h.e()`). Target: any `Player`
object other than self within `0.075` distance units
(`f9.a(item.k, PluginCore.dn, true) < 0.075`, `ba.cs:105`; using the
trace note's ≈240 m/unit this is **18 m**). Spell: **one random draw**
from a fixed 11-name stem list (Endurance/Regeneration/
Rejuvenation/Armor/Blade·Bludgeoning·Cold·Fire·Lightning·Piercing·Acid
Protection, all " Other I", `ba.cs:29-40`), resolved to the character's
best known tier via `this.m_a.i.a(id, false)` (`fk.a(int,bool)`
`fk.b` → private `fk.a(MySpell,bool)`, which walks the spell's
`RealFamily` for the highest-quality version the character can
currently cast — `fk.cs:130-141,170-196`).
Quoting the loop verbatim in structure (not pasting code): it retries
**up to 100 times**, and **returns `true` as soon as a randomly-picked
stem resolves to no castable spell** (`g == null`) — `ba.cs:116-124`.
Only if every one of 100 random draws happens to resolve to a castable
spell does the loop exhaust and `ValidNow` return `false`
(`ba.cs:125`). `Running(true)` then only actually casts when `g !=
null` (`ba.cs:152`), so a hit on the `g == null` branch makes the rule
fire for that tick and do **nothing** — a plausible retail
quirk/bug (the loop looks like it means to search *for* a castable
spell, but structurally fires early on the *opposite* condition). This
is quoted, not paraphrased away, per project convention on decomp
predicates; flagged again in §5/§6.
**acdream** (`acdream:MossTankPanel.cs:4047-4120`,
`TickRandomHelper`): same distance constant (`< 18d`,
`MossTankPanel.cs:4071`, matching retail's 0.075 unit band), same
11-name stem list verbatim (`MossTankPanel.cs:4079-4087`), same
"resolve to best known tier" (`OrderByDescending(Quality).ThenByDescending(Tier)`,
`MossTankPanel.cs:4094-4096`), same interval lock semantics
(`_randomHelperRemaining`, floor 0.25s, `MossTankPanel.cs:4112-4114`).
It does **not** reproduce the 100-random-draws/early-`true`-on-miss
structure: instead it round-robins **deterministically** through every
(player × stem) combination from a rolling cursor and only fires on
the first combination that is actually castable
(`MossTankPanel.cs:4034-4068`). This is a clear, intentional
divergence — arguably a fix for what §3.1 quotes as a retail
oddity — not a straight port; recorded here as a finding rather than a
gap since it is not obviously "worse" than retail, only *different*.
### 3.2 `UseHealersHeart` (`fb.cs`)
Narrow item-specific rule: requires `UseHealersHeart` setting,
inventory item named exactly `"The Healer's Heart"` or `"Legendary
Seed of Mornings"` (the latter takes priority if both present,
`fb.cs:85-96`), `Healing` skill (`CharFilterSkillType 33`) buffed ≥
245 and `Arcane Lore` (`CharFilterSkillType 14`) buffed ≥ 105
(`fb.cs:101-108`), and a fellow found under the `Recharge-Helper-HitP`
threshold via `PluginCore.dz.l.a(float)` (`ai.cs:387-395`, §3.4). Casts
by using the item on the target (`PluginCore.dz.aa.a(itemId, targetId)`)
then marks that target's health-vital cooldown (`PluginCore.dz.l.a(target,
CharFilterVitalType.Health)`, `fb.cs:141-145` — though `ai.a(int,
CharFilterVitalType)`, `ai.cs:430-432`, is itself an empty method body
in this build, so the cooldown mark is a no-op here).
**acdream**: `TryHealersHeart` referenced from
`VitalRechargePlanner.TryPlanHelper` (`acdream:VitalRecharge.cs:85-140`,
call at `~line 124-131`) is folded into the general helper-recharge
path rather than kept as a standalone rule, matching the *effect* (item
name gate + skill gate + fellow-under-threshold target) as far as
inspected; full field-by-field parity (the two skill thresholds, the
priority order between the two item names) was not re-verified against
the acdream implementation body in this pass.
### 3.3 `RechargeOther` (`gu.cs`) and the fellow tracker (`ai.cs`)
`gu.a()`/`gu.a(bool)` (`gu.cs:70-130`) build a `gh{a=HitP%, b=Stam%,
c=Mana%}` from `Recharge-Helper-HitP/Stam/Mana` and delegate to
`ai.b(gh)` (any fellow/tracked player below any threshold?) for
`ValidNow`, and `ai.a(gh)` (actually cast) for `Running`. `ai.a()`
private (`ai.cs:246-375`) computes the worst tracked percent across
**two pools**:
- **Fellowship members** (`ai.l`, keyed by id), refreshed by the
server's fellowship-vitals broadcast (message type `63408`, event
`704`, `ai.cs:145-178`) gated on the fellowship window being open
(`this.m_a`, toggled by message `63409` action `166`,
`ai.cs:122-143`) and the character actually being in a fellowship
(`j`, tracked via `ChangeFellowshipEventArgs`, `ai.cs:180-244`).
Entries older than `ai.b` seconds (10.0 default field value) are
ignored (`ai.cs:268`). Distance-gated per vital by
`HelperDistanceHitP/Stam/Mana` (`ai.cs:290,296,302`).
- **Non-fellowship tracked players** (`ai.m`), aged out after 2
**minutes** of no update (`ai.cs:314-322`), entries considered
within `ai.c` seconds (10.0 default), each with its own **per-vital
5-second (`ai.d`) re-cast cooldown** keyed by which vital was last
cast on that target (`ai.cs:354,360,366` checking `a11.i` against
the vital and `timeSpan2 < d`).
Target spell family is **fixed**, not the profile's choice: Health →
`Adja's Gift`, Stamina → `Replenish`, Mana → `Gift of Essence`
(`ai.cs:81-83`), each resolved to the caster's best current-castable
tier via `e.i.c(spell)` (`fk.c`, `ai.cs:456-465`) before casting.
**acdream** (`acdream:VitalRecharge.cs:85-200`ish,
`TryPlanHelper`/`Lowest`/`TryFindFamily`): same three fixed base spell
families (`SpellId.AdjaSGift`, `.Replenish`, `.GiftOfEssence`,
`VitalRecharge.cs:114-118`), same per-vital distance settings
(`HelperHealthDistance` etc.), same fellowship-membership gate
(`settings.HelpOthers && automation.Fellowship.IsInFellowship`,
`VitalRecharge.cs:102-106`). The non-fellowship "track any nearby
player's broadcast vitals and help them too" pool (`ai.m`) was not
located in the acdream implementation inspected here — flagged as a
possible gap (§5 G3) pending a closer read of `Fellowship`/tracked-
player plumbing outside the six named files.
### 3.4 `DispelAllies` (`af.cs`)
Requires setting `UseDispelDrum`, an inventory item named `"Awakener"`
or `"Attenuated Awakener"` (`af.cs:78-85`), and per-item skill gates:
`Awakener` needs `Item Enchantment` (`CharFilterSkillType 31`)
**Specialized** + `Healing` buffed ≥ 110; `Attenuated Awakener` accepts
Trained *or* Specialized Item Enchantment, same Healing floor
(`af.cs:87-105`). Target selection scans `ai.m` (the same tracked-player
pool as §3.3, but requiring an active *fellowship-visible* relation
via `dz.j.b(id, spell) > TimeSpan.Zero` and presence in `dz.j.b`,
`af.cs:112-116`) for the fellow whose **currently active vuln stack**
(all 7 elements' `Vuln` family, difficulty ≤ 350, quality-summed only
above 250 per element) is worst (`af.cs:117-141`), then uses the drum
item on them (`PluginCore.dz.aa.a(itemId, targetId)`).
**acdream**: not investigated beyond confirming
`DispelController.cs:93` gates on `UseDispelDrum` and calls
`TryStartAllyDispel`; the vuln-severity target-scoring logic was not
diffed line-for-line against `af.cs:117-141` in this pass.
## 4. Vitals
### 4.1 The trigger (`cr.cs` / `cg.cs`)
`cr` ("RechargeSelf2") is registered **twice** in `cLogic.cs`
(main position before `BuffSelf`, using `Recharge-Norm-*`; idle
position after target-approach, using `Recharge-NoTarg-*`;
`cLogic.cs:470,566`) — helper recharge (`gu`, §3.3) is the analogous
rule for `Recharge-Helper-*`. Gate: `ItemUse` lock free, then any of
`cg.c(healthSetting) || cg.b(stamSetting) || cg.a(manaSetting)`
(`cr.cs:75-91`) — note the deliberate **Health > Stamina > Mana**
short-circuit priority both in the gate and in dispatch
(`cr.cs:120-133`: Health checked first, Stamina second, Mana is the
`else` fallback). `cg.c/b/a` (`cg.cs:36-97`) each: read the setting as
a percent, compare against `100*current/max` for that vital, subtracting
`RechargeBoostAmount` first if the matching `RechargeLevelBoost_HP/
Stam/Mana` action lock is armed (armed for `RechargeBoostTimeSeconds`
by `he.cs:49,65` whenever a recharge cast/use hasn't yet landed —
§4.4) — i.e. **the boost temporarily lowers the effective reading** so
`cr` keeps re-firing (looking urgent) while a recharge action is still
in flight, rather than a boost that raises urgency intrinsically.
Default thresholds (`uTank2.Resources.defaultsettings.usd:203-267`):
| Setting | Default % |
|---|---|
| `Recharge-Norm-HitP` | 75 |
| `Recharge-Norm-Stam` | 50 |
| `Recharge-Norm-Mana` | 50 |
| `Recharge-NoTarg-HitP` | 1 |
| `Recharge-NoTarg-Stam` | 1 |
| `Recharge-NoTarg-Mana` | 1 |
| `Recharge-Helper-HitP` | 20 |
| `Recharge-Helper-Stam` | 1 |
| `Recharge-Helper-Mana` | 1 |
(`NoTarg` = idle recharge, effectively "only recharge idly once nearly
empty"; `Norm` = the in-combat/normal-stance thresholds, much more
eager.)
### 4.2 The handler-set table (`cRechargeManager.cs`)
Seven registered `IRechargeHandler`s (`cRechargeManager.cs:26-36`):
`m` (Regular Spell), `bn` (Health→Stamina), `hc` (Health→Mana), `c2`
(Mana→Health), `f2` (Stamina→Health), `a5` (Kit Recharge), `g4`
(Recharge With Food). Per (vital, stance) the ordered candidate rows
come from the `RechargeHandlerSet` settings TABLE
(`uTank2.Resources.defaultsettings.usd`, `RechargeHandlerSet` block),
parsed row = `(Vital 1/2/3=HitP/Stam/Mana, HandlerString, MinPercent,
MaxPercent, Stance 1=MagicMode/2=Other — `uTank2/eRechargeStance.cs`: `MagicMode = 1, Other`)`. Default table (26 rows,
decoded from the `.usd` TABLE literal):
| Vital | Stance | Ordered handlers (name, minmax %) |
|---|---|---|
| Health | MagicMode | Stamina→Health (015), Mana→Health (015), Regular Spell (015), Recharge With Food (015), Kit Recharge (0100), Stamina→Health (0100), Mana→Health (0100), Regular Spell (0100), Recharge With Food (0100) |
| Stamina | MagicMode | Kit Recharge (0100), Regular Spell (0100), Recharge With Food (0100) |
| Mana | MagicMode | Kit Recharge (0100), Recharge With Food (0100), Regular Spell (0100) |
| Health | Other | Recharge With Food (015), Kit Recharge (0100), Recharge With Food (0100), Stamina→Health (010), Regular Spell (0100) |
| Stamina | Other | Kit Recharge (0100), Recharge With Food (0100), Regular Spell (0100) |
| Mana | Other | Kit Recharge (0100), Recharge With Food (0100), Regular Spell (0100) |
Dispatch (`cRechargeManager.Recharge`, `cRechargeManager.cs:47-78`):
filter rows to the current stance, then walk in table order; a row is
attempted if percent is in `[Min,Max]` **or it is the last row in the
list** (guaranteed fallback attempt), and the first `Activate()` that
returns `true` wins. `GetCurrentStance()` (`cRechargeManager.cs:193-205`)
is simply `CombatMode == Magic ? MagicMode : Other`.
**acdream** (`acdream:VitalRecharge.cs:36-83`, `VitalRechargePlanner`)
explicitly documents itself as "VTank's default `RechargeHandlerSet`,
including its stance- and current-percentage-dependent order"
(`VitalRecharge.cs:36-40`) and takes a `VitalRechargeMethod[]` per
(vital, magic-mode-bool, percent, table) — structurally the same
design as `cRechargeManager`; the concrete default table values were
not independently re-verified cell-by-cell against the table above in
this pass (§6).
### 4.3 The seven handlers
| Handler | Cast/use logic | Key gates | Cite |
|---|---|---|---|
| `m` (Regular Spell) | Health→`Adja's Intervention`, Stamina→`Robustification`, Mana→`Meditative Trance`; picks best of the named spell vs. the caster's best current tier via `he.a(name,out,out)` | spell known only | `m.cs:47-85` |
| `bn`/`hc`/`c2`/`f2` (`dg` base) | vital-transfer spells named "X to Y Self" | non-Health→Health direction additionally requires the *other* vital's own `Recharge-Norm-*` NOT be under threshold (`cg.a/b/c`, i.e. don't drain Stamina/Mana to top off Health if Stamina/Mana is itself already low); for the Health-restoring direction, estimates whether the transfer would net-gain HP via `he.a()`/`he.a(spell,vital)` before committing | `dg.cs:54-139` |
| `a5` (Kit Recharge) | picks the highest-suitability kit item classified `fs.a/c/e` (Health/Stam/Mana Kit) meeting `MinimumHealKitSuccessChance`; if `Recharge-Norm-Stam` (Healing skill, `CharFilterSkillType 21`) not Trained/Specialized → unusable; blocked in Magic mode unless `UseKitsInMagicMode`; requires ≥15 Stamina except for Stamina kits themselves | `GoToPeaceModeToUseKits`: if set and not currently at Peace, **switches to Peace and returns (does not use the kit this call)** — the kit use happens on a later tick once Peace is confirmed | `a5.cs:49-128` |
| `g4` (Recharge With Food) | picks a non-cooling-down food/potion item classified `fs.b/d/f` (Health/Stam/Mana Food); if the item has a cooldown and the character is busy, waits; otherwise uses it | none beyond item availability | `g4.cs:48-91` |
`fs` item classification (`uTank2/PluginCore.cs:7445-7602`): built at
identify time from `WorldObject.Values`/`ObjectClass`/name; Health/
Stam/Mana **Food** (`fs.b/d/f`) comes from `LongValueKey 89 ==
2/4/6`; named Medicated/Eternal/Greater/Lesser Stamina/Mana **Kits**
map to `fs.c/e`; a generic Health Kit falls to `fs.a`;
`ObjectClass == 16` with `DoubleValueKey 137 != 1.0``fs.i` (the
worn-item mana-charge consumables, §4.5) vs. `== 1.0` with charge
fields present → `fs.g`.
**acdream** (`acdream:VitalRecharge.cs`, `VitalRechargeMethod` enum
`RegularSpell/StaminaToHealth/ManaToHealth/HealthToStamina/
HealthToMana/Kit/Food`, `VitalRecharge.cs:25-34`) mirrors the same seven
handler shapes plus the constant `HealingSkill = 21` (matching
`CharFilterSkillType 21`, `VitalRecharge.cs:43`); the item
classification rules (kit/food name-or-flag matching) were not
independently re-verified line-for-line against `PluginCore.cs:
7445-7602` in this pass.
### 4.4 `GoToPeaceModeToUseKits` sequencing and vital-transfer sizing
`a5.b()` line 121-125: `if (GoToPeaceModeToUseKits && CombatMode !=
Peace) { f9.a(Peace); return true; }` — this is a **raw** peace switch
(the "Family A" open-coded drop-to-peace pattern from the trace note),
not routed through `ForceCombatMode`; it fires once per tick until
Peace lands, then the *next* `a5.b()` call actually applies the kit.
`he.a(MySpell,eRechargeVital_Single)` (`he.cs:95-155`) estimates a
transfer spell's **effective HP delivered**: base current-vital reading
(minus a flat 30 for Mana, floored at 0, `he.cs:100-108`), times a
tier-dependent multiplier/cap by name suffix (`" I"`→0.9×/cap 50,
`" II"`→1.0×/cap 100, `" III"`→1.1×/cap 150, `" IV"`→1.2×/cap 200,
`" V"`/`" VI"`/`"Incantation of "`/other→1.351.75×/uncapped). Used by
the `dg` base handler (§4.3) to decide whether a transfer nets a
positive HP gain before committing, and by the "estimate Heal Self
cost/tier" helper `he.a()` (`he.cs:74-93`, a name→flat-cost lookup for
`Heal Self I..VI`/`Adja's Intervention`/`Incantation of Heal Self`,
default 10).
**acdream**: not independently re-verified (no `he.cs`-equivalent
sizing table was located during this pass; flagged §6).
### 4.5 Mana recharge of wielded/worn items (`dy.cs` / `a0.cs`)
`a0` ("RefillWieldedMana") gate/dispatch (`a0.cs:60-112`): first tries
`dz.ac.h()`/`.i()` (the `dy` worn-item queue, below); only if that has
nothing does it fall back to `dz.ab.d()`/`.e()` (a `ManaStoneUse`
action-locked path, `ev` class — not traced in this pass).
`dy` (§0) tracks, per **wear/wield event** (`dy.b(int itemId)`,
`dy.cs:284-289`, subscribed via the client's `g6.o` wield map), a FIFO
queue `dy.c` of newly-equipped items still below full charge (dropped
once `q.a(item, actions)` reports it no longer needs charge,
`dy.cs:292-303`) and a parallel list `dy.a`/`dy.b` of qualifying
**charge consumables** classified `fs.i`
(`ObjectClass==16 && DoubleValueKey 137 != 1.0`, i.e. "not a
usable-food flagged item" — the gems/food-panel mana charges),
partitioned by whether they're stacked (`item2.g()`) — rebuilt whenever
the profile changes (`dy.b()` private, `dy.cs:50-73`) by scanning
`PluginCore.dz.q.p[name]` for every name that `dz.m.h` (item-name →
classification, `da.cs:29`) classifies `fs.i` (`dz.q.p` is name → inventory
instances, `g6.cs:58`), i.e. **a global pool of charge consumables keyed by
the consumable's own name** — NOT scoped per worn item (corrected by the
citation pass 2026-09-06).
`h()` = "queue non-empty AND a matching charge item exists"
(`dy.cs:309-315`); `i()` = `ApplyItem(chargeItemId, dy.c[0])` — a
direct item-on-item apply, **oldest-queued worn item first**, not
sorted by current charge percent (`dy.cs:318-322`).
**acdream** (`acdream:ItemManaRecharge.cs`): `ItemManaRechargePlanner.
Plan` (`ItemManaRecharge.cs:17-54`) instead (a) matches charge
consumables by a **global** `ItemType & ManaStoneItemType` flag plus a
configured name allowlist (`consumableNames`, not auto-derived from
`fs.i`'s classification), and (b) picks the **lowest current
mana-percent** equipped item under the configured threshold, not the
oldest-queued one. Flagged as gap G4 in §5: since retail's pool is
global too, the surviving difference is selection order only (retail
applies to `c[0]`, the oldest-queued worn item; acdream the lowest-%).
### 4.6 Dispel self / dispel items
`CastDispelSelf` (`c8.cs`): requires `q.d("Chorizite") != 0`
(`c8.cs:72-75``g6.d(string)` sums a `LongValueKey` stack-count field
over owned items whose "identified name" equals the given string,
`g6.cs:222-211`ish; **`"Chorizite"` is read here as a literal
in-inventory item name gate**, not a debuff-detection API — see §6 for
residual uncertainty on why owning an item named "Chorizite" gates a
dispel-self spell), then the known `Eradicate Life Magic Self` spell
and a difficulty-vs-active-vuln check (`c4.a(mySpell.Difficulty)`,
`c8.cs:76-85`).
`UseDispelItems` (`cx.cs`): if the worst active vuln difficulty is
≥400 (`c4.a(400)`), tries `Rune of Dispel` → `Society Gem of
Dispelling` → `Black Market Gem of Dispelling`, in that priority
(`cx.cs:74-91`); else if ≥350 (`c4.a(350)`), tries `Rune of Dispel`
`Chocolate Gromnie``Condensed Dispel Potion``Gem of Stillness`
(`cx.cs:92-114`) — i.e. pricier items are reserved for the
worse-vuln tier, cheaper ones cover the lesser tier.
**acdream** (`acdream:DispelController.cs`): `TryStartSelfDispel`
(`DispelController.cs:108-157`) gates on the exact same literal
`"Chorizite"` item-with-stack check
(`DispelController.cs:115-117`) — a faithful (if unexplained) port,
not a divergence. `TrySelectDispelItem`
(`DispelController.cs:159-173`) reproduces the two-tier
400/350-difficulty item lists via `HasVulnerabilityAtOrBelow` +
`HighDifficultyItems`/`NormalDifficultyItems`. Not independently
re-verified item-for-item against `cx.cs:74-114` in this pass.
## 5. MossTank gap ledger (ranked by likely player-visible impact)
| # | Gap | Retail cite | acdream cite | Impact |
|---|---|---|---|---|
| **G1** | `BuffPlan.Build` snapshots skills/attributes/active-enchantments **once** per pass and queues fixed spell picks; retail's `eq.a()` re-reads skill/quality **every logic tick** (~293 ms) and re-picks fresh, so a mid-pass skill raise (e.g. casting a mastery buff) immediately unlocks a higher tier for the *next* candidate in the *same* pass. acdream cannot realize that cascade — it locks in tier choices from before the pass started. | `eq.cs:477-527` (fresh per call), `uTank2/cLogic.cs:145` (293 ms tick) | `BuffPlan.cs:104-213` (one `Build` call), `MossTankPanel.cs:3607-3698` (`BuildPlan` called once at pass start, never mid-pass) | **High** for any character whose unbuffed skill sits close to a tier's difficulty threshold — retail casts a stronger buff line than acdream will, specifically for spells downstream of the Creature-Enchantment-Mastery→Focus/Willpower→Item-Enchantment/Life-Magic dependency chain the acdream code's own comment (`BuffPlan.cs:305-336`) describes but the implementation doesn't dynamically realize. |
| **G2** | Retail auto-populates default enchant-spell candidates (auras / prots / banes) for a wand/shield/pet the **moment it's added to the profile**, keyed off the item's `LongValueKey 218103822` mastery mask (§2). No equivalent "profile item → default spell rows" pipeline was found in `BuffPlan.cs`/`BuffProfile.cs`; `BuffProfile.Build` only classifies the character's **own spellbook**. | `PluginCore.cs:8327-8445` | not located in the six named files | **High** if genuinely absent — this is how a fresh VTank user gets working weapon/shield enchants with zero configuration; if it lives elsewhere in the plugin it should be cited, otherwise it's a real feature gap for item-enchantment buffing specifically (as opposed to self-buffing, which G1 covers). |
| **G3** | Retail's helper-heal target pool includes **non-fellowship tracked players** (`ai.m`, populated from general server/client vitals echo dispatch, independent 5s-per-vital re-cast cooldown, 2-minute staleness) in addition to fellowship members. acdream's `TryPlanHelper` gates entirely on `settings.HelpOthers && automation.Fellowship.IsInFellowship` and was not seen to consult a non-fellowship tracked pool. | `ai.cs:246-375` (two pools, `l` fellowship + `m` non-fellowship) | `VitalRecharge.cs:94-140` (`TryPlanHelper`, fellowship-gated only) | **Medium** — narrows "help nearby strangers" to "help fellowship only"; likely intentional scoping (`DoHelp`'s own retail description even says "The fellowship window must be open to help fellows", `uTank2.Resources.defaultsettings.usd:499-503`, suggesting the non-fellow pool may be a secondary/legacy path) but worth a deliberate decision rather than an unnoticed drop. |
| **G4** | Worn-item mana-charge ordering: both retail and acdream draw charge consumables from a **global** pool (retail: every `fs.i`-classified name via `dz.m.h`/`dz.q.p`; acdream: `ManaStoneItemType` + name allowlist); retail drains the **oldest-queued** worn item first (`c[0]`), acdream the **lowest-mana-percent** one. | `dy.cs:50-73,309-322` | `ItemManaRecharge.cs:17-54` | **Low** — a different target on the same tick, never an incompatible apply (the per-worn-item keying claimed in the first draft was wrong; citation pass 2026-09-06). |
| **G5** | `ba.cs`'s `RandomHelper` fires `ValidNow = true` on the *first* of up to 100 random draws that resolves to an **uncastable** spell (doing nothing that tick), and only returns `false` if all 100 draws happen to be castable (§3.1). acdream instead deterministically round-robins every (player × stem) pair and only fires on an actually-castable one. | `ba.cs:116-125,152` | `MossTankPanel.cs:4034-4068` | **Low/positive** — acdream's version helps allies strictly more often and more predictably than retail's likely-buggy sampling loop; flagged as a behavioral divergence worth a conscious call (replicate the retail quirk vs. keep the fix) rather than an oversight, since the retail behavior reads as unintended. |
## 6. Could not determine
- **`g6.d("Chorizite")`'s in-game meaning.** Confirmed mechanically —
it sums a `LongValueKey`-backed count over owned items whose
identified name equals the literal string `"Chorizite"`
(`g6.cs:222-231`) — and confirmed that acdream ports the same literal
gate (`DispelController.cs:115-117`). Not resolved: *why* owning an
item named "Chorizite" is the prerequisite for casting `Eradicate
Life Magic Self` at all (no corroborating string or comment found in
the traced files; may be a VTank-internal placeholder/detection
object rather than a "real" tradeable AC item, or may key off a
specific quest/consumable whose purpose isn't documented in this
build's strings).
- RESOLVED (citation pass 2026-09-06): acdream's default handler table
(`VitalRechargePlanner.Handlers`, `VitalRecharge.cs:208-288`, selected by
`magicMode: mode == PluginCombatMode.Magic`) reproduces the 26 retail rows
cell-for-cell — the `magicMode` arm is stance 1 (MagicMode), the `else` arm
stance 2 (Other). And `he.a`'s HP-yield sizing has an exact equivalent:
`VitalRechargePlanner.EstimatedTransfer` (`VitalRecharge.cs:725-746`;
I 0.9/cap 50 … VI 1.5/uncapped, else 1.75), the Mana 30 floor at
`VitalRecharge.cs:699-701`, `EstimatedOrdinaryHeal` (`:712-723`,
17/25/32/45/67/87/115/135, default 10) and the net-gain pre-check at
`:708-709`.
- **`af.cs`'s vuln-severity scoring vs. acdream's `DispelController`
ally-dispel target selection**, and **`fb.cs`'s exact two-skill/
two-item-name gates vs. `TryHealersHeart`** — both confirmed present
in acdream at the call-site level (§3.2, §3.4) but not diffed
field-by-field against the retail source in this pass.
- **VTank's own `ba.cs` 100-draw loop**: whether the `g == null` /
`return true` branch (§3.1, §5 G5) is a genuine retail bug or
deliberate (e.g. "yield the tick so some other rule can act" —
though nothing downstream appears to rely on that), left unresolved;
quoted verbatim rather than guessed at.

View file

@ -0,0 +1,835 @@
# VTank knowledge base 05 — looting and `.utl`
Research only. No code changes. Decompiled source is the oracle; everything
below cites `file:line` against:
- `refs/vtank-classiclooter/decompiled/VTClassic/*.cs` and
`VTClassic.UTLBlockHandlers/*.cs` — VTClassic, the canonical loot-rule
plugin that reads/writes `.utl`. Real class names throughout (not
obfuscated).
- `refs/vtank/decompiled/*.cs` — the VTank host (`uTank2`), an obfuscated
ILSpy decompile (short letter-coded classes: `fo.cs`, `d0.cs`, `a1.cs`,
`g8.cs`, `el.cs`, `cLogic.cs`, `PluginCore.cs`, …). Field/method names are
ILSpy's synthetic `a`/`b`/`c…` — semantics below were derived by reading
call graphs and cross-referencing `uTank2.Resources.defaultsettings.usd`
setting names/descriptions, never guessed from names.
- `refs/vtank/decompiled/uTank2.LootPlugins/*.cs` — the public plugin
contract VTank exposes to a loot plugin (`LootPluginBase`, `GameItemInfo`,
`LootAction`, `ObjectClass`, …), shipped in the same assembly as the
obfuscated host.
- `src/AcDream.Plugins.MossTank/{Looting.cs,MossTankLootProfileStore.cs,
VtankLootProfileSerializer.cs,VtankLootRequirementEvaluator.cs}` and
`src/AcDream.Plugin.Abstractions/{LootClassifierPlugins.cs,
LootAutomation.cs}` — acdream's port.
- `docs/research/2026-07-29-vtank-plugin-automation-requirements.md` §1.5 —
prior secondary research; cross-checked below, no factual disagreement
found (this doc goes materially deeper: the `NeedsID` early-decision
optimization, the `pri`-field non-use, the `KeyExistsInt`/`KeyExistsDouble`
buff gate, and the exact `fo.cs` rare/fellow/priority mechanics are new
here).
---
## 1. The `.utl` format, exactly as VTClassic reads/writes it
`.utl` is a plain-text, line-oriented format. Every write goes through
`CountedStreamWriter` (`VTClassic/CountedStreamWriter.cs:6-221`), a
`StreamWriter` subclass that counts UTF-16 characters written so
length-prefixed blocks can self-report their byte length
(`CountedStreamWriter.cs:8-10,27-43`); `WriteLine` uses the writer's
`NewLine` (platform default, `\r\n` on Windows — VTank shipped
Windows-only). Reading is unbuffered `StreamReader.ReadLine()` /
`.Read(char[],int,int)` calls, so the format is a strict token stream: get
the read order wrong and the file desyncs silently.
### 1.1 Header and version
`cLootRules.Read` (`VTClassic/cLootRules.cs:71-105`):
| Case | Detection | Consequence |
|---|---|---|
| Versioned (v1) | first line literal `"UTL"` (`cLootRules.cs:78`) | next line = `UTLFileVersion` (int, must be `<= 1` or throws, `cLootRules.cs:80-84`); next line = rule count |
| Legacy (v0) | first line is NOT `"UTL"` | `UTLFileVersion = 0` (`cLootRules.cs:89`); that first line IS the rule count |
`UTLVersionInfo.VersionHasFeature` (`VTClassic/UTLVersionInfo.cs:7-20`) gates
two v1-only features by `eUTLFileFeature`
(`VTClassic/eUTLFileFeature.cs:3-7`): `RuleExpression` (a per-rule
free-text "custom expression" line, editor-only — see §2) and
`RequirementLengthCode` (every requirement payload is prefixed with its own
character count, so an unrecognized requirement type can be skipped without
understanding its payload). `MAX_PROFILE_VERSION = 1`
(`UTLVersionInfo.cs:5`) — v1 is the only version VTClassic itself ever
wrote; `cLootRules.Write` always emits header `"UTL"` / version `1`
(`cLootRules.cs:111-112`).
The write path (`cLootRules.Write`, `cLootRules.cs:107-125`) always writes:
`"UTL"``1` → rule count → each rule (§1.2) → the extra-block manager
(§1.4).
### 1.2 Rule block structure
One rule = `cLootItemRule` (`VTClassic/cLootItemRule.cs:87-162`). Read
order (feature-gated):
| Line(s) | Field | Notes |
|---|---|---|
| 1 | `name` | rule display name |
| 2 (v1 only) | `CustomExpression` | free text; **VTClassic writes it but its `Match`/`Classify` never read it** — editor-only round-trip field, not executed (`cLootItemRule.cs:34-56` has no expression evaluator) |
| 3 | `pri;act;type0;type1;…` | `;`-split: `pri` (int, `Priority()`), `act` (int cast to `eLootAction`), then one int per requirement (`eLootRuleType`) |
| conditional | `LootActionData` | only if `act == KeepUpTo`: one line, the keep-count (`cLootItemRule.cs:101-104`) |
| per requirement | length + payload (v1) / fixed lines (v0) | v1: one line = char count; for a RECOGNIZED type the count is read and discarded and `iLootRule.Read` consumes its own lines (`cLootItemRule.cs:112-118`); only an UNRECOGNIZED type consumes that many raw characters via `inf.Read(char[],0,num)` (`cLootItemRule.cs:119-123`); v0: no length prefix — `iLootRule.Read` consumes its own fixed line count directly (`cLootItemRule.cs:125-129`) |
`LootRuleCreator.CreateLootRule` (`VTClassic/LootRuleCreator.cs:5-43`) maps
each `eLootRuleType` int to its class; an unrecognized type under v1 becomes
a `cUnsupportedRequirement` that stores the raw payload bytes verbatim and
re-emits them unchanged on write (`VTClassic/cUnsupportedRequirement.cs:1-49`)
— this is VTClassic's own forward-compatibility mechanism for a requirement
type added by a newer VTClassic build. Under v0 there is no such
mechanism: an unknown type throws (`cLootRules.cs:83` equivalent path is
absent for v0 — `LootRuleCreator` returning `null` with no length code
means the rule cannot be safely skipped).
### 1.3 Requirement types (`eLootRuleType`, `VTClassic/eLootRuleType.cs:3-37`)
| Value | Type | Read payload (line order) | `Match` semantics | `MayRequireID` |
|---|---|---|---|---|
| `-1` | `UnsupportedRequirement` | raw byte blob (length-prefixed) | always `false` (never matches; exists to preserve unknown data) | `false` |
| `0` | `SpellNameMatch` | regex | any item spell name matches regex (`SpellNameMatch.cs:27-38`) | `true` |
| `1` | `StringValueMatch` | regex, `StringValueKey` | regex matches `GetValueString(vk)` (`StringValueMatch.cs:34-38`) | `IsIDProperty(vk)` |
| `2` | `LongValKeyLE` | int keyval, `IntValueKey` | `GetValueInt(vk) <= keyval` | `IsIDProperty(vk)` |
| `3` | `LongValKeyGE` | int keyval, `IntValueKey` | `GetValueInt(vk) >= keyval` | `IsIDProperty(vk)` |
| `4` | `DoubleValKeyLE` | double keyval, `DoubleValueKey` | `(float)GetValueDouble(vk) <= (float)keyval` | `IsIDProperty(vk)` |
| `5` | `DoubleValKeyGE` | double keyval, `DoubleValueKey` | `(float)GetValueDouble(vk) >= (float)keyval` | `IsIDProperty(vk)` |
| `6` | `DamagePercentGE` | double keyval | **retired — `Match` unconditionally returns `false`** (`DamagePercentGE.cs:26-29`); `EarlyMatch` always reports a decided non-match | `false` |
| `7` | `ObjectClass` | `ObjectClass` enum | `item.ObjectClass == vk` | `false` |
| `8` | `SpellCountGE` | int keyval | `item.Spells.Count >= keyval` | `true` |
| `9` | `SpellMatch` | matchRegex, excludeRegex, count | count of spells matching `matchRegex` and (if excludeRegex non-blank) not matching it, `>= count` (`SpellMatch.cs:35-52`) | `true` |
| `10` | `MinDamageGE` | double keyval | `Damage - DamageVariance*Damage >= keyval` (min roll of the damage range) | `true` |
| `11` | `LongValKeyFlagExists` | int keyval, `IntValueKey` | `(GetValueInt(vk) & keyval) > 0` | `IsIDProperty(vk)` |
| `12` | `LongValKeyE` | int keyval, `IntValueKey` | `GetValueInt(vk) == keyval` | `IsIDProperty(vk)` |
| `13` | `LongValKeyNE` | int keyval, `IntValueKey` | `GetValueInt(vk) != keyval` | `IsIDProperty(vk)` |
| `14` | `AnySimilarColor` | R,G,B,maxHueDiff,maxSVDiff | any item palette's HSV within `maxHueDiff`/`maxSVDiff` of the target color (`AnySimilarColor.cs:31-50`) | `false` |
| `15` | `SimilarColorArmorType` | R,G,B,maxHueDiff,maxSVDiff,ArmorGroup name | same HSV test restricted to the palette-slot indices in `ColorXML.SlotDefinitions[ArmorGroup]` (`SimilarColorArmorType.cs:67-92`, XML loaded from `ColorSlots.{Default,User}.xml` next to the plugin DLL or the Decal registry `ProfilePath`, `ColorXML.cs:26-97`) | `false` |
| `16` | `SlotSimilarColor` | R,G,B,maxHueDiff,maxSVDiff,slot index | HSV test on one fixed palette slot | `false` |
| `17` | `SlotExactPalette` | slot, palette id | `(palette & 0xFFFFFF) == (target & 0xFFFFFF)` on one fixed slot (low 24 bits only — masks out the high palette-template byte) | `false` |
| `1000` | `CharacterSkillGE` | int keyval, `VTCSkillID` | live character's **buffed** skill (`ISkillInfo.Buffed`) `>= keyval`, read via COM `CharacterFilter.Underlying[eSkillID]` (`CharacterSkillGE.cs:32-47`) | `false` |
| `1001` | `CharacterMainPackEmptySlotsGE` | int keyval | `102 - (count of own items with no container-capacity AND not ObjectClass Container(10)/Foci(38))` `>= keyval` (`CharacterMainPackEmptySlotsGE.cs:28-47`) | `false` |
| `1002` | `CharacterLevelGE` | int keyval | `CharacterFilter.Level >= keyval` | `false` |
| `1003` | `CharacterLevelLE` | int keyval | `CharacterFilter.Level <= keyval` | `false` |
| `1004` | `CharacterBaseSkill` | `VTCSkillID`, minskill, maxskill | live character's **base** skill (`ISkillInfo.Base`) in `[min,max]` | `false` |
| `2000` | `BuffedMedianDamageGE` | double keyval | `ComputedItemInfo.BuffedAverageDamage >= keyval` (median of buffed min/max roll) | `true` |
| `2001` | `BuffedMissileDamageGE` | double keyval | `ComputedItemInfo.BuffedMissileDamage >= keyval` | `true` |
| `2003` | `BuffedLongValKeyGE` | double keyval, `IntValueKey` | `ComputedItemInfo.GetBuffedLogValueKey(vk) >= keyval` | `true` (hardcoded, ignores `IsIDProperty`) |
| `2005` | `BuffedDoubleValKeyGE` | double keyval, `DoubleValueKey` | `(float)ComputedItemInfo.GetBuffedDoubleValueKey(vk) >= (float)keyval` | `true` (hardcoded) |
| `2006` | `CalcdBuffedTinkedDamageGE` | double keyval | `ComputedItemInfo.CalcedBuffedTinkedDamage >= keyval` (buffed damage plus the retail tinker-iron/granite auto-imbue simulation, §2.2) | `true` |
| `2007` | `TotalRatingsGE` | double keyval | sum of gear-rating `IntValueKey`s `370,371,372,373,374,375,376,379 >= keyval` | `true` |
| `2008` | `CalcedBuffedTinkedTargetMeleeGE` | 3 doubles: target DoT, target melee-defense bonus, target attack bonus | tinker-point simulation that spends points on defense/attack/damage in that priority order until all three targets are met or points run out (`CalcedBuffedTinkedTargetMeleeGE.cs`, `ComputedItemInfo.CanReachTargetValues`, §2.2) | `true` |
| `9999` | `DisabledRule` | bool `b` (`"true"`/`"false"` string) | `Match` returns `!b`**when `b==true` (disabled) the rule can never match**; this is how VTClassic represents a disabled requirement inline rather than deleting it | `false` |
`EarlyMatch` (used only by `NeedsID`, §2.3) mirrors `Match` for every
ID-independent type (character-state and color rules decide immediately;
`hasdecision=true`); every ID-dependent type (`GameInfo.IsIDProperty` true,
plus the hardcoded-`true` buffed family, plus `SpellCountGE`/`SpellMatch`/
`SpellNameMatch`, which gate on the item's own "identified" flag
`IntValueKey 218103824` bit `1` — when that bit is SET the rule reports undecided, i.e. the bit marks "spell data not yet revealed", not "identified"; e.g. `SpellCountGE.cs:31-43`) reports
`hasdecision=false` until ID data exists.
### 1.4 The extra-block manager
`UTLFileExtraBlockManager` (`VTClassic/UTLFileExtraBlockManager.cs:11-107`)
is a second, independent length-prefixed key/value stream appended after
the rule list: `blockType` line, `length` line, `length` raw characters.
One handler type is registered today,
`UTLBlock_SalvageCombine` (`BlockTypeID => "SalvageCombine"`,
`UTLBlockHandlers/UTLBlock_SalvageCombine.cs:28`); an unrecognized block
type is skipped by raw character count (`UTLFileExtraBlockManager.cs:83-87`)
— the same forward-compat pattern as `cUnsupportedRequirement`.
`CreateDefaultBlocks` (`UTLFileExtraBlockManager.cs:53-56`) always ensures a
`SalvageCombine` block exists even for a profile written before the block
existed, seeded with VTClassic's built-in defaults
(`UTLBlock_SalvageCombine.cs:30-59`: `DefaultCombineString = "1-6, 7-8, 9,
10"`, plus a fixed override table of 25 named gem/leather/ivory materials →
`"1-10"`, resolved through `GameInfo.GetMaterialID`).
`UTLBlock_SalvageCombine.Read/Write` (`UTLBlockHandlers/UTLBlock_SalvageCombine.cs:194-236`):
internal format version (`1`), `DefaultCombineString`, count + (material
id, combine-string) pairs, then — **only if the stream has more data**
(`if (!inf.EndOfStream)`, line 207) — count + (material id, value-mode
target) pairs. This trailing section is itself an undocumented-but-present
forward-compat gate: a `.utl` written by an older VTClassic build that never
had "value mode" simply omits it, and `Read` tolerates that.
`ChooseBagsToCombine`/`TryCombineMultiple`
(`UTLBlockHandlers/UTLBlock_SalvageCombine.cs:124-192`) buckets same-material
bags by workmanship range (parsed by `ParseCombineSting`, comma/semicolon
separated `a-b` or single-value tokens; `GetRangeIndex` returns the bucket
index, or `-1`/`Count` for out-of-range), then per bucket: if a
`MaterialValueModeValues` target exists for that material, sums
`IntValueKey 19` (Value) across the bucket and returns the whole bucket if
it meets the target, else randomly probes 12 pairs whose summed
`IntValueKey 92` (Structure) is `< 100` and returns the first such pair; if
no value-mode target, greedily accumulates bags (by loop order, not sorted)
until summed `IntValueKey 92 >= 100` and returns that prefix.
### 1.5 Encoding and the `--` default-profile naming convention
VTClassic itself has no hardcoded `.utl` string anywhere — the extension is
plugin-declared: `LootCore.Startup` returns
`new LootPluginInfo("utl", new string[0])`
(`VTClassic/LootCore.cs:167-180`; `LootPluginInfo` ctor at
`uTank2.LootPlugins/LootPluginInfo.cs:9-20` lower-cases and strips a leading
dot). The VTank host has no `".utl"` literal anywhere in
`refs/vtank/decompiled` either — profile-directory listing is fully generic:
`PluginCore.aa()` (`uTank2/PluginCore.cs:7130-7154`) iterates
`dz.ah.a()` (the set of extensions every *registered* loot plugin declared)
and calls `Directory.GetFiles(dq, "*." + item)` per extension. So a
different loot plugin (Alinco3/GearFoundry, cited in the prior research doc)
could ship its own extension through the same seam.
The **hidden per-character default profile** follows a naming convention
shared across all four profile families (settings `.usd`, nav `.nav`, meta
`.met`, and by the identical pattern loot `.utl`): on character login,
VTank sets the "current" file name to
`"--" + CharacterFilter.Name + "_" + CharacterFilter.Server + ".<ext>"`
(`uTank2/PluginCore.cs:3863-3866` shows this exactly for `.usd`/`.nav`/`.met`
— there is no `.utl` line in that block, consistent with §1's finding that
the host never hardcodes the loot extension there; the load/save calls for
loot profiles go through the generic `GetLootProfile`/`LoadLootProfile`
API, `PluginCore.cs:394-404`, which defers to whatever the loaded loot
plugin's `LootPluginInfo` declared). Every profile-directory listing
excludes filenames starting with `"--"` from the visible dropdown
(`PluginCore.cs:7020,7072,7144,7182`) — the per-character default is a
*hidden* file, edited implicitly by "the current profile," never listed by
name. MossTank's own `ByCharacter = "By char"` sentinel
(`MossTankLootProfileStore.cs:14`) mirrors VTank's own `"[By char]"` /
`"[None]"` dropdown entries (`PluginCore.cs:7134,7174`,`7215`) by design,
not by coincidence — same UX shape, different storage keys (MossTank keys
its "by character" document off `_characterName` through
`MossTankLootProfileStore.ProfileKey(...,byCharacter:true)`
`MossTankLootProfileStore.cs:284-291`, a SHA-256'd host-storage key rather
than a `--Name_Server.utl` file on disk).
---
## 2. Rule evaluation
### 2.1 Order: first-match-wins, list order — NOT the `pri` field
`cLootRules.Classify` (`VTClassic/cLootRules.cs:22-36`) is a plain
`foreach (cLootItemRule rule in Rules)`; the **first** rule whose `Match`
returns true wins, returning that rule's `Action()`/`LootActionData`/name.
**The per-rule `pri` field (`cLootItemRule.Priority()`,
`cLootItemRule.cs:14,24-27`) is read from and written to the file
(`cLootItemRule.cs:99,138`) but is never consulted by `Classify`, `Match`,
or `NeedsID`.** Nothing in `VTClassic/*.cs` calls `Priority()` outside the
getter itself. It is a persisted, round-tripped, *unused-at-classification*
field — most plausibly an editor-only display/sort aid inherited from an
earlier VTClassic UI. Evaluation order is 100% determined by the rules'
position in the file/list.
### 2.2 `ComputedItemInfo` — how buffed/calced values are derived
`ComputedItemInfo` (`VTClassic/ComputedItemInfo.cs:7-249`) wraps one
`GameItemInfo` and adds spell-aware derived values:
- **`GetBuffedLogValueKey(IntValueKey)`** / **`GetBuffedDoubleValueKey(DoubleValueKey)`**
(`ComputedItemInfo.cs:192-248`): **only computed if the base key already
exists on the item** (`KeyExistsInt`/`KeyExistsDouble` gate,
`ComputedItemInfo.cs:205,234` — if the item has no base value for that
key at all, the buffed value is just the caller's default, spell bonuses
are **not** added). If the key exists, the raw value is summed
(int) or changed (double: additive unless `(int)Change == 1` — i.e. `Change`
anywhere in [1.0, 2.0) truncates to 1 — in which case multiplicative; in
BOTH branches the operand applied is `Bonus`, never `Change`,
`ComputedItemInfo.cs:244`) with a bonus from a hardcoded spell-id →
`SpellInfo{Key,Change,Bonus}` table seeded in the static constructor
(`ComputedItemInfo.cs:88-139`). **Only the three-argument entries are
live**: the two-argument ctor (`ComputedItemInfo.cs:17-20`) sets
`Bonus = 0.0`, and both getters skip any entry with `Bonus == 0.0` (`:213`,
`:242`). So spell `1616` (`SpellInfo(218103842, 20.0)`, Bonus 0)
contributes **nothing**; a live example is spell `2598``+2` to
`IntValueKey 218103842` = Damage (`:96`). MossTank's tables at
`VtankLootRequirementEvaluator.cs:16-39` correctly carry only the live
entries (citation pass 2026-09-06).
- **`BuffedAverageDamage`** (`ComputedItemInfo.cs:36-45`): median of the
buffed max damage and its variance-adjusted min
(`max - variance*max`, averaged with max — same formula VTClassic exposes
as rule type `2000`).
- **`CalcedBuffedTinkedDamage`** (`ComputedItemInfo.cs:47-77`): simulates
the retail iron/granite tinker-imbue choice. Available tink count =
`max(10 - IntValueKey(171), 0)`, minus 1 if `IntValueKey(179)==0`
(untinkerable-material guard), forced to `0` if `IntValueKey(131)==0`
(no material at all). For each available tink, compares
`CalculateDamageOverTime(dmg+25, variance)` (an "iron"-style +1-damage
imbue) against `CalculateDamageOverTime(dmg+24, variance*0.8)` (a
"granite"-style -20%-variance imbue) and greedily takes whichever yields
higher expected damage-over-time
(`CalculateDamageOverTime(maxDamage,variance,critChance=0.1,critMultiplier=2.0)
= maxDamage*((1-critChance)*(2-variance)/2 + critChance*critMultiplier)`,
`ComputedItemInfo.cs:182-190`).
- **`CanReachTargetValues`** (rule `2008`, `ComputedItemInfo.cs:141-180`):
same tink-count computation, but each simulated tink point is spent in
fixed priority order — melee-defense bonus first (if below target,
`+0.01`), then attack bonus (if below target, `+0.01`), then damage
(same iron/granite choice as above) — until all three targets are met or
points run out; final result is whether all three targets were reached.
- **`TotalRatings`** (`ComputedItemInfo.cs:79`): flat sum of eight gear
rating `IntValueKey`s (`370``376`,`379` — skips `377`/`378`).
- **`BuffedMissileDamage`** (`ComputedItemInfo.cs:81`):
`BuffedLog(Damage) + (BuffedDouble(DamageVariance-ish key 167772174) - 1)*100/3 + BuffedLog(204)`.
### 2.3 What must be identified before evaluation — `NeedsID`/`EarlyMatch`
`LootPluginBase.DoesPotentialItemNeedID`
(`uTank2.LootPlugins/LootPluginBase.cs:25`) → VTClassic's
`LootCore.DoesPotentialItemNeedID` (`VTClassic/LootCore.cs:36-51`): if the
item already `HasIDData` returns `false` immediately; otherwise delegates to
`cLootRules.NeedsID` (`cLootRules.cs:38-64`), a single forward pass over the
rule list:
```
flag = false; lastUndecidedAction = NoLoot
for each rule in order:
if flag AND rule.act != lastUndecidedAction: return true // needs ID
rule.EarlyMatch(item, out hasdecision, out ismatch)
if hasdecision AND ismatch: return false // decided, no ID needed
if !hasdecision: flag = true; lastUndecidedAction = rule.act
return flag // true if ANY rule was undecidable and none matched first
```
This is a genuine optimization, not a naive "identify everything": a rule
that can decide (or definitely reject) an item without ID data short-
circuits immediately; a later ID-independent rule with the **same** action
as an earlier undecidable rule also short-circuits (the outcome is the same
either way, so identifying doesn't change the classification); only a
later rule with a **different** action than a still-open earlier rule
forces `NeedsID => true`, because the true first-match answer might still
be that earlier (as-yet-undecidable) rule once ID data exists.
`cLootItemRule.AnyReqRequiresID`/`EarlyMatch`
(`cLootItemRule.cs:34-44,58-85`) apply the same "AND of requirements, but
short-circuit on a definite non-match" logic per-rule that `Match` does.
### 2.4 The result contract VTank consumes
`LootPluginBase.GetLootDecision` (abstract,
`uTank2.LootPlugins/LootPluginBase.cs:27`) → `LootCore.GetLootDecision`
(`VTClassic/LootCore.cs:53-91`): calls `cLootRules.Classify`, maps
`VTClassic.eLootAction` (11 members, 0-10) onto the host's
`uTank2.eLootAction` (13 members, 0-12 — adds `ManaStone`/`ManaTank`,
`uTank2/eLootAction.cs:3-18`) wrapped in the public class
`uTank2.LootPlugins.LootAction` (`LootAction.cs:1-81`), stamping the matched rule's name onto
`LootAction.RuleName`. `LootAction` itself is a closed factory type (ctors
`internal`) exposing static singletons (`NoLoot`,`Keep`,`Salvage`,`Sell`,
`User1..5`) plus `GetKeepUpTo(maxcount)`; `IsRead`/`LootAction.Read` are
`internal` to the **uTank2 assembly** — **no loot plugin can construct a
`Read` action, VTClassic included** (VTClassic ships as its own assembly and
the tree carries no `InternalsVisibleTo`). Confirmed by the mapping switch
itself (`LootCore.cs:65-82`), which has no `Read` case **and no `User1..5`
cases** — both fall through to the `NoLoot` initializer, so VTClassic can only
ever return NoLoot/Keep/Salvage/Sell/KeepUpTo; `Read` and `User1..5` reach
`hv` only from host-internal paths. `GameItemInfo`
(`uTank2.LootPlugins/GameItemInfo.cs:7-279`) is the read side of the
contract: `ObjectClass`, `HasIDData`, `Id`, `Spells`/`ItemSpell` (resolved
through the plugin core's spell cache, not the raw wire spell-id list),
`Palettes` (lazily built from an internal `bb` struct exposing
`Palette`/`Offset`/`Length`/`ExampleColor`), and typed key accessors
(`GetValueInt/Quad/Bool/String/Double` + `KeyExists*`) backed by an
internal `fu` item object's five per-type dictionaries.
`ILootPluginCapability_SalvageCombineDecision2.ChooseBagsToCombine` is a
capability interface (`uTank2.LootPlugins/ILootPluginCapability_SalvageCombineDecision2.cs`)
VTClassic implements (`LootCore.cs:194-198`) so the host can ask the loot
plugin itself which bags to combine, forwarding to
`UTLBlock_SalvageCombine.TryCombineMultiple` (§1.4) — the combine decision
lives in the profile, not in host code.
---
## 3. VTank's own loot flow
VTank (`uTank2`) does corpse tracking, approach, open, and the
open/close/blacklist state machine itself; it calls into the loaded loot
plugin (VTClassic) only for the per-item classification decision (§2.4).
Everything below is host-side (`refs/vtank/decompiled`, obfuscated).
### 3.1 Corpse tracking — `fo` (`refs/vtank/decompiled/fo.cs`)
One `fo` instance owns a `MyDictionary<int, fo.a>` (`fo.cs:59`, field `e`)
keyed by corpse object id. Per-corpse state (`fo.a`, `fo.cs:10-49`):
`a`=released-from-view, `b`=done/looted, `c`=first-seen timestamp (set once
at creation, never bumped — the "age" clock for the public/fellow timers),
`d`=last-seen/re-touched timestamp (the clock `CorpseCacheTimeoutMinutes`
actually measures against), `e`=IsGeneratedRare, `f`=parsed killer name,
`g`=last "ownership denied" chat timestamp, `h`=is-my-own-death-corpse
(unused in the eligibility scan itself), `i`=long-description-processed
flag, `j`=open-attempt counter, `k`=blacklisted-since timestamp.
**Detection** (`WorldFilter.CreateObject` handler, `fo.cs:157-190`): fires
only for `ObjectClass == 27` (`Corpse` — matches
`uTank2.LootPlugins.ObjectClass.Corpse`, ordinal 27,
`uTank2.LootPlugins/ObjectClass.cs:32`). GUID-reuse defense: if the same
object id is already tracked but its new 2-D position differs from the
cached position by more than `0.004167` VTank distance units (~1 m at the
~240 m/unit conversion inferred in
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md`), the stale entry is
evicted and replaced (`fo.cs:167-170`) — the server recycled the object id
for an unrelated corpse. On genuine re-creation of an already-tracked id,
only `d` (last-seen) is bumped and `a` (released) cleared — `c` (first-seen
age) is untouched.
**Long-description parsing** (`WorldFilter.ChangeObject` on `StringValueKey
16`, `fo.cs:192-252`): regex `"(?:Killed by )([a-zA-Z\ \-\']*)(?:\..*)"`
extracts the killer name; a second regex requiring a trailing
`[gG]enerated` sets the rare flag `e=true`. A third regex,
`"([a-zA-Z\ \-\']*)\'s ([^\']*)"`, re-parses the extracted killer string
for a possessive form (a combat pet's name reads as `"Owner's Petname"`);
if it matches and the possessive owner is the local character, the killer
is rewritten to the local character's own name; else, if fellowship data is
available (`dz.aj.b()`), the fellow roster is scanned for a member matching
that owner name and the killer is rewritten to that member's own name field (`fo.cs:233`
assigns the same `item.Value.b` it matched case-insensitively — a case
normalization, not a fuller name) — kills by your own or a fellow's **combat pet** are attributed to the
owner. If the long description has **no** `"Killed by "` match at all
(non-monster corpse, or one killed by nothing recognizable), `f=""` and
**`e` (rare) is forced `true`** (`fo.cs:243-245`) — a permissive default so
`LootOnlyRareCorpses` doesn't silently skip a corpse VTank can't classify,
not a literal "this corpse drops a rare."
**Chat-driven ownership denial** (server message type `63408`, event `747`,
matched against `"...already in use by someone else!"` or `"You do not yet
have the right to loot..."`, `fo.cs:71-73,314-317`): records `g=Now` for
the currently-targeted corpse `i`; the eligibility scan (below) skips any
corpse denied within the last 10 seconds. **MossTank has no equivalent
chat-text listener** — see §4.
**Cache eviction** (`fo.cs:133-155`, on a `StartupComplete`/timer-poked
event with a bound `ey` rate-limiter fired every 30841 ms,
`fo.cs:57,84-93`): an entry is only removed once **both**
`a` (released from view) is true **and**
`(Now - d).TotalMinutes >= CorpseCacheTimeoutMinutes` (default 60,
`uTank2.Resources.defaultsettings.usd:667-671`) — a corpse still in view is
never evicted no matter how old.
### 3.2 Eligibility and selection — `fo.a(double maxRange, bool metric)` (`fo.cs:384-453`)
Per candidate, in order, `continue` (skip) if: already `b` (done), `a`
(released), denied within 10 s (`g`), or currently blacklisted
(`k` within `BlacklistCorpseOpenTimeoutSeconds`, default 200,
`defaultsettings.usd:1067-1071`); then compute distance
(`f9.a(key, CharacterFilter.Id, true)`) and skip if `> maxRange`; then skip
if `!i` (long description not yet processed) or (`!e` and
`LootOnlyRareCorpses`); then the ownership gate:
| Killer | Rule |
|---|---|
| Me | always eligible |
| Not me, corpse `e` (rare) | **always skipped** — VTank never crosses ownership on a rare corpse, at any age, regardless of `LootAllCorpses`/`LootFellowCorpses` |
| Not me, killer matches a fellow roster entry | requires `LootFellowCorpses` AND (that member's `ShareLoot`-style flag OR corpse age `>= 100 s`) |
| Not me, no fellow match (a stranger's kill) | requires corpse age `>= 100 s` AND `LootAllCorpses` |
Selection among the remaining eligible set (`fo.cs:436-446`) **strictly
prefers any rare corpse over any non-rare corpse regardless of distance**:
the first rare corpse found becomes the running best pick; once a rare pick
exists, only a *closer* rare corpse can replace it; only in the absence of
any rare pick does plain nearest-distance selection apply.
### 3.3 Approach — `g8`/`fd`, `CorpseApproachRange-Min/Max`
The corpse-approach step is `g8 : ILogicRule` (`g8.cs:7-157`,
`FriendlyName` = `"Navigate"` — a GENERIC wrapper, reused for corpse
approach, monster approach, and route navigation alike), wrapping the same
`fd` close-in mover already documented in
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` for its
peace-mode-creep behavior. For corpses it is constructed as
`new g8(0, "CorpseApproachRange-Min", "CorpseApproachRange-Max",
new fg("CorpseApproachRange-Max"))` (`cLogic.cs:492,535`): `fd` looks up
the min/max range from those two setting names (`fd.f()`,
`fd.cs:400-412`), and `fg` (friendly name `"CorpseApproach (...)"`,
`fg.cs:150`) is the `bz`-family target descriptor supplying the corpse's
live position — the SAME `bz` abstraction used for monster approach (`eb`,
`"MonsterApproach (...)"`, `cLogic.cs:559`) and route navigation (`ca`,
`cLogic.cs:507,569`), just with a different concrete descriptor.
`g8.b()` (ValidNow) bails immediately if `PluginCore.dz.o.s` (the "waiting
on corpse ID" flag, next paragraph) or `EnableNav` is off, or the
Navigation/`SpreadLockTargetRequested`/`DoorOpening` action locks are held
(`g8.cs:81-103`).
`SettingDelegate_SetWaitingOnCorpseId`
(`uTank2.Logic/SettingDelegate_SetWaitingOnCorpseId.cs:5-48`) resolves what
`CorpseApproachRange-Max = 0` (the shipped default,
`defaultsettings.usd:315-317`) actually means: the effective floor is
`max(1/48, CorpseApproachRange-Max)` VTank distance units (`1/48` ≈ 5 m at
the ~240 m/unit conversion) **plus** `1/24` (~10 m) — so a `Max` of `0`
does **not** mean unlimited, it means "use the fixed ~15 m floor." Within
that radius, if any radar-tracked corpse (`dz.v.c(f0.c.c)`) lacks full item
data in VTank's item cache, the WHOLE logic engine pauses for that tick
(`dz.o.s = true`) rather than act on stale info about a corpse that close.
### 3.4 Open, loot, and wait — `bj` ("OpenCorpse"), `d0` ("LootCorpse"), `a1` ("CorpseWait")
Three distinct rules, in this exact division of labor (corrected from an
earlier draft of this doc, which had `d0`'s and `bj`'s roles swapped — the
division below was independently confirmed by tracing `fo.g()`/`hv.a()`'s
actual call sites, not just each rule's `FriendlyName` string):
- **`bj : ILogicRule`** (`bj.cs`, `FriendlyName` = `"OpenCorpse"`,
`bj.cs:126`) is the fixed-range final approach *and* the open action.
Constructed as `new bj(0, 1.0/48.0)` (`cLogic.cs:498,544` — a fixed ~5 m
use-range, not a setting-name pair). `ValidNow` requires `EnableLooting`
and either an `ItemUse` lock already held (an attempt in flight) or
`fo.a(radius≈1/48, exact:true)` selecting an unopened corpse
(`bj.cs:64-79`). When it fires, it re-selects via `fo.a(...)` and calls
`fo.g()` (`fo.cs:325-351`) — which issues a raw `f9.p(corpseId)`
**UseItem** (bypassing the peace-mode guard the same way the FCM trace's
wand-recovery path does with `f9.p(wand)`), increments the per-corpse
open-attempt counter, and at `BlacklistCorpseOpenAttemptCount` (default
30, `defaultsettings.usd:1059-1063`) attempts resets the counter, stamps
the blacklist timestamp, and posts
`PluginCore.a("Blacklisting unopenable corpse \"<name>\" for
<BlacklistCorpseOpenTimeoutSeconds> seconds.")` (`fo.cs:349`). Arms
`ItemUse`/`Navigation`/`CorpseOpenAttempt` action locks for
`CorpseOpenTimeoutSeconds` (`bj.cs:109-112`).
- **`d0 : ILogicRule`** (`d0.cs:6-132`, `FriendlyName` = `"LootCorpse"`)
is the **item-pickup** step, not the open step: `ValidNow`
(`d0.cs:60-80`) requires `EnableLooting`, no `ItemUse` lock, the
last-attempted and currently-open corpse ids matching
(`dz.r.m == dz.r.j`), `fo.e()` true (a corpse IS currently open), **and**
`hv.e()` true (the item-tracker, §3.5, has pending pickups queued). When
it fires it calls `dz.s.a()``hv.a()` with no args, the mover that
pulls the next queued item — and arms `ItemUse`/`Navigation` locks for
`0.75 s` (`d0.cs:100-113`; the same lock-duration pattern already
documented for the wand/FCM path).
- **`a1 : ILogicRule`** (`a1.cs:6-122`, `FriendlyName` = `"CorpseWait"`)
waits for the item-tracker to finish and then closes the corpse:
`ValidNow` requires `EnableLooting`, no `ItemUse` lock, and `fo.e()` (a
corpse is open). When it fires and `hv.f()` (`m_a.s.f()`, "corpse fully
processed", §3.5) is also true, it calls `fo.b()` (`m_a.r.b()`,
`fo.cs:353-363`): marks the corpse done (`b=true`) and issues a
**second** raw `f9.p(corpseId)` UseItem on the same id — VTank
explicitly re-uses the corpse object to close the container view once
looting finishes, rather than simply walking away.
**Open confirmation** rides server message type `63408` (`fo.cs:269-323`):
event `406` with `container` = the corpse id and `itemCount` marks the
corpse as the tracked "currently open" one (`j = container`), starts the
item-tracker's enumeration for that corpse (`dz.s.a(itemCount)` = `hv.a(int)`,
§3.5), and — if a `CorpseOpenAttempt` action lock was held — clears it plus
`Navigation`/`ItemUse` and calls `SchedulePoke()` to re-run the logic
engine immediately rather than wait for the next tick. Event `82` and
event `34` (matching the open corpse's id) both mean "no longer open" and
reset `j=0` plus `hv.b()` (the item-tracker's reset). `fo.c()`/`fo.e()`
(`fo.cs:365-382`) additionally resync against
`PluginCore.dz.az.Actions.OpenedContainer` every poll — if the game client
itself reports nothing open while `fo` still thinks a corpse is open, `fo`
self-corrects (`j=0`).
### 3.5 Item enumeration, decision, pickup, and priority looting
The item-tracker is `hv` (field `s.s`, i.e. `PluginCore.dz.s`) — distinct
from `el.cs` (§3.7). `hv.a(int itemCount)` (`hv.cs:275-301`) is the
enumeration entry point fired at open confirmation: clears per-corpse
state, logs `"LootList Clear (NewCorpse)"`, then walks
`WorldFilter.GetByContainer(dz.r.j)` (the open corpse's contents). Items
that materialize *after* the initial snapshot are caught by a
`WorldFilter.CreateObject` handler, `hv.a(fu)` (`hv.cs:248-273`), which
runs the identical add-and-decide logic.
**Per-item needs-ID gate**, `hv.b(int)` (`hv.cs:389-404`) — `true`
(defer, request ID first) if: the CURRENT corpse is the player's own death
corpse (`fo.a.h`, "always ID everything on your own corpse"); or the loot
plugin's own `DoesPotentialItemNeedID` (`cu.b(int)``LootPluginBase`,
§2.3/§2.4) says so; or there's remaining mana-tank fill capacity and the
item is a known-needs-ID object. Otherwise the real decision runs
immediately.
**The plugin call site.** `PluginCore.dz.ah` is a `cu` instance — VTank's
loot-plugin MANAGER, not the item-tracker (`cu.cs:9`; loads plugins from
registry key `HKLM\Software\Decal\LootPlugins`, matches an active plugin by
declared file extension, §1.5). `cu.a(int objectId)` (`cu.cs:176-195`)
builds a `GameItemInfo`, checks `.IsValid`, and calls the loaded
`LootPluginBase.GetLootDecision(item)` (§2.4), catching any exception as
`LootAction.NoLoot`. `cu.b(int)` (`cu.cs:155-174`) is the matching
`DoesPotentialItemNeedID` call site. `hv` calls these through
`dz.ah.a(id)`/`dz.ah.b(id)` (`hv.cs:395,408`) — i.e. the item-tracker
*consumes* the plugin manager's decision, it does not host the plugin
itself. `PluginCore.cs` also exposes `FLootPluginClassifyImmediate`/
`FLootPluginClassifyCallback` (`PluginCore.cs:3082-3145`) as an
async-with-ID-wait convenience wrapper over the same two `cu` calls, used
by call sites outside the corpse-loot path.
**Per-item decision**, `hv.a(int, hv.a)` (`hv.cs:406-473`): calls
`cu.a(id)` and switches on the result's `eLootAction`:
- `NoLoot` falls through to three fallback checks (below) before the item
is finally skipped.
- `KeepUpTo`: counts existing same-named items plus an in-corpse
already-queued count; treated as `NoLoot` (same fallthrough) once at or
over the cap.
- Every other action (`Keep`/`Salvage`/`Sell`/`User1`-`5`, and an
under-cap `KeepUpTo`) is stored **verbatim** and queued
(`hv.cs:438-440`) — **the Salvage/Sell/Keep/User1-5 split is not applied
at decision time**, only later once the item is confirmed in inventory
(§3.7).
- **Fallback path** (only reached on a plugin `NoLoot`): (a) if the item
is a scroll eligible under `ReadUnknownScrolls` (`hv.a(id,commit:true)`,
§3.8) it is queued as `Read` even though the plugin rejected it; else
(b)/(c) if it's a usable mana stone / mana-tank tool and capacity
remains, a synthetic `ManaStone`/`ManaTank` action is queued.
**The pending-pickup queue and mover.** `hv.m_d` is
`MyDictionary<int, hv.a>`, the queue of decided-but-not-yet-moved items;
each entry (`hv.a`, `hv.cs:9-32`) carries its own **priority score**
(field `e`, an `int`) alongside pickup-in-progress/attempt-count/resolved-
action fields. `hv.a()` (no args, `hv.cs:361-387` — the mover `d0` calls)
picks the **highest-priority entry** in `m_d`, marks it in-progress,
increments its attempt counter, drops it from the queue once attempts
exceed `CorpseLootItemMaxAttempts` (`hv.cs:379-382`), else issues
`f9.p(itemId)` — the SAME raw-UseItem helper used to open/close the
corpse. **The priority field `hv.a.e` is dead.** It is declared
(`hv.cs:19`), read at `hv.cs:371,374`, and **never written anywhere** — the
ctor leaves it `0` (`hv.cs:25-31`) and neither construction site sets it
(`hv.cs:256,289`); `hv.a` is a `private class` so no external writer is
possible. The "highest-priority" scan therefore always keeps the FIRST key
`MyDictionary` enumerates (strict `>` against `int.MinValue`). VTank has no
effective per-item pickup priority (citation pass 2026-09-06).
**Readiness**, `hv.f()` (`hv.cs:315-350`) — "corpse fully processed":
`false` while items are still expected (`CorpseItemAppearanceTimeoutSeconds`,
logging `"Abandoned attempting to loot corpse. Item appearance timeout
occurred. (Empty corpse bug)"` on timeout, `hv.cs:320`), `false` while any
tracked item's ID is still pending beyond `CorpseItemIDTimeoutSeconds`
(logging `"Abandoned attempting to loot corpse. Unable to recieve ID for
all items."`, `hv.cs:338`), `false` while `m_d` still holds queued
pickups; else `true` — this is what `a1` ("CorpseWait") polls before
closing the corpse (§3.4).
### 3.6 Priority looting — the rule table
`cLogic.InitializeDefaultLogicRules`
(`refs/vtank/decompiled/uTank2/cLogic.cs:433-578`) places two parallel loot
chains in the master rule list (first-match-wins, `cLogic.cs:222-255`):
| Stage | Gate | Rules (in order) |
|---|---|---|
| `PREPRIORITYLOOTACTIONS``POSTPRIORITYLOOTACTIONS` (`cLogic.cs:486-490`) | `LootPriorityBoost` only | `er(0)` ("ReadScroll"), `aj(0)` ("StackCram"), `ar(0)` ("SalvageItems") — each a `LogicRulePreChain` |
| `PREPRIORITYLOOT``POSTPRIORITYLOOT` (`cLogic.cs:491-505`) | `EnableLooting` + `LootPriorityBoost` + `SettingDelegate_SetWaitingOnCorpseId` | `g8`+`fg` ("Navigate", approach) → `bj(0,1/48)` ("OpenCorpse") → `d0(0)` ("LootCorpse") → `a1(0)` ("CorpseWait") |
| `PREIDLELOOTACTIONS``POSTIDLELOOTACTIONS` (`cLogic.cs:529-533`) | none (empty `reqs4`), pre-action `cm(0)` (IdlePeace — drop to peace first) | same `er(0)`,`aj(0)`,`ar(0)` |
| `PREIDLELOOT``POSTIDLELOOT` (`cLogic.cs:534-551`) | `EnableLooting` only, pre-action `cm(0)` | same `g8`/`bj` chain, then bare `d0(num3++)`/`a1(num3++)` |
`PREATTACK`/`b4`/`POSTATTACK` combat sits structurally BETWEEN the
priority-loot block and the idle-loot block (`cLogic.cs:514-516`). So
`LootPriorityBoost` (default `False`, "corpses are looted before attacking
monsters", `defaultsettings.usd:659-665`) does not reorder a generic
priority number or touch `hv.a.e` — it duplicates the entire
approach→open→loot→wait chain into an earlier, `LootPriorityBoost`-gated
position ahead of combat; the SAME rule classes are re-registered
unconditionally (gated only by `EnableLooting`) after combat as the
"idle loot" copy, which is what actually runs when the setting is off.
`er`/`ar` ("ReadScroll"/"SalvageItems") are separate `ILogicRule`s that do
NOT run as part of the corpse-open/loot/wait chain:
- **`ar`** (`ar.cs`, `FriendlyName` = `"SalvageItems"`): `ValidNow`
requires `EnableLooting`, a `Salvage` lock active OR a Ust (salvage
tool) present in inventory, **no corpse currently open** (`!fo.e()`),
and pending salvage work (`c7.j()`, next paragraph). When it fires:
`f9.p(Ust)` (triggers the client's own salvage-combine dialog) then
`c7.i()` to drive the combine/split logic.
- **`er`** (`er.cs`, `FriendlyName` = `"ReadScroll"`): scans `dz.o.i`
(`MySortedList<int,int>`, spell-id → item-id, populated at pickup
confirmation, §3.7) for an eligible entry and issues `f9.p(itemId)`
directly (`er.cs:118,135`).
`aj` (`FriendlyName` = `"StackCram"`) is the rule that drives `el` — see
§3.7; it is NOT part of the corpse loot chain either.
### 3.7 Post-pickup differentiation, salvage staging, and the AutoStack/AutoCram mover
The Salvage/Sell/Keep/User1-5/Read/ManaStone/ManaTank split (deferred at
decision time, §3.5) is applied once the item is CONFIRMED in inventory:
`hv.a(object, ChangeObjectEventArgs)` (`hv.cs:106-216`) fires on a
container-change to the character's own inventory for an item still in
the pending queue `m_d`, and switches on the resolved action:
| Action | Handling |
|---|---|
| `Salvage` | if the item is still unidentified/generic and carries a salvage-material key, hands it to `c7.c(id)` (`dz.u`, salvage-combine staging, `hv.cs:139-146`) — else logs a "lacks a salvage material" warning and drops it |
| `Read` | adds `spellId → itemId` to `dz.o.i` for `er` ("ReadScroll") to process **later, as a separate idle-loop rule** — reading a scroll is NOT an immediate inline continuation of its pickup (`hv.cs:150-165`) |
| `ManaStone` | `dz.ac.c(id)` |
| `ManaTank` | `dz.ac.b(id)` (same unidentified-item guard as Salvage) |
| `Keep`/`Sell`/`KeepUpTo`/`User1`-`5` | added uniformly to `dz.o.h` (`MyDictionary<int,eLootAction>`) — **no Sell-specific queue or vendor-open gate was located reading this table back out**; whether a dedicated idle-loop rule consumes it for vendor selling, versus it being purely a bookkeeping/report table, is unresolved (§5) |
`c7` (`dz.u`) is VTank's salvage-combine staging class: `c7.c(int)` queues
an item, `c7.j()` reports whether combine- or split-mode work is pending,
`c7.i()` dispatches to whichever applies — consumed by `ar` ("SalvageItems")
above, never from inside the corpse-open/loot chain itself (`ar.ValidNow`
explicitly requires no corpse open).
**`el.cs` is NOT the corpse-loot mover** — independently confirmed by
reading it directly and by the background trace of `aj`/`cLogic.cs`: it is
VTank's `AutoStack`/`AutoCram` idle-inventory tidier
(`refs/vtank/decompiled/el.cs:7-208`), gated by those two settings
(`el.cs:81,129`), driven by the separate `aj` ("StackCram") rule
registered in the same sentinel-bounded stage as `er`/`ar`
(`cLogic.cs:488,531`) — not by `d0`/`a1`. `el.c()` finds either two
same-material stackable items with mismatched counts (a partial stack to
merge) or one loose item plus a container with free capacity, and `el.d()`
issues `PluginCore.dz.az.Actions.MoveItem(...)`, with an "abandon after 80
consecutive stuck ticks" blacklist (`el.cs:182-198`) chat-reported via
`PluginCore.a(...)` — the same shape as MossTank's own
`_combineAttempts`/40-attempt salvage-bag-combine abandon logic (§4), a
different subsystem and a different threshold (80 vs 40, neither
confirmed intentional).
### 3.8 `ReadUnknownScrolls` eligibility
`hv.a(int itemId, bool commit)` (`hv.cs:475-500`) is the eligibility test
referenced from both the per-item `NoLoot` fallback (§3.5, `commit=true`)
and (peek mode, `commit=false`) from `er` ("ReadScroll", §3.6) deciding
whether an already-queued scroll is still worth reading. Requires
`ObjectClass == 42` (Scroll) and `ReadUnknownScrolls` on; reads the
scroll's spell id and skips if already known
(`dz.q.y.Contains(spellId)`); then requires
`spell.Difficulty - 15 <= spell.SkillWithSchool` — the character's magic
school skill must be within 15 points of the spell's difficulty. In
`commit=true` mode, additionally requires the spell id not already queued
in `dz.o.i`. MossTank's `IsReadableUnknownScroll`
(`src/AcDream.Plugins.MossTank/Looting.cs:1370-1395`) matches this exactly
on the numeric threshold (`spell.Difficulty - 15 <= skill.Current`) and
the "already known" guard, substituting a documented adaptation for the
`ObjectClass==42` test — a name-ending-in-`" Scroll"` plus item-type-flag
heuristic, with an in-code comment explaining that Decal's `ObjectClass.Scroll`
is a derived client classification with no equivalent field on retail's
wire `PublicWeenieDesc`. The one confirmed behavioral difference: VTank
defers the actual read to a separate `er` ("ReadScroll") idle-loop rule
that fires independently, sitting behind whatever combat/idle-status rules
precede it in the list (§3.6); MossTank's `ContinuePostUse`
(`Looting.cs:857-903`) issues the read as an immediate continuation right
after the scroll is picked up.
---
## 4. The "MossTank gap"
**A real `.utl` file loads today.**
`MossTankLootProfileStore.TryImportLegacy`
(`src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs:205-264`) reads a
`.utl` from the host's `imports`/`exports` storage folders through
`VtankLootProfileSerializer.TryRead`
(`src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs:76-147`), which
independently re-implements §1's exact grammar: header/version detection
(`VtankLootProfileSerializer.cs:92-107`), the length-prefixed v1 payload
format and the fixed-line-count v0 legacy table
(`LegacyPayloadLineCount`, `VtankLootProfileSerializer.cs:376-388`
independently verified in this pass against every one of VTClassic's 31
`Read()` methods, §1.3/§1.2; every bucket matches exactly), the
`SalvageCombine` extra block including its optional trailing value-mode
section, and preserves any unrecognized block/requirement type verbatim
(`VtankLootExtraBlock`/`cUnsupportedRequirement`-equivalent
`VtankLootRequirement.Payload`, `VtankLootProfileSerializer.cs:11-15`).
Round-trip fidelity for every one of the 31 known requirement types plus
unknown-block preservation is exercised by
`tests/AcDream.Plugins.MossTank.Tests/VtankLootProfileSerializerTests.cs`
(all four tests read).
`VtankLootRequirementEvaluator.IsMatch`
(`src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs:110-190`)
implements the **entire** 31-entry requirement vocabulary (verified
switch-arm-by-switch-arm against §1.3's table, including the deliberately
dead `DamagePercentGE` always-`false` and the `DisabledRule`
never-matches-when-enabled semantics) plus every `ComputedItemInfo` formula
in §2.2 (`BuffedTinkedDamage`/`CanReachTarget` reproduce the iron/granite
tink loop and the priority-ordered target-reach loop bit-for-bit against
`ComputedItemInfo.cs`'s control flow).
Ranked semantic differences (highest impact first):
| # | Gap | VTClassic/VTank oracle | MossTank today | Impact |
|---|---|---|---|---|
| 1 | **No ID-avoidance optimization.** | `NeedsID`/`EarlyMatch` (§2.3) skip appraisal when the classification is already decidable, or when a later same-action rule makes an earlier undecidable rule moot. | `LootController.ContinueCurrentCorpse` (`Looting.cs:708-721`) calls `loot.Identify(item.ObjectId)` for **every** corpse item before any decision is made — there is no `DoesPotentialItemNeedID` analog anywhere in `Looting.cs`, and no test exercises one (`LootingTests.cs` has no `NeedsID`/`DoesPotentialItemNeedID` case). | High — changes appraisal/identify pacing and network chatter for every loot pass; a rule set that could skip IDing (e.g. "NoLoot everything except ObjectClass==Money") gets fully IDed anyway on live retail/ACE. |
| 2 | **Rare corpses are not prioritized in selection.** | `fo.a` (§3.2) strictly prefers ANY rare corpse over ANY non-rare corpse, regardless of distance, once any exists in the known set. | `LootController.Tick`'s candidate loop (`Looting.cs:557-561`) orders strictly by `.Distance` then `.ObjectId``CanLoot` gates eligibility per §4's fellow/all-corpse rules (see #4 below, this part IS faithful) but never re-orders for rarity. | High for players who loot mixed rare/mundane fields — a farther rare corpse can be skipped in favor of a nearer mundane one until the mundane one is done. |
| 3 | **No chat-text ownership-denial listener.** | `fo.cs:71-73,269-323,393` treats a "already in use by someone else!" / "you do not yet have the right to loot" chat line as an immediate 10-second skip for that corpse. | `Looting.cs` has no chat-message handling anywhere in the loot path; a denied corpse is only ever backed off via the generic `BlacklistCorpseOpenAttemptCount`/`BlacklistCorpseOpenTimeoutSeconds` retry-then-blacklist mechanism (`Looting.cs:386-387,1397-1417`), which the defaults confirm are faithfully ported (30 attempts / 200 s, matching `defaultsettings.usd:1059-1071` exactly). | Medium — same eventual outcome (corpse gets skipped) but far slower: retail's explicit refusal is immediate, MossTank's fallback needs up to 30 failed open attempts first. |
| 4 | **`BuffedInt`/`BuffedDouble` omit the base-key-exists gate.** | `ComputedItemInfo.GetBuffedLogValueKey`/`GetBuffedDoubleValueKey` (`ComputedItemInfo.cs:198-248`) only add a spell bonus if the item **already has** that base key (`KeyExistsInt`/`KeyExistsDouble`); otherwise the buffed value stays at the caller's default. | `VtankLootRequirementEvaluator.BuffedInt`/`BuffedDouble` (`VtankLootRequirementEvaluator.cs:415-448`) compute the base value through a default-returning lookup (no existence check) and then unconditionally add any matching spell bonus. It also mis-ports the branch selector: `(int)bonus.Bonus == 1` (`VtankLootRequirementEvaluator.cs:445`) tests the bonus where VTClassic tests the separate `Change` field (`ComputedItemInfo.cs:244`) — harmless today only because MossTank's table holds only entries where `Change == Bonus`. | Medium-low — for the common case (a weapon rule reading `Damage`, which virtually every weapon carries) this never differs; it only diverges for an item that lacks the base key entirely but is affected by a matching buff spell, an edge case not covered by any current test. |
| 5 | **The 100-second public/fellow-corpse "age" clock starts at a different moment.** | `fo.a.c` (§3.1) is stamped once at `WorldFilter.CreateObject` — i.e. as soon as the corpse object streams into the client's known-object set, which is typically a much larger radius than the loot-approach range. | MossTank's `_corpseFirstSeen` (`Looting.cs:394,530`) is only populated inside `CaptureCorpses(CorpseApproachRange)` (`Looting.cs:526-530`) — the clock cannot start until the corpse is already within the (much smaller) loot approach range. | Low-medium — makes MossTank's 100-second public-corpse and fellow-non-share timers start later than retail VTank's for a corpse seen from far away before the player walks up to it; converges to the same behavior once the player is in loot range for 100+ seconds regardless. |
| 6 | **VTank explicitly re-closes a finished corpse; MossTank does not.** | `a1`'s `fo.b()` (`fo.cs:353-363`) issues a second raw UseItem on the corpse id specifically to close the container view once looting completes. | `LootController.ContinueCurrentCorpse`'s completion path (`Looting.cs:750-760`) just clears local state and moves on — it never issues an explicit close action; whether the container view auto-closes depends on the host's `ILootAutomation`/retail behavior rather than an explicit port of `fo.b()`'s second UseItem. | Low — cosmetic/UI-state difference (an open corpse window lingering) rather than a loot-decision difference, unconfirmed whether retail's own container-close behavior makes this moot. |
| 7 | **`el.cs`'s 80-attempt stuck-item abandon threshold vs MossTank's 40-attempt salvage-combine abandon.** | VTank's `AutoStack`/`AutoCram` mover (`el.cs:182-198`) gives up after 80 consecutive stuck ticks. | MossTank's `ContinueSalvageBagCombine` gives up after 40 (`Looting.cs:1138`). | Low — different subsystem (stack/cram vs salvage-bag-combine) and an unconfirmed-as-intentional magic number on both sides; flagged only because the pattern shape is otherwise an exact match. |
| 8 | **Reading a scroll is an immediate continuation in MossTank; VTank defers it to a separate idle-loop rule.** | `hv`'s `Read` handling (§3.7) only enqueues `spellId → itemId` into `dz.o.i`; the actual read fires later, whenever `er` ("ReadScroll") next becomes `ValidNow` in the (priority- or idle-)loot stage — it can be delayed behind combat/idle-status rules ahead of it in the list (§3.6). | `LootController`'s `_postUseItem` continuation (`Looting.cs:825-831,857-903`) issues the read as the very next action after the scroll's pickup completes. | Low — same eventual outcome (scroll gets read once known), different latency/interleaving; a VTank session with `LootPriorityBoost` off and a full combat queue could sit on a picked-up scroll far longer than MossTank ever would. |
| — | **`cLootItemRule.pri`/`Priority` has no effect in VTClassic's own classifier — but VTank's item-tracker DOES have a real per-item pickup-priority field.** | `cLootRules.Classify` never reads `Priority()` (§2.1) — dead weight from VTClassic's own read/write perspective. VTank's item-tracker declares a priority field (`hv.a.e`) but **never writes it** (§3.5) — the "highest-priority" scan degenerates to dictionary-enumeration order, so VTank has no effective pickup priority. | `LootController.ContinueCurrentCorpse`'s pickup-order selection (`Looting.cs:764-769`) sorts already-decided candidates by `Decision.Priority` (descending) then rule index — the SAME shape as `hv.a.e`-based selection. | Low — MossTank's `Decision.Priority` ordering is a MossTank-side addition with no live VTank counterpart. Resolved: `hv.a.e` is dead (§3.5). |
Not gaps (verified faithful, listed so a future pass doesn't re-litigate
them): the entire `LootOnlyRareCorpses`/`LootFellowCorpses`/`LootAllCorpses`
ownership-gate cascade (`CanLoot`, `Looting.cs:1191-1230`) matches
`fo.a`'s ownership branch (`fo.cs:403-434`) line-for-line (one benign
difference: VTank compares killer and fellow names case-SENSITIVELY,
`fo.cs:407,417`; MossTank uses `OrdinalIgnoreCase`), including the
100-second thresholds and the "share loot" fellow-member flag; the
`BlacklistCorpseOpenAttemptCount`/`TimeoutSeconds` defaults (30/200) and the
`CorpseItemAppearanceTimeoutSeconds`/`CorpseItemIdentifyTimeoutSeconds`
defaults (6/60, matching `defaultsettings.usd`'s `CorpseItemAppearanceTimeoutSeconds`/
`CorpseItemIDTimeoutSeconds` exactly) are exact; the `.utl` v0/v1
read/write grammar (§1) round-trips byte-for-byte per its own test suite;
the `CalcedBuffedTinkedDamage`/`CanReachTarget` tink-simulation control
flow is an exact port of `ComputedItemInfo`'s loop structure and constants
(including the `0.9`/`0.2`/`0.8` damage-over-time and variance-decay
constants).
---
## 5. Could not determine
- **`fo.a`'s dual meaning of the `e` (rare) field** (`fo.cs:12`, set both
at `fo.cs:209` for a genuine "Killed by X...Generated..." match and at
`fo.cs:244` for NO "Killed by X." match at all) — whether these two
populations are actually disjoint on a live server (i.e. whether an
ordinary solo-killed monster corpse's inspect text ever lacks a
"Killed by X." clause, and so falls into the same `e=true` bucket as a
genuine rare) could not be determined from static code alone; would need
a live packet/cdb trace of an ordinary (non-rare) corpse's
`StringValueKey 16` text. This directly affects how literally to read
§4 item 2 (VTank preferring "rare" corpses) — the practical rare-corpse
population it actually prefers may be broader than "wear the retail rare
drop message" alone.
- **`id.a.j` — RESOLVED** (citation pass 2026-09-06): `id.cs:221` populates it
directly from the fellowship message's named field
(`a10.j = A_0.Value<int>("shareLoot") != 0;`, alongside `name`/`level`/
`maxHealth`, `id.cs:212-221`; field declared at `id.cs:30`). MossTank's
`member.ShareLoot` mapping (`Looting.cs:1224`) is confirmed correct.
- **`f0.c` enum members — RESOLVED** (citation pass 2026-09-06): `f0.c` is the
ID-request category. `a` = the "Set default profile" inventory-scan sweep
(`da.cs:577,581-583,594-599`), `b` = corpse-item ID (`hv.cs:260,293,306`),
`c` = corpse ID (`fo.cs:177`), `d` = door ID for the `OpenDoors` rule,
range-gated by `DoorIDRange` (`b7.cs:99-104`).
- **The full write/reset surface of `dz.o.s`/`dz.o.t`/`dz.o.c` and similar
single-letter bookkeeping fields** on the large (~1900+ line) `ga`/`s.o`
class, which covers combat, nav, and loot state together — only the
specific call sites this doc cites were confirmed; there may be
additional gating logic on these flags elsewhere in `ga.cs` not surfaced
here.
- **`dz.o.h` consumers — RESOLVED** (citation pass 2026-09-06): no internal
rule reads it. Its only readers are two external-plugin API surfaces gated
on `eExternalsPermissionLevel.FullUnderlying``GetByAction(eLootAction)`
(`uTank2/PluginCore.cs:274-278``PluginCore.a(eLootAction)` at
`:2752-2776`, which also prunes ids no longer in inventory) and
`GetCustomLootActionItems(eLootAction)` (`uTank2/PluginCore.cs:466-478`).
Writes: `hv.cs:184` (add), `hv.cs:73-75` (remove on release). It is a
bookkeeping + third-party query table, NOT a vendor-sell queue, so
MossTank's explicit `_sellPendingItem`/`ContinueSell` staging is a
confirmed MossTank-side addition with no VTank counterpart.
- **The exact semantic of network message type `63408`, event `34`**
treated here as "corpse closed/emptied" by contextual inference (it
resets the same state as event 82 / a failed event-406), not
independently confirmed against a protocol reference.
- **What `bz`/`ca`/`eb`** (the sibling target-provider classes to `fg`,
used respectively for route navigation and monster approach, §3.3) each
compute exactly — their own source was not read; only their call sites
and friendly-name strings were confirmed.
- **Exact chat text VTank posts on a successful loot/pickup** (as opposed
to the confirmed blacklist/abandon lines at `fo.cs:349`, `hv.cs:320`,
`hv.cs:338`, `el.cs:189`) — not traced; three distinct chat-output
helpers were identified (`PluginCore.a(string)` for user-facing lines,
the deduplicating top-level `ah.a(string)` for one-time warnings, and
`ga.a(string,eLogState)`/`dz.o.a(...)` for gated debug tracing), but no
"you loot X" style success message was independently located among them.
- **Whether MossTank's lack of an explicit corpse-close action (§4's
ranked item 6) produces any observable retail-visible difference** —
depends on whether ACE/retail auto-closes a corpse's container view once
its contents are all removed, which is outside this doc's scope
(wire/server behavior, not the VTank/`.utl` oracle).

View file

@ -0,0 +1,591 @@
# VTank KB 06 — Navigation and `.nav`
Oracle: `refs/vtank/decompiled/` (ILSpy of `utank2-i.dll`, obfuscated identifiers,
intact strings/settings). All citations are `file:line` against that tree
unless marked otherwise. Decompiled source is never pasted verbatim; every
value/behavior below was read directly from the cited lines. Real sample
files were read from `C:\Games\VirindiPlugins\VirindiTank\*.nav` (read-only,
526 `.nav` files, among 1,594 files of all types in that directory) to produce the worked decodes in §1.
Prior art consumed before writing this: `refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md`
(pins the rule engine `cLogic.cs`, the `g8`/`fd` approach machinery, the FCM
saga, and the 240 m/unit inference). This doc extends that trace into the
`.nav` file format, waypoint execution, movement/steering, door/lockpick,
follow, and priority wiring, then compares against the acdream port.
## 0. Class map (obfuscated name → role)
| Class | Role |
|---|---|
| `uTank2.x` (`x.cs`) | The nav **route** object: type (`eNavType`), waypoint list, filename I/O, mutators. Owns the `"uTank2 NAV 1.2"` format. |
| `uTank2.eWaypointType` / `uTank2.eNavType` | The two public enums (waypoint kind, route kind). |
| `bz` (`bz.cs`) + `g3` (`g3.cs`) | The waypoint contract: `bz` = position/distance/arrival/type/bonus/label/reset; `g3` = `TextReader`/`TextWriter` (de)serialization. Every waypoint class implements both. |
| `at` (`at.cs`) | Abstract base for the five "action" waypoint kinds (Pause, ChatCommand, OpenVendor, Portal2, UseNPC). Provides the run-once arrival gate (`p()`) and reports the **player's live position** as the node's own coordinate. |
| `fn`,`fq`,`a4`,`f4`,`e`,`v`,`e9`,`fa`,`gr`,`di` | The ten waypoint implementations (Point, Portal, Recall, Pause, ChatCommand, OpenVendor, Portal2, UseNPC, Checkpoint, Jump — same order as `eWaypointType`). |
| `gl` (`gl.cs`) | The single "follow this object" node used by `eNavType.Target` routes (VTank's Follow/Object-PF feature). |
| `ca` (`ca.cs`) | The **cycle-advance** driver: implements `bz` over the *whole route*, dispatches Circular/Linear/Once/Target semantics, and is itself handed to the mover as "the current thing to walk toward." |
| `fd` (`fd.cs`) | The **close-in mover**: per-tick steering/turning/creep state machine, reused for nav routes, corpse approach, and combat approach. |
| `g8` (`g8.cs`) | `ILogicRule` wrapper around one `fd` instance — the "Navigate" rule (also reused for corpse-approach and target-approach rules under different names/settings). |
| `b7` (`b7.cs`) | `ILogicRule` for the "OpenDoor" rule — door identify/open/lockpick, fully separate from `g8`/`fd`. |
| `eb` (`eb.cs`) | The `bz` used by the **combat** target-approach `g8` instance — same interface as nav waypoints, different backing data (current attack target). |
| `da` (`da.cs`) | Per-character macro-profile container; owns the `x` route instance (`da.k`), the `.nav` filename (`da.n`), and save/load (`da.r()`/`da.o()`). |
| `bi` (`bi.cs`) | The Jump waypoint's execution state machine (turn-to-heading → charge → release → wait-for-landing). |
| `d` (`d.cs`) | A lightweight EW/NS/Z coordinate+distance helper, independent of `sCoord`, used by Portal2/UseNPC/Checkpoint for "compass" positions and distance math. |
## 1. `.nav` format, byte-for-byte
### 1.1 Header and route-level fields
Format constant and reader/writer: `x.cs:9` (`"uTank2 NAV 1.2"`), reader
`x.cs:200-322` (`a(TextReader)`), writer `x.cs:324-360` (`a(TextWriter)`).
Every field is one line (`TextWriter.WriteLine`/`TextReader.ReadLine`),
`CultureInfo.InvariantCulture` throughout (`x.cs:209,227,241-243`), no
delimiter escaping — a `ChatCommand` payload containing an embedded newline
would desync the reader (not accounted for in the ground truth).
| Line(s) | Field | Values | Cite |
|---|---|---|---|
| 1 | Header | literal `uTank2 NAV 1.2` — mismatch → load refused, error to chat | `x.cs:9,204-208` |
| 2 | Route type (int) | `1`=Circular, `2`=Linear, `3`=Target, `4`=Once | `x.cs:210-223` |
| **If Target (3):** | | | |
| 3 | Follow target name (string) | display-only, not used to re-find the object | `x.cs:226` |
| 4 | Follow target object id (int) | the live game GUID to follow; `0` = no target added | `x.cs:227-231` |
| **Else (Circular/Linear/Once):** | | | |
| 3 | Waypoint count (int) | | `x.cs:235` |
| 4..N | One record per waypoint (§1.2) | | `x.cs:236-320` |
Writer mirrors this exactly (`x.cs:326-359`); there is no version field beyond
the header string itself — VTank has shipped exactly one `.nav` format
revision (1.2) as far as this decompile shows.
### 1.2 Per-waypoint record
Every waypoint, regardless of type, starts with a **fixed 5-line header**
(`x.cs:238-245` read / `x.cs:353-357` write):
| Line | Field | Notes |
|---|---|---|
| 1 | Waypoint-type int (0-9) | dispatches the `switch` at `x.cs:246-318`; matches `eWaypointType` numerically |
| 2 | East/West (double) | |
| 3 | North/South (double) | |
| 4 | Elevation/Z (double) | |
| 5 | **Discarded** — writer always emits literal `0.0` (`x.cs:357`); reader reads and throws the line away (`x.cs:245`) | Not `sWaypointDesc.bonus`; that field exists in the struct (`uTank2/sWaypointDesc.cs:7`) but is never populated from this stream. Dead placeholder in the shipped format. |
**Critical quirk — the header x/y/z triple is meaningless for five of the ten
waypoint types.** Point (`fn`), Portal (`fq`), Recall (`a4`), Checkpoint
(`gr`), and Jump (`di`) store/report a real, load-bearing coordinate here.
Pause (`f4`), ChatCommand (`e`), OpenVendor (`v`), Portal2 (`e9`), and UseNPC
(`fa`) all extend `at`, whose position accessor is
`f9.a(this.c.ay.CharacterFilter.Id, this.c.az.Actions)` — **the player's own
live position** (`at.cs:19-23`) — not a stored waypoint location. Because the
writer calls `item.e()` (`bz.e()`) uniformly for every node
(`x.cs:354-356`), the header x/y/z written for those five types is simply
wherever the character was standing at the moment "Save" was clicked, and is
never read back into anything meaningful. Confirmed against three real
files (§1.4): every Pause/ChatCommand node in a route shares the exact same
x/y/z as its neighbors, because they were all saved from one stationary
position.
Per-type payload (after the 5-line header), reader `x.cs:246-318`, writer
`x.cs:353-358` (dispatches to `item.f(TextWriter)`):
| Type | Class | Extra fields (in order) | Cite (read / write) |
|---|---|---|---|
| 0 Point | `fn` | *(none)* | `fn.cs:35-53` (both no-ops) |
| 1 Portal | `fq` | int: portal-use object id | `fq.cs:47-61` |
| 2 Recall | `a4` | int: recall spell id | `a4.cs:48-62` |
| 3 Pause | `f4` | int: duration, milliseconds | `f4.cs:24-38` |
| 4 ChatCommand | `e` | string: the literal command line | `e.cs:23-37` |
| 5 OpenVendor | `v` | int: vendor object id, then string: vendor name | `v.cs:28-44` |
| 6 Portal2 (`PortalByName`) | `e9` | string: object name; int: `ObjectClass`; then a `d`-record: bool "valid" + double EW + double NS + double Elev | `e9.cs:169-187` (delegates the last 4 lines to `d.a(TextReader/TextWriter)`, `d.cs:185-200`) |
| 7 UseNPC | `fa` | string: NPC name; int: `ObjectClass`; then the same `d`-record (bool+3 doubles) | `fa.cs:134-152` |
| 8 Checkpoint | `gr` | *(none)* | `gr.cs:40-58` (both no-ops — the header x/y/z **is** the real, load-bearing checkpoint coordinate here, unlike types 3-5) |
| 9 Jump | `di` | double: heading (degrees); bool: "Shift" (walk vs run); one combined line: `charge-ms formatted "0.0000" + direction digit` where digit ∈ {`3`=Forward,`4`=StrafeLeft,`5`=StrafeRight}, parsed by regex `^(?<mspart>[0-9]+\.[0-9]{4})(?<mvpart>3|4|5)$` (falls back to plain-double + Forward if the regex doesn't match, e.g. an old file predating the direction suffix) | `di.cs:116-172,180-183` |
Types 6/7's embedded `d`-record is the **real** target coordinate (matched
against live world objects with a name+class+proximity search — §2); the
outer header x/y/z for those two types is exactly as meaningless as it is
for 3/4/5, since `e9`/`fa` also extend `at`.
### 1.3 String encoding, coordinate units
- Every string field is one raw line via `TextWriter.WriteLine`/`ReadLine`
effectively the platform default text encoding of a `StreamWriter`
(`x.cs:189`, no explicit `Encoding` passed) with `\r\n` line endings on
Windows. No escaping of embedded newlines.
- Doubles are written with `Convert.ToString(double, InvariantCulture)`
(round-trip "R"-ish default formatting) and parsed with
`Convert.ToDouble(string, InvariantCulture)` — plain decimal, no scientific
notation guard (though .NET will happily parse `E`-notation on read, and a
small elevation value like `2.70833571751912E-05` was observed verbatim in
a real file — see §1.4).
- **Coordinate units are VTank's own EW/NS/Z "sCoord" units, not meters.**
The conversion constant `240.0` (meters per sCoord unit) is confirmed
independently at **four** unrelated call sites, which upgrades this past
a single-line inference:
- `d.cs:46-47` — converts a raw landblock+offset position into EW/NS by
dividing an accumulated offset by `240.0`.
- `uTank2/sCoord.cs:42` — converts a cell's height field with `hf2.k() / 240f`.
- `dz.cs:239` (cited in the prior trace note) — divides `MyRangeMeters` by
`240.0` to compare against an sCoord distance.
- `ch.cs:807,848` — the user-facing expression functions
`coordinatedistancewithz`/`coordinatedistanceflat` literally multiply
the internal `d`-distance by `240.0` to report meters to scripts.
So: **1 sCoord unit = 240 meters is CONFIRMED, not inferred** (superseding
the prior trace note's "one inference from one line" caveat). Worked
examples: `NavCloseStopRange` default `0.00833333333333333` = 1/120 =
**2 m**; `AttackDistance` default `0.0208333333333333` = 1/48 = **5 m**;
`UsePortalDistance` default `0.0166666666666667` = 1/60 = **4 m**; the
Portal2/UseNPC candidate-match radius constant `0.0104166` (`e9.cs:87`,
`fa.cs:82`) = **2.5 m**.
### 1.4 Worked decodes of real files
**`bunny_stuck_jump.nav`** (384 bytes, a hand-authored anti-stuck macro —
notable in its own right, see §3.2):
```
uTank2 NAV 1.2 <- header
4 <- route type 4 = Once
5 <- 5 waypoints
3 <- node 1: type 3 = Pause
59.3058208465576 <- EW (meaningless: at-derived, = player pos at save)
-28.5630541483561 <- NS
0.0500250021616618 <- Elev
0 <- discarded placeholder
2000 <- Pause duration, ms
4 <- node 2: type 4 = ChatCommand
59.3058208465576 / -28.5630541483561 / 0.0500250021616618 / 0 <- same, meaningless
/ub face 270 <- chat text (a third-party plugin's face-heading command)
3 / (coords) / 0 / 2000 <- node 3: Pause 2000 ms
4 / (coords) / 0 / /ub jumpw 250 <- node 4: ChatCommand "/ub jumpw 250"
3 / (coords) / 0 / 2000 <- node 5: Pause 2000 ms
```
Route: Pause 2 s → `/ub face 270` → Pause 2 s → `/ub jumpw 250` → Pause 2 s,
Once (consumes itself). This is direct field evidence for the finding in
§3.2: VTank's native waypoint types 3-9 give an author no built-in
generic-stuck recovery, so real users route around it with ChatCommand
nodes that invoke a *different* plugin's jump command, gated by Pause nodes
for timing. Every waypoint's header coordinate is identical across the
route (confirming the "at" position quirk from §1.2 — this was saved from
one standing position).
**`deathnav.nav`** (284 bytes) — a Point-based example, Once route:
```
uTank2 NAV 1.2
4 <- Once
4 <- 4 waypoints
0 / 58.5531522115072 / 84.7372886339823 / 2.70833571751912E-05 / 0 <- Point 1
0 / 58.5493430455526 / 84.7770081520081 / -0.00038958340883255 / 0 <- Point 2
0 / 58.5451157569885 / 84.821087773641 / -0.00184791684150696 / 0 <- Point 3
3 / 58.8850743492444 / 84.9831307411194 / -0.00372291654348373 / 0 / 10000 <- Pause 10 s
```
Three real, load-bearing Point coordinates (a short walk, e.g. away from a
corpse) followed by a 10-second Pause whose own header coordinate is simply
wherever the character stood after the last Point — consistent with §1.2.
## 2. Route types and waypoint execution semantics
### 2.1 The cycle-advance driver (`ca`, wraps the whole route)
`ca` (`ca.cs`) itself implements `bz` over the **entire route** and is what
`fd` (the mover) is actually given as its target (`cLogic.cs:507,569`:
`new g8(0, "NavCloseStopRange", "NavFarStopRange", new ca(PluginCore.dz))`).
`fd` never sees individual waypoints; each tick it asks `ca` for "current
position" (`ca.i()`, `ca.cs:56-75`) and "current distance" (`ca.n()`,
`ca.cs:84-346`), and `ca.n()` is where all route-type advancement logic
lives.
`ca.n()` walks a **while loop**: as long as the current waypoint's own
distance is below `NavCloseStopRange`, it advances the index (per the rules
below), calls the new waypoint's `.i()` reset hook, and re-checks — so a
tick can silently skip through several already-reached waypoints in one
call (`ca.cs:196-343`). A "still executing" waypoint (its `bz.g()` returns
true — mid-Pause, mid-portal-wait, etc.) is treated as an artificial huge
distance (`999999.0`, `ca.cs:442-447`) so the loop never tries to advance
past it.
| Mode | Enum | Index advance rule | Exhaustion behavior | Cite |
|---|---|---|---|---|
| Circular | `eNavType.Circular` (1) | `index++`, wraps to `0` at the end (or `index--` wrapping to `Count-1` if `o.m` "reverse" is set) | never exhausts | `ca.cs:122-146,196-250` |
| Linear | `eNavType.Linear` (2) | `index++` until the last node, then flips `o.m=true` and starts decrementing back to `0`, flipping again at `0` — a ping-pong | never exhausts (bounces forever) | `ca.cs:147-177,252-306` |
| Once | `eNavType.Once` (4) | always operates on index `0`; on arrival, `RemoveAt(0)`**the route list is mutated/consumed** | when the list empties, distance reports `0.0` (`ca.cs:335`); `PluginCore.PC.m()` (`ca.cs:340`) fires on **any** consumption, not only on exhaustion | `ca.cs:178-190,308-343` |
| Target | `eNavType.Target` (3) | not index-based — single `gl` node tracks a live object id; see §2.3/§4 | position/distance become "invalid" if the target object or the follower's own single node disappear | `ca.cs:58-68,87-97` |
`x.a(bool)` (`x.cs:81-94`) is a route-edit-in-progress flag: while editing
(`true`), waypoint mutations skip the "notify + reset index" side effects
(`x.cs:96-129`); on commit (`false`), the route data (`m.d()`), the UI grid
(`a0.l()`), and the current index (`o.k()`) are all refreshed at once. This
is the batching seam a Route-tab "Apply"/multi-edit UI hangs off of.
The user-visible `NavPrio`/`LootPrio`/`NavLootPrio` hotkeys (`da.cs:803-805`)
and the `NavPriorityBoost`/`LootPriorityBoost` settings are what move the nav
rule earlier in `cLogic`'s list — see §5.
### 2.2 Per-waypoint-type execution semantics
Every waypoint's `bz.g()` (`m()`/`p()` depending on class) answers "is this
node still busy" each tick; `bz.f()` answers "what is my distance"; `bz.e()`
answers "what is my position." The five `at`-derived types share one
run-once gate: `at.p()` (`at.cs:43-61`) calls the subclass's `e()` setup
exactly once, then calls `f()` every tick until `f()` first returns `false`,
at which point the node is **permanently done** for this visit (a later
`o()`/`bz.i()` reset call — issued when the route revisits the node on a
Circular/Linear lap — re-arms it, `at.cs:69-73`).
| Type | Arrival/completion condition | Timeout / retry | Notes |
|---|---|---|---|
| **Point** (`fn`) | `bz.g()` always `false` (never "busy") — distance is a plain live 3D calc via `f9.a()`; the cycle-advance loop treats it as arrived once within `NavCloseStopRange` | none | Simplest type — no other state. `fn.cs:79-89,115-119` |
| **Portal** (`fq`) | `bz.g()` (arrival-flag `m_e`) is normally `true`; goes `false` for exactly one tick right after a `TryingPortal` lock expires without ever having entered portal space, so the route advances past a portal that silently failed to trigger | Inside `UsePortalDistance`, sends `UseItem` once (guarded by `ItemUse` lock, 3 s) then holds a 30 s `TryingPortal` lock while waiting for the client to actually enter portal space | `fq.cs:94-141` |
| **Recall** (`a4`) | `bz.g()` (`m()`) is `true` ("busy") until the caster is confirmed stationary (moved `<0.01` sCoord ≈ 2.4 m since last check) **and** combat mode is forced to Magic via `ga.a(Magic,0,anyItem:true)` (the FCM path from the prior trace note), at which point the spell is cast and the node reports itself no-longer-busy on the *next* tick regardless of cast outcome | none — no cast-failure retry | Distance is a fixed `0.5` sCoord constant (not a real distance), so this node never blocks the cycle-advance "close enough" test on proximity, only on the busy flag. `a4.cs:82-123` |
| **Pause** (`f4`) | `f()` returns `Now < deadline` (busy while the timer hasn't elapsed); `e()` arms the deadline as `Now + durationMs` | none | Pure timer. `f4.cs:70-78` |
| **ChatCommand** (`e`) | `e()` arms a 200 ms guard timer; `f()` stays busy through that guard, then busy again while `Actions.BusyState != 0`, then sends the command via `f9.j(text)` and completes | none (no confirmation the command "worked") | `e.cs:69-87` |
| **OpenVendor** (`v`) | **Does not wait for the vendor window to actually open.** `f()`: if `Actions.VendorId == target` already, stays busy (blocks the route while that exact vendor is open — this only matters on a *later* re-arrival, see below); otherwise, **if** the object resolves and is `ObjectClass.Vendor`, returns `false` (done) after issuing one `UseItem` gated at ≥2 s since the last attempt; a missing object (`v.cs:87-92`) or a non-vendor (`v.cs:93-97`) instead warns and returns `true`, leaving the node busy. On a fresh node visit this fires-and-forgets a single open attempt and the cycle-advance loop treats the node as arrived (distance-when-not-busy is a constant `0.0`, `at.q()`) — **the route advances to the next waypoint whether or not the vendor actually opened.** | 2 s internal retry gate, but only relevant while the node stays busy from a *prior* successful open | `v.cs:76-104`, `at.cs:31-41` |
| **Portal2 / PortalByName** (`e9`) | Two-state machine: state **a** — waits out the `ItemUse` lock, re-finds the target by name+`ObjectClass`+nearest-of-the-`d`-filter match (radius `0.0104166` ≈ 2.5 m, `item.c()==0` gate of undetermined meaning — see §7), sends `UseItem`; state **b** — waits `PluginCore.@do >= startFrame + 2` (a **rendered-frame counter**, not a timer!) then checks the client is out of portal space and ≥`0.0625` sCoord (=15 m) from the pre-portal origin; if too close, warns "came out of portal space too close to the origin point" and restarts state a | none beyond the 2-frame + 15 m re-check loop | `e9.cs:94-143` |
| **UseNPC** (`fa`) | Re-finds the NPC each tick by the same name+class+2.5 m nearest-match; sends one `UseItem`; completes when a `ChatTextInterceptEventArgs` handler observes color-3 text starting with `"{name} tells you, "` **or** color-0 text starting with `"{name} gives you"` | none (waits forever for the chat line) | `fa.cs:65-107,160-177` |
| **Checkpoint** (`gr`) | Blocks (`p()` returns `true`) while within `NavCloseStopRange` of the stored coordinate by the mover's own tracking (`f9.a`) **but** the object-table's independently-tracked position (`fu.z`, via `PluginCore.dz.q.f(PluginCore.dn).z`) disagrees (still `>= NavCloseStopRange` from the target). This is the **only general-purpose stuck/anti-wedge mechanism in the whole nav engine** — see §3.2. | If the two trackers keep disagreeing for **15 s**, presses the forward key (`br.ai`) once, releases it 100 ms later, and bumps the global casting-suspend counter (`ga.h()`/`ga.e()`, the same counter used to pause all other logic rules mid-cast) around that single nudge | `gr.cs:84-131` |
| **Jump** (`di`) | Delegates entirely to the `bi` state machine (§3.3); `e()`/`f()` just start/poll it | none beyond `bi`'s own internal turn/charge/land phases | `di.cs:60-73` |
### 2.3 Target (Follow) route (`gl`)
`eNavType.Target` routes hold exactly one `gl` node tracking a live object
id (`x.a(int,string)`, `x.cs:47-54`). `gl` is the ONLY waypoint type with two
distinct behavior modes gated by the `FollowAroundCorners` setting:
- **Off**: position is simply the target's live position each tick
(`gl.cs:130`), distance is a straight 3D calc (`gl.cs:151`).
- **On**: `gl` records a breadcrumb trail of the target's positions
(deduping points closer than `0.0004` sCoord ≈ 0.1 m apart, `gl.cs:103-106`),
and walks that trail backward from the follower's current position,
discarding any breadcrumb segment already passed within `0.01` sCoord
(≈2.4 m) of the segment **and** within `1.0` sCoord (=240 m) of that breadcrumb point
overall (`gl.cs:98-116`) — i.e. it hands the mover the *nearest un-passed
breadcrumb*, not the target's raw live position, so the follower cuts
corners around the same path the target actually walked rather than
beelining through walls. This runs on a 70 ms-budgeted timer tick
(`gl.cs:67-79`, `d8.a("...", 70.0)`), independent of the main logic-tick
cadence.
## 3. Movement
### 3.1 Autorun vs. held keys, turning, stop ranges (`fd`)
`fd` is a single per-tick steering function (`fd.a()`, `fd.cs:311-386`)
reused by three different `g8` instances (§5): the main nav route
(`NavCloseStopRange`/`NavFarStopRange`), a corpse-approach rule
(`CorpseApproachRange-Min/Max`), and the combat target-approach rule
(`AttackDistance`/`ApproachDistance`, backed by `eb` rather than a waypoint).
Every tick:
1. Compute `num` = target's own reported distance (`bz.f()`), `num2` =
bearing to target (`f9.b(sCoord,sCoord)`), `value` = unsigned-magnitude
heading delta (`f9.b(double,double)`, always the *smaller* of the two
possible arcs, 0-180°).
2. **If the target node itself reports "busy"** (`bz.g()`, e.g. a Pause
counting down, a portal-use in flight, an unconfirmed vendor-open): every
held key and autorun is released and the tick returns immediately — the
character stands still while the waypoint's own action executes
(`fd.cs:322-328`).
3. **Else if `f9.j()`** (wraps `ad.a(PluginCore.dz.az)` — the exact predicate
is not independently confirmed, but its usage pattern strongly suggests
"chat input box has focus," since held movement keys would otherwise type
into an open chat box — see §7): releases strafe keys; if the heading
delta exceeds **4°**, stops all movement and re-issues an absolute
`Actions.FaceHeading()` server command at most once per 0.7 s (a snap-turn,
not a held key); if aligned, engages the **creep** state (§3.1.1)
(`fd.cs:329-347`).
4. **Else** (normal open-world steering): picks one of two strafe keys via
`f9.c(heading, bearing)` (a "nudge and see which way shrinks the error"
test, `f9.cs:1061-1068`) and holds it to gently curve the character while
walking forward, rather than snap-turning; then gates forward movement on
a **two-tier** heading tolerance keyed off distance:
`num > 0.0125` (≈3 m): allow forward unless `|value| > 45°`;
`num ≤ 0.0125`: allow forward unless `|value| > 15°`
(`fd.cs:348-379`). MossTank's `Steer()` reproduces this exact 3 m /
45° / 15° structure.
5. If none of the above (aligned within 4°, any distance): stop strafing,
move forward (`fd.cs:380-385`).
#### 3.1.1 The creep sub-band (SetAutorun vs. held Shift+Forward)
`fd.a(bool,double)` (`fd.cs:112-177`) is the single mover-state toggle,
distinguishing two DIFFERENT movement styles by whether the reported
distance is inside `1.0/160.0` (≈1.5 m), tested by the predicate method
`fd.a(double)` (`fd.cs:103-110`):
- **Inside 1/160 ("creep")**: holds the Shift key (`br.c8`) together with
Forward (`br.ai`) — i.e. a *walking*, not running, forward hold. If the
character is in Peace mode at this point, the mover force-switches combat
mode to Magic via `ga.a(Magic,0,anyItem:true)` (the same FCM saga as
Recall) and warns *"Idle peace selected with low waypoint minimum
distance. Will switch to magic mode."* if `IdlePeaceMode` is set
(`fd.cs:118-139`).
- **Outside 1/160 ("run")**: `SetAutorun(true)` (`fd.cs:154-158`).
Both styles **re-assert themselves every 4 seconds** while active
(`fd.a = TimeSpan.FromSeconds(4.0)` set in the constructor, re-armed at
`fd.cs:151-152,156-157,160-174`) — a periodic keepalive re-press of the held
key or re-call of `SetAutorun(true)`, the closest thing to an anti-desync
mechanism the general mover has (it is not a stuck *detector*, just an
unconditional re-assert). There is no equivalent of VTank's own 1/160 creep
sub-band or the 0.7 s `FaceHeading` snap-turn in MossTank's `Steer()` — see
§6.
### 3.2 Stuck detection / recovery — there is exactly one mechanism
There is **no string "Stuck" or "Unstick" anywhere in the decompiled tree**,
and no generic "player hasn't moved in N seconds, do something" watchdog.
The *only* built-in anti-wedge behavior is the Checkpoint waypoint's 15 s
dual-position-disagreement nudge (§2.2). A **Point** waypoint that the
player physically cannot reach (geometry-wedged, stuck on a rock) has **no**
native recovery at all — the mover will simply hold its steering forever.
This is corroborated by real user data: `bunny_stuck_jump.nav` (§1.4) is a
hand-authored Once route that exists *purely* to unstick the bot, and it
does so by chaining `ChatCommand` nodes into a **different, external**
plugin's jump command (`/ub jumpw 250` — not VTank's own `Jump` waypoint
type), bracketed by `Pause` nodes for timing. In practice, route authors
route around VTank's missing general stuck-recovery by hand, using
ChatCommand + a third-party utility, rather than relying on anything native.
### 3.3 Jump execution (`bi`)
`di.a(TextReader)`/`bi.a(double heading, bool shiftKey, double chargeMs,
bi.d direction)` (`bi.cs:496-516`) drives a small state machine
(`bi.cs:389-545`, states enumerated `a`-`i`):
1. **Turn-to-heading** (if start heading differs by ≥`0.01°` from the target, `bi.cs:78` — a
far tighter constant than `fd`'s general 4° tolerance; the first draft's
"3°" was wrong):
repeatedly calls `Actions.FaceHeading()` every 2000 ms until aligned
(`bi.cs:74-93`).
2. **Charge**: holds Forward (or the configured Strafe-Left/Strafe-Right key,
`bi.a(bi.d)``MovementForward`/`MovementStrafeLeft`/`MovementStrafeRight`,
`bi.cs:431-441`) plus optional Shift, plus the Jump key, for the
configured duration **clamped to at most 2000 ms**
(`bi.cs:502-504,524-526`) — a real, verifiable ceiling MossTank does not
visibly reproduce (see §6). Aborts mid-charge if `Actions.ChatState`
becomes true (`bi.cs:162-166`).
3. **Release**: releases held keys in order (jump, strafe/forward, shift)
with the state machine tracking which keys are actually down
(`bi.j.a/b/c` flags, `bi.cs:167-218`).
4. **Wait for landing**: polls up to a **15 s** timeout comparing the
captured pre-release position (`d`-record) against the live position
with a `1/120` sCoord (2 m) settle threshold (`bi.cs:358-362`; the 15 s timeout itself is armed at
`bi.cs:344`);
`PluginCore.dz.aw.d()` (the `bi.d()` "is busy" accessor) reports
completion.
## 4. Follow/stick and target approach
VTank reuses **one** mover class (`fd`) and **one** waypoint contract
(`bz`) for three conceptually different "walk toward a moving/fixed thing"
situations, each wired through its own `g8` instance in `cLogic.cs`:
| Situation | `g8` settings (min/max range) | `bz` backing | Cite |
|---|---|---|---|
| Nav route (Point et al.) | `NavCloseStopRange`/`NavFarStopRange` | `ca` (the whole route) | `cLogic.cs:507,569` |
| Nav route, **Target** mode (Follow) | same range settings | `gl` (single object-follow node, §2.3) | `x.cs:47-54`, `gl.cs` |
| Corpse approach (loot) | `CorpseApproachRange-Min/Max` | `fg` (not read in this pass — a corpse-specific advancer) | `cLogic.cs:492,535` |
| Combat target approach | `AttackDistance`/`ApproachDistance` | `eb` (tracks `PluginCore.dz.p.a.b`, the active combat target's live position) | `cLogic.cs:559`, `eb.cs:55-79` |
`eb` is deliberately simple relative to `gl` — a straight 2D/3D distance and
bearing to the current combat target's live position, no breadcrumb/corner
logic, no busy flag (`m()` is always `false`) — because in combat the
target is expected to be visible with a mostly-open line of movement, unlike
a nav Follow target that may be walking around a building. Both share `fd`
for the actual steering, which is the real "shared with combat" seam: **the
turning/creep/autorun machinery in §3.1 is identical code for nav-following
and combat-approaching** — only the position/distance provider differs.
## 5. Priority and interaction with other rules
### 5.1 Full `cLogic` rule order (`cLogic.cs:459-577`)
```
START
SpellCompMin-Critical
(critical spell components)
Recharge-Norm-*
BuffSelf (a0)
fz: normal rebuff-timer buff
SpellCompMin-Normal
POSTBUFF
DispelSelf (c8) / UseDispelItem (cx)
Recharge-Helper-* (fb, gu)
DispelAllies (af)
CraftFood (a9)
RefillPetCharges (dq, Normal)
POSTHELPER
Autofellow (g5)
POSTAUTOFELLOW
OpenDoor (b7) <-- door/lockpick, BEFORE all loot/nav/attack
PREPRIORITYLOOTACTIONS
priority loot actions (er/aj/ar, gated EnableLooting+LootPriorityBoost)
POSTPRIORITYLOOTACTIONS
PREPRIORITYLOOT
priority corpse-approach (g8, CorpseApproachRange, gated LootPriorityBoost)
priority salvage/loot (bj/d0/a1)
POSTPRIORITYLOOT
PREPRIORITYNAV
priority nav route (g8 "mr", NavCloseStopRange/NavFarStopRange,
gated NavPriorityBoost) <-- toggled by /vt nav priority equivalent
POSTPRIORITYNAV
PREATTACK
Attack (b4)
POSTATTACK
PREIDLESTATUS
idle spell comps / craft / pet refill (idle variants)
PREIDLELOOTACTIONS
idle loot actions (+ cm(0) IdlePeace pre-chain)
POSTIDLELOOTACTIONS
PREIDLELOOT
idle corpse approach (g8, CorpseApproachRange, + cm(0) pre-chain gated
on the SAME proximity-band custom delegate as the
mover itself)
idle salvage/loot (+ cm(0))
POSTIDLELOOT
PREIDLEBUFF
idle buff top-off (fz, gated IdleBuffTopoff)
POSTIDLEBUFF
PRETARGETAPPROACH
target approach (g8 "g11", AttackDistance/ApproachDistance, + cm(0) pre-chain)
POSTTARGETAPPROACH
PREIDLERECHARGE
Recharge-NoTarg-*
POSTIDLERECHARGE
PRENAVROUTE
main nav route (g8 "g12", NavCloseStopRange/NavFarStopRange, + cm(0)
pre-chain gated on the mover's own proximity-band delegate)
POSTNAVROUTE
ba (unidentified, low priority)
END
IdlePeace standalone (cm) <-- last resort: drop to peace if nothing else claimed it
```
Rule dispatch is **first-match-wins in list order** (`cLogic.cs:222-235`) —
"Priority" on `ILogicRule` is purely an index, not a numeric comparison.
This means the ONLY way navigation runs earlier than loot/attack is the
duplicate, `NavPriorityBoost`-gated `g8` instance placed physically earlier
in the list (`PREPRIORITYNAV`) — there is no dynamic re-sorting.
### 5.2 IdlePeace pre-chain and interruption
`cm` (IdlePeace) is registered at **six sites** (eight `new cm(...)`
instantiations: `cLogic.cs:530,531,532,540,548,560,570,577`): once as a `LogicRulePreChain`
pre-action on ReadScroll/StackCram/Salvage idle rules, idle corpse approach,
idle loot, target approach, and the main nav route, plus once standalone at
the very end (`cLogic.cs:530-577`, corroborated by the prior trace note).
Pre-chain semantics: when the *main* rule (e.g. main nav route) is picked as
the tick's winner, its pre-action (`cm`) runs **instead**, for that one
tick, if `cm` itself is currently valid; the following tick, `cm` is no
longer valid (already at peace) so the main rule finally runs — i.e. "go to
peace first, then act," spread over two ticks minimum. `cm`'s own gate is
just `IdlePeaceMode` setting + `CombatMode != Peace`
(prior trace note, `cm.cs:66,70`); everything else suppressing it is
**structural** — it only runs where explicitly chained ahead of an idle rule.
Combat/casting suspends the *entire* rule loop globally
(`ga.u` counter, `cLogic.cs:214`, incremented/decremented at `gj.cs:211/226`
and `gj.cs:198` respectively) — this is the same counter Checkpoint's 15 s
stuck-nudge bumps around its single key-press (§2.2), meaning a Checkpoint
nudge briefly pauses buffing/looting/attacking too.
Loot and combat interrupt/resume nav implicitly through list order and
locks, not through any nav-specific pause flag: `OpenDoor` (b7) takes
`ActionLockType.Navigation`/`ItemUse`/`DoorOpening` locks while opening a
door (`b7.cs:106,113-128,213-224`), and `g8.b()` (the Navigate rule's
`ValidNow`) explicitly refuses to run while `Navigation`,
`SpreadLockTargetRequested`, or `DoorOpening` locks are held
(`g8.cs:91-103`) — so door-opening always wins over the mover for as long
as its locks are held, and a higher-priority loot/attack rule simply never
lets control reach the nav-route rule that tick (first-match-wins).
## 6. MossTank gap analysis
Files reviewed: `src/AcDream.Plugins.MossTank/Navigation.cs`,
`VtankNavRouteSerializer.cs`, `MossTankRouteProfileStore.cs`, and their
tests (`tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs`,
`VtankNavRouteSerializerTests.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, 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):
| # | Gap | Ground truth | acdream (`Navigation.cs`) | Impact |
|---|---|---|---|---|
| 1 | **OpenVendor does not wait for confirmation in VTank, but MossTank does.** | `v.f()` fires one `UseItem` and reports itself done on the very first tick regardless of outcome (§2.2) — the route advances immediately, vendor-open success or not. | `TickUse`'s OpenVendor path waits for `ActiveVendorObjectId == waypoint.ObjectId`, retrying every 2 s up to a 30 s timeout (`Navigation.cs:730-736,805-826`). | **High** — behaviorally the single biggest divergence found: real VTank routes with an OpenVendor node effectively "fire and forget," while acdream's port will block the whole route for up to 30 s if the vendor never opens (e.g. NPC out of range, wrong id). Likely a deliberate improvement, but it is not the same routine and should be a conscious, documented choice per this project's "no workarounds/redesigns without flagging the tradeoff" rule. |
| 2 | **Lockpick selection strategy differs.** | `b7.a()` (`b7.cs:52-81`) only considers items the user has explicitly tagged in the "AssistItems" list as `fs.k`, and picks the tagged item with the **lowest** remaining uses (`bc.aa`) — i.e. a manually-curated list, consumed lowest-charge-first. | `SelectLockpick` (`Navigation.cs:515-549`) scans **all** owned items carrying a `LockpickPublicFlag` bit (auto-detected by item data, no user list) and picks the **highest**-bonus item (`LockpickModifierProperty`). | **Medium-high** — opposite selection heuristic (auto-detect + best-tool vs curated-list + use-up-worst-first) means a different physical item gets consumed first, and acdream needs no manual "tag this as a lockpick" step VTank required. Not a bug per se, but a real, evidenced behavioral difference worth a deliberate call-out. |
| 3 | **No creep sub-band or discrete snap-turn.** | Inside 1/160 sCoord (≈1.5 m), `fd` switches from held-key strafing to a walk-speed Shift+Forward creep, with periodic (0.7 s) absolute `FaceHeading()` snap-turns when misaligned, and force-switches combat mode to Magic if in Peace at this range (§3.1.1). | `NavigationController.Steer()` (`Navigation.cs:611-635`) has exactly one steering mode: continuous held-key `TurnLeft`/`TurnRight`/`Forward` with `Run: true` always set, for every distance band. No creep, no FaceHeading, no combat-mode interaction. | **Medium** — likely an intentional simplification (acdream's movement primitive may not need VTank's turning workaround), but it means acdream never walks (vs. runs) on final approach and never forces Magic mode near a tight waypoint, which was one of VTank's real, user-visible quirks (the "Idle peace selected with low waypoint minimum distance" warning has no acdream analogue at all). |
| 4 | **Recall's "must be stationary" + forced Magic-mode gate is not visibly reproduced.** | `a4.m()` refuses to cast until the caster has been stationary (movement `<0.01` sCoord ≈2.4 m) since the last check, and force-switches combat mode to Magic via the FCM sequence before casting (§2.2). | `TickRecall`/`SubmitRecall` (`Navigation.cs:850-914`) call `Automation.Magic.Cast(...)` directly with no visible stationary check or explicit combat-mode sequencing in this file. | **Medium** — could not determine whether `Automation.Magic.Cast` internally handles combat-mode sequencing at a lower plugin-abstraction layer (out of scope for this file); if it does not, casting Recall while still moving, or from Melee/Missile mode, would diverge from ground truth. |
| 5 | **Jump-charge duration is not clamped to VTank's 2000 ms ceiling.** | `bi.a(...)` clamps any requested charge duration to at most 2000 ms (`bi.cs:502-504,524-526`) — this appears to be a deliberate ceiling on how long the jump key can be held. | `RouteWaypoint.JumpChargeMilliseconds` is clamped to `[0, 10_000]` on load (`MossTankRouteProfileStore.cs:428-431`) and not further bounded in `TickJump`. | **Low-medium** — a route (VTank-authored or hand-edited) requesting >2000 ms would charge a jump far longer in acdream than real VTank/retail ever would; low likelihood in practice since real `.nav` files were themselves produced under the 2000 ms VTank ceiling, but a hand-edited or generated route could exceed it. |
| 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):
the exact 3 m/45°/15° steering tolerance tiers (§3.1 point 4); the
Checkpoint 15 s stuck-nudge threshold (`CheckpointRetrySeconds = 15d`
exactly matches `gr.cs`'s 15-second gate, using an analogous
live-vs-server-confirmed dual-position-source design); the Portal2/UseNPC
2.5 m reacquire radius (exactly `0.0104166 × 240`); the Portal-exit "too
close to origin" 15 m threshold (exactly `0.0625 × 240`); the door
identify/open ranges and lockpick excess-threshold *default numbers*
(20 m / 4 m / `-50`, byte-for-byte matches of `DoorIDRange`/`DoorOpenRange`/
`DoorLockpickDiffExcessThreshold` in `uTank2.Resources.defaultsettings.usd`);
and the Circular/Linear/Once index-advance semantics of §2.1.
## 7. Could not determine
- **`f9.j()` — RESOLVED** (citation pass 2026-09-06): it wraps `ad.a(PluginHost)`
(`ad.cs:153-164`), true when a VirindiViewService `HudTextBox` holds focus
(`ad.cs:166-173`) **or** `Actions.ChatState` is set — "a text-input field
has keyboard focus". The chat-focus guess was right but too narrow.
- **`item.c() == 0` — RESOLVED** (citation pass 2026-09-06): `fu.c()`
(`fu.cs:66-87`) returns property `218103810` (PropertyInstanceId.Container)
else `218103851` (Wielder) else 0, dispatched as `case 2:`/`case 3:` at
`g6.cs:1486-1530`. So `item.c() == 0` means the object is free-standing in
the world — neither in a container nor equipped — exactly the filter that
keeps Portal2/UseNPC from matching inventory items.
- **Exact default for `ApproachDistance`.** The `defaultsettings.usd` text
format's field grouping (`d`/value/`s`/description/`i`/category) was
reverse-engineered from context around `NavCloseStopRange` et al., but the
apparent default read for `ApproachDistance` (`0`) would make the
combat-approach `g8` instance's "in band" test degenerate (`fd.f()` would
treat almost any positive distance as "outside band"), which doesn't match
the feature clearly being functional in practice. Either the pairing was
misread for this one key, or `ApproachDistance` is normally set by the
user/character-file rather than left at its shipped default. Not resolved.
- **`PluginCore.PC.m()`** — fired when an `Once` route empties itself
(`ca.cs:340`). Presumably a "route complete"/UI-refresh notification; not
traced to its definition.
- **`ba` (`cLogic.cs:575`)** — a rule registered at the very end of the
main list, after `POSTNAVROUTE`, before the closing `END` sentinel and the
standalone `IdlePeace`. Not opened in this pass; low priority since it
sits after every rule this doc concerns itself with.
- **Exact left/right assignment of the `br.ah`/`br.aj` strafe-key pair**
(`fd.cs:350-359`) — confirmed *that* one of the pair is held based on
`f9.c()`'s sign, not confirmed *which enum member maps to which physical
strafe direction*, since `br` (`br.cs`) is a plain sequential int enum
with no name-to-action lookup table in the files read for this pass.

View file

@ -0,0 +1,787 @@
# VTank KB 07 — Meta and Expressions
Oracle: `refs/vtank/decompiled/` (obfuscated VTank 2.x decompile via ILSpy;
identifiers are single/double-letter, strings and method *shapes* are
intact). All citations are `file:line` against that tree. No decompiled
source is pasted verbatim below — every claim is paraphrased/tabulated and
cited so it can be checked against the file directly. Real sample `.met`
files under `C:\Games\VirindiPlugins\VirindiTank\*.met` were read (read-only)
to confirm the on-disk shape described here.
Cross-referenced against `docs/research/2026-07-29-vtank-plugin-automation-requirements.md`
(prior `.met`-structure pass) and
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` (UtilityBelt's
expression dialect). Disagreements are called out inline as **DISAGREES**. Two
found: the 2026-07-29 doc's "67 functions" count for retail VTank (this doc's
exact `[Expr_FunctionName]` attribute count is **74** — 17 base + 57 `ch`, §3.7; both prior figures were low, so this is "both undercounted", not a 67-vs-66 conflict); and the
2026-08-26 doc's UtilityBelt grammar audit states `;` "return[s] the final
result" (§3.2 there) where this doc's direct read of retail's own
`ExpressionEvaluator.cs:787-790` shows retail **VTank** returns the *first*
operand and discards the rest (§3.3) — i.e. UtilityBelt itself already
diverges from retail here, and MossTank correctly followed UtilityBelt's
(different) convention rather than retail's; see §5.2 gap 4.
Comparison target: `src/AcDream.Plugins.MossTank/` (Meta.cs, MetaViewManager.cs,
MossTankMetaProfileStore.cs, VtankMetaProfileSerializer.cs, Expressions/*.cs)
at the tree checked out in worktree `eloquent-hugle-42119e`.
---
## 0. Class-name key
| Obfuscated | Role | Notes |
|---|---|---|
| `hn` (field `dz.at`) | `MetaMgr` — the Meta subsystem owner: state→rule-list map, per-pass fired-set, state-entry clocks, call stack, expression evaluator instance, `.met` load/save | `hn.cs` (whole file) |
| `ch` (field `hn.k`, i.e. `dz.at.k`) | `MetaExpressionEvaluator` — the concrete `ExpressionEvaluator<byte>` subclass registering every VTank-specific function/variable | `ch.cs` (1743 lines) |
| `ExpressionEvaluator<W>` | The generic tokenizer/shunting-yard/eval engine (kept its class name after decompile) | `MyClasses/ExpressionEvaluator.cs` |
| `a7` | The per-pass chat-message buffer + three one-shot edge flags (death/portal-enter/portal-exit) | `a7.cs` |
| `h7` | The watchdog timer (10-sample position ring + expiry test) | `h7.cs` |
| `h` (enum) | `CondType` — 29 members `a..z,aa,ab,ac`; **`ab` is never registered** (a real gap in retail's own factory table) | `h.cs` |
| `bp` (enum) | `ActionType` — 16 members `a..p`; all 16 registered | `bp.cs` |
| `d9` | The condition/action factory: `Dictionary<h,az>` and `Dictionary<bp,ho>` mapping type id → prototype instance, `Create<T>(int)` news up a fresh one by id | `d9.cs` |
| `az` (interface) | Base condition contract: `h a()` (its own type id), `bool b()` (ValidNow) | `az.cs` |
| `ho` (interface) | Base action contract: `bp f()` (its own type id), `bool h()` (Execute — return meaning is **continue-pass** semantics, see §2) | `ho.cs` |
| `bx` (interface) | Shared base of `az`/`ho`: `g()` display type-name, `h(gy)`/`i()` deserialize/serialize, `j(object)` UI-build, `c()` referenced-state-name list (for the state-usage counter), `d()`/`a(bx)` parent link, `e()` UI-refresh cascade, `k()` display string | `bx.cs` |
| `ax` | One compiled rule: `{ az Condition, ho Action, string State }` | `ax.cs` |
| `MultipleBase<T>` (`uTank2.Meta` namespace) | Shared container base for compound condition/action lists (`All`/`Any` conditions, `All` action) | `uTank2.Meta/MultipleBase.cs` |
| `dj` | Shared base for the common "N named typed parameters" leaf condition/action shape | `dj.cs` |
| `dh` | Abstract `bx` base providing the parent-link/refresh plumbing; declares `i()`/`h(gy)` **abstract**, so it imposes no on-disk shape. Subclasses span every shape: zero-arg (`ct`,`do`,`gk`,`cy`,`i`,`co`,`ao`), K/V table (`ea`), `f6` blob (`ff`), scalar int (`ge`,`hy`,`gw`), and the `q``dj` form bases (citation pass 2026-09-06) | `dh.cs:5-14` |
| `q` | Shared N-row UI-form base (`q : dh`); **`dj` derives from it**. Direct subclasses: `c1`, `gc`, `e3` (bare-int scalar on disk) and `fm` (hand-rolled `bd("k","v")`). `ge`/`hy` extend `dh` directly, not `q` | `q.cs:8`, `dj.cs` |
| `bw` | The Meta-view registry: `Dictionary<string,aq>`, the 5-view cap, version gate | `bw.cs` |
| `aq` | One created view: XML parse, size-vs-window clamp, `Dictionary<string,c0>` named-control index | `aq.cs` |
| `c0` | Base view control (left/top/width/height/name attrs) | `c0.cs` |
| `c9` | `Button` view control (`text`, `actionexpr`, `setstate` attrs) | `c9.cs` |
| `au` | `Layout` view control (recursive `<control>` children container) | `au.cs` |
| `d6` | Control-type factory: `"BUTTON"→c9`, `"LAYOUT"→au`, anything else `→null` | `d6.cs` |
| `gy`/`bd`/`cw`/`y` | The generic line-text database primitives (self-typing cell / table / row / whole database) — **format fully documented in doc 01 §1**; this doc only adds the Meta-specific record shapes built on top of it | `gy.cs`, `bd.cs`, `cw.cs`, `y.cs` |
| `f6` | The length-prefixed raw-text blob cell type (tag `"ba"`) — used for embedded nav routes and CreateView XML payloads | `f6.cs` |
---
## 1. The `.met` format exactly as read/written
### 1.1 Container: reuses doc 01's `y`/`bd`/`cw`/`gy` grammar verbatim
A `.met` file **is** a `y`-format database (plain text, CRLF, no compression,
no checksum — doc 01 §1/§3) holding exactly **one table named `CondAct`**
with **5 columns**, all non-indexed (`"n"` flags):
```
CType AType CData AData State
```
— confirmed by `hn.k()` (save, `hn.cs:339-362`): `new bd("CType","AType",
"CData","AData","State")`, one row per rule, `cw2[0]=gy.a((int)Condition
type)`, `cw2[1]=gy.a((int)Action type)`, `cw2[2]=Condition.i()` (the
condition's own serialized `gy`), `cw2[3]=Action.i()`, `cw2[4]=gy.a(stateName)`.
Load is the mirror in `hn.a()` (`hn.cs:364-409`): `d9.a((h)gy.e(cell[0]))`
instantiates the condition by type id, `.h(cell[2])` deserializes it;
same for the action from cell[1]/cell[3]; cell[4] is the state name string.
**Any unsupported/unknown type id (including the real `h.ab` gap) fails the
whole load** — `hn.a()` clears the entire rule table and returns false,
logging `"Meta file load: unsupported cond/act type."` (`hn.cs:382-387`).
A DB-format failure (missing `"CondAct"` key) does the same
(`hn.cs:371-376`).
### 1.2 Worked decode — one real rule from a live `.met`
A representative row from a sample `.met` (`Chat` state, condition
`ChatMessage` matching `"^You have been slain"`, action `SetMetaState` to
`"HandleDeath"`) decodes as, in `gy` line order inside the `CondAct` table:
```
i <- CType tag
4 <- CType value = h.e = ChatMessage
i <- AType tag
1 <- AType value = bp.b = SetMetaState
s <- CData tag (ChatMessage condition = bare string, hl.cs:59-62)
^You have been slain
s <- AData tag (SetMetaState action = bare string, a3.cs:42-45)
HandleDeath
s <- State tag
Chat
```
A compound rule (`Any` condition wrapping two `ChatMessage` children,
`CallMetaState` action) instead nests a **`bd("K","V")` table** for CData
(uppercase key names — the `MultipleBase<T>` shape, `MultipleBase.cs:52-63`):
```
i / 3 <- CType = h.d = Any
i / 5 <- AType = bp.f = CallMetaState
TABLE / 2 / K / V / n / n <- nested bd header: 2 cols, "K","V", no index
2 <- row count = 2 children
i / 4 <- child K = h.e = ChatMessage
s / <pattern1> <- child V = the child condition's own gy (bare string)
i / 4
s / <pattern2>
TABLE / 2 / k / v / n / n <- AData: the CallMetaState action's OWN table (lowercase k/v dj shape, dj.cs:86-95, NOT the MultipleBase shape)
2
s / st / s / <target state>
s / ret / s / <return-to state>
s / <State name>
```
The **case of the K/V column names is load-bearing to reproduce byte-for-byte**
(not merely cosmetic): `MultipleBase<T>` always writes `"K","V"`
(`MultipleBase.cs:54`); every `dj`-based leaf (including the hand-rolled
`cp`/CallMetaState, which is not `dj`-derived but manually builds the same
shape) writes lowercase `"k","v"` (`dj.cs:86`, `cp.cs:61`). `Not` (`ea.cs`)
also uses uppercase `"K","V"` (`ea.cs:72`) with the row count pinned to
exactly 1 (`ea.cs:55`, rejects/ignores anything else on load).
### 1.3 Per-condition record layout (29 declared `h` ids; 28 wired, `h.ab`=27 is a real gap)
| # | `h` | Name (retail UI string) | Class | On-disk `CData` shape | Fields (name→gy type) | Cite |
|---|---|---|---|---|---|---|
| 0 | a | Never | `ic` | bare `gy.a(0)` (tag `i`, value `0`) | — | `ic.cs:9-45` |
| 1 | b | Always | `a6` | bare int 0 | — | (mirror of `ic`) |
| 2 | c | All | `gq` | `bd("K","V")`, N rows | per-child `K`=type id int, `V`=nested gy | `gq.cs:4-34`, `MultipleBase.cs:52-63` |
| 3 | d | Any | `e4` | same as All | — | `e4.cs` |
| 4 | e | Chat Message | `hl` | bare string | the regex pattern | `hl.cs:59-73` |
| 5 | f | Pack Slots <= | `c` | bare int | threshold | `c.cs:51-64` |
| 6 | g | Seconds in state >= | `c1` | bare int | seconds | `c1.cs:32-46` |
| 7 | h | Navroute empty | `ct` | bare int 0 | — | `ct.cs` |
| 8 | i | Character Death | `do` | bare int 0 | — | `do.cs` |
| 9 | j | Any Vendor Open | `gk` | bare int 0 | — | `gk.cs` |
| 10 | k | Vendor Closed | `cy` | bare int 0 | — | `cy.cs` |
| 11 | l | Inventory Item Count <= | `cd` | `dj` table, 2 fields | `n`=string item name (exact match), `c`=int count | `cd.cs:25-31` |
| 12 | m | Inventory Item Count >= | `hs` | same shape as 11 | `n`, `c` | mirror of `cd` |
| 13 | n | Monster Name Count Within Distance | `d7` | `dj` table, 3 fields | `n`=string regex, `c`=int count, `r`=double range (meters) | `d7.cs:57-64` |
| 14 | o | Monster Priority Count Within Distance | `bt` | `dj` table, 3 fields | `p`=int priority, `c`=int count, `r`=double range | `bt.cs:44-51` |
| 15 | p | Need to Buff | `gw` | bare int 0 | — | `gw.cs` |
| 16 | q | No Monsters Within Distance | `h4` | `dj` table, 1 field | `r`=double range (meters) | `h4.cs:9-14` |
| 17 | r | Landblock == | `ge` | bare int | landblock id (masked `&0xFFFF0000` at eval, not at storage) | `ge.cs:54-68` |
| 18 | s | Landcell == | `hy` | bare int | full cell id (no mask) | `hy.cs` (mirror of `ge` minus the mask) |
| 19 | t | Portalspace Entered | `i` | bare int 0 | — | `i.cs` |
| 20 | u | Portalspace Exited | `co` | bare int 0 | — | `co.cs` |
| 21 | v | Not | `ea` | `bd("K","V")`, **exactly 1 row** | `K`=child type id, `V`=nested gy | `ea.cs:53-62` |
| 22 | w | Seconds in state (P) >= | `gc` | bare int | seconds, compared against a **separate, non-macro-reset clock** — see §2.5 | `gc.cs:32-46` |
| 23 | x | Time Left On Spell >= | `fm` | `bd("k","v")` table, 2 rows (hand-rolled — `fm : q`, not `dj`-derived, same wart as `cp`) | `sid`=int spell id, `sec`=int seconds | `fm.cs:61-86` |
| 24 | y | Burden Percentage (eg. 100) | `e3` | bare int | percent threshold | `e3.cs:51-65` |
| 25 | z | Dist any route pt >= | `gf` | `dj` table, 1 field | `dist`=double meters | `gf.cs:7-12` |
| 26 | aa | Expression | `b3` | `dj` table, 1 field | `e`=string expression text | `b3.cs:42-47` |
| 27 | **ab** | **— unregistered —** | — | n/a | n/a | `d9.cs:14-57` (the gap itself at `d9.cs:40-41`, `h.aa``h.ac`) — `h.ab` is never passed to `a(h,az)`; loading this id fails the whole file (§1.1) |
| 28 | ac | Chat Message Capture | `c5` | `dj` table, 2 fields | `p`=string regex pattern, `c`=string semicolon-joined color-id list (empty = any color) | `c5.cs:16-22` |
**Retail UI-name vs. semantic mismatch (a real VTank wart, not a decompiler
artifact):** `gf`/"Dist any route pt >=" is actually a **universal**, not
existential, test — `c()` returns `true` only when **every** route point is
farther than `dist` (it returns `false` early the instant it finds ANY point
within `(epsilon, dist]`, `gf.cs:44-62`). The display name reads as "exists a
point at distance >= X"; the real semantics is "no point is within X" (i.e.
"far from the whole route"), the opposite quantifier from what the name
suggests.
### 1.4 Per-action record layout (16 declared `bp` ids; all 16 wired)
| # | `bp` | Name | Class | On-disk `AData` shape | Fields | Cite |
|---|---|---|---|---|---|---|
| 0 | a | None | `a` | bare int 0 | — | (not independently re-read; parity confirmed via serializer cross-check §5) |
| 1 | b | Set Meta State | `a3` | bare string | target state name | `a3.cs:42-56` |
| 2 | c | Chat Command | `h2` | bare string | verbatim chat text (no expression eval) | `h2.cs:40-58` |
| 3 | d | All | `f` | `bd("K","V")`, N rows | per-child `K`=type id, `V`=nested gy | `f.cs:4-37` |
| 4 | e | Load Embedded Nav Route | `ff` | `f6` blob (tag `ba`): line1=source-name, line2=point-count, remainder=raw serialized nav-route text | — | `ff.cs:31-61` |
| 5 | f | Call Meta State | `cp` | `bd("k","v")`, 2 rows, keyed by string tag not type id | `st`=string state to call, `ret`=string state to return to | `cp.cs:46-71` |
| 6 | g | Return From Call | `ao` | bare int 0 | — | `ao.cs` |
| 7 | h | Expression Action | `dv` | `dj` table, 1 field | `e`=string expression (result discarded) | `dv.cs:20-48` |
| 8 | i | Chat Expression | `n` | `dj` table, 1 field | `e`=string expression (result sent to chatbox) | `n.cs:20-56` |
| 9 | j | Set Watchdog | `f5` | `dj` table, 3 fields | `s`=string state to call, `r`=double range **meters** (stored raw; `/240` conversion happens only at execute time, `f5.cs:53`), `t`=double seconds | `f5.cs:6-13` |
| 10 | k | Clear Watchdog | `ht` | `dj` table, **0 fields** (an empty table, distinct byte shape from the bare-int "no data" encoding — see below) | — | `ht.cs:6-9` |
| 11 | l | Get VT Option | `fl` | `dj` table, 2 fields | `o`=string option name, `v`=string destination variable name | `fl.cs:8-13` |
| 12 | m | Set VT Option | `dt` | `dj` table, 2 fields | `o`=string option name, `v`=string expression to evaluate | `dt.cs:8-13` |
| 13 | n | Create View | `p` | `dj` table, 2 fields | `n`=string view name, `x`=`f6` blob = raw view XML | `p.cs:6-11` |
| 14 | o | Destroy View | `fr` | `dj` table, 1 field | `n`=string view name | `fr.cs:6-10` |
| 15 | p | Destroy All Views | `ag` | `dj` table, **0 fields** | — | `ag.cs:4-8` |
**Two distinct "no parameters" byte encodings exist and must not be
conflated:** the bare-cell zero-arg types (`ic`/`a6`/`ct`/`do`/`gk`/`cy`/`i`/`co`/
`ao`, plus the confirmed `bp.a`="None" action, `a.cs:42-44`; note `ic`, `a6`
and `a` implement `az`/`ho` directly and each spells out `gy.a(0)` itself) serialize as a **bare typed
int cell** — two lines, `"i"` then `"0"` (`ic.cs:42-44``gy.a(0)` resolves
to the `int` overload, not a true "void" `gy`). `dj`-based zero-arg types
(`ht`/`ClearWatchdog`, `ag`/`DestroyAllViews`) instead serialize as a
**`bd("k","v")` table header with a row count of `0`** — a `TABLE` tag,
column spec, and `"0"` — a different byte sequence with the same practical
meaning. A `.met` reader that treats "no data" as one canonical shape will
mis-parse one of these two families.
### 1.5 The embedded-nav-route blob (`ff`, action type 4) in detail
`ff.e()` (save, `ff.cs:52-61`) writes an `f6` blob whose *contents* are three
lines — source nav-profile name (or `"[None]"` if none was selected at
capture time), the route's `.c()` point count, then the **entire raw
serialized nav-route text** written by the nav-route object's own `.a
(TextWriter)` (`ff.cs:105-120` — this is the *same* format as a standalone
`.nav` file, embedded verbatim rather than referenced by path). Loading
(`ff.a(gy)`, `ff.cs:31-44`) reverses this via `StringReader`. `ff.b()`
(Execute, `ff.cs:149-165`) feeds the embedded text back into the active
nav-route object and always returns `true` (does not end the pass by itself;
see §2.2).
---
## 2. FSM semantics
### 2.1 What drives an evaluation pass
Meta is **not** polled on its own timer. It is evaluated once per scheduler
pass (`cLogic.a(MyList<ILogicRule>,MyList<ILogicRule>)`, the same pass that
drives the action-list scheduler documented in KB doc 02), gated on
`PluginCore.dz.o.c` (macro running) and the `EnableMeta` setting:
```
if (PluginCore.dz.o.c && f3.k("EnableMeta")) {
PluginCore.dz.at.h(); // one Meta FSM step
a7.e(); // clear the chat buffer + one-shot edge flags
}
```
`uTank2/cLogic.cs:188-196`. This means Meta's effective cadence is doc
02's scheduler cadence: the 293 ms heartbeat while running, plus the
fast-wake path on spell-cast/attack completion (doc 02 §1.1) — **not** a
fixed per-frame tick, and **not** independent of the main action-list
scheduler.
### 2.2 One pass, `hn.h()`, in full (`hn.cs:41-92`)
1. If the watchdog has expired (`h7.b() == h7.a.a`, §2.6): if the call stack
(`hn.j`, a `Stack<string>`) already holds >= 10000 entries, post
`"Meta Error: Call stack overflow (recursive call loop?)."` +
`"Disabling Meta."` and set `EnableMeta` false (`hn.cs:51-56`); otherwise
post `"Meta watchdog expired!"`, **push the current state** onto the call
stack, and transition to the watchdog's configured target state
(`hn.b(h7.d())`) — this reuses the SAME call/return stack as
`CallMetaState`/`ReturnFromCall` (§2.4), so a state entered via watchdog
expiry can later `ReturnFromCall` back to whatever state was active when
it tripped. This branch is **mutually exclusive** with step 2 below — no
rules are evaluated on a watchdog-triggering pass.
2. Otherwise, walk `hn.d[CurrentState]` (a `List<ax>`, insertion/authoring
order) **top to bottom**. For each rule not yet fired this state-entry
(`!hn.e.ContainsKey(rule)`) whose condition is true:
- Mark it fired (`hn.e[rule] = true`) — a rule fires **at most once per
state entry**, not once per pass; see §2.3.
- Log `"Meta executing action: " + action.k()`.
- Execute the action: `bool flag2 = !action.b.h()`.
- If a meta-profile reload happened *during* that action's execution
(`hn.m_c`, a flag only `hn.d(string)`/profile-load sets true), reset the
flag and **break** immediately regardless of `flag2`.
- Otherwise, **break only if** the current state changed (`text !=
hn.m_g`, where `text` was cached at the top of the pass) **or**
`flag2` is true (the action's own `.h()` returned `false`).
- **If neither**, continue the `foreach` — a **second (and third, …)
rule in the same state CAN fire within the same `hn.h()` call**, as
long as each fired action both keeps `CurrentState` unchanged and
returns `true`.
This is a genuinely different shape from "first rule wins, stop": it is
"walk in order, fire every eligible not-yet-fired rule whose action reports
*not done yet, and no state change*, then stop." **Every state-changing
action in the catalog (`SetMetaState`, `CallMetaState`, `ReturnFromCall`)
already returns `false`** (a3.cs:118, cp.cs:153, ao.cs:84), so in the current
action set the `flag2` check alone would suffice for those three — **except
inside a compound `All` action** (`bp.d`, class `f`), which unconditionally
executes every child via `item.h()` **ignoring each child's return value**,
and itself always returns `true` (`f.cs:30-37`). A `SetMetaState` nested
inside an `All` action still transitions state, but the `All` wrapper keeps
running every subsequent sibling child in the same call — the pass only
actually stops afterward because the *outer* `hn.h()` loop's `text !=
hn.m_g` check catches the state change once the whole `All` action returns.
**Every action listed with "returns true" in §1.4's execute semantics
(`ff`/EmbedNav, `f5`/SetWatchdog, `ht`/ClearWatchdog, `fl`/GetOpt,
`dt`/SetOpt=true-on-success, `p`/CreateView, `fr`/DestroyView, `ag`
/DestroyAllViews, `dv`/ExprAct, `n`/ChatExpression, `h2`/ChatCommand) permits
the pass to keep evaluating later rules in the same state during the same
call** — this is the mechanism that lets one Meta pass, e.g., clear a
watchdog *and* send a chat message *and* still fall through to whichever
rule comes next in list order, all in one `hn.h()` invocation.
### 2.3 Per-state-entry firing, not per-pass firing
`hn.e` (`Dictionary<ax,bool>`) is the fired-set, and it is **only** cleared
on `hn.b(string)` — a state transition (`hn.cs:115-124`, `e.Clear()` at `:117`) — never cleared
mid-state. So a rule that fires once in a state stays "spent" for every
subsequent pass until the FSM leaves and re-enters that state (even the same
state name via `SetMetaState "SameState"` counts as a fresh entry, since
`hn.b` unconditionally clears `hn.e`). This is why a state commonly needs an
explicit `Always`-gated rule near the top if it must re-fire every pass
(`Always`'s own condition just returns `true` every time it's re-checked,
but once it has fired once this state-entry it will not be re-evaluated
again — the *entry*, not the condition, is what the fired-set gates).
### 2.4 Call/return stack
`CallMetaState` (`cp.f()`, `cp.cs:142-154`) pushes the **return-to** state
string (`m_b`, defaulting to `"Default"` if left blank in the UI — not the
*current* state unless explicitly set to it) onto `hn.j`, then transitions to
the **call** state (`m_a`). `ReturnFromCall` (`ao.b()`, `ao.cs:73-85`) pops
`hn.j` and transitions there; popping an empty stack posts `"Meta Error: Call
stack underflow, cannot return."` and disables Meta. Both share the same
10000-entry overflow guard as the watchdog-expiry path (§2.2 step 1,
`cp.cs:144-150`) — `CallMetaState` only; `ReturnFromCall` has NO depth guard,
just an empty-stack underflow check (`ao.cs:75-81`). **Both producers of stack growth (CallMetaState, watchdog expiry) and the one
consumer (ReturnFromCall) operate on the literal same `Stack<string>`
instance** (`hn.j`), so a watchdog-triggered call can be closed by an
ordinary `ReturnFromCall` rule and vice versa.
### 2.5 Two independent "seconds in state" clocks
`hn.h` and `hn.i` (both `DateTimeOffset`) are reset together on every state
transition (`hn.b(string)`, `hn.cs:115-124`) **and** on Meta's constructor/
full reset (`hn.c()`, `hn.cs:32-39` — not `hn.g()`, which touches neither field) — but only **`hn.h`** is additionally reset every time
the macro is (re)started (`cLogic.StartMacro()`, `uTank2/cLogic.cs:324`,
confirmed as the only other write site to either field via a repo-wide
grep). `c1`/"Seconds in state >=" reads `hn.h` (`c1.cs:17`); `gc`/"Seconds in
state (P) >=" reads `hn.i` (`gc.cs:17`) — the UI's own description string
for `gc` spells this out verbatim: `"Seconds in state (start/stop
persistent):"` (`gc.cs:93`). **Practical effect**: stopping and restarting
the macro while parked in the same state resets the plain "Seconds in
state >=" clock to zero, but the "(P)" persistent variant keeps counting as
though the macro was never stopped.
### 2.6 Watchdog (`h7`) in full
`SetWatchdog` (`f5.c()`, `f5.cs:51-55`) arms `h7` with `(stateToCall,
rangeMeters/240.0, timeSpanSeconds)`. `h7.a(state,range,timeSpan)`
(`h7.cs:37-51`) then: sets the armed flag, stores the target state and
range, sets the sample interval `i = timeSpan/10.0`, resets the ring index to
0, and — **critically** — pre-fills all **10** position-sample slots with a
sentinel `(1000,1000,1000)` far away from any real in-game coordinate
(`global::d.a(1000.0,1000.0,1000.0)`, `h7.cs:45-49`), not the player's actual
position at arm time. `h7.b()` (`h7.cs:58-84`), called once per Meta pass
(§2.2 step 1): if `>= sampleInterval` seconds have elapsed since the last
sample, advance the ring index (mod 10) and overwrite that slot with the
current position; then, regardless, compare the **current** position against
**all 10** stored samples — if **any** one of them is farther than the
configured range, report **not expired**. Only once every one of the 10
samples (which, right after arming, are all the far sentinel and therefore
guaranteed to trip "farther than range") has been overwritten by a real,
close-together sample does expiry become reachable — i.e. the earliest a
freshly-armed watchdog can possibly expire is one full `timeSpan` after
arming, and only if the player genuinely didn't move more than `range`
meters throughout that whole window. `ClearWatchdog` (`ht.c()`, `ht.cs:47-50`)
is a documented **no-op**: a repo-wide grep for `h7.c(` (the disarm method)
finds exactly four call sites, all inside `hn.cs` (constructor, full reset,
state-transition, profile-load) — **never** from `ht.cs`. The "Clear
Watchdog" meta action does nothing to `h7`'s armed state; only an actual
state transition (which also happens to call `h7.c()` as a side effect of
`hn.b(string)`) disarms a running watchdog.
### 2.7 Embedded nav and chat capture — see §1.5 and §3.6/§4
---
## 3. The expression language
### 3.1 Tokenizer (`MyClasses/ExpressionEvaluator.cs:933-1116`)
Character classes (`ExpressionEvaluator<W>.a()`, `:754-783`): digits and `.`
are numeric-candidate chars; `+-*/%#<>=!^;&|` are all **operator** chars
(single-char-classified; multi-char operators like `&&`/`==`/`>=` are
recognized later by string content, `sExpressionToken.d()`, `:166-188`); `(`
and `)` are their own types; **everything else defaults to a "string"
char**. There is **no conventional `"quoted string"` literal** at all:
- A bareword — any run of non-operator, non-paren, non-whitespace,
non-backtick, non-backslash characters — becomes a **String** token
automatically (the tokenizer has no separate "identifier" class; a bare
`foo` and a function-name `foo` are only disambiguated by whether a `[`
immediately follows).
- **Backtick `` ` `` is the only quoting mechanism**: entering backtick mode
(`:958-964`) suspends normal char-classification until a matching closing
backtick; a doubled backtick `` `` `` inside a backtick run is an escaped
literal backtick (`:1057-1072`); an *empty* `` `` `` (opened and
immediately closed with nothing between, and no prior escaped content) is
a parse error, `"Empty \`\` not allowed"` (`:1067`). Backtick-quoted text
is never re-interpreted as a variable name or re-parsed as a number
(`item.e = true` marks it "already resolved", `:1083-1086, 1120-1121`).
- **Backslash `\` is a single-character escape** outside backtick mode
(`:966-970`, consumes the backslash and passes the next character through
literally) — this is how a bareword string embeds an otherwise-special
character (a space, an operator char, a bracket) without needing
backticks for the whole token.
- `true`/`false` are **not** literal grammar — they are two pre-registered
*variables* (`b()`, `:339-343`) returning `1.0`/`0.0`, looked up through
the ordinary bareword-as-string→variable-resolution path (`:1124-1130`) —
a bareword `true` that happens to also be a registered variable name
resolves to the variable's value, not literally to the string `"true"`.
- Function-call syntax is `name[arg1,arg2,...]` — square brackets, not
parens; parens are pure grouping. Nested `[...]` inside an argument
(another function call) is tracked via a bracket-depth counter so commas
inside a nested call don't split the outer argument list (`:986-1041`).
### 3.2 Grammar / precedence (`sExpressionToken.d()`, `:166-188`)
Shunting-yard over the tokenized/pre-resolved queue (`:1147-1226`), operators
compared by this integer precedence table (higher binds tighter; equal
precedence pops left-to-right since the comparison is `<=`, `:1175`):
| Precedence | Operators | Notes |
|---|---|---|
| 9,999,999 (lowest) | `;` | Statement/expression separator — see §3.3 for its unusual evaluation semantics |
| 1 | `&&` `\|\|` `^` | Boolean AND/OR/XOR **all at the same precedence** — no AND-binds-tighter-than-OR rule (unlike C-family languages) |
| 0 | `==` `<` `>` `>=` `<=` `!=` | Comparison |
| 1 | `#` | Regex-match operator (String `#` String → bool) — sits **between** comparison and additive, an unusual placement |
| 2 | `+` `-` | Additive |
| 3 (highest) | `*` `/` `%` | Multiplicative |
Any operator string not in this table throws `"Invalid operator '...'"`
(`:186`).
### 3.3 The `;` operator's actual semantics — returns the LEFT operand
`a(sExpressionToken A_0, A_1, A_2)` (the binary-op evaluator,
`:785-931`): `if (A_2.b == ";") return A_0;` (`:787-790`) — given `a ; b`,
the result is **`a`, discarding `b`'s value entirely**, even though `b` was
still fully evaluated (for side effects) to get there. This is the opposite
of the "last statement's value wins" convention common to most
statement-separator designs. Since `;` participates in the *same*
shunting-yard as every other operator (not a special top-level-only
construct), it can appear **nested anywhere an operator can**, including
inside parens or (per the tokenizer's bracket-depth counter) inside a
function argument slot, as long as normal operator-precedence composition
allows it — e.g. `(a; b) + 1` legally parses and evaluates to `a + 1`.
### 3.4 Types and coercions
`eTokenCharType` values a leaf token can settle into: `b`=Number (double),
`d`=String, `h`=opaque Object (a CLR object riding along in `.d`, with `.b`
holding a display string — used for `Stopwatch`, `Coordinates` (class `d`),
`WorldObject` (class `fu`), and view/control references (`aq`/`c0`
subtypes)). Binary operators are defined **only** for Number/Number and
String/String pairs (`:791-929`); mixing types, or using either operand
Object-typed, throws `"Attempted to operate on two disparate types"` (`:930`)
— Object tokens have **no** operators at all, not even `==`; they can only be
produced/consumed by dedicated functions. A numeric literal is parsed with
`double.TryParse(..., NumberStyles.Any, InvariantCulture)` (`:1135`) — a
parse failure throws a bare `TokenParseError("")` (no message text).
`getobjectinternaltype[]` exposes the raw type-id numbering used internally:
`0`=none/uninitialized, `1`=Number, `3`=String, `7`=Object (`ExpressionEvaluator.cs:482`
description string — note the enum's actual C# ordinals are `a=0,b=1,c=2,
d=3,...,h=7`, so the description's "3"/"7" line up with `eTokenCharType.d`
and `.h` respectively, skipping the intermediate parser-only states `c`/`e`/
`f`/`g`).
### 3.5 Variables (`ch.cs:131-256`)
Six functions, all operating on a single `Dictionary<string,sExpressionToken>
a` field private to the `ch` subclass (a session-scoped store that lives for
the whole VTank process run, cleared only by `clearallvars[]` or an explicit
`clearvar[]`):
| Function | Arity | Semantics | Cite |
|---|---|---|---|
| `testvar[name]` | 1 | `true` iff a variable of that name is currently defined | `ch.cs:131-151` |
| `getvar[name]` | 1 | Returns the stored value, or `false` (a Number 0) if undefined — **never throws for a missing variable** | `ch.cs:153-173` |
| `setvar[name,value]` | 2 | Stores `value` under `name` (any type), returns `value` | `ch.cs:175-193` |
| `touchvar[name]` | 1 | If undefined, defines it as `false` and returns `false`; if already defined, returns `true` **without changing the existing value** | `ch.cs:195-217` |
| `clearvar[name]` | 1 | Removes the variable if present; returns whether it had existed | `ch.cs:233-255` |
| `clearallvars[]` | 0 | Clears every variable | `ch.cs:219-231` |
There is exactly **one** scope — no persistent (disk-backed) or global
(cross-character) variable tier in retail VTank's expression engine itself
(persistence across sessions is a *profile* concept — `.usd`/`.met` files —
not an expression-variable concept). The backing `Dictionary` (`ch.a`) is
never cleared by anything in this doc's oracle except `clearvar`/
`clearallvars` themselves or a fresh `ch`/`hn` construction
(`s.cs:216`, the **only** `new hn()` call site found by a repo-wide grep) —
since `hn` is constructed once per plugin load, not once per character
login, variables in practice **do survive a character relog** within the
same running Decal/VTank process, matching the earlier research doc's
"Variables persist to relog" note (`2026-07-29-vtank-plugin-automation-
requirements.md:205`) — that note and this doc's "no disk-backed tier"
finding describe the same fact from two angles, not a disagreement.
### 3.6 Lists / dicts
**Retail VTank's expression engine has no list or dictionary type at all.**
`eTokenCharType` has exactly the four settled kinds (Number/String/Object/
uninitialized) enumerated in §3.4; there is no array/collection literal
syntax, no `listcreate[]`/`dictcreate[]`-style function anywhere in `ch.cs`'s
74-function catalog (§3.7), and no collection case in the binary-operator
dispatch (`:785-931`). Any list/dict-shaped function names encountered
belong to **UtilityBelt's** dialect, not retail VTank's — see §5.
### 3.7 Complete built-in function table
17 inherited from the base `ExpressionEvaluator<W>` plus 57 registered by the
`ch` subclass (all discovered via one-time reflection over every
`[Expr_FunctionName]`-attributed method across the class hierarchy,
`ExpressionEvaluator.cs:306-318`) — **74 total** (plus the two pre-registered *variables* `true`/`false`,
`ExpressionEvaluator.cs:341-342`, for 76 resolvable names; exact
`[Expr_FunctionName]` attribute counts: 17 in `ExpressionEvaluator.cs`, 57 in
`ch.cs`). Every function validates
its own arity by comparing `A_0.Count` against the hardcoded literal in its
body (the `[Expr_ParamCount]` attribute is metadata for the in-game function
browser, not itself enforced at call time). "Coordinates"/"WorldObject"/
"ViewControl" params are runtime type-checked against the boxed `.d` field's
CLR type (`typeof(d)`/`typeof(fu)`/subclass-of `c0`).
**Base engine (17 functions + 2 variables):**
| Name | Arity | Semantics | Cite |
|---|---|---|---|
| `true` (variable) | 0 | `1.0` | `ExpressionEvaluator.cs:345-348` |
| `false` (variable) | 0 | `0.0` | `:350-353` |
| `isfalse[x]` | 1 | `true` iff `x` is Number `0` | `:355-374` |
| `istrue[x]` | 1 | `true` iff `x` is Number and nonzero | `:376-395` |
| `iif[cond,a,b]` | 3 | `a` if `cond` is a nonzero Number, else `b` | `:397-423` |
| `randint[min,max]` | 2 | Random int in `[min,max)` | `:425-446` |
| `cstr[n]` | 1 | Number→string, `ToString()` (current-thread culture) | `:448-463` |
| `strlen[s]` | 1 | String length | `:465-480` |
| `getobjectinternaltype[x]` | 1 | Raw type-id number (§3.4) | `:482-493` |
| `cstrf[n,fmt]` | 2 | Number→string with a .NET format string | `:495-515` |
| `stopwatchcreate[]` | 0 | New Stopwatch object (not started) | `:517-534` |
| `stopwatchstart[sw]` | 1 | Starts it, returns it | `:536-561` |
| `stopwatchstop[sw]` | 1 | Stops it, returns it | `:563-588` |
| `stopwatchelapsedseconds[sw]` | 1 | Elapsed ms / 1000 | `:590-615` |
| `cnumber[s]` | 1 | String→double via `TryParse`, `0` on failure | `:617-633` |
| `floor[n]` | 1 | `Math.Floor` | `:635-652` |
| `ceiling[n]` | 1 | `Math.Ceiling` | `:654-671` |
| `round[n]` | 1 | `Math.Round` | `:673-690` |
| `abs[n]` | 1 | `Math.Abs` | `:692-709` |
**`ch` subclass (57):**
| Name | Arity | Semantics | Cite |
|---|---|---|---|
| `testvar` `getvar` `setvar` `touchvar` `clearvar` `clearallvars` | 1/1/2/1/1/0 | §3.5 | `ch.cs:131-256` |
| `getcharintprop[key]` | 1 | Character `IntValueKey` property, `false` if unset | `ch.cs:257-283` |
| `getcharquadprop[key]` | 1 | Character `QuadValueKey` (precision loss above 2^531, doubles) | `ch.cs:285-311` |
| `getchardoubleprop[key]` | 1 | `DoubleValueKey` | `ch.cs:313-339` |
| `getcharboolprop[key]` | 1 | `BoolValueKey` | `ch.cs:341-367` |
| `getcharstringprop[key]` | 1 | `StringValueKey` | `ch.cs:369-395` |
| `getisspellknown[spellid]` | 1 | Spell present in spellbook | `ch.cs:397-418` |
| `getcancastspell_hunt[spellid]` | 1 | Castable per hunt-tier scarab/skill check (`SpellDiffExcessThreshold-Hunt`) | `ch.cs:420-446` |
| `getcancastspell_buff[spellid]` | 1 | Same, buff-tier (`SpellDiffExcessThreshold-Buff`) | `ch.cs:448-474` |
| `getcharvital_base[1/2/3]` | 1 | Base Health/Stamina/Mana (1=H,2=S,3=M; clamps result to a minimum of 1) | `ch.cs:476-496` |
| `getcharvital_current[1/2/3]` | 1 | Current value | `ch.cs:498-...` |
| `getcharvital_buffedmax[1/2/3]` | 1 | Buffed max | `ch.cs:520-...` |
| `getcharskill_traininglevel[skill]` | 1 | 0=Unusable,1=Untrained,2=Trained,3=Specialized | `ch.cs:543-559` |
| `getcharskill_base[skill]` | 1 | Base skill value | `ch.cs:560-576` |
| `getcharskill_buffed[skill]` | 1 | Buffed skill value | `ch.cs:577-591` |
| `getplayerlandcell[]` | 0 | Current landcell id (incl. landblock portion) | `ch.cs:592-608` |
| `getplayercoordinates[]` | 0 | Coordinates object, physics-predicted position | `ch.cs:610-632` |
| `coordinategetns[c]` `coordinategetwe[c]` `coordinategetz[c]` | 1 each | N/S, W/E, Z components of a Coordinates object | `ch.cs:634-713` |
| `coordinatetostring[c]` | 1 | String form (`d.ToString()`) | `ch.cs:715-740` |
| `coordinateparse[s]` | 1 | Parses `"00.0N, 00.0W"` (no Z); `false` on failure | `ch.cs:742-767` |
| `coordinatedistancewithz[c1,c2]` | 2 | 3D distance in **meters** (raw units × 240) | `ch.cs:769-808` |
| `coordinatedistanceflat[c1,c2]` | 2 | 2D distance in meters (Z ignored) | `ch.cs:810-849` |
| `wobjectgetphysicscoordinates[obj]` | 1 | Coordinates object for a WorldObject | `ch.cs:851-881` |
| `wobjectgetname[obj]` | 1 | Object's display name | `ch.cs:883-907` |
| `wobjectgetobjectclass[obj]` | 1 | ObjectClass number | `ch.cs:909-...` |
| `wobjectgettemplatetype[obj]` | 1 | Template-type number | `ch.cs:936-960` |
| `wobjectgetisdooropen[obj]` | 1 | Bool | `ch.cs:961-...` |
| `wobjectfindnearestmonster[]` | 0 | Nearest **non-blacklisted** monster or `false` | `ch.cs:991-1032` |
| `wobjectfindnearestdoor[]` | 0 | Nearest door or `false` | `ch.cs:1034-1072` |
| `wobjectfindnearestbyobjectclass[class]` | 1 | Nearest object of a given ObjectClass (excludes self) | `ch.cs:1075-1120` |
| `wobjectfindininventorybytemplatetype[type]` | 1 | First inventory match | `ch.cs:1122-1157` |
| `wobjectfindininventorybyname[name]` | 1 | First inventory match, **exact** name | `ch.cs:1159-1185` |
| `wobjectfindininventorybynamerx[pattern]` | 1 | First inventory match, regex (default case-sensitivity — new `Regex(pattern)` with no options) | `ch.cs:1187-1222` |
| `wobjectgetselection[]` | 0 | Currently-selected object or `false` | `ch.cs:1224-1245` |
| `wobjectgetplayer[]` | 0 | The player's own WorldObject | `ch.cs:1247-1268` |
| `wobjectfindnearestbynameandobjectclass[class,pattern]` | 2 | Nearest match on both criteria | `ch.cs:1270-1322` |
| `actiontryselect[obj]` | 1 | Selects the object; always returns `false` | `ch.cs:1324-1349` |
| `actiontryuseitem[obj]` | 1 | Uses it if owned; bool success | `ch.cs:1351-1381` |
| `actiontryapplyitem[a,b]` | 2 | Applies `a` to `b` (select `b`, use `a`, restore prior selection) | `ch.cs:1383-1436` |
| `actiontrygiveitem[item,target]` | 2 | Gives an item to a player/NPC | `ch.cs:1438-1488` |
| `actiontryequipanywand[]` | 0 | One step toward equipping any profile wand; `true` if already equipped | `ch.cs:1490-1505` |
| `actiontrycastbyid[spellid]` | 1 | Untargeted cast attempt; `0`/`1`/`2` (not-yet/begun/impossible) | `ch.cs:1507-1539` |
| `actiontrycastbyidontarget[spellid,obj]` | 2 | Targeted cast attempt, same return convention | `ch.cs:1541-1575` |
| `chatbox[s]` | 1 | Sends `s` verbatim to chat | `ch.cs:1577-1591` |
| `chatboxpaste[s]` | 1 | Pastes into the chat input box (strips control chars) without sending | `ch.cs:1593-1624` |
| `statushud[key,value]` | 2 | Updates a Virindi HUD Status-HUD row under the fixed group name `"VTank Meta"` | `ch.cs:1626-1647` |
| `statushudcolored[key,value,rgb]` | 3 | Same, with an explicit RGB color (forced fully opaque via `\| 0xFF000000`) | `ch.cs:1649-1672` |
| `uigetcontrol[viewname,controlname]` | 2 | Opaque ViewControl reference into a Meta-created view, or `false` | `ch.cs:1674-1700` |
| `uisetlabel[control,text]` | 2 | Sets a Button's label; **throws for any control type other than Button** | `ch.cs:1702-1720` |
| `uisetvisible[control,bool]` | 2 | Sets any control's visibility | `ch.cs:1722-1742` |
### 3.8 Error behavior
Every function/tokenizer failure throws `TokenParseError` (a plain
`Exception` subclass, `ExpressionEvaluator.cs:263-271`); the **top-level**
entry point `ch.a(string,W,out bool,out bool)` (invoked as `dz.at.k.a(text,
0, out consumed, out isError)` everywhere in the codebase) catches only
`TokenParseError` and converts it into a String-typed result token holding
the error message, setting the `isError` out-param — **no other exception
type is caught** (`ExpressionEvaluator.cs:734-752`). Callers (every
condition/action's own execute method) uniformly just log the error string
to chat (`ah.a("Error in ...: " + text + " (" + result.b + ")")`) and
otherwise degrade to a safe default (a condition treats an error as `false`;
most actions still return `true`, i.e. still let the pass continue). There
is no exception propagation up to the scheduler — a malformed expression
never crashes Meta, it just silently no-ops that one condition/action for
that pass (with a chat warning).
---
## 4. Views from metas (`CreateView`)
### 4.1 The 5-view cap — actually a 6-view cap (off-by-one)
`bw.a(string,string)` (`bw.cs:23-45`) refuses a new `CreateView` only when
`bw.b.Count > 5`**strictly greater than**, not `>=`. Since this check
runs *before* the new view is added, a 6th distinct view name is still
permitted (the check only blocks the 7th). `bw.b(name,xml)` also requires
the host's Decal version to be `>= 1.0.0.45` (`bw.cs:14-21`) — below that,
`CreateView` silently no-ops. Creating a view under a name that already
exists **replaces** it (destroys the old one first, `bw.cs:29-32`) rather
than stacking a duplicate.
### 4.2 The markup dialect (`aq.b(string)`, `aq.cs:41-106`)
- The XML **root element's tag name is never checked** — only its `title`
(string, `aq.cs:59`), `width`, `height` (ints, `aq.cs:60-61`) attributes
are read. Any root tag name works.
- Requested `width`/`height` are validated against `RegionWindow.{Width,
Height} - 100` in each dimension (`aq.cs:62-73`) — an **oversized request
is rejected outright** (view creation fails with a chat error naming the
current max), never silently clamped.
- The window's **only required content** is its root's `FirstChild`, which
must literally be an element named `"control"` (case-insensitive,
`aq.cs:108-118`); anything else there yields a window with no content.
- Exactly **two** `type=` values are recognized by the control factory
(`d6.a(string)`, `d6.cs:3-11`, case-insensitive): `"BUTTON"` → class `c9`,
`"LAYOUT"` → class `au`. Any other `type` value returns `null` (that
`<control>` and everything under it is silently dropped).
- Base control attributes (`c0.a(XmlNode,...)`, `c0.cs:20-28`): `left`,
`top`, `width`, `height` (ints), `name` (string — if non-empty, the
control is registered by that name into the view's flat
`Dictionary<string,c0>`, **regardless of nesting depth**, for later
`uigetcontrol[viewname,controlname]` lookup).
- `Button` (`c9.a`, `c9.cs:22-32`) adds `text` (label), `actionexpr` (an
arbitrary meta expression string, evaluated on click via the *same*
`dz.at.k` evaluator instance used everywhere else — errors chat-logged,
never crash the click handler), and `setstate` (a state name — if
non-empty, transitions Meta on click, **independently of and in addition
to** `actionexpr`; both fire on the same click if both are present,
`c9.cs:34-49`).
- `Layout` (`au.a`, `au.cs:18-32`) is a pure container: it recursively
parses every **direct** child XML node via the same `aq.a(node,...)`
dispatcher (so `Layout`-inside-`Layout` nesting is unlimited), rendering
each child at its own declared `left/top/width/height` inside the parent's
`HudFixedLayout`.
- There is **no** list/combo/slider/edit/checkbox/tab control type in this
dialect — it is a small, deliberately minimal 2-tag subset, unrelated to
the ~9-tab, list-heavy `mainView.xml` dialect documented in KB doc 08 for
VTank's *own* main window (that one goes through
`VirindiViewService.XMLParsers.Decal3XMLParser`, a completely different,
much larger parser).
### 4.3 Control binding to variables/expressions
Bindings are all **one-directional and event-driven**, not a live/continuous
data-binding model:
- `uigetcontrol[view,name]` → an opaque ViewControl token, consumed later by
`uisetlabel`/`uisetvisible`.
- A Button's `actionexpr` runs **once per click**, through the shared
session-scoped expression-variable store (§3.5) — there is no
automatic re-evaluation on a timer or on variable change; a view that
needs to reflect changing state must be driven explicitly (e.g. a
`Chat Expression`/`Expression Action` meta rule calling `uisetlabel[...]`
each pass).
- `DestroyView[name]`/`DestroyAllViews[]` are the only teardown paths besides
the implicit "recreate under the same name" replace-on-CreateView
behavior (§4.1).
---
## 5. The MossTank gap
Files read: `Meta.cs`, `MetaViewManager.cs`, `MossTankMetaProfileStore.cs`,
`VtankMetaProfileSerializer.cs` (+ `VtankMetaProfileSerializerTests.cs`),
`Expressions/ExpressionEngine.cs`, `Expressions/ExpressionRuntime.cs`,
`Expressions/ExpressionValue.cs`, `Expressions/CoreExpressionFunctions.cs`,
`Expressions/HostExpressionFunctions.cs`, `Expressions/
MossTankExpressionRuntime.cs`.
### 5.1 Can a real `.met` load today? — Yes, at the byte/structural level
`MossTankMetaProfileStore.Import` (`MossTankMetaProfileStore.cs:119-157`)
reads a file named `<profile>.met` out of the plugin's `imports/`/`exports``
storage and calls `VtankMetaProfileSerializer.TryLoad`
(`VtankMetaProfileSerializer.cs:23-63`). Cross-checking that serializer's
condition/action type-id tables (`ConditionType`/`ConditionKind`,
`VtankMetaProfileSerializer.cs:463-527`; `ActionType`/`ActionKind`,
`:529-569`) against the retail `h`/`bp` enum ordinals derived independently
in §1.3/§1.4 (via `d9.cs`'s registration order) shows a **byte-for-byte
match on every single type id, including the real retail gap at `h.ab`=27**
(the serializer has no `case 27`, matching retail's own unregistered id).
Every per-type field layout checked (§1.3/§1.4's key names: `n`/`c`/`r`/`p`
for the monster/inventory conditions, `st`/`ret` for CallMetaState, `s`/`r`/
`t` for SetWatchdog, `o`/`v` for Get/SetOption, `n`/`x` for CreateView, `p`/`c`
for ChatMessageCapture, the uppercase `K`/`V` vs. lowercase `k`/`v` compound-
vs-leaf table distinction, and the two distinct "zero fields" encodings from
§1.4) matches the decompiled source **exactly**, including the embedded
nav-route's own sub-format (mode 1/2/3/4 dispatch, node-type-dependent extra
line counts). This is an unusually precise reverse-engineering result — the
`.met` **byte format itself is not where the gaps are**.
### 5.2 Ranked semantic gaps
| # | Gap | Retail behavior | MossTank behavior | Impact |
|---|---|---|---|---|
| 1 | **`CreateView` markup is not translated at all** | Root tag name irrelevant; requires a `<control type="Button"\|"Layout">` first child (§4.2) | `MetaViewManager.Create` (`MetaViewManager.cs:37-40`) requires the XML root to be literally named `"panel"` (acdream's own native panel-markup convention) and hands the raw string straight to `RegisterPanelContent` — no VTank Button/Layout/`actionexpr`/`setstate` parser exists anywhere in the plugin (confirmed absent by grep) | **Highest.** A real `.met`'s `CreateView` action (root tag anything, first child `<control type="Layout">`) will be rejected outright by the `"panel"` check and never render. The 5/6-view off-by-one quirk itself *is* faithfully preserved (`MetaViewManager.cs:15,32`, with an explicit code comment citing `bw.a`'s `Count > 5`) — only the control dialect is missing. |
| 2 | **Nested `All` action short-circuits instead of unconditionally running every child** | `f.b()` (`f.cs:30-37`) calls every child's `.h()` **ignoring its return value**, and the `All` action itself always returns `true` — only the *outer* pass loop's separate "did the state change" check can end the pass once `All` finishes | `Meta.cs:374-380`: `foreach (child) { if (!ExecuteAction(child)) return false; }` — stops at the **first** child that reports "don't continue" | **High.** A `.met` rule using `All` to sequence e.g. `SetMetaState` + `ChatCommand` + `ClearWatchdog` will, in MossTank, run only `SetMetaState` and silently drop the remaining siblings (since `SetMetaState` always reports `false`); retail runs every sibling regardless, and only the whole *pass* stops afterward. |
| 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)
- The full expression-variable family (`getvar`/`setvar`/`testvar`/
`touchvar`/`clearvar`/`clearallvars`) is present under identical names and
identical semantics (`CoreExpressionFunctions.cs:43-81`) — MossTank
additionally exposes **persistent** (`pgetvar`/…) and **global**
(`ggetvar`/…) scoped variants layered on top via the same helper (a
superset addition, not a compat break, since the retail names still work
unqualified).
- The "Seconds in state" vs. "(P)ersistent" reset-on-macro-start-vs-not
distinction (§2.5) is reproduced exactly: `_stateSeconds` resets in both
`Transition()` and `SetEnabled(true)` (`Meta.cs:172-188, 222-230`);
`_persistentStateSeconds` resets only in `Transition()`.
`ChatMessageCapture`'s malformed-color-list failure mode (retail: the
whole filter list is discarded and the condition then matches nothing;
MossTank: `ParseKinds` returns an empty-but-non-null set on any bad token,
`Meta.cs:515-527`, which likewise matches nothing) is behaviorally
equivalent despite a different internal representation.
- The tokenizer is a strict **superset** of retail's grammar: it accepts
backtick, single-, and double-quoted strings plus retail's bareword
fallback (`ExpressionEngine.cs:374-399`) — every legal retail `.met`
expression string still tokenizes the same way.
- `floor`/`ceiling`/`round`/`abs`/`getobjectinternaltype` and the whole
"nearest object" function family
(`wobjectfindnearestbyobjectclass`/`…bynameandobjectclass`/`…door`/
`…monster`) are all present, just registered through small helper
functions rather than one-line literal string registrations (initially
looked like gaps under a naive grep; verified present by direct read,
`HostExpressionFunctions.cs:567-576`, `CoreExpressionFunctions.cs:87-95`).
- Retail VTank's expression engine has **no** list/dict type at all (§3.6);
MossTank's extensive `list*`/`dict*` function family
(`CoreExpressionFunctions.cs`) is a pure **UtilityBelt-dialect addition**
with no retail counterpart to diverge from.
---
## 6. Could not determine
- RESOLVED (citation pass 2026-09-06): `fm.cs:61-86` hand-rolls `bd("k","v")`
with rows `sid`/`sec` (UI labels "SpellID" / "Time Left (Seconds)",
`fm.cs:43-44`); `a.cs:42-44` writes bare int `0`, `a.cs:32-34` loads nothing,
`a.cs:86-89` returns `true` — the inferred shapes were right.
- **`ChatMessageCapture`'s wiki-referenced color-id numbering** ("Chat
colorid list", `c5.cs:19-20`) — the exact mapping from small integers to
named chat colors was not traced here; it lives in KB's chat-color
reference material (`memory/reference_retail_chat_colors.md`), out of
scope for this expression/meta-focused doc.
- **Whether a `.met` rule's `State` field can itself contain a `;`- or
bracket-bearing string** that would confuse the flat-line `y`/`bd` reader
— not tested against a real hand-crafted adversarial file; the worked
decode in §1.2 uses only well-formed sample data actually observed on
disk.
- **The exact behavior of `MetaViewManager`'s hashed window-id collision
handling** (`MetaViewManager.cs:78-82`, SHA-256-derived id) under two
*different* view names that happen to collide in the truncated 8-byte
hash — astronomically unlikely, not pursued.

View file

@ -0,0 +1,498 @@
# Campaign VT knowledge base — 08: UI views
Research-only. No code changes. Sources cited by path; `file:line` given for
every code claim. Decompiled VTank/VVS code is **never quoted verbatim**
below — every fact is paraphrased or given as a name/number/attribute list.
Primary sources:
- `refs/vtank/uTank2.ViewXML.mainView.xml` — the main window: 856×210,
9 `<page>` tabs, **156 controls, 38 list columns** (counted directly from
the file; matches the brief's "156 controls").
- `refs/vtank/uTank2.ViewXML.AdvancedOptionsView.xml`,
`SelfBuffChoiceView.xml`, `FriendlyLootEditorView.xml` — secondary popup
views (the last is a vestigial resource nothing loads, see §6).
- `refs/vtank/dc.xml` — a fourth secondary view the first draft missed:
268×300, icon 8241, "Virindi Tank Debug", an `lRefcounts` list view loaded
at `dc.cs:52` (citation pass 2026-09-06).
- `refs/vtank/uTank2.Resources.defaultsettings.usd` — VTank's serialized
default-settings catalog (setting name / type / default / description /
category-bitmask). This is the authoritative cross-reference for what
each Options/Vitals control actually persists.
- `refs/vvs/decompiled/VirindiViewService.XMLParsers/Decal3XMLParser.cs`
the XML→control mapping table (progid string → concrete `Hud*` class +
constructor arguments). This is the ground truth for "what does this XML
attribute actually do at parse time."
- `refs/vvs/decompiled/VirindiViewService.Controls/*.cs` — control behavior
(`HudList`, `HudCombo`, `HudTabView`, `HudHSlider`/`LinearPositionControl`,
`HudTextBox`, `HudButton`, `HudCheckBox`, `HudPictureBox`, `HudControl`).
- `refs/vvs/decompiled/VirindiViewService/HudView.cs`,
`ViewProperties.cs`, `ACImage.cs` — window chrome, icon-id resolution,
`StoredViewInfo` persistence.
- `refs/vvs/decompiled/VirindiViewService.Themes/Float_Theme.cs` — default
theme pixel constants (`TabHeight`, `SliderNubWidth`, etc.) — the default
because `HudViewDrawStyle.a()` registers it at index 1 and the registry
value `HKLM\SOFTWARE\Decal\Services\{DBAC9286-…}\Theme` defaults to 1
(`HudViewDrawStyle.cs:118-167`; `Theme2` is a string-name override).
- `refs/vtank/decompiled/uTank2/PluginCore.cs` — the class that loads
`mainView.xml`, casts every named control to a typed wrapper interface,
and wires `Click`/`Change`/`Change_Old` handlers. **Heavily obfuscated**
(single/double-letter field and method names) — see §6.
- `refs/vtank/decompiled/MetaViewWrappers.VirindiViewServiceHudControls/List.cs`
— the `IList` wrapper VTank actually programs against (row/cell
accessors, `Click(row,col)`).
- Our side: `src/AcDream.App/UI/MarkupDocument.cs`, `docs/plugin-ui-markup.md`,
`src/AcDream.Plugins.MossTank/mosstank.xml`.
---
## 0. Window-level facts (item 4)
| Fact | Value | Source |
|---|---|---|
| Declared size | `width="856" height="210"` | `mainView.xml:2` |
| `properties.Width`/`Height` | XML value **minus** `HudViewDrawStyle.Theme_OldDecal`'s border `Size` (border chrome is subtracted from the declared size before it becomes the client area) | `Decal3XMLParser.cs:33-37` |
| Icon attribute | `icon="10667"` | `mainView.xml:2` |
| Icon resolution | `ViewProperties.Icon` is typed `ACImage`; the parser assigns it the **int** returned by `a(XmlAttribute)` (the `icon` attribute parsed as a plain integer), which converts via `ACImage`'s `implicit operator ACImage(int p)`. Every `ACImage` int-constructor path ORs the input with `0x6000000` before resolving it as a portal.dat id (`portalfile \| 0x6000000`) | `Decal3XMLParser.cs:32`, `ACImage.cs:618` (`op_Implicit`), `ACImage.cs:282-410` (the `\| 0x6000000` pattern repeated on every constructor) |
| → confirmed formula | icon **10667** (`0x29AB`) resolves to portal.dat id **`0x0600` `29AB`** = `0x060029AB` — i.e. exactly the task's guessed `0x06000000 + 10667` formula, confirmed structurally (VTank's own icon id space is bare portal.dat indices, same convention our `PluginIcons.Normalize` documents for `did` bindings) | `ACImage.cs:618`, cross-ref `docs/plugin-ui-markup.md`'s icon-id grammar section |
| Title | Set at runtime, not in XML: `"Virindi Tank v." + <assembly version>` (` (SIMULATION MODE)` appended when simulating) | `refs/vtank/decompiled/uTank2/PluginCore.cs:1608-1613` |
| Window icon (taskbar/system) | Overridden separately after load via `this.k.SetIcon(101, GetModuleHandle("utank2-i"))` — a **Win32 resource icon** from the plugin DLL itself, unrelated to the `icon="10667"` DAT-portal icon (that one is VVS's own window-manager/hud-bar icon) | `PluginCore.cs:1614` |
| Resizability | `UserResizeable = true`; `MinimumClientArea = new Size(792, <initial height>)`; `MaximumClientArea = new Size(<initial width>, 9999)` — width is effectively pinned near its authored value (a little horizontal shrink to 792 allowed, no growth past the authored width), height can grow unbounded (to 9999) but not shrink below the authored height. This matches a list-heavy layout where the user wants to see more rows, not more columns | `PluginCore.cs:1859-1862` |
| `HudView.UserResizeable` effect on border | Resizeable views use a **different, thicker** themed border (`ViewBorder*_Size_Resizeable` vs `ViewBorder*_Size`) — border thickness is data-driven per resizability state, not a fixed constant | `HudView.cs:561-607` |
| Persistence store | SQLite file at the registry path `HKLM\SOFTWARE\Decal\Services\{DBAC9286-...}\StoredInfoFile`, table **`StoredViewInfo`**: `ViewKey TEXT UNIQUE PK, LocX INT, LocY INT, LocSticky INT, UserW INT, UserH INT, Ghost INT, Theme TEXT, Enabled INT, ClickThrough INT, IsCustomTheme INT, ThemeID INT, ThemeID2 TEXT` | `HudView.cs:1868-1883` (`CREATE TABLE`), `:1916-2018` (`LoadUserSettings`/`ad()` — the `select` matching this schema), `:2020-2072` (`ac()`/the `insert or replace` on save) |
| `ViewKey` (`a9` field) | `"<calling assembly name>:<windowKey>"` — VTank passes no explicit window key to its main `HudView`, so persistence keys off the assembly name + an empty/default key unless a `pWindowKey` constructor overload is used | `HudView.cs:1711-1726` (`a(...)` ctor helper, `ba`/`a9` fields) |
| What's persisted | Location (X/Y), a 4-bit "sticky edge" mask (`LocSticky`, ghost-docking to a screen edge), user-resized W/H, `Ghosted` (hudified/pinned mode), visibility, click-through, and the active theme (by name or numeric index, plus a custom-theme flag) | same range as above |
| Save trigger | Debounced onto the thread pool (`ThreadPool.QueueUserWorkItem`) any time position/size/ghost/click-through/theme changes, not on a timer | `HudView.cs:2020-2040` |
| Right-click / context menu | Exists **only at the window-chrome level** (`HudView.l` = `ContextMenu<a>`), with fixed entries `close`/`alphaup`/`alphadown`/`ghost`/`clickthrough`/`nexttheme`/`resettheme` plus one per user-added titlebar button (`"user~N"`) and one per theme (`"themeset"`). There is **no** per-control or per-list-row right-click anywhere in the base control classes (`HudControl`/`HudList` only ever wire left-button `MouseDown`/`MouseUp`/`MouseMove`/`MouseWheel`) | `HudView.cs:1583-1643` (menu dispatch), confirmed absent by a repo-wide grep for right/second-button handling in `refs/vvs/decompiled` (`RightMouseDown`/`MouseButton.Right`/etc. — zero matches) |
| Double-click | Not implemented anywhere in the base control classes. `HudControl.Hit`/`MouseUp` fires once per press-release pair inside the same control; there is no timestamp-based double-click detector in `HudControl`, `HudList`, or `HudCombo` | same grep + direct read of `HudControl.cs:429-474` |
---
## 1. Per-tab control tables
All geometry, names, and text below is transcribed directly from
`mainView.xml` (100% exact — every `left`/`top`/`width`/`height`/`text` is a
literal attribute value). "Setting/action" columns marked **[confirmed]**
are cross-referenced against `defaultsettings.usd` and/or a direct
`PluginCore.cs` line; those without a bracket are a naming-convention
inference (VTank's own control names are self-describing in most cases) and
are called out as such.
### Tab: Options (37 controls: 36 leaf + 1 `FixedLayout` container, 0 list columns)
| Control | Type | L,T,W,H | Text | Setting / action | Notes |
|---|---|---|---|---|---|
| Label12 | StaticText | 8,8,80,16 | "Monster Range:" | — | label for txtRange |
| txtRange | Edit | 8,24,40,16 | "" | `AttackDistance` **[confirmed]** — `defaultsettings.usd:283-289` (a fractional "distance" unit, default `0.0208333333333333`) | `imageportalsrc="4726"`**dead attribute**, see §2 |
| Label22 | StaticText | 8,40,80,16 | "Ring Range:" | — | |
| txtRingRange | Edit | 8,56,40,16 | "" | `RingDistance` **[confirmed]** — `defaultsettings.usd:307-313` | |
| Label23 | StaticText | 8,72,80,16 | "Approach Range:" | — | |
| txtApproachRange | Edit | 8,88,40,16 | "" | `ApproachDistance` **[confirmed]** — `defaultsettings.usd:299-305` | |
| Label24 | StaticText | 8,104,80,16 | "Follow/Nav Min Distance:" | — | |
| txtWPRange | Edit | 8,120,40,16 | "" | `NavCloseStopRange` (inferred by name/purpose match; not directly grepped to this field) — `defaultsettings.usd:331-337` | |
| bForceBuff | PushButton | 90,8,112,25 | "Force Buff" | action (no persisted setting) | wired `IButton.Click` in `PluginCore.cs:1629,1752` (`this.z`) |
| bCancelForceBuff | PushButton | 90,38,112,25 | "Cancel Force Buff" | action | `PluginCore.cs:1630,1772` (`this.aa`) |
| cEnableBuffing | Checkbox | 90,68,120,20 | "Enable Buffing" | `EnableBuffing` **[confirmed]** — `defaultsettings.usd:163-169` | |
| cEnableCombat | Checkbox | 90,88,120,20 | "Enable Combat" | `EnableCombat` **[confirmed]** — `defaultsettings.usd:171-177` | |
| c1EnableNav | Checkbox | 90,108,120,20 | "Enable Navigation" | `EnableNav` **[confirmed]** — `defaultsettings.usd:155-161` | |
| c1EnableLooting | Checkbox | 90,128,120,20 | "Enable Looting" | `EnableLooting` **[confirmed]** — `defaultsettings.usd:147-153` | |
| c1EnableMeta | Checkbox | 90,148,120,20 | "Enable Meta" | `EnableMeta` **[confirmed]** — `defaultsettings.usd:1027-1033` | |
| cEnableAutoStack | Checkbox | 220,8,160,20 | "Enable Autostack" | `AutoStack` **[confirmed]** — `defaultsettings.usd:419-425` | |
| cEnableAutoCram | Checkbox | 220,28,160,20 | "Enable Autocram" | `AutoCram` **[confirmed]** — `defaultsettings.usd:411-417` | |
| cGems | Checkbox | 220,48,120,20 | "Use Dispel Items" | `UseDispelItems` **[confirmed]** — `defaultsettings.usd:403-409` | XML name ("Gems") doesn't match the text/setting — a VTank-internal inconsistency |
| cDispel | Checkbox | 220,68,120,20 | "Cast Dispel Self" | `CastDispelSelf` **[confirmed]** — `defaultsettings.usd:395-401` | |
| cAutoFellow | Checkbox | 220,128,120,20 | "Auto Fellow Mgmt." | `AutoFellowManagement` **[confirmed]** — `defaultsettings.usd:811-817` | |
| cMChargesWhenOff | Checkbox | 220,148,160,20 | "M. Charges When Off" | `ManaChargesWhenOff` **[confirmed]** — `defaultsettings.usd:803-809` | |
| cNavPriorityBoost | Checkbox | 386,8,160,20 | "Boost Nav. Priority" | `NavPriorityBoost` **[confirmed]** — `defaultsettings.usd:851-857` | |
| cLootPriorityBoost | Checkbox | 386,28,160,20 | "Boost Loot Priority" | `LootPriorityBoost` **[confirmed]** — `defaultsettings.usd:659-665` | |
| cLootOnlyRareCorpses | Checkbox | 386,48,160,20 | "Loot Only Rare Corpses" | `LootOnlyRareCorpses` **[confirmed]** — `defaultsettings.usd:907-913` | |
| cIdlePeace | Checkbox | 386,68,160,20 | "Peace Mode When Idle" | `IdlePeaceMode` **[confirmed]** — `defaultsettings.usd:515-521` | |
| cTopoffBuffs | Checkbox | 386,88,160,20 | "Rebuff When Idle" | `IdleBuffTopoff` **[confirmed]** — `defaultsettings.usd:571-577` | |
| cFastCast | Checkbox | 386,108,160,20 | "Fastcast Buffs" | `FastCastBuffs` **[confirmed]** — `defaultsettings.usd:1139-1145` | |
| cCollisionChecks | Checkbox | 386,128,160,20 | "Don't Shoot at Walls" | `UseProjectileAwareness` **[confirmed]** — `defaultsettings.usd:1155-1161` | inverse-named XML control name vs. the actual boolean sense (checked = awareness ON) |
| cDebuffFallback | Checkbox | 386,148,160,20 | "Fallback Debuffs if Blocked" | `AllowDebuffFallback` **[confirmed]** — `defaultsettings.usd:1227-1233` | |
| cSummonPets | Checkbox | 552,8,160,20 | "Summon Pets" | `SummonPets` **[confirmed]** — `defaultsettings.usd:1075-1081` | |
| cCustomPetRange | Checkbox | 552,28,120,20 | "Custom Pet Range:" | `PetRangeMode` (enum 0=`AttackDistance`,1=`PetCustomRange`, per `SettingsEnumInfo` table) **[confirmed]** — `defaultsettings.usd:1083-1089,2260-2271` | |
| txtCustomPetRange | Edit | 672,28,40,16 | "" | `PetCustomRange` **[confirmed]** — `defaultsettings.usd:1091-1097` | |
| Label1_PetDensity | StaticText | 552,48,125,16 | "Pet Min. Monsters:" | — | |
| txtPetDensity | Edit | 672,48,40,16 | "" | `PetMonsterDensity` **[confirmed]** — `defaultsettings.usd:1123-1129` | |
| cShowAdvanced | Checkbox | 611,128,233,20 | "Advanced Options" | UI-only (not in `defaultsettings.usd`) — opens/closes the `AdvancedOptionsView` popup (`refs/vtank/decompiled/db.cs:79`, icon 9660 "gear" per `AdvancedOptionsView.xml:2`) | not a persisted macro setting, a window-visibility toggle |
| cOn | Checkbox | 611,148,233,20 | "Run Macro" | runtime master enable, not in `defaultsettings.usd` | |
### Tab: Profiles (18 controls, 0 list columns)
Every row follows the same **enable-checkbox + profile-name combo +
CopyTo/New button (+ optional Reset/Show-Editor)** pattern, one per macro
sub-system:
| Control | Type | L,T,W,H | Text | Binds to |
|---|---|---|---|---|
| Label123 | StaticText | 8,8,142,16 | "Macro Settings" | — |
| cmbSettingsSet | Choice | 140,8,140,20 | (populated at runtime) | named macro-settings profile list |
| bSettingsSaveAs | PushButton | 280,8,40,20 | "CopyTo" | copy current profile to a new name |
| cSettingsShowAll | Checkbox | 440,8,80,20 | "Mine only" | filter profile list to the current character |
| bSettingsReset | PushButton | 330,8,100,20 | "Clear profile!" | reset current profile to defaults |
| cNav | Checkbox | 8,32,142,20 | "Enable Auto-Navigation" | `EnableNav` **[confirmed]** (same setting the Options-tab `c1EnableNav` binds — two controls, one setting) |
| cmbNavSet | Choice | 140,32,140,20 | — | named nav-profile list |
| bNavSaveAs | PushButton | 280,32,40,20 | "CopyTo" | |
| bNavReset | PushButton | 330,32,100,20 | "Clear nav profile!" | |
| cLoot | Checkbox | 8,56,142,20 | "Enable Looting" | `EnableLooting` **[confirmed]** (same setting as `c1EnableLooting`) |
| cmbLootSet | Choice | 140,56,140,20 | — | named loot-profile list |
| bLootSaveAs | PushButton | 280,56,40,20 | "New" | (label differs from the Nav/Settings "CopyTo" — loot profiles are created fresh, not copied) |
| cShowLootEditor | Checkbox | 440,56,80,20 | "Show Editor" | opens/closes the `FriendlyLootEditorView` popup **[confirmed]** — `PluginCore.cs:1667,1764,6270-6289` (`bb.Change_Old` handler calls `dz.ah.f()`/`dz.ah.b()` to open/close) |
| cMeta | Checkbox | 8,80,142,20 | "Enable Meta Actions" | `EnableMeta` **[confirmed]** (same setting as `c1EnableMeta`) |
| cmbMetaSet | Choice | 140,80,140,20 | — | named meta-profile list |
| bMetaSaveAs | PushButton | 280,80,40,20 | "CopyTo" | |
| bMetaClearViews | PushButton | 330,80,100,20 | "Del. Meta Windows" | closes any floating meta-debug windows |
### Tab: Vitals (28 controls, 0 list columns)
Three groups of 3 sliders + 3 percent-readout labels, each slider bound to
one of the nine `Recharge-<Stance>-<Vital>` settings — **fully confirmed**
by both the initializer and every change handler:
| Control | Type | L,T,W,H | Label | Setting **[confirmed]** | Evidence |
|---|---|---|---|---|---|
| slMyHP | Slider | 96,0,144,16 | "Heal at:" | `Recharge-Norm-HitP` | `PluginCore.cs:4417-4427` (handler `aj`), `:4566` (init) |
| slMyStam | Slider | 96,16,144,16 | "Restam at:" | `Recharge-Norm-Stam` | `:4404-4415` (`ak`), `:4567` |
| slMyMana | Slider | 96,32,144,16 | "Get Mana at:" | `Recharge-Norm-Mana` | `:4391-4402` (`al`), `:4568` |
| slTopOffHP | Slider | 96,56,144,16 | "Top-off HP:" | `Recharge-NoTarg-HitP` | `:4339-4349` (`ap`), `:4569` |
| slTopOffStam | Slider | 96,72,144,16 | "Top-off Stam:" | `Recharge-NoTarg-Stam` | `:4570` (init; handler by symmetry with `ap`) |
| slTopOffMana | Slider | 96,88,144,16 | "Top-off Mana:" | `Recharge-NoTarg-Mana` | `:4571` |
| slOtherHP | Slider | 96,112,144,16 | "Heal others at:" | `Recharge-Helper-HitP` | `:4378-4389` (`am`), `:4572` |
| slOtherStam | Slider | 96,128,144,16 | "Restam others at:" | `Recharge-Helper-Stam` | `:4573` |
| slOtherMana | Slider | 96,144,144,16 | "Infuse others at:" | `Recharge-Helper-Mana` | `:4574` |
Each slider has a matching `minimum="0" maximum="100"` (`mainView.xml:81-83` etc.)
percent readout label (`lblVitals_H_Self`, `_S_Self`, `_M_Self`, `_H_Idle`,
`_S_Idle`, `_M_Idle`, `_H_Help`, `_S_Help`, `_M_Help` — 9 `StaticText`
controls at 248,0 / 248,16 / … / 248,144, each `80×16`, default text `"0%"`)
updated from the slider's `.Position` on every `Change` (e.g.
`cx.Text = co.Position + "%";``PluginCore.cs:4560-4561`). Plus the 9
"Heal at:"/"Restam at:"/… `Label1`/`Label2`/`Label3`/`Label11`/`Label21`/
`Label31`/`Label111`/`Label211`/`Label311` captions (all at left=8, 80×16).
### Tab: Monsters (26 controls: 25 leaf + 1 container, **23 list columns**)
The single `lstMonsters` list (0,16,856,116) has **23 declared columns**,
14 checkbox + 7 text + 2 icon — matching (with two anomalies) the
**21-field `MyMonsters` settings table** (`defaultsettings.usd:48-91`:
`MonsterName, AttackPriority, DamageType, WeaponToUse, Imperil, Vuln,
Yield, GravityW, Attack, Ring, Broadside, Fester, WeakeningCurse,
FesteringCurse, Corruption, DestructiveCurse, Corrosion, Streak,
SecondaryVuln, SecondaryEquip, PetDamageType`):
| # | Column `name` | VVS type | fixedwidth | Data-field match | Header (separate StaticText above the list) |
|---|---|---|---|---|---|
| 1 | clFester | CheckColumn | 16 | `Fester` | "F" (`Label_Mon_F`) |
| 2 | clBroadside | CheckColumn | 16 | `Broadside` | "B" |
| 3 | clGravity | CheckColumn | 16 | `GravityW` | "G" |
| 4 | clImp | CheckColumn | 16 | `Imperil` | "I" |
| 5 | clYield | CheckColumn | 16 | `Yield` | "Y" |
| 6 | clVuln | CheckColumn | 16 | `Vuln` | "V" |
| 7 | clWar | CheckColumn | 16 | `Attack` | "A" |
| 8 | clRings | CheckColumn | 16 | `Ring` | "R" |
| 9 | clRings *(duplicate name — bug in VTank's own XML, see §6)* | CheckColumn | 16 | `Streak`? (unclear — see §6) | "S" |
| 10 | clWeakeningCurse | CheckColumn | 16 | `WeakeningCurse` | "WC" |
| 11 | clFesteringCurse | CheckColumn | 16 | `FesteringCurse` | "FC" |
| 12 | clCorruption | CheckColumn | 16 | `Corruption` | "Cp" |
| 13 | clDestructiveCurse | CheckColumn | 16 | `DestructiveCurse` | "DC" |
| 14 | clCorrosion | CheckColumn | 16 | `Corrosion` | "Cs" |
| 15 | clMonName | TextColumn | 120 | `MonsterName` | "Name" |
| 16 | clPriority | TextColumn | 20 | `AttackPriority` | "P" |
| 17 | clDamageType | TextColumn | 56 | `DamageType` | "Dmg type" |
| 18 | clExVulnType | TextColumn | 56 | `SecondaryVuln` (inferred) | "Ex. Vuln" |
| 19 | clWeaponToUse | TextColumn | 80 | `WeaponToUse` | "Weapon" |
| 20 | clOffHand | TextColumn | 80 | `SecondaryEquip` (inferred) | "Offhand" |
| 21 | clPetDamageType | TextColumn | 56 | `PetDamageType` | "PetDmg" |
| 22 | clMoveUp | IconColumn | 16 | (UI-only reorder arrow, no data field) | — |
| 23 | clMoveDown | IconColumn | 16 | (UI-only reorder arrow, no data field) | — |
Below the list: `txtNewMonster` (Edit, 8,136,388,16), `cmdNewMonster`
(PushButton "Add", 404,136,50,16), `cmdNewMonsterFromCur` (PushButton
"Add Sel", 462,136,50,16). Nine tooltip strings are assigned to the header
labels at runtime (`bm.TooltipText = "Fester"` … `b0.TooltipText =
"Priority"`, `PluginCore.cs:1693-1707`) — the single-letter header glyphs
are deliberately terse and rely on a hover tooltip to spell out the full
curse/spell-family name.
### Tab: Items (6 controls, 2 list columns)
| Control | Type | L,T,W,H | Text | Columns |
|---|---|---|---|---|
| Label4a | StaticText | 0,0,320,16 | "Weapons / Wands / Shields / Pets" | — |
| Label4b | StaticText | 320,0,80,16 | "Hands" | — |
| lstBuffItems | List | 0,16,420,116 | — | `clWeaponName` (TextColumn, 320), `clHandedness` (TextColumn, 80) |
| cmdNewWeapon | PushButton | 10,136,140,16 | "Add" | — |
| cmdNewWeapon_NoBuffs | PushButton | 170,136,140,16 | "Add (no buffs)" | — |
Underlying data table (inferred by column-name match): `BuffedItems`
(`Object`, `Spell` fields — `defaultsettings.usd:14-20`) for the weapon
list; handedness likely a derived/wrapper property, not itself a
`defaultsettings.usd` field.
### Tab: Consumables (8 controls, 3 list columns)
| Control | Type | L,T,W,H | Text | Columns |
|---|---|---|---|---|
| Label7a | StaticText | 0,0,400,16 | "Gems / Food / Kits / Potions / Charges / Grenades / Lockpicks" | — |
| lstExtraBuffItems | List | 0,16,344,116 | — | `clExtraBuffItemsC1` (TextColumn, 320) |
| cmdNewBuffItem | PushButton | 0,136,260,16 | "Add" | — |
| cmdAddAllPeas | PushButton | 264,136,80,16 | "Add All Peas" | — |
| Label7b | StaticText | 448,0,344,16 | "Excluded Scarab Types:" | — |
| lstExcludedComponents | List | 448,16,344,116 | — | `1` (IconColumn, 16 — unnamed/numeric column name, another minor authoring wart), `2` (TextColumn, no fixedwidth → auto-sized) |
| cmdNewExcludedComponent | PushButton | 448,136,344,16 | "Add Selected" | — |
Underlying data (inferred): `GemFoodItems` (`Name`, `Spell`
`defaultsettings.usd:26-31`) for the left list; `BlacklistedSpellComps`
(a bare string-list setting — `defaultsettings.usd:1035-1041`) for the
right list.
### Tab: Buffs (7 controls, 2 list columns)
| Control | Type | L,T,W,H | Text | Columns |
|---|---|---|---|---|
| LabelBPa | StaticText | 0,0,320,16 | "Extra Buff Spells (best similar will be used)" | — |
| lstBuffSpells | List | 0,16,320,116 | — | `clBuff` (TextColumn, 320) |
| cmdNewBuffSpell | PushButton | 10,136,140,16 | "Add..." | — |
| LabelBPb | StaticText | 472,0,320,16 | "Blacklisted Buff Families" | — |
| lstAntiBuffSpells | List | 472,16,320,116 | — | `clBuff` (TextColumn, 320 — **same column name as the left list**, harmless since each `HudList` instance owns its own column namespace) |
| cmdNewAntiBuffSpell | PushButton | 472,136,140,16 | "Add..." | — |
Data tables (confirmed by name): `ExtraBuffSpells` and
`AntiExtraBuffSpells`, both single-field `ExemplarId` tables
(`defaultsettings.usd:1-6,21-25`).
### Tab: Route (20 controls, 2 list columns)
| Control | Type | L,T,W,H | Text | Notes |
|---|---|---|---|---|
| Label5 | StaticText | 0,0,370,16 | "Navigation Waypoints" | |
| lstWaypoints | List | 0,16,370,116 | — | columns `clWP` (TextColumn, 330), `clWPc` (TextColumn, 30) |
| layoutRouteBottomControls | FixedLayout | 0,136,370,16 | — | nested layout, XML comment: "A layout containing stuff below the points list to make resizing easier" |
| cmbNavType | Choice | 0,0,80,16 (nested) | selected=0 | options: Circular / Linear / Follow / Once |
| cmbNavInsertMode | Choice | 198,0,120,16 (nested) | selected=0 | options: Add to End / Insert Above / Insert Below |
| btnNavDown | Button (image) | 322,0,16,16 (nested) | — | image button, `Background=100673789` **[confirmed]** — `PluginCore.cs:1820`; tooltip "Advance Current Point" |
| btnNavUp | Button (image) | 338,0,16,16 (nested) | — | `Background=100673788` **[confirmed]** — `PluginCore.cs:1822`; tooltip "Regress Current Point" |
| btnNavResetPoint | Button (image) | 354,0,16,16 (nested) | — | `Background=100667895` **[confirmed]** — `PluginCore.cs:1824`; tooltip "Select Nearest Point" |
| cmdNewWaypoint | PushButton | 376,16,88,16 | "Add" | |
| cmdNavOpenVendor | PushButton | 470,16,88,16 | "Open Vendor" | |
| cmdNewWaypointPortal | PushButton | 376,38,88,16 | "Add Portal/NPC" | |
| cmdNavUseNPC | PushButton | 470,38,88,16 | "Add NPC Talk" | |
| cmdNewRecall | PushButton | 376,60,88,16 | "Add Recall" | |
| cmbRecallType | Choice | 470,60,120,16 | selected=0 | 27 options: Primary/Secondary/LS/LS Sending/Portal/Aphus/Sanctuary/Caul/GW/Aerlinthe/Mt. Lethe/Ulgrim's/Bur/PtOIA/Graveyard/Colosseum/Fac. Hub/Gear K. Camp/Neftet/Candeth/FacHub/Rynthid/VR Rocks/VR Tree/Soc. CH/Soc. RB/Soc. EW (note: "Fac. Hub" and "FacHub" both present — another authoring duplicate) |
| cmdNewWaypointPause | PushButton | 376,82,88,16 | "Add Pause" | |
| txtPauseWaypointTime | Edit | 470,82,68,16 | "5" | one of only two Edit controls in the file with a non-empty default `text` (the other is `txtChatWaypoint`, `"/ls"`) |
| Label52 | StaticText | 543,82,50,16 | "seconds" | |
| cmdNewWaypointChat | PushButton | 376,104,88,16 | "Add Chat" | |
| txtChatWaypoint | Edit | 470,104,120,16 | "/ls" | |
The `100673789`/`100673788`/`100667895` background image ids assigned to
the three nav image-buttons decode (subtracting VVS's `0x6000000` DAT
block prefix) to portal.dat indices `0x28FD`, `0x28FC`, `0x11F7` (full ids `0x060028FD`,
`0x060028FC`, `0x060011F7`; the first draft mis-decoded the decimals) — retail DAT icon glyphs, not synthesized art.
### Tab: Meta (5 controls, 6 list columns)
| Control | Type | L,T,W,H | Text | Columns |
|---|---|---|---|---|
| lstMetaRules | List | 0,16,856,116 | — | `clDelete` (IconColumn,16), `clMoveUp` (IconColumn,16), `clMoveDown` (IconColumn,16), `clState` (TextColumn,150), `clCondition` (TextColumn, no fixedwidth → auto-sized), `clAction` (TextColumn, no fixedwidth → auto-sized) |
| cmdMetaCreate | PushButton | 462,136,50,16 | "Create" | |
| lblmetacurrentstate | StaticText | 4,136,136,16 | "Current State:" | bold, left-justified |
| cmbMetaCurrentState | Choice | 140,136,120,16 | selected=0 | one placeholder option `"---"` — populated at runtime from the profile's actual state list |
### Secondary view: AdvancedOptionsView (392×300, icon 9660)
| Control | Type | L,T,W,H | Columns |
|---|---|---|---|
| lOptionList | List | 4,4,260,160 | `clOpt` (TextColumn,180), `clVal` (TextColumn,62) |
| txtInfo | StaticText | 4,188,384,80 | — (description readout for the selected option) |
| txtEditbox | Edit | 4,168,260,16 | — |
| lFilterList | List | 268,4,120,180 | `clEn` (CheckColumn, no fixedwidth), `clSetting` (TextColumn, no fixedwidth) |
This is the raw editor over the full `Settings` table
(`defaultsettings.usd:135-144`: `Setting, Value, Description,
SettingType`) — **137 rows** per the file's own row count
(`defaultsettings.usd:145`), matching MossTank's `mosstank.xml:107`
tooltip text "Select one of Virindi Tank's 137 advanced options."
`lFilterList` is a checklist of `SettingsCategories` bitmask groups
(`defaultsettings.usd:1513-1518`) used to filter `lOptionList`.
### Secondary view: SelfBuffChoiceView (268×236, icon 8241)
| Control | Type | L,T,W,H | Text | Columns |
|---|---|---|---|---|
| lExemplarList | List | 4,4,260,160 | — | `clVal` (TextColumn, no fixedwidth) |
| lblSearch | StaticText | 4,168,384,16 | "Search:" | — |
| txtSearch | Edit | 4,188,260,16 | "" | — |
A simple filtered single-column exemplar picker, opened from the Buffs
tab's "Add..." buttons (`cmdNewBuffSpell`/`cmdNewAntiBuffSpell`) — the
`txtSearch` live-filters `lExemplarList` (loaded twice under two different
obfuscated class names, `e7.cs:108` and `hz.cs:108`, once per "Add..."
button, each with its own filter predicate).
### Secondary view: FriendlyLootEditorView (268×300, icon 8241)
Two-tab `Notebook1`:
| Page | Control | Type | L,T,W,H | Text | Columns |
|---|---|---|---|---|---|
| Rules | LabelA1 | StaticText | 4,2,80,16 | "Loot Rule:" | — |
| Rules | cmbRuleList | Choice | 4,18,240,20 | — | — |
| Rules | LabelA2 | StaticText | 100,140,150,16 | "This page not yet completed." | — |
| Names | LabelB1 | StaticText | 4,2,80,16 | "Name match:" | — |
| Names | LabelB2 | StaticText | 230,2,30,16 | "Exact" | — |
| Names | lNames | List | 4,18,260,180 | — | `clName` (TextColumn,228), `clExact` (CheckColumn,32) |
| Names | LabelB3 | StaticText | 4,200,80,16 | "New name:" | — |
| Names | txtNewName | Edit | 4,212,196,16 | "" | — |
| Names | cExact | Checkbox | 204,212,40,16 | "Ex." checked=true | — |
| Names | bAddName | PushButton | 234,212,30,16 | "Add" | — |
**The "Rules" page is a stub in retail VTank itself** — its own XML
literally reads "This page not yet completed." (`FriendlyLootEditorView.xml:10`).
Only the "Names" (name-match) editor is functional. This is the one place
where our `mosstank.xml`'s loot editor (§5) is **more complete than this
resource** — but note (§6) that VTank never loads `FriendlyLootEditorView.xml`;
the editor a VTank user actually sees belongs to the external loot plugin
(VTClassic), outside `refs/vtank/`, so this is not a comparison against
what ships.
---
## 2. VVS control semantics to reproduce for parity
| VVS control | Key semantics | Source |
|---|---|---|
| `HudList` | Vertically scrolling grid; every row has the same ordered column set (`cColInfo`: `ControlType`/`Width`/`Name`), each cell is a live child control (`HudStaticText`/`HudCheckBox`/`HudPictureBox`/`HudButton`/`HudTextBox`/nested `HudList`/`HudFixedLayout`/`HudTabView`). **No built-in header row** — the header glyphs seen in `mainView.xml` (F/B/G/I/…) are ordinary `StaticText` controls placed manually above the list at `top=0`, with the list itself starting at `top=16`. Row height defaults to `ControlHeight=16px`; `Padding=1px` (vertical), `WPadding=7px` (horizontal gap between columns), `WPaddingOuter=3px` (left/right list margin). A `0`-width text/button/edit/list/fixedlayout/notebook column auto-sizes: all such columns in one list share the remaining width equally after fixed-width and icon (fixed at declared width) and check (fixed 13px) columns are subtracted. Selection is not built in as a highlighted-row concept — `Click` is a **per-cell** event `delClickedControl(sender, row, col)`, fired from `MouseUp` for the cell containing the **press** point (`HudList.cs:482` tests `orig` only — the release point is never checked against the cell, so press-in-A/release-in-B still reports cell A); callers implement "selected row" state themselves. Scrolling is a `HudVScrollBar` child, width from the theme's `VScrollBarButtonSize` (16px in `Float_Theme`), driven by mouse-wheel or drag. | `HudList.cs:14-609` (whole file); `Float_Theme.cs:58` |
| `HudList` column mapping (from progid) | `IconColumn``HudPictureBox` (default 16px), `TextColumn``HudStaticText`, `CheckColumn``HudCheckBox` (fixed 13px regardless of declared width), plus (less commonly used) `FixedLayout``HudFixedLayout`, `Notebook``HudTabView`, `PushButton``HudButton`, `Checkbox``HudCheckBox`, `Edit``HudTextBox`, `StaticText``HudStaticText`, `List``HudList` (nested) | `Decal3XMLParser.cs:257-424` |
| `HudCombo` (`Choice`) | A dropdown: closed state shows the selected item's control (usually a `HudStaticText`) in a `ComboBackground_Unselected` box with a themed up/down arrow (16px square, `arrowrect`). Opening spawns a **separate top-level popup surface** (`DxHud`, its own device/texture, `+1` z-priority above the owning view) sized to `min(item count, 10) * 16px` rows (`m_j=10` fixed max-visible, `dropdownamt`), with its own `HudVScrollBar` if the item count exceeds 10. Selecting an item raises `Change`; closing happens on any click outside the drop rect, or via `FireLostFocus`. `AddItem`/`InsertItem` accept either a raw string (wrapped in a `HudStaticText`) or an arbitrary `HudControl` + opaque tag object — so combo items are not restricted to plain text. | `HudCombo.cs:15-744` |
| `HudTabView` (`Notebook`) | Tab strip height is themed (`TabHeight=16px` in `Float_Theme`). Each tab's width auto-sizes to its caption's rendered text width (capped at 280px) plus themed left/right border sizes (`TabLeftBorder_Size`/`TabRightBorder_Size`, both `0` in `Float_Theme`) plus a themed horizontal padding (`TabTextHPadding=6px`). Selected vs. deselected tabs use **separate font options** (`TabTextFontName/Size/Weight/Color/Shadow*_Selected` vs `_Deselected`). Mouse wheel over the tab strip cycles tabs; over the body it's forwarded to the active child. Only one tab's content control is ever `CanDraw=true` at a time — switching tabs disposes no state, it just flips visibility/draw flags on the existing child controls (so per-tab state survives switching, which matters for MossTank/acdream parity: switching tabs must not reset in-progress edits). | `HudTabView.cs:12-455`; `Float_Theme.cs:91-93` |
| `HudHSlider` (`Slider`) | Backed by `LinearPositionControl` (`Min`/`Max`/`Position`, `Changed(min,max,pos)` event, `Percentage` helper = `100*Position/(Max-Min)`). Draws as a 3px-tall two-tone bar (`SliderBarOuter`/`SliderBarInner`) plus a themed nub (`SliderNubWidth=7px`, `SliderNubHeight=12px` in `Float_Theme`) whose X position is `GetPositionProportionalPixels`. Drag-to-set, click-to-jump-to-position, and mouse-wheel (±1 unit per notch, accumulator-smoothed via `v.a`) are all supported; `UserChangeable=false` disables all three without hiding the control. | `HudHSlider.cs:9-131`, `LinearPositionControl.cs:6-186`, `Float_Theme.cs:54-55` |
| `HudTextBox` (`Edit`) | Single-line text input with a blinking caret (500ms toggle via an internal timer `r`), left/right/Home/End/Backspace/Delete/Ctrl+V (paste from the Windows clipboard) key handling, and an optional numeric-only input mode (`eTextBoxInputType.Int_Numeric`/`Float_Numeric`) that rejects any edit that doesn't `TryParse`. Background is themed (`TextBoxBackground`/`_MouseOver`) with a 1px `ButtonHighlight`/`ButtonShadow` bevel. **The `imageportalsrc="4726"` attribute present on every `Edit` control in `mainView.xml` is never read by `Decal3XMLParser.cs`'s `"decalcontrols.edit"` case** — only `text` is consumed; the parser never sets `HudPictureBox.Image` for an Edit control, and a repo-wide grep of VTank's own source found no other code path assigning it either. This is a **dead/vestigial XML attribute**, likely copy-pasted from an older Decal 3.0 view-XML dialect; acdream's markup does not need to reproduce it. | `HudTextBox.cs:11-417` (esp. `RawKeyAction`/`KeyDown` `260-350`, `DrawNow` `371-416`); parser evidence `Decal3XMLParser.cs:205-212`; dead-attribute confirmed by grep across `refs/vtank/decompiled/` (only hits are the literal XML strings and one string-builder-generated XML fragment in `h8.cs:104`) |
| `HudButton` (`PushButton`) vs. `HudImageButton` (`Button`) | **These are two different progids mapping to two different classes.** `PushButton``HudButton`: text-only push button, themed `ButtonBackground`/`_MouseOver`/`_Down`/`_Down_MouseOver` fills with a 1px bevel, optional `ImagePressed`/`OverlayImage` override. `Button``HudImageButton`: an image-only button taking `iconlibrary` (a portal-dat "icon library" grouping id), `background`/`icon`/`pressedicon` (each resolved via `ACImage.FromIconLibrary` unless the numeric value already looks like a raw image id, per `global::b.d`), and `matte` (a background color override, with `Color.Cyan` specifically mapped to fully transparent). Neither VTank's `mainView.xml` uses `Button` directly (only the three Route-tab nav arrows use it, via VTank's own C# code assigning `.Background` post-construction rather than XML `icon=`/`background=` attributes — see the Route-tab table above) nor does `AdvancedOptionsView.xml`/`FriendlyLootEditorView.xml`. | `HudButton.cs:9-226`; parser `Decal3XMLParser.cs:157-164` (`PushButton`), `:165-193` (`Button`) |
| `HudCheckBox` (`Checkbox`) | Fixed **13×13px** glyph box regardless of the declared control width/height (only the optional trailing label text uses the extra width). Three visual states: unchecked/checked (each with a `_MouseOver` variant) plus a transient "partial" press-feedback state while the mouse is down inside the box. `UserChangeable=false` disables toggling without hiding it. Label text (when present) starts at a fixed `+17px` offset from the left edge. | `HudCheckBox.cs:10-267` |
| `HudControl` (base) | Only left-button `MouseDown`/`MouseUp`/`MouseMove`/`MouseWheel` are wired; there is no right-click or double-click concept anywhere in the base class or in any of the concrete controls surveyed. A `Hit` event fires once per press-then-release-inside-the-same-control. Focus is a single static `HudView.FocusControl` — only one control across the *entire* VVS runtime (all views, all plugins) can have keyboard focus at a time. | `HudControl.cs:13-712` |
---
## 3. Mapping to acdream markup
### 1:1 today
| VVS control | acdream element | Notes |
|---|---|---|
| `PushButton`/`Button` (text) | `<button>` | text + `onclick`; acdream's `<button icon=... iconkind=...>` already covers VVS's image-button case more generally (any of did/spell/item, not just a bare portal-dat icon-library lookup) |
| `Checkbox` | `<toggle>` | `checked`/`onclick`; acdream's is a bound bool with no separate `UserChangeable`-style disable — use the common `enabled` attribute instead |
| `Slider` | `<slider>` | `value`/`onchange`; VVS's `Min`/`Max` are not directly exposed in acdream's `<slider>` today — see gap below |
| `StaticText` | `<label>` | acdream's `color` attribute already generalizes VVS's `textcolor`/`justify`/font options (acdream fixes font via the DAT bitmap font, so per-label font-face/size isn't a parity concern) |
| `Edit` | `<field>` | `text`/`onchange`/`onsubmit`; VVS's numeric-only input mode (`eTextBoxInputType`) has no acdream equivalent yet — not currently exercised by `mainView.xml` (every `Edit` there is `Any`-typed) so not urgent |
| `Notebook` (page tabs) | acdream's existing `<tab>` + one `<group visible=...>` per page (the pattern `mosstank.xml` already uses) | Functionally equivalent to `HudTabView`, but acdream implements it as N independent buttons + N groups rather than one owning control — fine for parity since VVS tab state (selected index) has no behavior beyond visibility toggling anyway |
| `Choice` (dropdown) | `<menu>` | `items`/`selected`/`onchange`; acdream's `openupward` (default false = VVS's always-downward) matches; `rows` does **not**`<menu>` never sets `UiMenu.Scrollable`, so items past `rows` wrap into extra **columns** (`UiMenu.cs:356-360`) instead of scrolling one column like `HudCombo`; parity means exposing `Scrollable` from markup (added to the gap table below) — acdream's `openupward` is actually a **superset** of VVS (VVS's `HudCombo` always opens downward from `DropRect`, computed as `base.n.Top` downward with no upward flip) |
### Needs a markup extension
| Gap | What VVS has | What acdream's `<list>` has today | Proposed extension |
|---|---|---|---|
| **Multi-column lists with typed columns** | `HudList`: N columns per row, each independently typed (`TextColumn`/`CheckColumn`/`IconColumn`/nested controls), per-cell `Click(row,col)` | Exactly one text column + one optional icon column (`docs/plugin-ui-markup.md`'s explicit LIMITATION note) | Add a `<column>` child element under `<list>`, e.g.:<br>`<list x= y= w= h= rowheight=17 selected="{Idx}" onchange="{Select}">`<br>` <column type="text" width="120" header="Name" items="{NameCol}"/>`<br>` <column type="check" width="16" header="F" values="{FesterCol}" onchange="{ToggleFesterCol}"/>`<br>` <column type="icon" width="16" iconkind="did" values="{MoveUpIcons}" onclick="{MoveUpCol}"/>`<br>`</list>`<br>Each `<column>` binds its own `IEnumerable<T>` parallel to the row count (mirroring VVS's per-column `cColInfo`), keeping the existing single-column `<list items=...>` form as sugar for a one-`text`-column list (backward compatible with every current markup file). `type="check"`/`type="icon"` columns need their own per-row `onchange`/`onclick` binding returning the row index (VVS's `Click(row,col)` folded into a per-column callback, since acdream's binding model is per-attribute rather than per-cell) |
| **Header row** | Not built into `HudList` at all (VTank places separate `<label>`s above the list, exactly as acdream would today with `<label>` + `<list>`) | N/A — acdream already matches VVS here for free | No extension needed; document the pattern (static `<label>` row above a `<list>`) as the recommended idiom, matching `mainView.xml`'s Monsters-tab header |
| **Dropdown-from-a-list-of-arbitrary-controls** | `HudCombo.AddItem(HudControl, tag)` — combo items aren't restricted to text | acdream's `<menu items="{Strings}">` is text-only | Not worth adding — no VTank tab actually uses a non-text combo item; flagged only for completeness |
| **Menu overflow scrolls in VVS, wraps in acdream** | `HudCombo`: one column, at most 10 visible rows, `HudVScrollBar` beyond that (`HudCombo.cs:35,102-146`) | `<menu>` never sets `UiMenu.Scrollable`; items past `rows` wrap into extra columns (`UiMenu.cs:356-360`) | Expose `Scrollable` from `<menu>` markup (a `scroll="true"` attribute) so a VTank `Choice` with many entries keeps VVS's one-column look (citation pass 2026-09-06) |
| **Slider `Min`/`Max`** | `HudHSlider`/`LinearPositionControl` expose `Min`/`Max` independent of a fixed 0-100 (every VTank slider in `mainView.xml` happens to use `minimum="0" maximum="100"`, but the control supports arbitrary ranges) | **confirmed fixed 0.01.0**: `<slider>` reads only `value`/`onchange` (`MarkupDocument.cs:374-399`) and `UiScrollbar.SetScalarPosition` clamps to `[0,1]` (`UiScrollbar.cs:100-101`) | Add optional `min`/`max` literal attributes (defaulting to today's implicit range) so a 1:1 VTank slider port doesn't need a value-rescaling shim in the plugin's ViewModel layer |
| **Edit-box background image** (`imageportalsrc`) | Present in every VTank `Edit` control's XML, but confirmed dead in VVS itself (see §2) | acdream's `<field background=...>` already exists (a real, live attribute, unlike VVS's dead one) | No extension needed — acdream's `<field background>` is strictly more useful than what VVS actually does with this attribute |
None of the three secondary popup views (`AdvancedOptionsView`,
`SelfBuffChoiceView`, `FriendlyLootEditorView`) need anything beyond the
gaps above — they're smaller compositions of the same List/Edit/Choice/
Checkbox/StaticText primitives.
---
## 4. Window-level facts
Covered in §0 above (kept together with the other numbered items per the
task's own ordering; repeated here only as a pointer since §0 precedes §1
in this document for readability).
---
## 5. The MossTank gap
Comparing `src/AcDream.Plugins.MossTank/mosstank.xml` (586 lines) against
the `mainView.xml` transcription above:
| Area | VTank (`mainView.xml`) | MossTank (`mosstank.xml`) | Assessment |
|---|---|---|---|
| Window size | 856×210 (9-tab strip fits in 210px tall because every tab's content scrolls/paginates within a fixed 116-134px list) | 800×244 (`mosstank.xml:7`) | Deliberately reshaped, not a bug — 56px narrower, 34px taller; matches acdream's own control metrics (16px checkbox rows read slightly differently in acdream's DAT font vs. VVS's) rather than being a literal port |
| Tab strip | `HudTabView`, one owning control, auto-sized tab widths | 9 independent `<tab>` buttons with hand-picked `x`/`w` per tab (`mosstank.xml:10-27`) | Correct idiom given the gap noted in §3; hand-tuned widths look reasonable against each tab's caption length (spot check: "Consumables" gets `w="86"`, the widest, correctly) |
| Options tab | 4-column layout, single flat page, 37 controls | 4-visual-column layout preserved (`mosstank.xml:29-99`), but **folds VTank's separate `AdvancedOptionsView` popup into an in-panel group** (`AdvancedOptionsVisible`, `mosstank.xml:101-118`) reachable via the same "Advanced Options" button | Deliberate redesign (single-window UX vs. VVS's popup-window pattern), consistent with acdream's plugin panels not each spawning independent OS-level windows. Not a defect — a documented architectural choice |
| Missing controls | — | `cShowLootEditor`'s VTank behavior (open a *separate* `FriendlyLootEditorView` popup) is instead an in-panel `LootEditorVisible` group (`mosstank.xml:519-585`), same pattern as Advanced Options | Consistent design choice, not an omission — and per §1's Loot-Editor table, MossTank's rules editor is functionally **more complete** than VTank's own (VTank's Rules page is a literal stub) |
| Monsters tab — **the real gap** | 14 checkbox columns + 7 text columns, all inline per row in one scrolling grid (23 columns total) | A single-column `<list>` (`mosstank.xml:232-234`) showing pre-formatted text rows, with the 14 boolean flags exposed as **14 separate `<toggle>` controls below the list** (`mosstank.xml:269-282`) that edit only the *currently selected* row | This is the direct consequence of the multi-column-list gap in §3. It's a legitimate current workaround (edit-the-selected-row, rather than edit-any-cell-inline) but loses VTank's at-a-glance grid view (seeing every monster's full flag row without selecting it first). Closing the §3 `<column>` gap would let this tab go back to VTank's original one-row-per-monster grid layout |
| Items/Consumables/Buffs tabs | 2-column (Items), 1-column ×2 (Consumables), 1-column ×2 (Buffs) lists | Single-column lists with padded text rows (comment in `docs/plugin-ui-markup.md:116`: `$"{name,-16}{value,6}"`) | Same root cause as Monsters, smaller blast radius since these VTank lists are only 1-2 columns to begin with — a `<column>` extension would let Items regain its true 2-column (name/handedness) layout, but the workaround here is much less lossy than Monsters' 14-checkbox grid |
| Route/Meta tabs | `lstWaypoints` (2 cols), `lstMetaRules` (2 icon + 4 text = 6 cols, including inline per-row Delete/Up/Down icon buttons) | Single-column padded-text lists (`mosstank.xml:367-369`, `461-463`) with the row-reorder/delete actions pulled out to buttons below the list (`↑`/`↓`/`Remove`, `mosstank.xml:410-415`, `509-512`) | Same selected-row-editor pattern as Monsters — reasonable given the gap, loses VTank's inline per-row icon buttons (click the up-arrow *in that row* vs. select-then-click-a-shared-up-arrow-button) |
| Oversized/undersized controls | n/a (authored, VTank's own values) | Nothing found materially oversized against its own 800×244 canvas; every group is sized to `w="784" h="194"` (leaving room for the 8px margin + tab strip), consistent across all 9 tabs (`mosstank.xml:30,101,122,180,229,291,313,331,365,442,519`) | No sizing defects found |
| Extra controls VTank doesn't have | — | `AdvancedOptionsVisible` and `LootEditorVisible` groups (discussed above) are additions, by design, not accidental scope creep | — |
**Bottom line:** the single architectural gap driving every "lossy"
difference above is the one already flagged in
`docs/plugin-ui-markup.md`'s explicit LIMITATION note — acdream's `<list>`
has no multi-column support. MossTank's current "select a row, edit it with
separate controls below" pattern is a reasonable adaptation to that gap,
not an authoring mistake, and the §3 extension spec would let a future pass
restore VTank's true inline-grid layout for Monsters/Items/Route/Meta
without any other markup change.
---
## 6. Could not determine
- **The exact handler bodies for the majority of the 156 controls.**
`PluginCore.cs` (the class wiring `mainView.xml`) uses single/double-letter
field and method names throughout (an obfuscator artifact, consistent
with `feedback_bn_decomp_field_names.md`'s "heuristic field names, name ≠
semantics" caution, extended here to a *different* decompiler/obfuscator
than the Ghidra/Binary Ninja one that note originally warns about). I
traced specific handlers only where a distinguishing string literal
(a settings-key string, or a numeric `.Background=` assignment) let me
disambiguate one letter-named method from the thousands of others in the
same class (the Vitals-tab sliders, the Route-tab nav-arrow icon ids, the
loot-editor checkbox). For the remaining controls (all of Profiles,
Items, Consumables, Buffs, Route's recall/pause/chat buttons, all of
Meta), the "binds to" column above is a naming-convention inference
against `defaultsettings.usd`/the XML's own control names, not a
traced `file:line` proof.
- **`clRings` duplicate column — RESOLVED** (citation pass 2026-09-06): VTank's
C# never addresses a column by name (no `clRings` reference outside the
XML); cells are addressed by integer index (`MetaViewWrappers/dClickedList.cs`
is `void(object, int row, int col)`). `PluginCore.cs:7904-7948` switches on
the column index and writes fourteen distinct fields (case 7 → `a10.j`,
case 8 → `a10.s`), so column 9 ("S") is genuinely its own `Streak` field and
the duplicate `name` is inert metadata. Bonus: case 6 ("A"/Attack) writes
inverted (`a10.t = !…`); column 14 is the monster name (`clMonName`).
- **`FriendlyLootEditorView.xml` call site — RESOLVED: there is none.** Every
VTank view loads via `ad.f(host, name)`; the only five call sites are
`mainView.xml` (`PluginCore.cs:1608`), `AdvancedOptionsView.xml`
(`db.cs:79`), `SelfBuffChoiceView.xml` (`e7.cs:108`, `hz.cs:108`) and
`dc.xml` (`dc.cs:52`). `PluginCore.dz` is typed `s` (`PluginCore.cs:941`);
`s.ah` is a `cu` (`s.cs:75`), the `uTank2.LootPlugins` manager, whose `f()`/
`b()` (`cu.cs:115,136`) delegate to `OpenEditorForProfile()`/
`CloseEditorForProfile()` on the EXTERNAL loot plugin. The resource is
vestigial.
- **VTank's exact on-disk schema for per-character nav/loot/meta/settings
*profile* documents** (as opposed to the global `defaultsettings.usd`
default-value catalog, which I did read in full). `cmbSettingsSet`/
`cmbNavSet`/`cmbLootSet`/`cmbMetaSet` clearly select among named,
persisted profiles, and `lstWaypoints`/`lstMetaRules`/loot rules are
per-profile row data, but none of that per-profile storage format is
present under `refs/vtank/` — only the default/template values are.
- **Whether VVS's numeric-input `HudTextBox` mode
(`eTextBoxInputType.Int_Numeric`/`Float_Numeric`) is used anywhere in
`mainView.xml`.** The XML attribute space has no field for input-type
(it's set programmatically post-construction, if at all), and I did not
find a `PluginCore.cs` line setting `.InputType`/equivalent on any of the
wrapped `ITextBox` instances within the effort budget for this pass —
flagged as low-priority since every `Edit` control observed accepts
free-form text either way (VTank does its own string→number parsing on
submit rather than relying on VVS to reject bad input at the keystroke
level, going by the numeric fields' plain-`Edit` progid with no visible
extra attribute).

View file

@ -0,0 +1,544 @@
# VTank KB 09 — Commands and Interop
Oracle: decompiled retail VTank at `refs/vtank/decompiled/` (namespace root
`uTank2`; obfuscated single/double-letter file names throughout — `dz` is
VTank's central logic-object aggregate, `PC` is the plugin-core self
reference). Loot-plugin cross-check: `refs/vtank-classiclooter/decompiled/`
(`VTClassic.LootCore`). Consumer cross-check: a real third-party Decal
plugin, `MosswartMassacre/vTank.cs` + `VtankControl.cs`. acdream side:
`src/AcDream.Plugins.MossTank/MossTankCommands.cs`,
`src/AcDream.Plugin.Abstractions/PluginCommands.cs`,
`src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs`,
`src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs`,
`src/AcDream.App/Plugins/AppAutomationSurface.cs`,
`src/AcDream.Core/Plugins/PluginCommandRegistry.cs`. All decompiled facts
are cited `file:line` against `refs/vtank/decompiled/...` unless another
root is named. UtilityBelt's VTank integration lives in the full source at
`C:\Users\erikn\source\repos\utilitybelt.gitlab.io` (the older
`utilitybelt.service` clone has none) — see §6 for the Harmony-patch route.
## How `/vt` reaches the parser
VTank subscribes to Decal's chat-parser interception event; the handler is
a thin adapter that hands the raw line to the real parser and marks it
"eaten" (swallowed, never reaches the AC server or later plugins in the
interception chain) when the parser recognized it:
```
private void a(object A_0, ChatParserInterceptEventArgs A_1)
{
((EatableEventArgs)A_1).Eat = l(A_1.Text);
}
```
`PluginCore.cs:3970-3980`. `l(string)` is the actual command parser
(`PluginCore.cs:4732-6249`, ~1500 lines, a single cascading `if/else`
chain keyed on `text.CompareTo(...)`/`text.StartsWith(...)` after
stripping a leading `/` or `@` and the `vt ` prefix, `PluginCore.cs:4756-4761`).
VTank's own Decal plugin registers under GUID
`{642F1F48-16BE-48BF-B1D4-286652C4533E}` (`PluginCore.cs:3990`).
## 1. The `/vt` command table
`/vt help` prints exactly this four-line catalog (`PluginCore.cs:4765-4768`):
profiles, actions, game info, debug. The table below is every verb the
parser actually recognizes, grouped the same way, with syntax/effect/output
drawn from the parser body.
### Profiles
| Verb | Syntax | Effect | Output / error | `file:line` |
|---|---|---|---|---|
| `settings` | `/vt settings [save\|load\|savechar\|loadchar] [filename]` | save/load `.usd` settings profile; `savechar`/`loadchar` prefix the filename with the character-scoped folder (`dw`) | usage text on bad args | `PluginCore.cs:5671-5725` |
| `nav` | `/vt nav [save\|load] [filename]` | save/load `.nav` route file (`dz.m.n`, `dz.m.r/o`) | usage text on bad args | `PluginCore.cs:5634-5670` |
| `loot` | `/vt loot [load\|new] [filename]` | load or create a loot profile; validates the filename has no `\`, and that its extension matches an **active loot plugin's** registered extension (`dz.ah.a(ext)`) — else lists active plugins via `dz.ah.c()` | "Filename contains invalid characters.", "Invalid file extension (.xxx). Active loot plugins are:" + list, or usage text | `PluginCore.cs:5726-5775` |
| `meta` | `/vt meta [save\|load] [filename]` | save/load `.met` Meta (state-machine) profile | usage text on bad args | `PluginCore.cs:5776-5813` |
| `opt` | `/vt opt list` / `get [name]` / `set [name] [value]` / `setinall [name] [value]` | list/read/write a VTank setting by name via the reflection-typed `cw`/`gy` value wrappers (double/int/float/string/bool); `setinall` additionally pushes the value into **all** monster-rule profiles | "Usage: /vt opt [list/get/set/setinall]"; "Option set: Invalid option specified."; "Option set: Invalid value specified. Proper type of X is Y." | `PluginCore.cs:5446-5622` |
| `testitem` | `/vt testitem` | classifies the currently selected item against loot rules (forces an ID request first if unidentified) | "TestItem: No item selected." or the rule match / classify text (see `b(fu)` helper) | `PluginCore.cs:5129-5144` |
| `propertydump` | `/vt propertydump` | dumps every retail Property enum value (Int/Int64/Bool/Float/String/DataId/InstanceId + link/resource values + palette entries) known for the selected object | "Propertydump: Either no object selected or current selection object is invalid." | `PluginCore.cs:5981-5996`, dump body `PluginCore.cs:4600-4730` |
| `addnavpt` | `/vt addnavpt [coords]` or bare | appends a route waypoint at the given `sCoord` or the player's current position; refused while nav type is `Target` | "Usage: /vt addnavpt [coords] OR /vt addnavpt" | `PluginCore.cs:6111-6158` |
| `refresh` | `/vt refresh` | re-reads settings (`f3.a()`), refreshes editor pages | "Refreshed settings pages." | `PluginCore.cs:5145-5151` |
| `getdb` | `/vt getdb` | triggers `dz.y.c()` (VTank's bundled game-info DB re-fetch/reload) | none printed by the command itself | `PluginCore.cs:5623-5627` |
| `addnavjump` | `/vt addnavjump [heading] [shift] [ms] [strafeleft\|straferight\|forward]` | appends a jump waypoint to the route (heading in degrees, `shift`=walk/run bool, `ms`=charge time, optional strafe direction, default forward) | two-line usage text on bad args | `PluginCore.cs:4928-4952`, `6231-6246` |
| `addnavcheckpoint` | `/vt addnavcheckpoint [coords]` or bare | same as `addnavpt` but inserts a `gr` (checkpoint) waypoint, not a `fn` (point) | "Usage: /vt addnavcheckpoint [coords] OR /vt addnavcheckpoint" | `PluginCore.cs:6159-6206` |
### Actions
| Verb | Syntax | Effect | Output / error | `file:line` |
|---|---|---|---|---|
| `start` | `/vt start` | `dz.d.StartMacro()` | none | `PluginCore.cs:6035-6039` |
| `stop` | `/vt stop` | `dz.d.StopMacro()` | none | `PluginCore.cs:6040-6044` |
| `forcebuff` | `/vt forcebuff` | `dz.k.i()` (starts the self-buff-only decision cycle) | "Force buff enabled." | `PluginCore.cs:6045-6050` |
| `cancelforcebuff` | `/vt cancelforcebuff` | `dz.k.e()` | "Force buff canceled." | `PluginCore.cs:6051-6056` |
| `setmetastate` | `/vt setmetastate [state]` | transitions the Meta state machine to `state` (case-sensitive); falls back to `"Default"` if the name is unknown | "Usage: /vt setmetastate [somestate]" + case-sensitivity note; "Warning: Attempted to set an unused state. Setting to default instead." | `PluginCore.cs:6057-6080` |
| `fakedeath` | `/vt fakedeath` | fires the internal death-event handler with null args (`a((object)null,(DeathEventArgs)null)`) | none | `PluginCore.cs:5997-6001` |
| `deletemonster` | `/vt deletemonster` | forces the client to purge the selected monster object (`f9.f(guid)`) — only if a monster is selected | "Forcing the client to delete NAME (guid)!!" or "Select a monster, then do /vt deletemonster" | `PluginCore.cs:5427-5445` |
| `reverseroute` | `/vt reverseroute` | toggles `dz.o.m` (navigate the route backwards) | "Setting nav backwards to: True/False" | `PluginCore.cs:5213-5219` |
| `reverseroutequery` | `/vt reverseroutequery` | reads `dz.o.m` | "Nav backwards is: True/False" | `PluginCore.cs:5220-5225` |
| `equipitemsfor` | `/vt equipitemsfor [monster name]` | drives one equip-decision step (peace mode, wield weapon, etc.) toward the named monster; explicitly documented as multi-call | "Usage: ..." + two-line note; "Changing items for monster \"X\", ready: {bool}" | `PluginCore.cs:5169-5192` |
| `mexec` | `/vt mexec [expression]` | evaluates a Meta expression via `dz.at.k.a` and prints the token or error | "MExec evaluating expression: \"X\""; "Result: X" or "Expression error: X" | `PluginCore.cs:5014-5032` |
| `echo` | `/vt echo [text]` | posts `text` verbatim through `eChatType.Echo` | the text itself | `PluginCore.cs:5033-5040` |
| `tapjump` | `/vt tapjump` | `dz.aw.a(100.0)` — a fixed 100 ms jump tap | none | `PluginCore.cs:4896-4900` |
| `jump` | `/vt jump [heading] [shift] [ms] [strafeleft\|straferight\|forward]` | performs one jump immediately (does not touch the route) — same arg grammar as `addnavjump` | two-line usage text | `PluginCore.cs:4901-4926`, `6212-6230` |
| `setattackbar` | `/vt setattackbar [0..1]` | sets the retained attack-power slider (`f9.a(value)`) | "Usage: /vt setattackbar [0 to 1]" if out of `[0,1]` or unparsable | `PluginCore.cs:6086-6110` |
### Game info
| Verb | Effect | Output | `file:line` |
|---|---|---|---|
| `dumpspells` | writes every `MySpell` (id, name, family/override, saying, duration, difficulty, fellowship/offensive/untargeted/instant flags, school, turn-to requirement) to `c:\spelldump.txt` | "Spelltable dump complete. (c:\spelldump.txt)" | `PluginCore.cs:5231-5312` |
| `dumpspecies` | writes the species id/name table to `c:\speciesdump.txt` | "Species dump complete. (...)" | `PluginCore.cs:5313-5334` |
| `dumpmats` | writes the material id/name table to `c:\matdump.txt` | "Material dump complete. (...)" | `PluginCore.cs:5335-5356` |
| `dumpskills` | writes id/name/credits-to-train/specialize/description/attributes to `c:\skilldump.txt` | "Skill dump complete. (...)" | `PluginCore.cs:5357-5402` |
All four are **hardcoded to the `C:\` drive root** — no filename argument, no
configurable directory.
### Debug
| Verb | Effect | Output | `file:line` |
|---|---|---|---|
| `log` | bare: report active `eLogState` flags; `[type] [on\|off]`: toggle one flag (`ActiveRule SalvageList SpellCast RuleInfo Timers CastInfo DebuffChoice Loot CharProps Misc BusyState`, or `All`) | state summary / "Set X" / "Reset X" | `PluginCore.cs:5814-5942` |
| `testmonster` | evaluates monster-rule attack-distance targeting for the selected monster | "TestMonster: No monster selected." or evaluation result + `allowedtarget` | `PluginCore.cs:4864-4880` |
| `lockdump` | dumps `dz.o.f()` (unstoppably-busy flag) + every held `ActionLockType` with expiry and expired-yet? bool | multi-line dump | `PluginCore.cs:5109-5118` |
| `dumptracker` | writes every tracked world object plus a full property dump for each to `c:\trackerdump.txt` | none in chat | `PluginCore.cs:5403-5426` |
| `clearlocks` | `dz.o.n.a().Clear()` | none | `PluginCore.cs:5119-5123` |
| `clearbusy` | `dz.o.e()` | none | `PluginCore.cs:5124-5128` |
| `listmonstervariables` | lists supported monster-rule expression variable names (`cl.b()`) | comma-joined list | `PluginCore.cs:5193-5212` |
| `dumpmetavars` | lists every assigned Meta expression variable | `name = value` per line | `PluginCore.cs:5041-5049` |
| `listmetafunctions` | lists builtin Meta expression functions, wrapped at ~250 chars/line | comma-joined, chunked | `PluginCore.cs:5075-5108` |
| `metafunchelp [name]` | prints one function's description, parameter count, and per-parameter name/type/description | "Function not defined \"X\"" or the multi-line block | `PluginCore.cs:5050-5074` |
| `fakeimp` | casts a hardcoded fake "Gossamer Flesh" imperil (`dz.f.b("Gossamer Flesh")`, `PC.d(id, target, 3000000)`) at the current selection | "Fake cast complete." | `PluginCore.cs:6002-6012` |
| `pscount` | reports the portal-space toggle counter | "Portal space toggle count: N" | `PluginCore.cs:6081-6085` |
| `testspell [id]` | resolves a `MySpell` by id and reports range, school, computed/raw skill, castability under both hunting and buffing thresholds | "Usage: /vt testspell [spellid]"; "Invalid spellid."; multi-line report | `PluginCore.cs:4953-5013` |
| `testpet` | times `bm.a()` (can-pet-spawn probe) | "Pet can spawn: X, test time: Yms" | `PluginCore.cs:4818-4826` |
### Undocumented (not in the `/vt help` catalog, still parsed)
These exist in the parser but are absent from the four `/vt help` lines
(`PluginCore.cs:4765-4768`) — pure engineering scaffolding: `test`,
`waterdepth`, `physobj`, `testcollision`, `clearshapes`, `zoneid`,
`activespells`, `burdentest`, `explain [idqueue]` (`k.cs:1-56`), `debugon`,
`debugdump`, `skillvalues`, `throwex` (throws `Exception("Testing 123")`),
`spheredist``PluginCore.cs:4771-4901, 5152-5230, 5943-6034`; `obtest``PluginCore.cs:5628-5633`.
**No `/vt pause`.** Only `start` and `stop` exist in the parser; there is
no separate pause verb in this decompiled build.
## 2. Chat output conventions
### `eChatType` and the `d5` sink
`eChatType` (`uTank2/eChatType.cs:3-11`): `CommandLine, Logging, Warnings,
Errors, MetaUpdates, Echo`.
`d5` (`d5.cs`) is the single sink every command handler calls through
(`a(...)` at `PluginCore.cs:4298` wraps `d5.a(eChatType.CommandLine, text)`
directly; other call sites use `d5.a` with an explicit type). At
`PluginInitComplete` it registers a Decal "output preset" per type
(`d5.cs:27-36`, category name / description / color index / target-window
array), then every emitted line is prefixed `"[VTank] "` (`d5.m_b`,
`d5.cs:29,84`) and routed through `fh.b(name, text, color, targets)`
(`fh.cs:75-97`), which either forwards to VVS's `Presets.FilterOutputPreset`
(if VCS5 is loaded and running) or falls back to
`PluginHost.Actions.AddChatText(text, color, target)` per target window
(`fh.cs:78-89`):
| `eChatType` | color idx | target window(s) | description string |
|---|---|---|---|
| `CommandLine` | 7 | 1 | "Generic plugin text" |
| `Logging` | 2 | 5 | "/vt log messages" |
| `Warnings` | 6 | 1 | "Non-fatal warnings" |
| `Errors` | 6 | 1,2,3,4,5 | "Errors which stop operation" |
| `MetaUpdates` | 7 | 1 | "Meta status updates" |
| `Echo` | 14 | 1,2,3,4,5 | "Messages from /vt echo" |
(`d5.cs:30-35`). If VTank is running inside `Direct3D9_Container` or
`DecalContainer` (`d5.b()`, `d5.cs:48-52`) every line is also mirrored to
`Console.WriteLine`/`Debug.Print` for the developer console (`d5.cs:85-89`).
### `ah` — the deduped-warning sink
`ah` (`ah.cs`) wraps `d5.a(eChatType.Warnings, ...)` behind a
`Dictionary<string, byte>` of messages already shown
(`ah.cs:6,13-30`). `ah.a(string)` checks `ContainsKey` first and emits (and records) a
message once per session. The `params object[]` overload is **broken**: it
tests `ContainsKey(A_0)` against the *format* string but records the
*formatted* result (`ah.cs:24-28`), so any message with a placeholder is never
deduped and re-emits every call. `ah.a()` (no args) clears the cache. This is
how VTank avoids spamming the same warning every tick (69 call sites, e.g.
`ar.cs:73` no salvage ust, `b7.cs:146,151,161` unpickable doors,
`cl.cs:265,271` monster-spec parse errors, `d7.cs:31` invalid Meta-condition
regex). Note VTank also emits near-identical warning text through the
**un**-deduped `d5.a(eChatType.Warnings, …)` path — missing loot profile
(`en.cs:23,32`), invalid Meta regex (`c5.cs:35`, `hl.cs:120`), species with no
auto-damage entry (`e0.cs:168`) — so the same warning class can spam or not
depending on which sink the call site chose (citation pass 2026-09-06).
### Input-box text injection (distinct mechanism)
Separate from `d5.a` (which posts to chat *output*), `ab.a(int, string)`
(`ab.cs:67-...`) drives synthetic keyboard input (`GetKeyboardState` +
an internal virtual-key queue) to **type text into the chat entry box**
without submitting it — used for "click here" style prompts, e.g.
`/vt loot new ` is typed into the box (cursor left after the trailing
space) so the player only has to supply a filename
(`PluginCore.cs:6261,6335,6339,6357,6968`). This is keystroke simulation,
not a chat message.
## 3. Exported plugin API
VTank's cross-plugin surface has three tiers, from least to most gated.
### Tier 1 — ungated public members directly on `PluginCore`
`PluginCore` keeps a public static self-reference
(`public static PluginCore PC;`, `PluginCore.cs:937`). Because classic
Decal/Virindi plugins share one process/AppDomain, **any** other plugin
assembly can reflect over `AppDomain.CurrentDomain.GetAssemblies()`,
find `uTank2.PluginCore`, read the static `PC` field, and call any public
member with no permission check at all. This tier includes:
- Public **events**: `AuthorizationComplete`, `NavWaypointChanged`,
`NavRouteChanged`, `MacroStateChanged`, `ProfileChanged`,
`LootProfileChanged`, `SpellCastComplete`, `SpellCastAttempting`,
`AllSpellsExpired`, `RequestSpreadLock`, `RequestTargetSelection`,
`SetInvalidTarget` (`PluginCore.cs:1040-1360`).
- Public **methods**, notably the `F*`-prefixed family meant for other
Decal plugins to query VTank's live world/loot state:
`FLootPluginClassifyImmediate`, `FLootPluginQueryNeedsID`,
`FLootPluginClassifyCallback` (async callback list keyed by object id,
`PluginCore.cs:3082-3148`), `FWorldTracker_GetAllInInventoryWithName`,
`FWorldTracker_GetInventory`, `FWorldTracker_GetInContainer`,
`FWorldTracker_GetWithName`, `FWorldTracker_GetWithObjectClass`,
`FWorldTracker_GetWithID`, `FWorldTracker_GetWithVendorObjectTemplateID`,
`FWorldTracker_CountStackedInventoryObjectsWithName`,
`FGameInfo_QueryAutoDamageElementList`, `FMonsterList_QueryFinalDamageType`
(`PluginCore.cs:3150-3268`), plus `PushStackCramSettings`/
`PopStackCramSettings` (a save/restore stack for the `AutoStack`/`AutoCram`
settings, `PluginCore.cs:3269-3287`).
### Tier 2 — the RSA-gated trusted relay
`GetExternalInterface()` (no args) always returns `null`
(`PluginCore.cs:2351-2354`) — it is a dead stub. The real entry point is:
```
public cExternalInterfaceTrustedRelay GetExternalInterface(
string CallerAssemblySignature, string AssemblyKey)
```
(`PluginCore.cs:2537-2553`). It looks `AssemblyKey` up in `c8`
(`MyDictionary<string, eExternalsPermissionLevel>`, `PluginCore.cs:885`),
loaded at startup from `signkeys.txt` next to `signkeys.txt.signature.txt`
in VTank's data directory (`cu.g` analog — actual loader is
`PluginCore.g()`, `PluginCore.cs:2567-2598`): the signature file is itself
RSA-verified against a hardcoded public key
(`PluginCore.cs:2582`), and each remaining line pair is
`AssemblyKey` / `int permissionFlags`. `AssemblyKey` doubles as a
compact RSA public-key blob (`Modulus~Exponent`, url-safe-base64,
`PluginCore.cs:2405-2535`): the caller must supply
`CallerAssemblySignature` = the SHA1 hash of its own calling assembly,
signed with the **private** half of that same key
(`PluginCore.cs:2375-2387`). Only a partner whose public key VTank's
author has hand-added to `signkeys.txt` — and RSA-signed with VTank's own
master key — can obtain a non-`None` relay.
`RequestKeyVerificationDownload(string)` (`PluginCore.cs:2555-2557`) is an
**empty stub in this build** — see §6.
`cExternalInterfaceTrustedRelay` (`PluginCore.cs:34-586`) is a nested
class holding one `eExternalsPermissionLevel` flags field
(`ReadSettings=1, WriteSettings=2, FullUnderlying=4, LogicObject=8`,
`uTank2/eExternalsPermissionLevel.cs`); every member calls a private
guard `a(eExternalsPermissionLevel required)` that throws
`"Permission denied..."` if the flag isn't set (`PluginCore.cs:579-585`).
Members, by required flag:
| Flag | Members |
|---|---|
| `LogicObject` | `LogicObject` property (returns `dz.d`, the macro-engine object); `SpellSystem_GetSpellById/ByName`, `SpellSystem_QueryCombatSpellForElement`, `SpellSystem_QueryBestSpellAvailableInSameFamilyAsSpellNamed`, `SpellSystem_CastNormalSpell`, `SpellSystem_CastEquippedWandSpell`, `SpellSystem_CanUseWandSpell`; `Decision_Lock/UnLock/IsLocked`; `Equipment_TryEquipAnyWand`, `Equipment_TryEquipWandWeapon`, `Equipment_TryEquipWandWeaponWithArrows`; `Decision_GlobalBusyFlag` getter |
| `ReadSettings` | `GetSetting`, `GetSettingType` |
| `WriteSettings` | `SetSetting` (5 overloads), `ResetPanels` |
| `FullUnderlying` | `Version`, `CurrentMetaState`, `CurrentRulePriority`, `NavCurrent`, `NavNumPoints`, `NavType`, `MacroEnabled`, `HelperBonusTimeoutSeconds`, `HelperBonusActionWaitSeconds`, `ForceBuff`, `CancelForceBuff`, `ShowMainUI`, `IncrementBusyFlag`, `DecrementBusyFlag`, `AcceptSpreadLockTarget`, `SetLockedByOther`, `GetByAction`, `SetSpreadFire`, the `Nav*` waypoint CRUD family (`NavGetPoints/Point`, `NavBeginChanges/EndChanges`, `NavSetPoint`, `NavSetPointPortal2`, `NavSetUseNPC`, `NavSetOpenVendor`, `NavRouteClear`, `NavSetFollowTarget`, `NavGetFollowTargetInt/String`, `NavInsertPoint`, `NavDeletePoint`), the profile get/load family (`Get/LoadSettingsProfile`, `Get/LoadNavProfile`, `Get/LoadLootProfile`, `Get/LoadMetaProfile`), `HelperPlayerUpdate/SetInvalid`, `LogSpellCast`, `LogAllSpellsExpired`, `SetAnInvalidTarget`, `LogCastAttempt`, `SetMacroSetting`, `NeedToBuffInNext`, `GetCustomLootActionItems`, `Equipment_TryEquipWeaponsForMonster` |
`PermissionLevel` itself is always readable, and `Decision_GlobalBusyCount`
has **no guard at all** (`PluginCore.cs:161`, a getter-only oversight
relative to its `LogicObject`-gated sibling `Decision_GlobalBusyFlag`).
### MosswartMassacre's real usage (call-by-call)
MosswartMassacre — a real published third-party Decal plugin — never goes
through the signed handshake. `vTank.cs:33-58` (`Enable()`):
1. Reflects the **internal** `cExternalInterfaceTrustedRelay(eExternalsPermissionLevel)`
constructor via `GetConstructors(BindingFlags.Instance|BindingFlags.NonPublic)[0]`
and invokes it with `eExternalsPermissionLevel.None` — legal because the
ctor's own internal check (`Assembly.GetCallingAssembly() ==
Assembly.GetExecutingAssembly()`, `PluginCore.cs:163-173`) only compares
assemblies, and reflection's calling-assembly is the *invoking* code, not
VTank itself, so this actually **fails** that check and would set
`m_a = None`... except step 2 overwrites it directly.
2. Immediately reflects the private field `m_a` (aliased `"a"` in the
decompile) and force-sets it to `15` — i.e. `ReadSettings|WriteSettings|
FullUnderlying|LogicObject`, every flag at once — completely bypassing
the RSA gate.
3. Separately reflects VTank's internal chat-message class (assembly-private
type `"a7"`, its static field `"a"`) to obtain the **live outbound chat
queue** (`IList`) VTank itself drains, so `Tell(message, color, target)`
(`vTank.cs:99-113`) can enqueue a chat line that VTank's own Meta
condition matcher will see as if it came from the game.
4. `Decision_Lock`/`Decision_UnLock` (`vTank.cs:75-90`) call straight through
to the relay's `LogicObject`-gated members — this is the one path that
*would* have worked legitimately if the permission flag were honestly
granted.
`VtankControl.cs` layers a small helper API on top of the (illegitimately
acquired) `vTank.Instance`:
- `VtSetMetaState` (`VtankControl.cs:17-22`) does **not** call
`CurrentMetaState`'s setter — it instead calls
`PluginCore.Decal_DispatchOnChatCommand("/vt setmetastate {state}")`,
MosswartMassacre's own P/Invoke wrapper around Decal's
`DispatchOnChatCommand` export (`MosswartMassacre/PluginCore.cs:1289-1305`)
— i.e. it re-injects a **fabricated chat command string** for VTank's own
chat-parser hook to consume, rather than touching the object graph. This
is the same trick as calling `/vt` yourself, done programmatically.
- `VtGetMetaState`, `VtGetSetting`, `VtSetSetting`, `VtMacroEnabled`
(`VtankControl.cs:30-106`) call the relay properties directly
(`CurrentMetaState`, `GetSetting`, `GetSettingType`/`SetSetting`,
`MacroEnabled`) — these work because of the forced `15` permission mask.
- `VtAdvanceWaypoint` (`VtankControl.cs:114-226`) is the clearest evidence
of an API gap: there is **no relay method to advance the current
waypoint index**. The comment block documents the author's own search
through the decompile (`"From decompiled code: external interface uses
PC.NavCurrent which references dz.o.l"`) before falling back to (a)
reflecting `uTank2.PluginCore`'s public static `PC` field (tier 1, no
permission needed) and invoking the private method `i(object,
MVControlEventArgs)` — the exact handler wired to the in-UI "next
waypoint" button (`PluginCore.cs:3621-3639`, subscribed at
`PluginCore.cs:1819` as `cd.Click += i;` — it increments `dz.o.l`, clamps to
`dz.m.k.b.Count - 1`, and no-ops for `eNavType.Target`/`Once`, exactly what
MosswartMassacre's own fallback re-implements) — and (b), if that reflection fails, walking
`dz.o.l` (the raw current-index field) directly and incrementing it by
hand. Both are reflection into implementation-private state because the
public surface simply doesn't expose the operation.
## 4. Classic Looter ↔ VTank boundary
### The contract (`uTank2.LootPlugins`)
`LootPluginBase` (`uTank2.LootPlugins/LootPluginBase.cs:3-28`) is the
abstract SPI a loot plugin implements:
| Member | Purpose |
|---|---|
| `LootPluginInfo Startup()` | one-time init; return value declares the profile file extension (and optional extra search directories) |
| `void Shutdown()` | teardown |
| `void LoadProfile(string filename, bool newprofile)` | load an existing profile or create a blank one |
| `void UnloadProfile()` | clear active profile |
| `void OpenEditorForProfile()` / `CloseEditorForProfile()` | editor lifecycle hooks (VTank shows/hides its own "Loot" checkbox based on these, `cu.cs:105-134`) |
| `bool DoesPotentialItemNeedID(GameItemInfo item)` | VTank asks this **before** classifying an unidentified item, to decide whether to send an Inquiry first |
| `LootAction GetLootDecision(GameItemInfo item)` | the actual keep/salvage/sell/etc. decision |
`LootPluginInfo(string ProfileFileExtension, params string[] ExtraDirectories)`
(`uTank2.LootPlugins/LootPluginInfo.cs:9-20`) normalizes the extension
(strip leading dot, lowercase). Optional capability interfaces extend the
contract, e.g. `ILootPluginCapability_SalvageCombineDecision2` (adds
`ChooseBagsToCombine(List<GameItemInfo>)`, used by Classic Looter's own
salvage-combine block handler) and `ILootPluginCapability_GetExtraOptions`
(lets a plugin hide VTank's editor checkbox, `cu.cs:96-108`).
### Registration (discovery, load, wiring)
VTank discovers loot plugins from the **Windows registry**, not from a
plugin folder scan: `cu.i()` (`cu.cs:228-312`) opens
`HKLM\Software\Decal\LootPlugins`, iterates subkeys, and for each reads
string values `""` (display name), `Assembly` (dll filename), `Object`
(fully-qualified type name), `Path` (containing directory). It loads the
assembly (`Assembly.Load(new AssemblyName{CodeBase=...})`), resolves the
type, checks `type.IsSubclassOf(typeof(LootPluginBase))`, and
`Activator.CreateInstance`s it. On success it wires the instance's
internal `Host`/`ViewSystem` fields to a fresh `VTHost`/
`FlexibleViewSystem` and calls `Startup()`; a null/throwing `Startup()`
unregisters the plugin (`cu.cs:290-310`). The result is stored as an `e8`
record (`e8.cs:6-16`: `LootPluginBase a; LootPluginInfo b; Assembly c;
string d,e; eLootPluginExtraOption f;`) in a `List<e8>` (`cu.c`).
Only **one** loot plugin can be active at a time (`cu.m_a`, singular);
`/vt loot load` / `/vt loot new` (`PluginCore.cs:5726-5775`) match the
requested filename's extension against the registered `e8.b.a` values,
select that plugin, and call its `LoadProfile`. Classifying an item routes
through `cu.a(int objectId)` → `this.m_a.a.GetLootDecision(new
GameItemInfo(objectId))` (`cu.cs:176-195`), and needs-ID checks through
`cu.b(int objectId)``DoesPotentialItemNeedID` (`cu.cs:155-174`).
### Classic Looter's implementation (`VTClassic.LootCore`)
`LootCore : LootPluginBase, ILootPluginCapability_SalvageCombineDecision2`
(`refs/vtank-classiclooter/decompiled/VTClassic/LootCore.cs:9-199`):
`Startup()` returns `new LootPluginInfo("utl", new string[0])`
(`LootCore.cs:167-180` — extension `.utl`, no extra directories) and
records the static singleton `Instance`. `GetLootDecision` delegates to
`cLootRules.Classify(item, out matchedrulename, out data)`, which returns
VTank's internal `eLootAction` enum (`Keep, NoLoot, Salvage, KeepUpTo,
Sell`), translated 1:1 to the public `LootAction` type
(`LootCore.cs:53-91`, with `KeepUpTo` carrying the count via
`LootAction.GetKeepUpTo(data)` and the matched rule name attached to
`val.RuleName`). `LoadProfile`/`UnloadProfile` own a `cLootRules` instance
directly from/to a `.utl` file with a `CountedStreamWriter`
(`LootCore.cs:93-143`). This is the whole boundary: VTank never sees rule
internals, only the four-verb `LootAction` result plus a display name for
the matched rule.
No installer artifact for the registry-key registration itself
(`HKLM\Software\Decal\LootPlugins\<name>`) is present in the vendored
`refs/vtank-classiclooter` tree — see §6.
## 5. "MossTank gap" vs `src/AcDream.Plugins.MossTank`
### What acdream already ports faithfully
- **`/vt` chat surface.** `MossTankCommands.cs` (`MossTankPanel`, partial
class) ports essentially every retail production verb from §1's
Profiles/Actions/Game-info tables — `help/start/stop/forcebuff/
cancelforcebuff/settings/nav/loot/meta/opt/setmetastate/mexec/echo/
setattackbar/tapjump/jump/addnavjump/addnavpt/addnavcheckpoint/
reverseroute/reverseroutequery/deletemonster/equipitemsfor/testitem/
propertydump/testmonster/testspell/testpet/listmonstervariables/
dumpmetavars/listmetafunctions/metafunchelp/fakedeath/pscount/refresh/
getdb/log/lockdump/dumptracker/clearlocks/clearbusy/fakeimp/dumpspells/
dumpspecies/dumpmats/dumpskills` (`MossTankCommands.cs:48-223`) — with
the same usage strings, same case-sensitivity notes, and the same
argument grammar (e.g. jump's `heading shift ms [direction]`,
`MossTankCommands.cs:491-524` vs `PluginCore.cs:4901-4952`). The
retail-only pure-debug verbs (`test, waterdepth, physobj, testcollision,
clearshapes, zoneid, activespells, burdentest, explain, debugon,
debugdump, skillvalues, throwex, spheredist, obtest`) are correctly
**not** ported — they were VTank's own engineering scaffolding, never
documented in `/vt help`.
- **The loot-plugin SPI** has a modern equivalent:
`IPluginLootClassifier`/`IPluginLootClassifierRegistry`
(`LootClassifierPlugins.cs:40-93`) collapses `LootPluginBase`'s `GetLootDecision` into `Classify` and adds
two post-decision notifications retail never had (`OnLooted`/
`OnItemRemoved`), with VTank's own action
vocabulary reproduced verbatim as `PluginLootAction` (`NoLoot, Keep,
Salvage, Sell, Read, User1..User5, KeepUpTo`,
`LootClassifierPlugins.cs:4-17`) — but registration is **in-process,
machine-local, and lifetime-scoped to the owning plugin**
(`LootClassifierPlugins.cs:54-58`), not a signed-DLL/registry-key/
`Assembly.Load` discovery pass. This is a deliberate simplification: no
loot-plugin author needs a Windows registry entry or a separate
assembly to extend acdream's classifier.
- **Chat-command routing.** `IPluginCommandRegistry`/
`PluginCommandRegistry` (`PluginCommands.cs:15-23`,
`PluginCommandRegistry.cs:10-142`) replace Decal's
`ChatParserInterceptEventArgs.Eat` chain-of-responsibility with an
**exclusive verb ownership** model: `Register(verb, handler)` throws if
the verb is already claimed (`PluginCommandRegistry.cs:29-33`) instead of
letting multiple plugins race to "eat" the same text. This is simpler
and safer but means acdream cannot reproduce VTank's own precedent of
silently falling through to a second handler for an unclaimed verb — by
design, every verb has exactly one owner.
- **Cross-instance telemetry**, not cross-instance control:
`LocalPluginPeerRegistry` (`LocalPluginPeerRegistry.cs:12-225`) is
acdream's analogue of VTank's `uTank2.P2P` folder — but it is
explicitly **data-only** ("The files carry data only—never commands.",
`LocalPluginPeerRegistry.cs:9-11`), publishing bounded heartbeat JSON
documents to a shared directory and reading back other live clients'
position/vitals/tags (`INetworkAutomation.CaptureClients()`,
`NetworkAutomation.cs:21-25`, explicitly "Read-only discovery").
### The gap — five most important interop pieces a ported MosswartMassacre would need and does not have
1. **No cross-plugin object reference at all.** VTank's tier-1 surface
(public events + `F*` methods reachable via the public static `PC`
field, §3) and tier-2 relay both assume one plugin can obtain a live
reference to *another loaded plugin's* running instance in the same
process. acdream's `IPluginHost`/`IAcDreamPlugin` surface
(`IPluginHost.cs`, `IAcDreamPlugin.cs`) has no enumeration of other
loaded plugins, no "get plugin by name/id" call, and no shared
interface a second plugin could implement to be discovered — there is
currently no way for one acdream plugin to reach into another's state
the way MosswartMassacre reaches into VTank's `PC` field or relay.
2. **No signed-partner / permission-tier concept.** VTank's
`eExternalsPermissionLevel` + RSA `signkeys.txt` handshake (§3, Tier 2)
has no analogue — acdream's plugin command/loot-classifier registries
are unauthenticated by design (any loaded plugin can call `Register`),
which is *simpler* but means there is no way to expose a **privileged**
subset of one plugin's API to only a trusted second plugin, the way
VTank could grant `LogicObject`-level spell-casting control to one
named partner assembly and nothing to everyone else.
3. **No fabricated-chat-command injection path, and no "advance waypoint"-
style gap-filler.** MosswartMassacre's `VtSetMetaState` and
`VtAdvanceWaypoint` both exist *because* the legitimate API was
incomplete, forcing either chat-command re-injection
(`Decal_DispatchOnChatCommand`) or reflection into private fields.
acdream's `TryHandle` (`PluginCommandRegistry.cs:40`) exists only on the
concrete host registry and is absent from the `IPluginCommandRegistry`
abstraction plugins see (`PluginCommands.cs:15-23`, `Register` only) — it is
host-internal (chat
text typed by the human player), not something a second plugin can
invoke to simulate a `/vt` command against a first plugin — there is no
equivalent of Decal's `DispatchOnChatCommand` P/Invoke for plugin code
to programmatically submit a command into *another* plugin's verb
table.
4. **No public event surface on `MossTankPlugin`/`MossTankPanel` at all**
(no `SpellCastComplete`, `ProfileChanged`, `MacroStateChanged`,
`NavWaypointChanged`/`NavRouteChanged`, `AllSpellsExpired`,
`RequestSpreadLock`/`RequestTargetSelection` equivalents). A ported
MosswartMassacre that wants to react to MossTank's macro
starting/stopping, its Meta state changing, or a spell cast completing
would need new events added to the plugin (or to
`AcDream.Plugin.Abstractions`) — none exist today.
5. **No settings-by-name get/set surface for cross-plugin config.**
VTank's relay exposes `GetSetting`/`SetSetting`/`GetSettingType` by
string name, reflecting over its own typed setting store; MossTank's
equivalent state (`_combatSettings`, `_inventorySettings`, etc.,
referenced throughout `MossTankCommands.cs`) is private to the plugin
with no by-name accessor exposed through
`AcDream.Plugin.Abstractions` for a second plugin to read or write —
only the plugin's own `/vt opt` chat verb can touch it, and only a
human (or a plugin willing to fabricate chat text through the *host's*
own submit path, not another plugin's) can drive that verb.
## 6. Could not determine
- **`RequestKeyVerificationDownload`'s real implementation.** The method
body is empty in this decompiled build (`PluginCore.cs:2555-2557`) — it
is unclear whether this build ever shipped a live network-backed key
request/download flow, or whether trusted-partner keys were always
distributed out-of-band (the author manually appending a line to
`signkeys.txt` for a named partner). No network call, URL, or server
contract for this flow exists anywhere in `refs/vtank/decompiled/`.
- **The actual current contents of `signkeys.txt`** (which third-party
assemblies, if any, hold a real signed key and at what permission level)
are not part of the decompiled source; the file ships alongside the
installed plugin, not in this repository's `refs/`.
- **How Classic Looter's own registry key
(`HKLM\Software\Decal\LootPlugins\<name>`) gets written at install
time.** No `.reg` file, installer project, or registration code is
present in `refs/vtank-classiclooter/decompiled/` — this is presumably
handled by an external installer (NSIS/MSI or similar) not vendored
here. Narrowed by the citation pass: VTank itself never writes the key —
the only `Software\Decal\LootPlugins` access in the whole decomp is the
read-only `OpenSubKey` at `cu.cs:231` — and VTank's own expression help
cites `http://www.virindi.net/repos/virindi_public/trunk/VirindiTankLootPlugins/VTClassic Shared/Constants.cs`
(`ch.cs:260,287,314,342,370,1074,1270`), placing Classic Looter inside the
VTank author's own SVN tree, so it almost certainly shipped in the Virindi
Plugins installer rather than self-registering.
- **UtilityBelt/VTank integration — RESOLVED** (citation pass 2026-09-06; the
first draft looked in the wrong tree). The integration lives in the full
UtilityBelt source (`C:\Users\erikn\source\repos\utilitybelt.gitlab.io`, and
a vendored copy at `MosswartMassacre/Unused/utilitybelt.gitlab.io/UtilityBelt/`):
`Tools/VTankControl.cs` (2,279 lines), `Tools/VTankExtensions.cs`,
`Tools/VTankFellowHeals.cs`, `Lib/VTNav/VTNavRoute.cs`, plus
`UBHelper.vTank.Decision_Lock(...)` calls across `AutoVendor.cs`,
`AutoTrade.cs`, `AutoSalvage.cs`, `InventoryManager.cs`,
`EquipmentManager.cs`. It is a THIRD bypass route beyond the two §3 tiers:
it never uses the signed handshake and **Harmony-patches VTank's internals**
(`HarmonyLib`, `harmonyClassic`/`harmonyExpressions`,
`VTankExtensions.cs:25-27`), resolving obfuscated types by name off
`typeof(uTank2.PluginCore).Assembly` (`hi`, `eq`, `fk`, `c5`, `dv`, `b3`,
`dt`, `fl`, `hl`, `f9`, `bo`, `gj`, `aw`) and reading the private static `dz`
through tier-1 `PluginCore.PC` (`VTankExtensions.cs:419,524`).
MosswartMassacre's `vTank.cs` is a copy of UtilityBelt's `UBHelper.vTank`
helper. Takeaway for acdream: **every** real-world VTank consumer examined
bypassed the permission system — the RSA gate gated nothing in practice.

View file

@ -0,0 +1,45 @@
# VTank knowledge base (Campaign VT, VT1)
Written 2026-09-06 from the decompiled oracle under `refs/` (gitignored; see
`docs/plans/2026-09-06-campaign-vt-vtank-oracle.md` for what is there and how
to regenerate it). Every behavioral claim cites `file:line` into
`refs/vtank/decompiled/` (VTank), `refs/vtank-classiclooter/decompiled/`
(Classic Looter) or `refs/vvs/decompiled/` (VirindiViewService). Decompiled
code is never pasted; behavior is re-implemented from these docs. When the
older wiki-derived research (`docs/research/2026-07-29-vtank-plugin-automation-requirements.md`,
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md`) disagrees
with a doc here, the doc here wins.
Each doc ends with a **MossTank gap** section (ranked by player impact) and a
**Could not determine** list. VT2 consolidates the gap sections into the
implementation order.
| # | Doc | What it pins | Oracle files most cited |
|---|---|---|---|
| 01 | [settings and profiles](01-settings-and-profiles.md) | All 137 settings (type, default, tab control, consumer), the self-describing `.usd` table grammar, profile naming/selection, `/vt opt` | `f3.cs` (typed accessors), `gy/cw/bd/y.cs` (table reader), `defaultsettings.usd` |
| 02 | [scheduler and actions](02-scheduler-and-actions.md) | The single-winner priority list (24 live rule classes, 49 instantiations across 66 list entries incl. 26 sentinels), 293 ms heartbeat + event poke, force-combat-mode `ga.a` and stuck-state recovery, IdlePeace in full | `uTank2/cLogic.cs`, `ga.cs`, `f9.cs`, `cm.cs`, `fz.cs` |
| 03 | [combat](03-combat.md) | Target acquisition/selection (3 methods, ghost/blacklist), monster rules, weapon/damage/ammo choice, attack execution, the hardcoded 12-debuff order, pets | `dz.cs`, `hi.cs`, `b4.cs` |
| 04 | [buffs and vitals](04-buffs-and-vitals.md) | Buff plan and per-tick re-evaluation, item enchantments (`-1` "no buffs"), helper buffs, the nine recharge thresholds, kits/potions/food, dispel, worn-item mana | `eq.cs`, `fz.cs`, `ba.cs`, `ai.cs`, `dy.cs`, `a5.cs`, `MySpell.cs` |
| 05 | [looting and .utl](05-looting-and-utl.md) | The `.utl` format and all requirement types, `EarlyMatch` identify-avoidance, `ComputedItemInfo`, VTank's corpse selection/approach/open/loot rules, ownership-denial regexes | VTClassic `cLootRules`, `UTLBlock_*`; VTank `fo.cs`, `d0.cs`, `el.cs`, `hv.cs` |
| 06 | [navigation and .nav](06-navigation-and-nav.md) | `uTank2 NAV 1.2` byte-for-byte (11 waypoint types), route types, steering/creep band, jump, door/lockpick, recall, priority interactions, 240 m/unit | `x.cs`, `ca.cs`, `fd.cs`, `bi.cs`, `b7.cs`, `g8.cs` |
| 07 | [meta and expressions](07-meta-and-expressions.md) | `.met` format, FSM semantics, the expression language and built-in table, views created by metas | `ExpressionEvaluator.cs`, `ch.cs`, `a7.cs`, `cLogic.cs:191-196` |
| 08 | [UI views](08-ui-views.md) | Every control of the nine tabs and three secondary views (type, geometry, binding), VVS control semantics our markup must offer, the markup extension needed (multi-column lists) | `uTank2.ViewXML.*.xml`, VVS `HudList/HudCombo/HudTabView`, `Decal3XMLParser.cs` |
| 09 | [commands and interop](09-commands-and-interop.md) | The `/vt` verb table (~48 documented + 15 parser-only), chat sinks and dedup, the three-tier export API, Classic Looter SPI, MosswartMassacre's real usage | `uTank2/PluginCore.cs`, `d5.cs`, `ah.cs`, `eExternalsPermissionLevel.cs` |
**Owner decision 2026-09-06 — no interop.** All plugins are rewritten for acdream against its own API (VTank depends on Decal hooking the client; acdream controls it by API). Doc 09's export-API, relay and Classic Looter SPI sections are historical context, not a build target.
**Owner amendment 2026-09-06 — metas and navs use metaf `.af`.** Docs 06 §1
and 07 §1 document VTank's binary `.nav`/`.met` layouts; MossTank does not
implement them. It reads and writes the human-readable metaf format (the
reference converter is `C:\Users\erikn\source\repos\metas\metaf_monolithic.py`,
see `claude-memory/reference_metaf_af_format.md`). The semantics sections of
06/07 (execution, FSM, expressions, views) remain the behavior oracle.
Local companion note (not committed): `refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md`
— the line-level trace of the combat-mode choke point, the eight
drop-to-peace sites and the wield sequencing.
Corrections folded in by the lead during spot-checks are marked in place
("lead verification 2026-09-06"). Files written by agents into the main
checkout by mistake were moved here; check `git status` in the main checkout
after any catalog session.

View file

@ -100,70 +100,24 @@
<EmbeddedResource Include="..\..\assets\icons\acdream-client-256.png"
LogicalName="AcDream.App.Rendering.Icons.acdream-client-256.png" />
</ItemGroup>
<ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
<ProjectReference Include="..\AcDream.Plugins.Smoke\AcDream.Plugins.Smoke.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<Target
Name="CopySmokePluginToBuildOutput"
AfterTargets="Build"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
</PropertyGroup>
<MSBuild
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
Targets="GetTargetPath"
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginBuildTarget" />
</MSBuild>
<MakeDir Directories="$(_SmokePluginDestDir)" />
<Copy
SourceFiles="@(_SmokePluginBuildTarget)"
DestinationFolder="$(_SmokePluginDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
File="$(_SmokePluginDestDir)/plugin.json"
Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>
<Target
Name="CopySmokePluginToPublishOutput"
AfterTargets="Publish"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
</PropertyGroup>
<MSBuild
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
Targets="GetTargetPath"
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginPublishTarget" />
</MSBuild>
<MakeDir Directories="$(_SmokePluginPublishDestDir)" />
<Copy
SourceFiles="@(_SmokePluginPublishTarget)"
DestinationFolder="$(_SmokePluginPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
File="$(_SmokePluginPublishDestDir)/plugin.json"
Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>
<!-- MossTank ships the same way as the smoke plugin, plus its panel markup:
MossTankPlugin resolves mosstank.xml relative to its own assembly, so the
two files must land in the same plugin directory. -->
<!-- MossTank ships as a copy-only plugin, plus its panel markup:
MossTankPlugin resolves mosstank.xml and every mosstank-*.xml popup
panel relative to its own assembly, so all of them must land in the
same plugin directory. Fix round B item 13: globbed (mosstank*.xml)
instead of named one by one — a new popup used to need a matching
manual edit in both Copy targets below, the plugin csproj's own
CopyToOutputDirectory list, and the test project's Link list; a
missed one silently dropped a panel at mount instead of failing the
build. -->
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<_MossTankPluginMarkup Include="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank*.xml" />
</ItemGroup>
<Target
Name="CopyMossTankPluginToBuildOutput"
AfterTargets="Build"
@ -179,7 +133,7 @@
</MSBuild>
<MakeDir Directories="$(_MossTankDestDir)" />
<Copy
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
SourceFiles="@(_MossTankPluginBuildTarget);@(_MossTankPluginMarkup)"
DestinationFolder="$(_MossTankDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
@ -203,7 +157,7 @@
</MSBuild>
<MakeDir Directories="$(_MossTankPublishDestDir)" />
<Copy
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
SourceFiles="@(_MossTankPluginPublishTarget);@(_MossTankPluginMarkup)"
DestinationFolder="$(_MossTankPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile

View file

@ -721,7 +721,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
defaultFont,
d.DebugFont,
controls,
iconComposer);
iconComposer,
d.TextureCache);
// Review fix round F12 (2026-08-15): constructed ONCE per
// composition and captured by the ResolveText closure below,
// rather than a fresh DatStringResolver per lookup. The

View file

@ -64,6 +64,8 @@ internal sealed class AppAutomationSurface
private MagicCatalog _magicCatalog = MagicCatalog.Empty;
private IReadOnlyDictionary<uint, string> _skillNames =
new Dictionary<uint, string>();
private IReadOnlyDictionary<uint, uint> _skillIcons =
new Dictionary<uint, uint>();
private Func<int, string> _speciesName = static _ => string.Empty;
private IChargenPaletteColorSource? _paletteColors;
private Func<uint, uint, bool>? _equip;
@ -381,6 +383,18 @@ internal sealed class AppAutomationSurface
_skillNames = skillNames;
}
/// <summary>
/// Supply retail skill icon RenderSurface DIDs (SkillTable
/// <c>SkillBase.IconId</c>), read once alongside <see cref="BindSkillNames"/>
/// from the same portal.dat SkillTable pass.
/// </summary>
public void BindSkillIcons(IReadOnlyDictionary<uint, uint> skillIcons)
{
ArgumentNullException.ThrowIfNull(skillIcons);
lock (_gate)
_skillIcons = skillIcons;
}
/// <summary>Supply the immutable retail spell/component DAT catalog.</summary>
public void BindMagicCatalog(MagicCatalog catalog)
{
@ -766,6 +780,7 @@ internal sealed class AppAutomationSurface
BaseRangeConstant = meta.BaseRangeConstant,
BaseRangeModifier = meta.BaseRangeModifier,
FormulaComponentIds = meta.FormulaComponents,
IconId = meta.IconId,
};
/// <summary>
@ -929,10 +944,12 @@ internal sealed class AppAutomationSurface
{
RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names;
IReadOnlyDictionary<uint, uint> icons;
lock (_gate)
{
character = _character;
names = _skillNames;
icons = _skillIcons;
}
if (character is null || names.Count == 0)
return Array.Empty<PluginSkillInfo>();
@ -940,7 +957,8 @@ internal sealed class AppAutomationSurface
var built = new List<PluginSkillInfo>(names.Count);
foreach (KeyValuePair<uint, string> pair in names)
{
if (TryProjectSkill(character, pair.Key, pair.Value, out PluginSkillInfo skill))
uint iconId = icons.TryGetValue(pair.Key, out uint icon) ? icon : 0u;
if (TryProjectSkill(character, pair.Key, pair.Value, iconId, out PluginSkillInfo skill))
built.Add(skill);
}
built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
@ -952,22 +970,25 @@ internal sealed class AppAutomationSurface
{
RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names;
IReadOnlyDictionary<uint, uint> icons;
lock (_gate)
{
character = _character;
names = _skillNames;
icons = _skillIcons;
}
if (character is not null)
{
string name = names.TryGetValue(skillId, out string? n) ? n : string.Empty;
return TryProjectSkill(character, skillId, name, out skill);
uint iconId = icons.TryGetValue(skillId, out uint icon) ? icon : 0u;
return TryProjectSkill(character, skillId, name, iconId, out skill);
}
skill = default;
return false;
}
private static bool TryProjectSkill(
RuntimeCharacterState character, uint skillId, string name,
RuntimeCharacterState character, uint skillId, string name, uint iconId,
out PluginSkillInfo skill)
{
if (!character.View.TryGetSkill(skillId, out var snapshot))
@ -984,6 +1005,7 @@ internal sealed class AppAutomationSurface
skillId, name, Training(snapshot.Status), currentLevel)
{
Base = baseLevel,
IconId = iconId,
};
return true;
}
@ -1148,9 +1170,12 @@ internal sealed class AppAutomationSurface
}
/// <summary>
/// Routed to retail's ClientLocal log type (0x1A) — the channel the client
/// uses for its own notices. Nothing reaches the server, so a plugin cannot
/// accidentally speak in the player's name.
/// Owner direction 2026-09-07 (register row AD-124): plugin-originated
/// text now lands in the chat window (retail <c>Default</c>/0x00),
/// matching Decal's own <c>AddChatText</c> behavior — not retail's
/// ClientLocal (0x1A) SpewBox-only channel this previously used.
/// Nothing reaches the server, so a plugin cannot accidentally speak in
/// the player's name.
/// </summary>
public void PostSystemMessage(string text)
{
@ -1159,7 +1184,7 @@ internal sealed class AppAutomationSurface
RuntimeCommunicationState? communication;
lock (_gate)
communication = _communication;
communication?.AddText(text, RetailLogTextType.ClientLocal);
communication?.AddText(text, RetailLogTextType.Default);
}
public bool Submit(string text)
@ -1867,6 +1892,7 @@ internal sealed class AppAutomationSurface
? item.AppraisedSpellIds.ToArray()
: Array.Empty<uint>(),
ActiveSpellIds = activeSpells,
IconId = item?.IconId ?? 0u,
};
}
@ -2376,6 +2402,7 @@ internal sealed class AppAutomationSurface
MaterialType = item.MaterialType ?? 0u,
ObjectClass = ClassifyObject(item),
Palettes = ProjectPalettes(runtime, item.ObjectId),
IconId = item.IconId,
});
}
built.Sort(static (left, right) =>

View file

@ -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; }
}

View file

@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage
public IReadOnlyList<string> 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<string>();
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)

View file

@ -0,0 +1,16 @@
namespace AcDream.App.Plugins;
/// <summary>
/// The graphical host's default root for <c>IPluginHost.VtankProfiles</c>
/// when <c>RuntimeOptions.VtankProfileDirectoryOverride</c>
/// (<c>ACDREAM_VTANK_PROFILE_DIR</c>) is unset — extracted out of
/// <c>Program.cs</c>'s inline composition into its own pure, injectable-root
/// function so the "built with <see cref="Path.Combine(string, string)"/>
/// only, never a hard-coded Windows path" guarantee is a real, failable unit
/// test rather than something only checkable by reading the source.
/// </summary>
internal static class VtankProfilesDefault
{
internal static string Resolve(string dataDirectory) =>
Path.Combine(dataDirectory, "vtank");
}

View file

@ -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,

View file

@ -1019,9 +1019,14 @@ public sealed class GameWindow :
}
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
var icons = new Dictionary<uint, uint>(skillTable.Skills.Count);
foreach (var entry in skillTable.Skills)
{
names[(uint)entry.Key] = entry.Value.Name;
icons[(uint)entry.Key] = entry.Value.IconId;
}
_automation.BindSkillNames(names);
_automation.BindSkillIcons(icons);
}
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(

View file

@ -42,8 +42,10 @@ namespace AcDream.App.Rendering;
/// Vulkan backend gets from the pass description for free.</para>
///
/// Uses per-bucket ring allocations flushed in up to three draw calls per
/// layer, to avoid a per-vertex "use texture" flag. Rects are drawn first so
/// text sits on top of background panels.
/// layer, to avoid a per-vertex "use texture" flag. The untextured-rect bucket
/// (<see cref="DrawRect"/>'s <c>_rectBuf</c>) composites AFTER every sprite
/// segment regardless of submission order — see that method's doc comment for
/// why the retained UI (<c>AcDream.App.UI</c>) no longer feeds it.
/// </summary>
public sealed class TextRenderer : IDisposable
{
@ -155,6 +157,16 @@ public sealed class TextRenderer : IDisposable
internal (int VertexCount, float Alpha) DebugTextBuffer
=> (_textVerts, _textBuf.Count > 0 ? _textBuf[7] : 0f);
/// <summary>
/// Test-only: vertex count queued in the NORMAL-layer untextured rect bucket
/// (<see cref="DrawRect"/>'s <c>_rectBuf</c> — see that method's doc comment for why
/// the retained UI no longer feeds it). Used to prove a fix stays fixed: nothing
/// should land here for retained-UI content, since this bucket always flushes AFTER
/// every sprite segment regardless of submission order (see <see cref="DrawLayer"/>).
/// <c>AcDream.App.Tests</c>-only via <c>InternalsVisibleTo</c>.
/// </summary>
internal int DebugRectVertexCount => _rectVerts;
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
@ -250,7 +262,29 @@ public sealed class TextRenderer : IDisposable
OverlayMode = false;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
/// <summary>
/// Draw a filled rectangle into the SEPARATE untextured-rect bucket
/// (<c>_rectBuf</c>/<c>_overlayRectBuf</c>), which <see cref="Flush"/> always
/// composites AFTER every sprite segment in the layer, REGARDLESS of submission
/// order — the opposite of painter order.
///
/// <para>
/// <b>The retained UI (<c>AcDream.App.UI</c>) does not call this.</b> It used to,
/// through <see cref="AcDream.App.UI.UiRenderContext.DrawRect"/> — every
/// <c>BorderColor</c> outline (<see cref="AcDream.App.UI.UiPanel"/>,
/// <see cref="AcDream.App.UI.UiMarkupList"/>, via
/// <see cref="AcDream.App.UI.UiRenderContext.DrawRectOutline"/>) landed here and
/// so always composited above every OTHER window's sprite content, no matter which
/// window actually drew later. Owner report: with the MossTank plugin window
/// BEHIND the inventory window, the plugin buttons' border outlines drew on top
/// of the inventory paperdoll. <c>UiRenderContext.DrawRect</c> now forwards to
/// <see cref="DrawFill"/> instead (the same untextured SPRITE-bucket segment,
/// painter/submission order), so this bucket is currently unreached by any
/// caller in <c>src/</c> outside this class. Kept — not deleted — for a future
/// non-retained-UI caller (e.g. a debug overlay) that genuinely wants "always on
/// top of every sprite" compositing; do not route new retained-UI code through it.
/// </para>
/// </summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
@ -258,10 +292,11 @@ public sealed class TextRenderer : IDisposable
}
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
/// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.
/// when active), so it composites in painter/submission order with sprites, outline
/// segments, and dat-font text. Every retained-UI fill and outline
/// (<see cref="AcDream.App.UI.UiRenderContext.DrawRect"/>/<c>DrawFill</c>/
/// <c>DrawRectOutline</c>) routes through here, not <see cref="DrawRect"/> — see that
/// method's doc comment for why.
///
/// <para>Slice V6d: this used to route through a 1×1 white texture, relying on
/// white × colour = colour. The shader now has an untextured branch that produces
@ -271,7 +306,10 @@ public sealed class TextRenderer : IDisposable
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(UiTextureTableHandle.None, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
/// <summary>Draw a 1-pixel-thick outline rect through the (unreached-by-retained-UI)
/// rect bucket — see <see cref="DrawRect"/>'s doc comment. Retained-UI outlines go
/// through <see cref="AcDream.App.UI.UiRenderContext.DrawRectOutline"/> instead, which
/// composites in painter order via <see cref="DrawFill"/>.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
// top, bottom, left, right
@ -502,21 +540,28 @@ public sealed class TextRenderer : IDisposable
});
encoder.BindPipeline(_pipeline);
// LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
// composite on top, in that order. The OVERLAY layer repeats all three
// AFTER the normal layer, so open popups beat even the rect backgrounds.
// THREE buckets per layer, drawn in this fixed order:
// 1. RGBA dat sprites AND untextured fills/outlines — window chrome,
// panel backgrounds, vital bars, and every retained-UI BorderColor
// outline (DrawFill/DrawRect route here — see DrawFill's doc comment).
// Draws in SUBMISSION (painter) order via _spriteSegs, so painter
// z-order is preserved for sprite-on-sprite, fill-on-sprite, AND
// outline-on-sprite compositing alike.
// 2. The SEPARATE untextured-rect bucket (_rectBuf) — always composites
// AFTER every bucket-1 segment regardless of submission order; see
// DrawRect's doc comment for why the retained UI no longer feeds it.
// 3. Text glyphs (debug bitmap-font text; retained-UI dat-font text is
// also a bucket-1 sprite segment, not this bucket) — on top.
// The OVERLAY layer repeats all three AFTER the normal layer, so open
// popups beat even the normal layer's bucket-1 content.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font, frame, encoder);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font, frame, encoder);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
/// <summary>Draw one compositing layer: sprites + fills + outlines, all in submission
/// (painter) order, one call per texture-table slot → the separate untextured-rect
/// bucket (unused by the retained UI) → debug-font text. Shared by the normal and
/// overlay layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,

View file

@ -38,7 +38,21 @@ public sealed class TextureCache
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
//
// Review fix round (Slice B, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// finding 6): keyed by (id, nearest), not id alone. GetOrUploadRenderSurface's
// sampler is chosen at UPLOAD time (UploadUiTexture bakes `nearest` into the
// registered IGpuSampler) and a table slot is one fixed (texture, sampler)
// pair — the same source id legitimately needs BOTH samplers in the same
// session (UiDatFont's glyph atlases already call this nearest:true while
// ResolveChrome's background/border art calls it nearest:false with no
// `nearest` argument at all). An id-only key made whichever caller asked
// FIRST win the sampler for every later caller of the same id; a plugin
// markup <icon did=...>/<button icon>/<list icons> (nearest:true, Slice B)
// could therefore silently inherit a chrome sprite's linear sampler (or
// vice versa) purely by upload order.
private readonly Dictionary<(uint SurfaceId, bool Nearest), GpuUiTextureEntry>
_renderSurfaceGpuTextures = new();
// Campaign LA gate round 2: the OTHER magenta cause GetOrUploadRenderSurface can
// hit — a non-zero id that simply isn't a RenderSurface in either dat (as opposed
@ -231,7 +245,18 @@ public sealed class TextureCache
/// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
{
if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing))
// Residual round finding N5 (nit): keying by (renderSurfaceId, nearest)
// means a DID requested BOTH nearest (plugin markup icons,
// RetailMarkupIconResolver.ResolveDid) and non-nearest (chrome art via
// ResolveChrome) decodes and uploads the same source bytes twice —
// GetOrCreateLinearUiTwin below exists to share one already-uploaded
// texture across samplers, but only in the nearest-registered-first
// direction, and no caller wires this method through it. Left
// unaddressed (nit, not required for this round): the rewiring would
// need a lookup keyed by id alone before falling back to per-sampler
// upload, which this method does not have today.
var cacheKey = (renderSurfaceId, nearest);
if (_renderSurfaceGpuTextures.TryGetValue(cacheKey, out GpuUiTextureEntry existing))
{
width = existing.Width; height = existing.Height;
return UiTextureTableHandle.FromSlot(existing.Slot);
@ -262,7 +287,7 @@ public sealed class TextureCache
}
GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}");
_renderSurfaceGpuTextures[renderSurfaceId] = entry;
_renderSurfaceGpuTextures[cacheKey] = entry;
width = decoded.Width; height = decoded.Height;
return UiTextureTableHandle.FromSlot(entry.Slot);
}

View file

@ -136,6 +136,17 @@ public sealed record RuntimeOptions(
/// process configuration directly.</summary>
public IReadOnlyList<string> PluginTags { get; init; } = [];
/// <summary>
/// <c>ACDREAM_VTANK_PROFILE_DIR</c> override for the directory a
/// VTank-compatible plugin's <c>IPluginHost.VtankProfiles</c> storage is
/// rooted at (real <c>.usd</c>/<c>.ast</c>/<c>.af</c> files — e.g. a real
/// installed VTank's own profile folder for direct interop).
/// <see langword="null"/> (the default) means "no opinion": the host
/// composes <c>applicationPaths.DataDirectory/vtank</c> instead. See
/// <c>docs/launch-options.md</c>.
/// </summary>
public string? VtankProfileDirectoryOverride { get; init; }
/// <summary>
/// Build options from the process environment. Used by
/// <c>Program.cs</c> 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")),
};
}

View file

@ -0,0 +1,197 @@
using AcDream.App.Rendering;
using AcDream.Content;
using AcDream.Core.Items;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI;
/// <summary>
/// Host seam that lets plugin markup draw real DAT icons (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>) without
/// importing App/UI or Core.Items types across the plugin boundary.
/// <see cref="MarkupDocument.Build"/> accepts an optional instance; when
/// supplied, the <c>&lt;icon&gt;</c> element and the button/list icon
/// extensions route every id through it. See <c>docs/plugin-ui-markup.md</c>
/// for the full grammar.
/// </summary>
public interface IMarkupIconResolver
{
/// <summary>
/// Resolve a raw RenderSurface DID to (GL texture, width, height). The
/// caller (<see cref="MarkupDocument"/>) has already run the id through
/// <see cref="AcDream.Plugin.Abstractions.PluginIcons.Normalize"/>, so
/// implementations need not re-normalize. 0/unresolvable → <c>(0, 0, 0)</c>,
/// which draws nothing.
/// </summary>
(uint tex, int w, int h) ResolveDid(uint did);
/// <summary>
/// Resolve retail's composited spell icon (power-level backing + art +
/// reversed/normal tint + self/fellow overlay — <c>IconComposer.GetSpellIcon</c>,
/// retail <c>ClientMagicSystem::CompositeSpellIcon</c>).
/// </summary>
(uint tex, int w, int h) ResolveSpell(uint spellId);
/// <summary>
/// Resolve retail's composited item icon (type-default underlay + custom
/// underlay + base + custom overlay + effect recolor —
/// <c>IconComposer.GetIcon</c>) for a live object id, reading its icon
/// fields from the SAME <see cref="AcDream.Core.Items.ClientObjectTable"/>
/// the inventory UI already uses.
/// </summary>
(uint tex, int w, int h) ResolveItem(uint objectId);
}
/// <summary>
/// Production <see cref="IMarkupIconResolver"/>. Built once in
/// <see cref="RetailUiRuntime.MountPlugins"/> from
/// <see cref="RetailUiAssets.Dats"/>, <see cref="RetailUiAssets.TextureCache"/>,
/// <see cref="RetailUiAssets.Icons"/> (the one <see cref="IconComposer"/> every
/// authored panel shares), and the live <see cref="ClientObjectTable"/> the
/// inventory UI's own icon resolution already borrows
/// (<c>ToolbarRuntimeBindings.Objects</c>/<c>MagicRuntimeBindings.Objects</c> —
/// both <c>d.Inventory.Objects</c>, the same instance) — no second texture
/// cache or object lookup is introduced.
///
/// <para>
/// <b>UI-thread-only.</b> <see cref="_resolvedDidCache"/>/<see cref="_missOrder"/>
/// are plain mutable collections with no locking: every caller of
/// <see cref="ResolveDid"/> is a draw-time icon source (<see cref="UiMarkupIcon"/>,
/// <see cref="UiMarkupList"/>, <see cref="PluginSidePanel.PluginShelfButton"/>),
/// which only ever run on the single UI/render thread that ticks
/// <see cref="UiRoot"/>. Do not call this from a background thread or a
/// plugin worker without adding synchronization first.
/// </para>
/// </summary>
public sealed class RetailMarkupIconResolver : IMarkupIconResolver
{
private readonly IDatReaderWriter _dats;
private readonly IconComposer _icons;
private readonly ClientObjectTable _objects;
/// <summary>
/// Upper bound on cached MISS entries (see <see cref="_missOrder"/>).
/// Chosen generously above any real plugin icon-id population (Slice B's
/// own doc estimate: "small... nothing like the world's full RenderSurface
/// population") — this is a leak guard against a misbehaving plugin markup
/// that binds a different bogus/unresolvable DID every frame (e.g. an
/// id computed from a changing counter), not a tuning knob for the normal
/// case.
/// </summary>
private const int MaxCachedMisses = 256;
/// <summary>
/// Residual round finding N4 (perf): memoizes <see cref="ResolveDid"/>'s
/// result per DID, including the <c>(0, 0, 0)</c> miss. Without this, an
/// unresolvable id (e.g. the shelf's initials-fallback path, or a
/// standalone <c>&lt;icon&gt;</c> whose id never resolves) re-probed
/// Portal AND HighRes — two cache misses plus two B-tree lookups under
/// <c>DatDatabaseWrapper</c>'s database lock — on EVERY frame, forever;
/// a resolvable id re-entered the DAT lock via
/// <see cref="TextureCache.GetOrUploadRenderSurface"/> every frame too,
/// even though that call's own cache already made the SECOND upload
/// free — the wasted cost was the per-frame database-lock re-entry, not
/// a duplicate GPU upload.
///
/// <para>
/// HIT entries (a real installed RenderSurface) are never evicted: that
/// population is bounded by the DAT's own real surface count, nothing
/// like a leak. MISS entries ARE bounded (<see cref="MaxCachedMisses"/>,
/// FIFO via <see cref="_missOrder"/>) because a miss key has no such
/// natural ceiling — a plugin author who binds a bad or ever-changing id
/// would otherwise grow this dictionary by one entry per distinct id,
/// forever, for the lifetime of the process.
/// </para>
/// </summary>
private readonly Dictionary<uint, (uint tex, int w, int h)> _resolvedDidCache = new();
/// <summary>FIFO of MISS keys currently sitting in <see cref="_resolvedDidCache"/>,
/// oldest-first. A key enters here exactly once per miss (re-entering only
/// after its prior entry was evicted), so its count always equals the
/// number of miss entries currently cached — see <see cref="ResolveDid"/>.</summary>
private readonly Queue<uint> _missOrder = new();
public RetailMarkupIconResolver(
IDatReaderWriter dats,
IconComposer icons,
ClientObjectTable objects)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_icons = icons ?? throw new ArgumentNullException(nameof(icons));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
}
/// <summary>
/// Review fix round (Slice B finding 1): probes for the RenderSurface's
/// existence in Portal/HighRes BEFORE ever calling
/// <see cref="TextureCache.GetOrUploadRenderSurface"/> — that method's 1x1
/// magenta placeholder for a missing id is load-bearing for authored
/// chrome (its own doc comment), and a plugin markup id that resolves to
/// nothing must draw NOTHING, never a scaled-up magenta square (see
/// <c>feedback_ui_resolve_zero_magenta.md</c>: guard on the id, never on
/// the resolved handle). <see cref="IDatReaderWriter.Portal"/>/<see cref="IDatReaderWriter.HighRes"/>'s
/// own <c>TryGet</c> already serializes concurrent DAT access internally
/// (<c>DatDatabaseWrapper.TryGet</c>'s <c>_databaseLock</c>) — the same
/// synchronization <see cref="IconComposer.TryDecode"/> relies on for
/// every one of its own DAT reads — so no additional lock is taken here.
/// A resolvable id is NOT blitted raw: it goes through
/// <see cref="IconComposer.GetKeyedIcon"/> — retail's <c>RenderIcons</c>
/// composite with no overlay and no effects, which replaces the art's
/// pure-white keyed pixels with the effects==0 solid-black tile. Item,
/// spell and component art reserve pure white as that key, so a raw blit
/// shows a white ring (owner report 2026-09-06, the MossTank shelf icon);
/// the composed texture is uploaded nearest-sampled by the composer, the
/// same convention every other icon in the client draws with.
/// </summary>
public (uint tex, int w, int h) ResolveDid(uint did)
{
if (did == 0u)
return (0u, 0, 0);
if (_resolvedDidCache.TryGetValue(did, out (uint tex, int w, int h) cached))
return cached;
(uint tex, int w, int h) result;
bool isMiss;
if (!_dats.Portal.TryGet<RenderSurface>(did, out _)
&& !_dats.HighRes.TryGet<RenderSurface>(did, out _))
{
result = (0u, 0, 0);
isMiss = true;
}
else
{
result = _icons.GetKeyedIcon(did);
isMiss = result.tex == 0u;
}
_resolvedDidCache[did] = result;
if (isMiss)
{
// Bound only the miss population (see MaxCachedMisses's doc):
// evict the OLDEST cached miss once a new one would exceed the
// cap, so a runaway distinct-miss-per-frame source cannot grow
// this dictionary without bound.
_missOrder.Enqueue(did);
if (_missOrder.Count > MaxCachedMisses)
_resolvedDidCache.Remove(_missOrder.Dequeue());
}
return result;
}
public (uint tex, int w, int h) ResolveSpell(uint spellId)
{
if (spellId == 0u) return (0u, 0, 0);
uint tex = _icons.GetSpellIcon(spellId);
return tex == 0u ? (0u, 0, 0) : (tex, 32, 32);
}
public (uint tex, int w, int h) ResolveItem(uint objectId)
{
if (objectId == 0u) return (0u, 0, 0);
ClientObject? item = _objects.Get(objectId);
if (item is null || item.IconId == 0u) return (0u, 0, 0);
uint tex = _icons.GetIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
return tex == 0u ? (0u, 0, 0) : (tex, 32, 32);
}
}

View file

@ -1,11 +1,25 @@
namespace AcDream.App.UI;
/// <summary>Panel state that is not completely described by outer-frame bounds.</summary>
/// <param name="RequestedVisible">
/// Review fix round (finding 3, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
/// Slice A): the panel's own persisted show/hide INTENT, independent of the
/// outer frame's derived <see cref="UiElement.Visible"/> (which for a
/// controller like <c>PluginSidePanel</c> also folds in an availability gate —
/// see that class's <c>ApplyVisibility</c>). Null means "no override, fall back
/// to <see cref="RetailWindowHandle.IsVisible"/>" — every existing state
/// controller that never had an intent distinct from derived visibility keeps
/// working unchanged. <see cref="RetailWindowLayoutPersistence"/>'s
/// <c>Capture</c>/<c>Apply</c> read and write this instead of the outer frame's
/// raw visibility so an availability-driven hide is never mistaken for (and
/// then persisted as) a user hide.
/// </param>
public readonly record struct RetainedWindowState(
bool Collapsed = false,
bool Maximized = false,
float? PersistedTop = null,
float? PersistedHeight = null);
float? PersistedHeight = null,
bool? RequestedVisible = null);
/// <summary>
/// Optional state seam used by retained-window persistence. Bounds are restored

View file

@ -264,6 +264,46 @@ public sealed class IconComposer
return iconId == 0 ? 0u : GetOrCreateDragIcon(iconId, overlayId, effects)?.Texture ?? 0u;
}
/// <summary>
/// A bare RenderSurface icon drawn the way retail draws EVERY icon it composites
/// (<c>IconData::RenderIcons</c> 0x0058d180 with no custom overlay and no effects):
/// the art, then <c>SurfaceWindow::ReplaceColor</c> of its pure-white keyed pixels
/// from the effects==0 fallback tile (the solid-black 0x21 tile 0x060011C5). Item,
/// spell and component art in the DAT reserve pure white as the "recolor me" key —
/// blitting such art raw shows a white ring around the icon (owner report
/// 2026-09-06 on the plugin shelf's MossTank icon; the same defect the inventory
/// had before the effect recolor landed). This is exactly the drag-icon composite
/// (<see cref="GetDragIcon"/>) with neither overlay nor effects, so it shares that
/// cache. Used by the plugin markup <c>did</c> sink and the plugin shelf.
/// Returns <c>(0, 0, 0)</c> when the surface does not exist.
/// </summary>
public (uint tex, int w, int h) GetKeyedIcon(uint iconId)
{
if (iconId == 0u) return (0u, 0, 0);
ComposedIcon? icon = GetOrCreateDragIcon(iconId, overlayId: 0u, effects: 0u);
return icon is null ? (0u, 0, 0) : (icon.Texture, icon.Width, icon.Height);
}
/// <summary>Test seam for <see cref="GetKeyedIcon"/>: the composed RGBA8 pixels.</summary>
internal bool TryGetKeyedIconRgba(uint iconId, out byte[] rgba, out int w, out int h)
{
rgba = Array.Empty<byte>(); w = 0; h = 0;
if (iconId == 0u) return false;
ComposedIcon? icon = GetOrCreateDragIcon(iconId, overlayId: 0u, effects: 0u);
if (icon is null) return false;
rgba = icon.Rgba; w = icon.Width; h = icon.Height;
return true;
}
/// <summary>Test seam: the raw decoded RGBA8 of one RenderSurface, no compositing.</summary>
internal bool TryDecodeRaw(uint renderSurfaceId, out byte[] rgba, out int w, out int h)
{
rgba = Array.Empty<byte>(); w = 0; h = 0;
if (!TryDecode(renderSurfaceId, out DecodedTexture decoded)) return false;
rgba = decoded.Rgba8; w = decoded.Width; h = decoded.Height;
return true;
}
private ComposedIcon? GetOrCreateDragIcon(uint iconId, uint overlayId, uint effects)
{
var key = (iconId, overlayId, effects);

View file

@ -3,6 +3,7 @@ using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI;
@ -29,9 +30,19 @@ public static class MarkupDocument
/// their text through the same glyph path as authored panels; without it
/// they fall back to the development bitmap font and look foreign.
/// </param>
/// <param name="icons">
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// resolves <c>&lt;icon&gt;</c>, <c>&lt;button icon&gt;</c>, and
/// <c>&lt;list icons&gt;</c> ids to drawable DAT icons. Null (the
/// default, and what every pre-Slice-B caller still passes) makes those
/// three surfaces resolve to nothing rather than throwing — a panel
/// authored against Slice B markup still loads under a host/test that
/// has not wired icon resolution.
/// </param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> resolve,
ControlsIni? style = null, UiDatFont? datFont = null)
ControlsIni? style = null, UiDatFont? datFont = null,
IMarkupIconResolver? icons = null)
{
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel")
@ -45,7 +56,31 @@ public static class MarkupDocument
Height = F(root, "h"),
};
// 2026-09-07 (docs/plans — owner direction "the size of the entire
// window needs to be enlarged for default and should also be
// resizeable"): a plugin panel is FIXED-SIZE by default —
// resizable="true" is the opt-in that arms real user drag-resize
// (both axes; UiRoot's generic edge/grip-drag mechanism already
// exists for every UiElement with Resizable=true — see
// UiElement.Resizable/ResizeX/ResizeY and RetailWindowManager.ResizeTo).
// minw/minh set the floor UiRoot's live drag and
// RetailWindowLayoutPersistence's restore clamp both already honor
// (UiElement.MinWidth/MinHeight); they default to the AUTHORED w/h so
// a resizable panel never shrinks below the layout its author tested.
bool resizable = B(root, "resizable", false);
panel.Resizable = resizable;
panel.MinWidth = FOr(root, "minw", panel.Width);
panel.MinHeight = FOr(root, "minh", panel.Height);
panel.ResizeX = resizable;
panel.ResizeY = resizable;
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
// Only meaningful once resizable="true" already armed the master
// switch above — Resizable=false (the default) blocks any drag-resize
// regardless of these axis flags, so this attribute alone can no
// longer make a panel resizable the way it silently could before
// resizable="true" existed (UiNineSlicePanel's own Resizable=true
// constructor default used to make the master switch a no-op).
string? resize = (string?)root.Attribute("resize");
if (resize is not null)
{
@ -80,7 +115,7 @@ public static class MarkupDocument
}
foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont);
AddElement(panel, el, binding, resolve, datFont, icons);
return panel;
}
@ -89,7 +124,8 @@ public static class MarkupDocument
XElement el,
object binding,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
UiDatFont? datFont,
IMarkupIconResolver? icons)
{
switch (el.Name.LocalName)
{
@ -114,7 +150,7 @@ public static class MarkupDocument
ApplyCommon(group, el, binding);
parent.AddChild(group);
foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont);
AddElement(group, child, binding, resolve, datFont, icons);
break;
case "meter":
@ -129,7 +165,8 @@ public static class MarkupDocument
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
// anchor= is applied uniformly for every element by
// ApplyCommon below; no per-element handling needed here.
SpriteResolve = resolve,
BackLeft = Hex((string?)el.Attribute("backleft")),
BackTile = Hex((string?)el.Attribute("backtile")),
@ -200,12 +237,104 @@ public static class MarkupDocument
if (el.Attribute("border") is not null)
button.BorderColor = Color(
(string?)el.Attribute("border"));
// Slice B: <button icon="..." iconkind="did|spell|item">.
// Review fix round finding 4: the iconkind literal is
// validated here regardless of whether a resolver is
// wired — a typo like iconkind="spel" must throw at
// Build on every host, not only ones with icon support
// turned on. Finding 7: IconSource is only ASSIGNED when
// a resolver exists — UiSimpleButton now reserves its
// icon column whenever IconSource is non-null (see
// UiPanel.cs), so setting it to an always-empty func on
// an icons:null host would permanently reserve a column
// that never draws anything.
string? buttonIcon = (string?)el.Attribute("icon");
if (buttonIcon is not null)
{
string? buttonIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(buttonIconKind);
// Residual round finding N2: BindUintLiteralOrBinding
// must run UNCONDITIONALLY — same rule as
// ValidateIconKind just above — so a malformed
// icon="{Typo}" throws FormatException at Build on
// every host, not only ones with icons wired. Only
// the ASSIGNMENT onto button.IconSource stays gated
// on icons is not null (finding 7: an always-empty
// IconSource would permanently reserve the icon
// column on a resolver-less host).
Func<uint> buttonIconReader =
BindUintLiteralOrBinding(buttonIcon, binding, "button icon");
if (icons is not null)
{
button.IconSource = BuildIconSource(
buttonIconKind,
buttonIconReader,
icons);
}
}
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
parent.AddChild(button);
break;
case "icon":
{
// Residual round finding N7: unlike <button>/<list>,
// <icon> derives its kind from WHICH of did/spell/item is
// set (below) — iconkind is meaningless here and was
// previously silently ignored (a plugin author's
// iconkind="spell" typo on an <icon did="..."> would
// never do what it looked like it did). Reject it
// loudly instead, same "malformed markup throws at
// Build" rule every other attribute in this grammar
// follows.
if (el.Attribute("iconkind") is not null)
{
throw new FormatException(
"iconkind applies to button and list; icon derives its kind from did/spell/item");
}
string? didAttr = (string?)el.Attribute("did");
string? spellAttr = (string?)el.Attribute("spell");
string? itemAttr = (string?)el.Attribute("item");
int sourceCount = (didAttr is not null ? 1 : 0)
+ (spellAttr is not null ? 1 : 0)
+ (itemAttr is not null ? 1 : 0);
if (sourceCount != 1)
{
throw new FormatException(
"<icon> requires exactly one of did/spell/item");
}
string iconKind = didAttr is not null ? "did"
: spellAttr is not null ? "spell"
: "item";
string iconExpression = didAttr ?? spellAttr ?? itemAttr!;
Func<uint> iconReader = BindUintLiteralOrBinding(
iconExpression, binding, $"icon {iconKind}");
var icon = new UiMarkupIcon
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = FOr(el, "w", 32f),
Height = FOr(el, "h", 32f),
IconSource = BuildIconSource(iconKind, iconReader, icons),
};
ApplyCommon(icon, el, binding);
// A tooltip needs this element to be a real hit-test
// target — see UiMarkupIcon's own doc comment. Review fix
// round finding 9: an EMPTY tooltip="" must not swallow
// clicks either — match ApplyCommon's own
// !IsNullOrWhiteSpace predicate rather than a bare
// attribute-presence check.
string? iconTooltip = (string?)el.Attribute("tooltip");
if (!string.IsNullOrWhiteSpace(iconTooltip))
icon.ClickThrough = false;
parent.AddChild(icon);
break;
}
case "tab":
string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding);
@ -277,6 +406,38 @@ public static class MarkupDocument
+ $"Action<float> property on {binding.GetType().Name}");
}
// KB 08 §3 gap: VVS's HudHSlider exposes an arbitrary Min/Max
// range (VTank's own Vitals sliders are minimum="0"
// maximum="100"); acdream's <slider> historically only ever
// bound a fixed 0.0-1.0 value. Omitting both attributes keeps
// that exact identity range so every pre-existing <slider>
// (which never sets min/max) is byte-for-byte unaffected.
float sliderMin = FOr(el, "min", 0f);
float sliderMax = FOr(el, "max", 1f);
// Fix round B item 16: max<=min used to silently fall back to
// a range of 1 (via the old "== 0f" check) rather than being
// caught as an authoring error — a max<min case slipped
// through entirely, producing a slider whose drag direction
// is inverted from its declared range. Both are now a
// build-time author error, same rule as every other
// <slider>/<menu> attribute-format check in this file.
if (sliderMax <= sliderMin)
{
throw new FormatException(
$"<slider min=\"{sliderMin}\" max=\"{sliderMax}\"> must have max > min");
}
float sliderRange = sliderMax - sliderMin;
Func<float?> sliderValueSource = BindFloat(
(string?)el.Attribute("value"),
binding);
// Fix round B item 11: <slider style="..."> mirrors <menu
// style> — plain (default) draws a flat track/nub with no
// DAT art at all; style="retail" opts into the sprite chrome
// RetailScrollbarChrome.ApplyHorizontal applies below.
bool sliderRetailArt = ValidateArtStyle("slider", (string?)el.Attribute("style"));
var slider = new UiScrollbar
{
Left = F(el, "x"),
@ -285,12 +446,18 @@ public static class MarkupDocument
Height = F(el, "h"),
Horizontal = true,
SpriteResolve = resolve,
ScalarPositionSource = BindFloat(
(string?)el.Attribute("value"),
binding),
ScalarChanged = changed,
RetailArt = sliderRetailArt,
ScalarPositionSource = () =>
sliderValueSource() is { } declaredValue
? Math.Clamp(
(declaredValue - sliderMin) / sliderRange, 0f, 1f)
: (float?)null,
ScalarChanged = changed is null
? null
: normalized => changed(sliderMin + normalized * sliderRange),
};
RetailScrollbarChrome.ApplyHorizontal(slider);
if (sliderRetailArt)
RetailScrollbarChrome.ApplyHorizontal(slider);
ApplyCommon(slider, el, binding);
parent.AddChild(slider);
break;
@ -357,6 +524,7 @@ public static class MarkupDocument
Func<string?> menuSelected = BindString(
(string?)el.Attribute("selected"),
binding);
bool menuRetailButtonArt = ValidateArtStyle("menu", (string?)el.Attribute("style"));
var menu = new UiMenu
{
Left = F(el, "x"),
@ -369,6 +537,14 @@ public static class MarkupDocument
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", false),
// Same track/thumb/arrow chrome ConfigOptionsPageController
// and VendorUiController already apply to their own
// Scrollable menus — harmless to set unconditionally since
// a non-scrollable menu never reads these.
ScrollTrackSprite = 0x06004C5Fu,
ScrollThumbSprite = 0x06004C63u,
ScrollUpSprite = RetailScrollbarChrome.UpNormal,
ScrollDownSprite = RetailScrollbarChrome.DownNormal,
TextIndent = 6f,
ButtonTextIndent = 6f,
NormalSprite = 0x06004D65u,
@ -376,6 +552,15 @@ public static class MarkupDocument
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
RetailButtonArt = menuRetailButtonArt,
// Owner live-client report 2026-09-07: a plugin dropdown
// scrolls a single column (VTank's own HudCombo shape)
// rather than wrapping into more grid columns once it
// overflows its "rows" window; the scrollbar itself is
// hidden entirely (0x79 semantics) while everything
// fits, matching retail's vendor category popup.
Scrollable = true,
PopupScrollbarHideWhenDisabled = true,
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
OnSelect = payload =>
{
@ -383,6 +568,12 @@ public static class MarkupDocument
menuChanged?.Invoke(value);
},
};
// The popup's own scrollbar always draws retail's chrome —
// "we use the same assets as we do in for example chat or
// inventory window" — regardless of RetailButtonArt (the
// owner's earlier plain-row directive only ever covered the
// ROWS, never this bar).
RetailScrollbarChrome.ApplyToMenuPopup(menu);
void RefreshMenu()
{
menu.Items = menuItems()
@ -405,6 +596,32 @@ public static class MarkupDocument
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
+ $"Action<int> property on {binding.GetType().Name}");
}
// Campaign VT slice 1 Part B: <list><column .../></list>
// (docs/research/vtank-kb/08-ui-views.md §3). A non-<column>
// child is always malformed — the element previously had no
// children at all, so this is purely additive.
var listChildren = el.Elements().ToList();
foreach (var child in listChildren)
{
if (child.Name.LocalName != "column")
{
throw new FormatException(
$"<list> children must all be <column>, got <{child.Name.LocalName}>");
}
}
bool listUsesColumns = listChildren.Count > 0;
if (listUsesColumns
&& (el.Attribute("items") is not null
|| el.Attribute("icons") is not null
|| el.Attribute("colors") is not null))
{
throw new FormatException(
"<list> with <column> children cannot also use the "
+ "items/icons/colors attributes (Slice B's own single-column "
+ "form) — express every row source as a <column> instead");
}
var list = new UiMarkupList
{
Left = F(el, "x"),
@ -413,26 +630,272 @@ public static class MarkupDocument
Height = F(el, "h"),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
DatFont = datFont,
ItemsSource = BindStringList(
(string?)el.Attribute("items"),
binding,
"list items"),
ItemColorsSource = BindUintList(
(string?)el.Attribute("colors"),
binding,
"list colors"),
// Owner live-client report 2026-09-07: an overflowing
// <list> draws the same retail scrollbar chrome the chat
// window and inventory use — resolved through the same
// sprite resolver every other markup sink already uses.
SpriteResolve = resolve,
SelectedIndexSource = BindRequiredIntReader(
(string?)el.Attribute("selected"),
binding,
"list selected"),
SelectionChanged = listChanged,
// Campaign VT slice 7 resemblance re-check: VVS lists draw
// no persistent row-selection fill by default (matches
// both single-column and <column> mode now — see
// UiMarkupList.SelectionBandEnabled). A plugin that wants
// one back opts in with <list selectionband="true">.
SelectionBandEnabled = B(el, "selectionband", false),
};
if (listUsesColumns)
{
int lastColumnIndex = listChildren.Count - 1;
list.Columns = listChildren
.Select((columnEl, index) => BuildListColumn(
columnEl, binding, icons, index, index == lastColumnIndex))
.ToList();
}
else
{
list.ItemsSource = BindStringList(
(string?)el.Attribute("items"),
binding,
"list items");
list.ItemColorsSource = BindUintList(
(string?)el.Attribute("colors"),
binding,
"list colors");
// Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
// Same two rules as <button icon> above: iconkind validates
// regardless of resolver wiring (finding 4), and
// IconIdsSource/IconResolve are only set when a resolver
// exists (finding 7) — UiMarkupList already reserves its
// icon column whenever IconIdsSource is non-null.
string? listIcons = (string?)el.Attribute("icons");
if (!string.IsNullOrWhiteSpace(listIcons))
{
string? listIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(listIconKind);
// Residual round finding N2: same rule as the button's
// icon reader above — BindUintList must run
// UNCONDITIONALLY so icons="notabinding" (a malformed,
// non-{Binding} literal — list icons has no literal
// grammar) throws FormatException at Build even with no
// resolver wired. Only the assignment stays gated.
Func<IReadOnlyList<uint>> listIconIdsReader =
BindUintList(listIcons, binding, "list icons");
if (icons is not null)
{
list.IconIdsSource = listIconIdsReader;
list.IconResolve = BuildRowIconResolve(listIconKind, icons);
}
}
}
ApplyCommon(list, el, binding);
parent.AddChild(list);
break;
default:
// Review fix round finding 11: an unknown or miscased
// element name previously vanished silently (the switch had
// no default arm) — the same "malformed markup throws at
// Build" rule every other element already follows.
throw new FormatException($"unknown element <{el.Name.LocalName}>");
}
}
/// <summary>
/// Review fix round finding 4: validates an <c>iconkind</c> attribute
/// (default <c>"did"</c>) UNCONDITIONALLY — before either
/// <see cref="BuildIconSource"/> or <see cref="BuildRowIconResolve"/>'s
/// null-resolver early return, so <c>iconkind="spel"</c> throws
/// <see cref="FormatException"/> at <c>Build</c> on every host, even one
/// with no <see cref="IMarkupIconResolver"/> wired at all. A malformed
/// attribute is a Build-time author error regardless of what the host
/// happens to support.
///
/// <para>
/// Fix round item 5: <paramref name="context"/> (default <c>"iconkind"</c>
/// for the non-column call sites — <c>&lt;icon&gt;</c>, <c>&lt;button
/// icon&gt;</c>, <c>&lt;list icons&gt;</c>) prefixes the throw message so
/// <c>&lt;column type="icon"&gt;</c>'s own call site can identify which
/// column failed (<c>column[2] type="icon" iconkind</c>).
/// </para>
/// </summary>
private static string ValidateIconKind(string? iconKind, string context = "iconkind") =>
(iconKind ?? "did") switch
{
"did" or "spell" or "item" => iconKind ?? "did",
var other => throw new FormatException(
$"{context} must be did, spell, or item (got \"{other}\")"),
};
/// <summary>
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): validates <c>style="..."</c> on
/// <c>&lt;menu&gt;</c> (returning <see cref="UiMenu.RetailButtonArt"/>'s
/// value) and, since fix round B item 11, <c>&lt;slider&gt;</c> (returning
/// <see cref="UiScrollbar.RetailArt"/>'s value) — the same grammar, the
/// same default. Default (attribute absent, or explicit
/// <c>style="plain"</c>) is the flat VTank/Decal look (<c>false</c>) —
/// retail's gold/sprite art is an explicit <c>style="retail"</c> opt-in
/// for a plugin panel that genuinely wants it. Any other value is a
/// Build-time author error, same rule as <see cref="ValidateIconKind"/>.
/// </summary>
private static bool ValidateArtStyle(string elementName, string? style) => style switch
{
null or "plain" => false,
"retail" => true,
var other => throw new FormatException(
$"<{elementName} style=\"{other}\"> must be plain or retail"),
};
/// <summary>
/// Builds the zero-argument icon resolver the <c>&lt;icon&gt;</c> element
/// uses: dispatch by <c>iconkind</c> (default <c>"did"</c>) to the
/// matching <see cref="IMarkupIconResolver"/> method, normalizing
/// <c>did</c> through <see cref="PluginIcons.Normalize"/> (spell/item ids
/// are never DAT RenderSurface DIDs, so they never pass through it).
/// Null <paramref name="icons"/> (no resolver wired) always resolves to
/// nothing rather than throwing — a standalone <c>&lt;icon&gt;</c> draws
/// nothing either way, so there is no column-reservation concern here
/// the way there is for <c>&lt;button icon&gt;</c>/<c>&lt;list icons&gt;</c>
/// (see their own call sites in <see cref="AddElement"/>).
/// </summary>
private static Func<(uint tex, int w, int h)> BuildIconSource(
string? iconKind, Func<uint> idReader, IMarkupIconResolver? icons)
{
string kind = ValidateIconKind(iconKind);
if (icons is null)
return static () => (0u, 0, 0);
return kind switch
{
"did" => () => icons.ResolveDid(
PluginIcons.Normalize(idReader())),
"spell" => () => icons.ResolveSpell(idReader()),
"item" => () => icons.ResolveItem(idReader()),
_ => throw new InvalidOperationException(
"unreachable — ValidateIconKind already rejected anything else"),
};
}
/// <summary>
/// Same dispatch as <see cref="BuildIconSource"/>, shaped for
/// <c>&lt;list icons&gt;</c>'s per-row resolve (the row's own icon id is
/// the argument rather than a captured reader). Callers only invoke this
/// after confirming <paramref name="icons"/> is non-null (see the
/// <c>&lt;list&gt;</c> case in <see cref="AddElement"/>) so
/// <see cref="UiMarkupList.IconResolve"/> is never set to an
/// always-empty delegate.
/// </summary>
private static Func<uint, (uint tex, int w, int h)> BuildRowIconResolve(
string? iconKind, IMarkupIconResolver icons)
{
string kind = ValidateIconKind(iconKind);
return kind switch
{
"did" => id => icons.ResolveDid(
PluginIcons.Normalize(id)),
"spell" => icons.ResolveSpell,
"item" => icons.ResolveItem,
_ => throw new InvalidOperationException(
"unreachable — ValidateIconKind already rejected anything else"),
};
}
/// <summary>
/// Resolves a <c>did</c>/<c>spell</c>/<c>item</c> attribute to a live
/// <see cref="uint"/> reader: a <c>{Prop}</c> binding re-reads a property
/// every frame; a literal accepts hex (<c>0x...</c>) or decimal, matching
/// every other markup id attribute's "malformed literal throws at Build"
/// rule.
/// </summary>
/// <remarks>
/// Review fix round finding 5: accepts ANY integral property type
/// (<see cref="int"/>, <see cref="long"/>, <see cref="uint"/>,
/// <see cref="ushort"/>, a nullable of any of those, …), not only an
/// exact <see cref="uint"/> match — matching <see cref="BindUint"/>'s own
/// leniency below. Decal-facing bindings are commonly <c>int</c> end to
/// end (e.g. MosswartMassacre's <c>HudPictureBox.Image</c>), so requiring
/// a literal <c>uint</c> property rejected every one of them at Build. A
/// property whose runtime value cannot convert (a non-numeric type) still
/// throws — just from <see cref="Convert.ToUInt32(object, IFormatProvider)"/>
/// at read time rather than a type check at Build, the same tradeoff
/// <see cref="BindUint"/> already makes.
/// </remarks>
/// <remarks>
/// Residual round finding N3: a NEGATIVE bound value (Decal's own
/// convention for "no icon" — e.g. <c>HudPictureBox.Image = -1</c>) used
/// to throw <see cref="OverflowException"/> straight out of
/// <see cref="Convert.ToUInt32(object, IFormatProvider)"/> — every frame,
/// from inside <c>UiSimpleButton.OnDraw</c>. DECIDED: any value that does
/// not fit in a <see cref="uint"/> — negative, or above
/// <see cref="uint.MaxValue"/> — maps to <c>0u</c> (draws nothing) rather
/// than throwing at draw time. <see cref="Convert.ToUInt32(object, IFormatProvider)"/>
/// already throws exactly <see cref="OverflowException"/> for both of
/// those cases (never for a non-numeric type, which still throws
/// <see cref="InvalidCastException"/>/<see cref="FormatException"/> as
/// before), so catching only that one exception type is sufficient.
/// </remarks>
private static Func<uint> BindUintLiteralOrBinding(
string expression, object binding, string context)
{
if (!IsBinding(expression))
{
uint literal = ParseUintLiteral(expression, context);
return () => literal;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null)
{
throw new FormatException(
$"{expression} did not resolve to a property on "
+ binding.GetType().Name + $" ({context})");
}
return () => property.GetValue(binding) switch
{
uint u => u,
null => 0u,
var v => ToUintOrZero(v),
};
}
/// <summary>
/// <see cref="Convert.ToUInt32(object, IFormatProvider)"/>, mapping an
/// out-of-range value (negative, or above <see cref="uint.MaxValue"/>) to
/// <c>0u</c> instead of propagating <see cref="OverflowException"/>. See
/// <see cref="BindUintLiteralOrBinding"/>'s finding N3 remark.
/// </summary>
private static uint ToUintOrZero(object value)
{
try
{
return Convert.ToUInt32(value, CultureInfo.InvariantCulture);
}
catch (OverflowException)
{
return 0u;
}
}
private static uint ParseUintLiteral(string text, string context)
{
string trimmed = text.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
if (uint.TryParse(trimmed.AsSpan(2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint hex))
return hex;
}
else if (uint.TryParse(trimmed, NumberStyles.Integer,
CultureInfo.InvariantCulture, out uint dec))
{
return dec;
}
throw new FormatException($"{context}=\"{text}\" is not a valid uint literal");
}
/// <summary>
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
/// literal text unchanged. The indirection matters: binding to a
@ -535,6 +998,23 @@ public static class MarkupDocument
: Array.Empty<string>();
}
/// <remarks>
/// Review fix round finding 5: also accepts <see cref="IEnumerable{T}"/>
/// of <see cref="int"/> — Decal is <c>int</c> end to end
/// (MosswartMassacre's <c>FlagTrackerView.cs</c> feeds
/// <c>HudPictureBox.Image</c> from <c>int</c> ids), so a plugin porting
/// that convention hands the host <c>IEnumerable&lt;int&gt;</c>, not
/// <c>IEnumerable&lt;uint&gt;</c>.
/// </remarks>
/// <remarks>
/// Residual round finding N3: the per-element conversion used to be an
/// UNCHECKED reinterpret (<c>-1</c> silently wrapped to <c>0xFFFFFFFF</c>
/// — a bogus, almost-certainly-unresolvable id drawn as if it were a real
/// one, rather than the "no icon" <c>0u</c> the same negative value maps
/// to on the scalar path — see <see cref="ToUintOrZero"/>). DECIDED: a
/// negative element now maps to <c>0u</c> here too, matching the scalar
/// path's contract exactly (both "no icon" conventions agree).
/// </remarks>
private static Func<IReadOnlyList<uint>> BindUintList(
string? expression,
object binding,
@ -545,16 +1025,203 @@ public static class MarkupDocument
if (!IsBinding(expression))
throw new FormatException($"{context} must be a uint-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
if (property is null)
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<uint> property on "
+ binding.GetType().Name);
$"{expression} did not resolve to an IEnumerable<uint> or "
+ "IEnumerable<int> property on " + binding.GetType().Name);
}
return () => property.GetValue(binding) is IEnumerable<uint> values
if (typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
{
return () => property.GetValue(binding) is IEnumerable<uint> values
? values.ToArray()
: Array.Empty<uint>();
}
if (typeof(IEnumerable<int>).IsAssignableFrom(property.PropertyType))
{
return () => property.GetValue(binding) is IEnumerable<int> values
? values.Select(static v => v < 0 ? 0u : (uint)v).ToArray()
: Array.Empty<uint>();
}
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<uint> or "
+ "IEnumerable<int> property on " + binding.GetType().Name);
}
/// <summary>
/// <c>&lt;column type="check" values="{IReadOnlyList&lt;bool&gt;}"&gt;</c>
/// (Campaign VT slice 1 Part B). Required — unlike <see cref="BindUintList"/>'s
/// "silent if omitted" carve-out for the optional <c>list colors</c>
/// attribute, a check column with no <c>values</c> binding is a Build-time
/// author error (there is nothing sensible to draw).
/// </summary>
private static Func<IReadOnlyList<bool>> BindBoolList(
string? expression, object binding, string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a bool-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable<bool>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<bool> property on "
+ binding.GetType().Name + $" ({context})");
}
return () => property.GetValue(binding) is IEnumerable<bool> values
? values.ToArray()
: Array.Empty<uint>();
: Array.Empty<bool>();
}
/// <summary>
/// Same grammar as <see cref="BindUintList"/> but REQUIRED — used by
/// <c>&lt;column type="icon" values="..."&gt;</c>, where (unlike the
/// single-column list's optional <c>icons</c> attribute) there is no
/// "no icon column at all" fallback: an icon column with no
/// <c>values</c> binding is a Build-time author error.
/// </summary>
private static Func<IReadOnlyList<uint>> BindRequiredUintList(
string? expression, object binding, string context)
{
if (string.IsNullOrWhiteSpace(expression))
throw new FormatException($"{context} must be a uint-list binding");
return BindUintList(expression, binding, context);
}
/// <summary>
/// <c>&lt;column&gt;</c>'s <c>onchange</c> (check)/<c>onclick</c> (icon) —
/// unlike every other <c>Action&lt;int&gt;</c> sink in this file (the
/// list's own <c>onchange</c>, which is optional), a column callback is
/// REQUIRED: a check/icon column that never fires anything is a
/// Build-time author error, not a silently-inert control.
/// </summary>
private static Action<int> BindRequiredIntAction(
string? attribute, object binding, string context)
{
if (attribute is null || !IsBinding(attribute))
throw new FormatException($"{context} must be an Action<int> binding");
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
if (property is null || !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{attribute} did not resolve to an Action<int> property on "
+ binding.GetType().Name + $" ({context})");
}
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
}
/// <summary>
/// Builds one <see cref="UiMarkupListColumn"/> from a <c>&lt;column&gt;</c>
/// child of <c>&lt;list&gt;</c> (Campaign VT slice 1 Part B —
/// <c>docs/research/vtank-kb/08-ui-views.md</c> §3's proposed extension).
/// <paramref name="icons"/> follows the same "validate iconkind
/// unconditionally, wire the resolver only when one exists" rule as the
/// legacy <c>&lt;list icons&gt;</c> path (<see cref="BuildRowIconResolve"/>'s
/// own call site above): a malformed <c>iconkind</c> throws at Build even
/// on a resolver-less host, but <see cref="UiMarkupListColumn.IconResolve"/>
/// stays null (draws nothing) rather than ever pointing at a null resolver.
/// </summary>
private static UiMarkupListColumn BuildListColumn(
XElement columnEl, object binding, IMarkupIconResolver? icons, int index, bool isLast)
{
string? type = (string?)columnEl.Attribute("type");
(float width, bool isAutoWidth) = ParseColumnWidth(columnEl, index, type, isLast);
switch (type)
{
case "text":
{
var textSource = BindStringList(
(string?)columnEl.Attribute("items"), binding, ColumnContext(index, "text", "items"));
string? colorsAttr = (string?)columnEl.Attribute("colors");
Func<IReadOnlyList<uint>>? colorsSource = colorsAttr is null
? null
: BindUintList(colorsAttr, binding, ColumnContext(index, "text", "colors"));
// Fix round finding 1: optional onclick — a text cell that
// declares one fires it with the row index instead of
// selecting; one that doesn't keeps the original
// select-on-click behavior. Same "resolve if binding-shaped,
// throw only if malformed" rule as the list's own onchange
// above — omitting the attribute entirely is fine.
string? textOnClickAttr = (string?)columnEl.Attribute("onclick");
Action<int>? textOnClick = BindIntAction(textOnClickAttr, binding);
if (textOnClickAttr is not null && textOnClick is null)
{
throw new FormatException(
$"{ColumnContext(index, "text", "onclick")} did not resolve to an "
+ $"Action<int> property on {binding.GetType().Name}");
}
return UiMarkupListColumn.Text(width, textSource, colorsSource, textOnClick, isAutoWidth);
}
case "check":
{
var checkSource = BindBoolList(
(string?)columnEl.Attribute("values"), binding, ColumnContext(index, "check", "values"));
var onChange = BindRequiredIntAction(
(string?)columnEl.Attribute("onchange"), binding, ColumnContext(index, "check", "onchange"));
return UiMarkupListColumn.Check(width, checkSource, onChange, isAutoWidth);
}
case "icon":
{
var valuesSource = BindRequiredUintList(
(string?)columnEl.Attribute("values"), binding, ColumnContext(index, "icon", "values"));
string? iconKind = (string?)columnEl.Attribute("iconkind");
ValidateIconKind(iconKind, ColumnContext(index, "icon", "iconkind"));
var onClick = BindRequiredIntAction(
(string?)columnEl.Attribute("onclick"), binding, ColumnContext(index, "icon", "onclick"));
Func<uint, (uint, int, int)>? resolve = icons is not null
? BuildRowIconResolve(iconKind, icons)
: null;
return UiMarkupListColumn.Icon(width, valuesSource, resolve, onClick, isAutoWidth);
}
default:
throw new FormatException(
$"column[{index}] has unknown type=\"{type}\" (expected text, check, or icon)");
}
}
/// <summary>
/// Fix round finding 5: every column-attribute throw message identifies
/// the offending column by position and declared type
/// (<c>column[2] type="check" values</c>) rather than the generic
/// <c>"column values"</c> the initial slice used — a plugin author with
/// several columns of the same <c>type</c> needs the index to find which
/// one is wrong.
/// </summary>
private static string ColumnContext(int index, string type, string attribute) =>
$"column[{index}] type=\"{type}\" {attribute}";
/// <summary>
/// Fix round item 2: <c>&lt;column width&gt;</c> semantics. <c>"*"</c>
/// (any column, including the last) means auto — this column shares the
/// list's remaining width equally with every other auto column at
/// layout time (see <see cref="UiMarkupList"/>'s column-layout helper).
/// A NON-last column with a missing, unparseable, or non-positive width
/// is a Build-time author error (there is nothing sensible to lay out).
/// The LAST column is exempt from this validation entirely — it always
/// absorbs whatever room remains regardless of its own declared width,
/// so an invalid value there is harmless and never thrown; only an
/// explicit <c>"*"</c> there is actually meaningful (it makes the last
/// column share evenly with any OTHER auto columns instead of taking
/// 100% of the remainder alone — see <see cref="UiMarkupListColumn"/>'s
/// own doc for exactly how the last column's implicit auto-ness
/// combines with an explicit one).
/// </summary>
private static (float width, bool isAutoWidth) ParseColumnWidth(
XElement columnEl, int index, string? type, bool isLast)
{
string? raw = (string?)columnEl.Attribute("width");
if (raw == "*")
return (0f, true);
if (isLast)
return (F(columnEl, "width"), false);
if (!float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float width)
|| width <= 0f)
{
throw new FormatException(
$"{ColumnContext(index, type ?? "(missing)", "width")} must be a positive "
+ "number or \"*\", got " + (raw is null ? "(missing)" : $"\"{raw}\""));
}
return (width, false);
}
private static bool IsBinding(string value) =>
@ -567,6 +1234,18 @@ public static class MarkupDocument
{
element.Name = (string?)source.Attribute("name")
?? (string?)source.Attribute("id");
// 2026-09-07: anchor="left top right bottom" (space-separated; any
// subset; default "left top" — today's fixed placement) on ANY
// markup element. Semantics are identical to UiElement.Anchors/
// AnchorEdges/ApplyAnchor: "left right" stretches width with the
// parent, "top bottom" stretches height, "right" alone pins to the
// right edge at fixed width. A <group>'s own children resolve their
// anchor relative to the GROUP (their direct Parent), not the panel,
// because UiElement.ApplyAnchor always measures against Parent.Width/
// Height — no extra propagation code is needed for that.
element.Anchors = ParseAnchor((string?)source.Attribute("anchor"), source);
BindBool((string?)source.Attribute("visible"), binding,
value => element.Visible = value,
sourceReader => element.VisibleSource = sourceReader);
@ -717,19 +1396,48 @@ public static class MarkupDocument
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
private static AnchorEdges Anchor(string? csv)
/// <summary>
/// Parses <c>anchor="left top right bottom"</c> (space-separated, any
/// subset of the four tokens, case-insensitive) into <see cref="AnchorEdges"/>.
/// Absent/blank defaults to <c>Left | Top</c> — today's fixed top-left
/// placement, unchanged. An unrecognized token is a Build-time author
/// error, same "malformed markup throws" rule every other attribute in
/// this grammar follows (see e.g. <see cref="ValidateIconKind"/>) — the
/// message names the offending element via <see cref="ElementIdentity"/>
/// so a plugin author with several anchored siblings can find which one
/// is wrong.
/// </summary>
private static AnchorEdges ParseAnchor(string? tokens, XElement source)
{
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
var a = AnchorEdges.None;
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
a |= part.ToLowerInvariant() switch
if (string.IsNullOrWhiteSpace(tokens))
return AnchorEdges.Left | AnchorEdges.Top;
var edges = AnchorEdges.None;
foreach (string token in tokens.Split(
(char[]?)null, System.StringSplitOptions.RemoveEmptyEntries))
{
edges |= token.ToLowerInvariant() switch
{
"left" => AnchorEdges.Left,
"top" => AnchorEdges.Top,
"right" => AnchorEdges.Right,
"bottom" => AnchorEdges.Bottom,
_ => AnchorEdges.None,
_ => throw new FormatException(
$"{ElementIdentity(source)} anchor=\"{tokens}\" has unknown token "
+ $"\"{token}\" (expected left, top, right, bottom)"),
};
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
}
return edges;
}
/// <summary>Identifies a markup element for a Build-time error message:
/// <c>&lt;button name="Foo"&gt;</c> when it carries a <c>name</c>/<c>id</c>,
/// else just <c>&lt;button&gt;</c>.</summary>
private static string ElementIdentity(XElement source)
{
string? name = (string?)source.Attribute("name") ?? (string?)source.Attribute("id");
return name is null
? $"<{source.Name.LocalName}>"
: $"<{source.Name.LocalName} name=\"{name}\">";
}
}

View file

@ -6,21 +6,196 @@ namespace AcDream.App.UI;
/// <summary>
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
/// presentation visibility; it never touches plugin enable/session lifetime.
///
/// <para>
/// Slice A (2026-09-06, <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// the shelf is itself a retained window (<see cref="RetailUiRuntime.MountPlugins"/>
/// registers it as <see cref="WindowNames.PluginShelf"/>) so it gets drag, the UI
/// lock, and <see cref="RetailWindowLayoutPersistence"/> position/visibility/
/// collapsed persistence for free, exactly like every other retained window. It
/// implements <see cref="IRetainedWindowStateController"/> for the collapse flag
/// (and now the persisted show/hide intent — see <see cref="RestoreWindowState"/>)
/// and <see cref="IRetainedPanelController"/> to separate "has entries to show"
/// (availability) from the user's own show/hide choice — the same pattern
/// <c>PluginWindowVisibilityController</c> uses for individual plugin windows,
/// just applied synchronously (see <see cref="ApplyVisibility"/>) so unit tests
/// that never call <see cref="UiRoot.Tick"/> still observe <see cref="Visible"/>
/// update immediately after <see cref="Add"/>/window-unregister, matching the
/// pre-Slice-A behavior when the shelf is used unregistered.
/// </para>
///
/// <para>
/// Review fix round (2026-09-06): the grip and collapse toggle are now REAL
/// child elements — <see cref="ShelfGripPanel"/> (<see cref="UiElement.WindowMoveHandle"/>
/// <see langword="true"/>) and a plain <see cref="UiSimpleButton"/> toggle beside
/// it — exactly the mechanism every other retail window with a non-title-bar move
/// strip already uses (<c>ChatLayoutConformanceTests.MountedChatWindow_TopStrip_IsAMoveHandleNotAGrip</c>
/// pins the same pattern on the imported chat window's top strip). The original
/// Slice A implementation drew the grip/toggle and computed a live, cursor-position-
/// dependent <see cref="UiElement.HandlesClick"/> override instead — a real design
/// smell (input handling required reading the CURRENT mouse position out of
/// <see cref="UiRoot"/> from inside a hit-test-adjacent property getter) kept only
/// because a real toggle CHILD would have had to sit outside the grip's own
/// <see cref="UiElement.WindowMoveHandle"/> subtree — which is exactly where it
/// belongs anyway: <see cref="UiRoot.FindDragHandleWindow"/> only walks INTO a
/// <see cref="UiElement.WindowMoveHandle"/> ancestor chain looking for the flag: a
/// SIBLING toggle button never enters that walk, so nesting was never required.
/// </para>
///
/// <para>
/// <see cref="UiElement.Draggable"/> is <see langword="false"/> on the shelf
/// itself (verified against <see cref="UiRoot.FindDragHandleWindow"/>, which does
/// not read a window's own <see cref="UiElement.Draggable"/> at all — it walks
/// from the pressed element for a <see cref="UiElement.WindowMoveHandle"/>
/// ancestor-or-self, then climbs to the nearest child of <see cref="UiRoot"/>. A
/// press on <see cref="_grip"/> therefore resolves <c>handleWindow == this</c> and
/// drags regardless of <see cref="UiElement.Draggable"/>). Leaving the shelf
/// <see cref="UiElement.Draggable"/> would ALSO satisfy the grip drag, but would
/// additionally arm <c>UiRoot.OnMouseDown</c>'s whole-window-drag fallback
/// (<c>window is {{ Draggable: true }}</c>) for any press that lands on the
/// shelf's own padding — between entry buttons, or in the margin around them —
/// since neither the padding nor the shelf itself declares
/// <see cref="UiElement.HandlesClick"/>. That is exactly the behavior Slice A's
/// review round asked NOT to have ("the GRIP drags, the padding between buttons
/// does NOT"), so <see cref="UiElement.Draggable"/> stays false: only
/// <see cref="_grip"/>'s <see cref="UiElement.WindowMoveHandle"/> subtree can
/// start a move. The one cost is that a press on padding or an entry/toggle
/// button no longer raises the shelf to front via <c>UiRoot</c>'s
/// <c>FindWindow(target)</c> fallback (which requires <see cref="UiElement.Draggable"/>
/// or <see cref="UiElement.Resizable"/> on an ancestor-or-self) — only a grip
/// press does. This is an acceptable trade: a click can only ever land on the
/// shelf's exposed region in the first place (whatever already won the Z-order
/// hit-test), so there is nothing behind it left to reveal.
/// </para>
/// </summary>
public sealed class PluginSidePanel : UiPanel, IDisposable
public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowStateController, IRetainedPanelController
{
private const float OuterPadding = 4f;
private const float ButtonExtent = 28f;
private const float ButtonGap = 4f;
private const float DefaultTop = 116f;
/// <summary>
/// Minimum/bitmap-fallback height of the top drag-grip band while
/// EXPANDED. Owner-reported defect (2026-09-06): with this literal
/// hard-coded regardless of font, the retail 16px-tall dat-font glyph
/// line box (<see cref="UiDatFont.LineHeight"/>) overhangs a 12px band —
/// measured against the real DAT (font 0x40000000): the outline
/// (background/shadow) plane's inflated quad is clipped by 1px at the
/// top and 2px at the bottom of a 12px band, even though the FILL glyph
/// itself happens to fit (measured '&lt;'/'&gt;': OffsetY=4 Height=7
/// VerticalOffsetBefore=5 — the fill quad lands at local y=[3,10],
/// inside [0,12]; the border-inflated outline quad would span
/// y=[-1,14], clipped to [0,12]). <see cref="ExpandedGripBandHeight"/>
/// now derives the real band from the font's own metrics so neither
/// plane clips for any font, and this constant remains only as the
/// bitmap-font (no <see cref="UiDatFont"/>) fallback.</summary>
private const float GripHeight = 12f;
/// <summary>
/// The actual EXPANDED grip/toggle band height for the current font: the
/// bitmap fallback (<see cref="GripHeight"/>) when there is no
/// <see cref="UiDatFont"/>, else <c>max(GripHeight, font.LineHeight + 2)</c>
/// so the whole-line-height glyph box (fill AND the border-inflated
/// outline plane) always fits with margin to spare. <c>internal</c> so
/// tests can derive expected coordinates from the real value instead of
/// re-hard-coding the constant this replaces (the shelf's own EXPANDED
/// band height is always this value, never <see cref="GripHeight"/>
/// directly, once a dat font is supplied).
/// </summary>
internal float ExpandedGripBandHeight =>
_font is { } f ? MathF.Max(GripHeight, f.LineHeight + 2f) : GripHeight;
/// <summary>Width of the collapse-toggle button, anchored to the grip's
/// right end.</summary>
private const float ToggleWidth = 16f;
/// <summary>Collapsed shelf width — just enough for the toggle glyph.</summary>
private const float CollapsedWidth = ToggleWidth + OuterPadding * 2f;
private static readonly Vector4 ToggleGlyphColor = new(0.86f, 0.72f, 0.32f, 1f);
private readonly RetailWindowManager _windows;
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
private readonly UiDatFont? _font;
private readonly Dictionary<RetailWindowHandle, ShelfEntry> _entries = [];
private readonly ShelfGripPanel _grip;
private readonly UiSimpleButton _toggle;
private bool _disposed;
private float _lastLayoutHeight = -1f;
/// <summary>Collapsed presentation: only the grip band remains, button
/// entries hidden. Persisted through <see cref="CaptureWindowState"/>/
/// <see cref="RestoreWindowState"/> (<c>UiWindowLayout.Collapsed</c>).</summary>
private bool _collapsed;
/// <summary>The user's/persisted show-hide intent, independent of whether
/// there are currently any entries to show (see <see cref="ApplyVisibility"/>).
/// Shift+Ctrl+F1 (<c>InputAction.TogglePluginManager</c>) flips this via
/// <see cref="Show"/>/<see cref="Hide"/>. Persisted directly (review fix
/// round finding 3): see <see cref="CaptureWindowState"/>/
/// <see cref="RestoreWindowState"/> and <see cref="RetainedWindowState.RequestedVisible"/>.</summary>
private bool _requestedVisible = true;
/// <summary>True once the shelf has been dragged away from its current
/// docked position (a real grip drag, or a persisted layout with a
/// differing position restored onto it — see <see cref="OnHandleMoved"/>).
/// While false, the shelf is still docked and <see cref="Reflow"/> preserves
/// the DOCKED (top-right) corner on growth; once true, it preserves the
/// top-left corner instead (see the class doc + Slice A plan item 2).
/// Tracked via <see cref="RetailWindowHandle.Moved"/> on the shelf's own
/// registration, so unregistered (legacy unit-test) use never sets this and
/// behaves like the shelf has always been docked.</summary>
private bool _userPositioned;
/// <summary>True once the one-time initial dock placement (right edge, top
/// 116) has run — gates both that placement (never re-applied) and the
/// anchor-preserving <see cref="Reflow"/> math (which needs a prior placed
/// position to preserve a corner of).</summary>
private bool _initialDockApplied;
/// <summary>
/// The last position <see cref="Reflow"/> or the one-time dock placement
/// left the shelf at while it was still docked (<see cref="_userPositioned"/>
/// is false). Review fix round finding 6: <see cref="RetailWindowHandle.Moved"/>
/// fires unconditionally on EVERY window-drag release — including a
/// zero-movement grip click, and every <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/>
/// reachability re-clamp — not only a genuine drag. Comparing the handle's
/// CURRENT position against this recorded dock placement (rather than
/// treating any <see cref="RetailWindowHandle.Moved"/> as "the user moved
/// it") is what lets a same-position click, or an unrelated clamp that
/// happens to leave the shelf exactly where the dock formula already had
/// it, pass through without flipping <see cref="_userPositioned"/>.
/// </summary>
/// <remarks>
/// Residual round finding NEW-2 (2026-09-06): a stored <see cref="_dockLeft"/>
/// stops being a reliable "still docked" oracle the moment the PARENT
/// resizes, because <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/>
/// does not know about this shelf's own dock formula — it clamps generically
/// to <c>screen.Width - handle.Width</c>
/// (<c>RetailWindowLayoutPersistence.cs</c>, the <c>ClampAllToScreen</c>
/// method), 4px (<see cref="OuterPadding"/>) to the right of what
/// <see cref="OnTick"/>'s dock formula (<c>parent.Width - Width -
/// OuterPadding</c>) would have produced for the SAME new parent width. A
/// screen shrink that triggers that clamp therefore left <c>Left !=
/// _dockLeft</c> even though the shelf never moved through anything a user
/// would call "dragging it" — a false "user positioned" flip. <see cref="OnHandleMoved"/>
/// now re-derives what "still docked" means for the CURRENT parent size at
/// comparison time (the live dock formula's result, AND the clamp
/// <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/> would have
/// produced from the PREVIOUS docked position) instead of only checking
/// against the stale field values captured before the resize.
/// </remarks>
private float _dockLeft;
private float _dockTop;
/// <summary>The shelf's own retained-window handle, captured the moment
/// <c>RetailUiRuntime.MountPlugins</c> registers it as
/// <see cref="WindowNames.PluginShelf"/> (via <see cref="OnWindowRegistered"/>).
/// Null when the shelf is used unregistered, e.g. by tests that predate
/// Slice A.</summary>
private RetailWindowHandle? _handle;
public PluginSidePanel(
RetailWindowManager windows,
Func<uint, (uint tex, int width, int height)> resolve,
@ -31,17 +206,65 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
_font = font;
Width = ButtonExtent + OuterPadding * 2f;
Height = OuterPadding * 2f;
Height = ExpandedGripBandHeight + OuterPadding * 2f;
Top = DefaultTop;
Anchors = AnchorEdges.None;
// See the class doc for why this stays false: FindDragHandleWindow does
// not require it for the grip to drag, and leaving it true would also
// arm the whole-window-drag fallback for padding clicks.
Draggable = false;
ConstrainDragToParent = true;
Resizable = false;
// Nit 10: the shelf's Width/Height are entirely derived (Reflow), so a
// restored layout's saved dimensions must never stomp them via ResizeTo.
ResizeX = false;
ResizeY = false;
BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f);
BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f);
BorderThickness = 1f;
Visible = false;
// Anchors = None on both: the shelf is the SOLE layout owner of these
// children (LayoutChrome, called from Reflow, repositions them every
// time the shelf's own Width/Height changes — collapse/expand,
// entries added/removed, row-wrap). UiElement.ApplyAnchor is called
// for every child on every draw; for any child whose Anchors is not
// AnchorEdges.None it captures the Left/Top/Width/Height margins
// ONCE on the FIRST draw and re-applies that frozen snapshot on
// every subsequent draw, silently overwriting whatever LayoutChrome
// had just written. Left at the default Left|Top, the grip/toggle
// therefore stayed pinned at their EXPANDED geometry after a
// collapse (owner report 2026-09-06: the toggle ended up outside
// the collapsed shelf and the ancestor clip removed it — "I dont
// see the < after I minimize the window"). Anchoring is simply the
// wrong mechanism for a child whose layout owner already re-derives
// its full geometry every reflow; ResetAnchorCapture() would only
// patch this one call site; the actual fix is to never engage the
// capture-and-freeze machinery for these children at all.
_grip = new ShelfGripPanel
{
WindowMoveHandle = true,
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
Anchors = AnchorEdges.None,
};
_toggle = new UiSimpleButton
{
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
TextColor = ToggleGlyphColor,
DatFont = _font,
Outline = true,
TextSource = () => _collapsed ? "<" : ">",
Anchors = AnchorEdges.None,
};
_toggle.Click += ToggleCollapsed;
AddChild(_grip);
AddChild(_toggle);
LayoutChrome();
_windows.WindowUnregistered += OnWindowUnregistered;
_windows.WindowRegistered += OnWindowRegistered;
}
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
@ -105,41 +328,199 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
Reflow();
}
/// <summary>Show the shelf (expanding it first if collapsed). Called by
/// <c>InputAction.TogglePluginManager</c>'s handler when the shelf is
/// currently hidden. A no-op on the underlying <see cref="Visible"/> flag
/// when there are no entries — the availability gate in
/// <see cref="ApplyVisibility"/> still wins, matching "a hidden shelf
/// stays hidden [an availability-gated hide] is not a user hide".</summary>
public void Show()
{
_requestedVisible = true;
if (_collapsed)
_collapsed = false;
Reflow();
_handle?.NotifyStateChanged();
}
/// <summary>Hide the shelf. Preserves entries/positions; never disables a
/// plugin or touches any plugin window's own visibility.</summary>
public void Hide()
{
_requestedVisible = false;
Reflow();
_handle?.NotifyStateChanged();
}
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
// Screen-edge dock: root bounds become authoritative at draw time, so
// compute this from the live parent rather than capturing an anchor
// margin while the pre-first-frame root still measures 0x0.
// Nit 12: dim the grip under the global UI lock, the same visual cue
// every retail window gets from RetailWindowLockPresentationController —
// that controller only recognizes AUTHORED dat chrome ids, and the grip
// is a runtime-created element with no DatElementId, so it needs its
// own lock-driven dim here.
_grip.Opacity = _windows.IsLocked ? 0.5f : 1f;
if (Parent is { } parent)
{
// Row-wrap: re-flow into however many columns fit the live parent
// height. Independent of the one-time dock below — this keeps
// adapting every tick the available height actually changes,
// same as before Slice A.
float availableHeight = MathF.Max(
ButtonExtent + OuterPadding * 2f,
parent.Height - Top - OuterPadding);
parent.Height - Top - ExpandedGripBandHeight - OuterPadding);
if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f)
{
_lastLayoutHeight = availableHeight;
Reflow(availableHeight);
}
Left = MathF.Max(0f, parent.Width - Width - OuterPadding);
// One-time right-edge dock (Slice A plan item 2): only while the
// parent has a real size, and only while the shelf has never been
// moved (a drag) or had a differing layout restored onto it. After
// this the shelf stays wherever it ends up; growth then preserves
// whichever corner Reflow decides based on _userPositioned.
if (!_initialDockApplied && !_userPositioned && parent.Width > 0f)
{
float dockLeft = MathF.Max(0f, parent.Width - Width - OuterPadding);
float dockTop = Top;
// Review fix round residual NEW-7: _dockLeft/_dockTop are set
// to the TARGET dock position BEFORE the MoveTo call below,
// not after. handle.MoveTo raises Moved synchronously
// (RetailWindowManager.MoveTo -> UiRoot.NotifyWindowMoved ->
// RetailWindowManager.OnWindowMoved -> handle.NotifyMoved),
// which re-enters OnHandleMoved on this same call stack; that
// handler's "is this still the dock position" check (see its
// own doc, NEW-2) needs _dockLeft/_dockTop to already read the
// NEW target so it recognizes the move as the dock placement
// itself rather than a user drag.
_dockLeft = dockLeft;
_dockTop = dockTop;
_initialDockApplied = true;
// NEW-7: route the one-time dock through the retained-window
// handle when the shelf is registered, so the first-run
// docked position is captured by RetailWindowLayoutPersistence
// (subscribed to Moved) immediately, rather than sitting
// unsaved until some LATER unrelated event fires a save.
// Unregistered use (pre-Slice-A tests that construct the
// panel without registering it as a window) falls back to the
// direct field write, exactly as before.
if (_handle is { } handle)
handle.MoveTo(dockLeft, dockTop);
else
Left = dockLeft;
}
}
foreach (RetailWindowHandle handle in _entries.Keys)
KeepWindowReachable(handle);
// The shelf remains reachable even after ordinary windows are raised.
if (Parent is { } root)
// Review fix round finding 2: the per-tick "always highest ZOrder" raise
// that used to live here is DELETED. Registration already gives the
// shelf press-to-raise (a grip press resolves as this window's own
// move-drag and calls UiRoot.BringToFront before the drag starts);
// forcing it back to the top of EVERY sibling on every tick fought
// RetailDialogFactory.Tick's own re-raise of open dialogs (it
// re-asserts each dialog's Z-order every tick specifically so the
// screen's opaque backdrop cannot bury it — see that method's own doc
// comment), so a dialog opened while the shelf was visible could never
// actually end up on top of it. Retail dialogs must outrank the shelf.
}
private void ToggleCollapsed()
{
_collapsed = !_collapsed;
Reflow();
_handle?.NotifyStateChanged();
}
private void OnWindowRegistered(RetailWindowHandle handle)
{
if (!ReferenceEquals(handle.OuterFrame, this)) return;
_handle = handle;
handle.Moved += OnHandleMoved;
_windows.WindowRegistered -= OnWindowRegistered;
}
/// <summary>
/// Decides whether the position <see cref="RetailWindowHandle.Moved"/>
/// just landed on is still "docked" or a genuine user move. See NEW-2
/// (the <see cref="_dockLeft"/>/<see cref="_dockTop"/> remarks) for why a
/// straight <c>Left != _dockLeft</c> check goes wrong across a display
/// resize: <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/>'s
/// generic reachability clamp can land the shelf 4px
/// (<see cref="OuterPadding"/>) off the dock formula's own result for the
/// new parent size. Two candidates count as "still docked" for TODAY'S
/// parent size: (1) the live dock formula's own result, and (2) what
/// <c>ClampAllToScreen</c>'s <c>Math.Clamp(handle.Left, 0,
/// screen.Width - handle.Width)</c> would produce when applied to the
/// PREVIOUS docked <see cref="_dockLeft"/> — i.e. exactly the clamp a
/// display-change reachability sweep performs on a shelf that was docked
/// before the resize. Only a position matching NEITHER is a real drag.
/// </summary>
private void OnHandleMoved(RetailWindowHandle _)
{
if (Parent is { } parent)
{
int highest = 0;
foreach (UiElement sibling in root.Children)
float currentDockLeft = MathF.Max(0f, parent.Width - Width - OuterPadding);
float reachabilityClampOfPriorDock = Math.Clamp(
_dockLeft, 0f, MathF.Max(0f, parent.Width - Width));
bool stillDocked = Top == _dockTop
&& (Left == currentDockLeft || Left == reachabilityClampOfPriorDock);
if (stillDocked)
{
if (!ReferenceEquals(sibling, this))
highest = Math.Max(highest, sibling.ZOrder);
// Re-anchor: a later genuine drag away from THIS (possibly
// clamp-adjusted) position must still be detected.
_dockLeft = Left;
_dockTop = Top;
return;
}
if (ZOrder <= highest)
ZOrder = highest == int.MaxValue ? highest : highest + 1;
}
if (Left != _dockLeft || Top != _dockTop)
_userPositioned = true;
}
// ── IRetainedPanelController: separates "has entries" (availability) from
// the user's own show/hide request — see ApplyVisibility. ────────────────
void IRetainedPanelController.OnShown() => _requestedVisible = true;
void IRetainedPanelController.OnHidden()
{
// Hidden because EntryCount hit zero is temporary (availability gate);
// hidden while entries remain is a real user hide.
if (_entries.Count > 0)
_requestedVisible = false;
}
// ── IRetainedWindowStateController: collapse + requested-visible intent ──
public RetainedWindowState CaptureWindowState() =>
new(Collapsed: _collapsed, RequestedVisible: _requestedVisible);
/// <summary>
/// Review fix round finding 3: restores BOTH the collapse flag and the
/// persisted show/hide INTENT directly onto <see cref="_requestedVisible"/>,
/// then re-derives <see cref="Visible"/> through the same synchronous
/// <see cref="ApplyVisibility"/> path every other state change uses — never
/// through <see cref="Show"/>/<see cref="Hide"/>, so a login-time restore
/// never fires an extra <c>BringToFront</c> or a redundant state-changed
/// save. <see cref="RetailUiRuntime"/> registers <see cref="WindowNames.PluginShelf"/>
/// as one of <see cref="RetailWindowLayoutPersistence"/>'s state-managed
/// visibility windows, so <c>RetailWindowLayoutPersistence.Apply</c> calls
/// this instead of <see cref="RetailWindowHandle.Show"/>/<see cref="RetailWindowHandle.Hide"/>
/// for visibility.
/// </summary>
public void RestoreWindowState(RetainedWindowState state)
{
_collapsed = state.Collapsed;
if (state.RequestedVisible is { } requestedVisible)
_requestedVisible = requestedVisible;
Reflow();
}
private void OnWindowUnregistered(RetailWindowHandle handle)
@ -154,6 +535,73 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
Reflow();
}
/// <summary>
/// Per-tick reachability clamp for one plugin window entry, called from
/// both <see cref="Add"/> (the first tick a window may not get for a
/// while if entries are added but the shelf's own OnTick has not run) and
/// <see cref="OnTick"/>'s per-entry loop.
/// </summary>
/// <remarks>
/// Residual round finding NEW-3 (2026-09-06): plugin windows are ALSO
/// attached to <see cref="RetailWindowLayoutPersistence"/> (they are
/// ordinary registered windows), whose own
/// <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/> performs a
/// generic screen-driven reachability sweep over every attached window,
/// including these. Evidence gathered before deciding whether that makes
/// this method redundant:
/// <list type="bullet">
/// <item>
/// <see cref="RetailWindowManager.MoveTo"/> (<c>RetailWindowManager.cs</c>
/// line 178: <c>if (frame.Left == left &amp;&amp; frame.Top == top) return
/// true;</c>) already short-circuits a call that would not change
/// anything, BEFORE it ever raises <c>Moved</c> — so neither clamper can
/// write settings.json (via <see cref="RetailWindowLayoutPersistence.OnChanged"/>)
/// when the position it computes is already where the handle sits.
/// </item>
/// <item>
/// Persistence's screen size (<c>RetailUiPersistenceBindings.ScreenSize</c>,
/// wired from <c>d.Window.Size</c> in
/// <c>InteractionRetainedUiComposition.cs</c>) and this method's <c>parent</c>
/// (<c>Host.Root</c>) both derive from the identical live window size —
/// <c>UiHost.Draw</c> (<c>UiHost.cs</c> line 96-97) sets
/// <c>Root.Width/Height = screenSize</c> every frame — so in steady state
/// they are the same value. During an active resize they can differ for
/// exactly one frame: <see cref="RetainedGameplayUiFrame.Render"/>
/// (<c>PrivatePresentationRenderer.cs</c> lines 162-165) calls
/// <c>Tick</c> (which is what reaches this method, via <see cref="OnTick"/>)
/// BEFORE <c>Draw</c> in the SAME frame, so this method always observes
/// <c>parent.Width/Height</c> from the PREVIOUS frame's <c>Draw</c> —
/// while <see cref="RetailUiRuntime.Draw"/>'s
/// <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/> call
/// (<c>RetailUiRuntime.cs</c> lines 1019-1024) runs against the CURRENT
/// frame's real size, resolves the window's position for that size FIRST,
/// and only THEN does <c>Host.Draw</c> publish that size into
/// <c>Root.Width/Height</c>. By the time this method next sees the new
/// size, <c>ClampAllToScreen</c> has already settled the position for it
/// — so this method's own pass for the screen-resize case is a structural
/// no-op (the short-circuit above fires), never a second write.
/// </item>
/// <item>
/// This method is NOT redundant, though: it is the only reachability
/// guarantee for (a) a plugin window whose OWN geometry fields are
/// written directly rather than through <see cref="RetailWindowHandle.MoveTo"/>
/// — <c>PluginSidePanelTests.FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas</c>
/// pins exactly this (no screen resize occurs there at all — a plugin
/// window's <c>Left</c>/<c>Top</c> are mutated directly, the way a
/// plugin's own content code could) — and (b) a host wired with no
/// <see cref="RetailWindowLayoutPersistence"/> at all (<c>layoutStore is
/// null</c> in <c>InteractionRetainedUiComposition.cs</c> — persistence,
/// and therefore <c>ClampAllToScreen</c>, may not exist).
/// </item>
/// </list>
/// Decision: keep this per-tick clamp as the SOLE owner of "position
/// drift not caused by a screen-size change," and leave
/// <c>ClampAllToScreen</c>'s parallel coverage of these same handles in
/// place (it is persistence's generic, non-opt-outable screen-resize
/// sweep for every registered window) — the two do not race per the
/// ordering evidence above, and removing either one regresses a real,
/// currently-green invariant.
/// </remarks>
private static void KeepWindowReachable(RetailWindowHandle handle)
{
if (handle.OuterFrame.Parent is not { } parent
@ -175,14 +623,78 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
handle.MoveTo(left, top);
}
private void Reflow(float maximumHeight = float.PositiveInfinity)
/// <summary>Repositions the grip/toggle children to span the current
/// Width — called whenever <see cref="Reflow"/> changes it (entries added/
/// removed, collapse toggled, row-wrap) so the toggle always sits flush
/// against the shelf's right edge and the grip fills the rest of the band.
///
/// <para>
/// Owner-reported defect fix (2026-09-06, "I dont see the &lt; after I
/// minimize the window"): while COLLAPSED the band is <see cref="ButtonExtent"/>
/// tall (matching an ordinary entry button) instead of the 12px grip band —
/// direct evidence (a real-DAT draw probe, see
/// <c>PluginSidePanelToggleGlyphClipTests</c>) showed the toggle glyph's FILL
/// plane was never actually erased by the 12px band's self-clip (it measured
/// well inside it), so a bare clip fix would not have addressed the report;
/// the real problem is that the collapsed shelf shrinks to a barely-visible
/// 24x12 near-black sliver at the screen edge — several times smaller than
/// every other clickable affordance in the UI. Growing the collapsed tab to
/// button size makes it findable again, and the toggle glyph fills that
/// taller band (vertically centered by the unchanged <c>(Height -
/// dat.LineHeight) * 0.5f</c> formula in <see cref="UiSimpleButton.OnDraw"/>).
/// While EXPANDED the band uses <see cref="ExpandedGripBandHeight"/> (derived
/// from the font, not the bitmap-fallback constant) so neither the fill nor
/// the border-inflated outline glyph plane clips for any font metrics.
/// </para>
/// </summary>
private void LayoutChrome()
{
int maximumRows = float.IsPositiveInfinity(maximumHeight)
float bandHeight = _collapsed ? ButtonExtent : ExpandedGripBandHeight;
_grip.Left = 0f;
_grip.Top = 0f;
_grip.Width = MathF.Max(0f, Width - ToggleWidth);
_grip.Height = bandHeight;
_toggle.Left = Width - ToggleWidth;
_toggle.Top = 0f;
_toggle.Width = ToggleWidth;
_toggle.Height = bandHeight;
}
/// <summary>
/// Recomputes button positions and the shelf's own Width/Height for the
/// current entries + collapsed state, then (once the shelf has been
/// placed — <see cref="_initialDockApplied"/>) preserves the appropriate
/// corner: top-right while still docked (<see cref="_userPositioned"/> is
/// false), top-left once the user has moved it. Always reflows the full
/// button set (even while collapsed, with <c>Visible=false</c>) so
/// expanding is instant, per Slice A plan item 4.
///
/// <para>Review fix round finding 5: the parameterless default no longer
/// forces a single-column layout. Every OTHER call site (<see cref="Add"/>,
/// unregister, <see cref="Show"/>/<see cref="Hide"/>, the collapse toggle,
/// <see cref="RestoreWindowState"/>) calls this with no argument — only
/// <see cref="OnTick"/>'s row-wrap logic ever passes an explicit height.
/// Defaulting to "unbounded" on every OTHER call site collapsed a
/// multi-column wrapped layout back to one column for a single frame
/// (until the next <see cref="OnTick"/> row-wrap pass corrected it),
/// visible as a one-frame reflow flash on every collapse/expand. Reusing
/// <see cref="_lastLayoutHeight"/> (the last height <see cref="OnTick"/>
/// actually measured) when no explicit height is given keeps the same
/// column count these calls would already be using.</para>
/// </summary>
private void Reflow(float? maximumHeight = null)
{
float effectiveHeight = maximumHeight
?? (_lastLayoutHeight >= 0f ? _lastLayoutHeight : float.PositiveInfinity);
float oldWidth = Width;
int maximumRows = float.IsPositiveInfinity(effectiveHeight)
? Math.Max(1, _entries.Count)
: Math.Max(
1,
(int)MathF.Floor(
(maximumHeight - OuterPadding * 2f + ButtonGap)
(effectiveHeight - OuterPadding * 2f + ButtonGap)
/ (ButtonExtent + ButtonGap)));
int index = 0;
foreach (ShelfEntry entry in _entries.Values)
@ -191,20 +703,55 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
int row = index % maximumRows;
entry.Button.Left = OuterPadding
+ column * (ButtonExtent + ButtonGap);
entry.Button.Top = OuterPadding
entry.Button.Top = ExpandedGripBandHeight + OuterPadding
+ row * (ButtonExtent + ButtonGap);
entry.Button.Visible = !_collapsed;
index++;
}
int rows = Math.Min(index, maximumRows);
int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows;
Width = OuterPadding * 2f
+ columns * ButtonExtent
+ Math.Max(0, columns - 1) * ButtonGap;
Height = OuterPadding * 2f
+ rows * ButtonExtent
+ Math.Max(0, rows - 1) * ButtonGap;
Visible = index > 0;
if (_collapsed)
{
// Findability fix: the collapsed tab is exactly one entry
// button's height, not the thin grip band — see LayoutChrome's
// doc comment for the owner-reported symptom this addresses.
Width = CollapsedWidth;
Height = ButtonExtent;
}
else
{
Width = OuterPadding * 2f
+ columns * ButtonExtent
+ Math.Max(0, columns - 1) * ButtonGap;
Height = ExpandedGripBandHeight
+ OuterPadding * 2f
+ rows * ButtonExtent
+ Math.Max(0, rows - 1) * ButtonGap;
}
if (_initialDockApplied && !_userPositioned)
Left += oldWidth - Width;
LayoutChrome();
ApplyVisibility();
if (_initialDockApplied && !_userPositioned)
{
_dockLeft = Left;
_dockTop = Top;
}
}
/// <summary>Availability (has entries) AND the user's requested-visible
/// intent both have to hold. Applied synchronously (not via
/// <see cref="UiElement.VisibleSource"/>) so it takes effect the instant
/// entries change, with no dependency on a tick ever running — the shelf
/// worked this way (unregistered) before Slice A and must keep doing so.</summary>
private void ApplyVisibility()
{
Visible = _requestedVisible && _entries.Count > 0;
}
public void Dispose()
@ -213,6 +760,9 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
return;
_disposed = true;
_windows.WindowUnregistered -= OnWindowUnregistered;
_windows.WindowRegistered -= OnWindowRegistered;
if (_handle is { } ownHandle)
ownHandle.Moved -= OnHandleMoved;
foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries)
{
@ -228,7 +778,30 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
PluginShelfButton Button,
PluginMinimizeButton Minimize);
private sealed class PluginShelfButton : UiSimpleButton
/// <summary>The top drag-grip band — a real <see cref="UiElement.WindowMoveHandle"/>
/// child (review fix round finding 1), drawn as three short dashes centered
/// in its own bounds (which already exclude the toggle's rect — see
/// <see cref="LayoutChrome"/>). <see cref="UiElement.Opacity"/> is dimmed by
/// <see cref="OnTick"/> under the global UI lock (nit 12).</summary>
private sealed class ShelfGripPanel : UiPanel
{
private static readonly Vector4 DashColor = new(0.62f, 0.48f, 0.16f, 1f);
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
const float dashWidth = 5f;
const float dashGap = 4f;
float totalDashWidth = dashWidth * 3f + dashGap * 2f;
float dashX = MathF.Max(2f, (Width - totalDashWidth) * 0.5f);
float dashY = Height * 0.5f - 1f;
for (int i = 0; i < 3; i++)
ctx.DrawFill(dashX + i * (dashWidth + dashGap), dashY, dashWidth, 2f, DashColor);
}
}
internal sealed class PluginShelfButton : UiSimpleButton
{
private static readonly Vector4 HiddenBackground =
new(0.025f, 0.025f, 0.02f, 0.96f);
@ -243,6 +816,20 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
private readonly uint _iconSurfaceId;
private readonly string _tooltip;
private readonly string _initialsFallback;
/// <summary>
/// Review fix round finding 10: whether the FIRST resolve attempt
/// (memoized here, never re-attempted, to avoid re-running
/// <see cref="Initials"/>'s string work every frame) found real art.
/// A non-zero <see cref="_iconSurfaceId"/> whose resolve never
/// succeeds (a bad Decal index, a DAT id from a different
/// installation) falls back to <see cref="_initialsFallback"/> —
/// the same fallback an id of exactly 0 already used — rather than
/// rendering a blank button.
/// </summary>
private bool _iconResolveAttempted;
private bool _iconAvailable;
internal PluginShelfButton(
PluginPanelDescriptor descriptor,
@ -253,17 +840,30 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
{
_handle = handle;
_resolve = resolve;
_iconSurfaceId = descriptor.IconSurfaceId;
// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md item
// 6): normalize through the shared grammar so a Decal-style bare
// portal index (MosswartMassacre's convention) resolves the same
// way every markup icon sink does, instead of silently drawing
// nothing because it was never a RenderSurface DID to begin with.
_iconSurfaceId = PluginIcons.Normalize(descriptor.IconSurfaceId);
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
StringComparison.Ordinal)
? descriptor.Title
: $"{ownerDisplayName} — {descriptor.Title}";
Text = _iconSurfaceId == 0
? Initials(descriptor.IconText, descriptor.Title)
: string.Empty;
_initialsFallback = Initials(descriptor.IconText, descriptor.Title);
Text = _iconSurfaceId == 0 ? _initialsFallback : string.Empty;
DatFont = font;
Outline = true;
BorderThickness = 1f;
// Anchors = None for the same reason as _grip/_toggle above:
// Reflow() is the sole owner of each entry button's Left/Top
// (row-wrap column/row placement), rewritten on every add/
// remove/collapse. Left at the default anchor, ApplyAnchor would
// freeze a button at whatever column/row it first drew in and
// never let a later removal's row-wrap actually move it. Set in
// the ctor (rather than at the Add() call site) so a second
// construction path cannot miss it.
Anchors = AnchorEdges.None;
_handle.Shown += OnVisibilityChanged;
_handle.Hidden += OnVisibilityChanged;
RefreshPresentation();
@ -279,8 +879,24 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
protected override void OnDraw(UiRenderContext ctx)
{
// Decided once, on the first draw: a non-zero id whose resolve
// never yields a texture falls back to initials permanently
// (rather than re-attempting — and re-allocating Initials'
// string — every frame). Text must be settled BEFORE
// base.OnDraw runs, since that call is what actually draws the
// caption.
if (!_iconResolveAttempted && _iconSurfaceId != 0)
{
_iconResolveAttempted = true;
(uint tex, int w, int h) = _resolve(_iconSurfaceId);
_iconAvailable = tex != 0 && w > 0 && h > 0;
if (!_iconAvailable)
Text = _initialsFallback;
}
base.OnDraw(ctx);
if (_iconSurfaceId == 0)
if (_iconSurfaceId == 0 || !_iconAvailable)
return;
(uint texture, int width, int height) = _resolve(_iconSurfaceId);

View file

@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome
bar.ThumbBotPressedSprite = ThumbBotPressed;
}
/// <summary>
/// Wires the retail vertical skin onto a <see cref="UiMenu"/> popup's own
/// procedural scrollbar properties (<see cref="UiMenu.ScrollTrackSprite"/>
/// etc). Owner live-client report 2026-09-07 ("For scrollable dropdown or
/// the meta window we use the same assets as we do in for example chat or
/// inventory window"): the popup's own <c>DrawPopupScrollbar</c> draws a
/// simpler Normal-only chrome (no hover/pressed states — matching how
/// <c>VendorUiController</c>/<c>ConfigOptionsPageController</c> already
/// wire these exact ids), so only the Normal-state constants are needed
/// here.
/// </summary>
internal static void ApplyToMenuPopup(UiMenu menu)
{
menu.ScrollTrackSprite = Track;
menu.ScrollThumbTopSprite = ThumbTopNormal;
menu.ScrollThumbSprite = ThumbMidNormal;
menu.ScrollThumbBottomSprite = ThumbBotNormal;
menu.ScrollUpSprite = UpNormal;
menu.ScrollDownSprite = DownNormal;
}
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
internal static void ApplyHorizontal(UiScrollbar bar)

View file

@ -39,7 +39,12 @@ public sealed record RetailUiAssets(
UiDatFont? DefaultFont,
BitmapFont? DebugFont,
ControlsIni Controls,
IconComposer Icons);
IconComposer Icons,
// Review fix round (Slice B finding 1 + 6): RetailMarkupIconResolver
// needs the nearest-sampled upload path (nearest:true) that ResolveSprite
// above never exposes (it is ResolveChrome, always nearest:false for
// background/border art) — see that resolver's own ResolveDid doc.
TextureCache TextureCache);
public sealed record VitalsRuntimeBindings(VitalsVM ViewModel);
@ -678,6 +683,13 @@ public sealed class RetailUiRuntime : IDisposable
// session that ended mid-trade was restoring an empty
// open window at every launch).
WindowNames.SecureTrade,
// Review fix round finding 3 (2026-09-06): the shelf folds
// an availability gate (EntryCount > 0) on top of the
// user's own show/hide request — PluginSidePanel's
// IRetainedWindowStateController restores the saved INTENT
// itself (RestoreWindowState), so this layer must not also
// drive Show/Hide from the derived layout.Visible.
WindowNames.PluginShelf,
]);
}
@ -1042,8 +1054,45 @@ public sealed class RetailUiRuntime : IDisposable
"In-game help is unavailable because the retail help plugin is not installed.");
return true;
case AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager:
_bindings.Options.DisplaySystemMessage(
"The retail plugin manager is not available in acdream.");
// Slice A (2026-09-06): retail's plugin-manager chord is the
// honest home for acdream's own plugin shelf — acdream has no
// other plugin manager. The Configure Keyboard row's caption
// for this action is resolved live from the installed DAT's
// action-map string table (KeyboardConfigController.BuildActionRow,
// RetailActionMapRow.LabelHash) rather than a literal in our
// code, so there is nothing here to rename to "Plugin Shelf";
// the row keeps showing retail's own authored name.
if (_pluginSidePanel is not { EntryCount: > 0 } shelf)
{
_bindings.Options.DisplaySystemMessage(
"No plugin windows are registered.");
return true;
}
if (shelf.Visible)
{
shelf.Hide();
// Finding 8: hiding has no other affordance to bring it
// back (no menu entry, no button) — tell the user the
// exact chord that reopens it. The show branch stays
// silent; showing something is its own feedback.
//
// NEW-5 (residual round): TogglePluginManager is rebindable
// through Configure Keyboard, so a literal "Shift+Ctrl+F1"
// here would lie the moment a player rebinds it. Format the
// CURRENT binding through Layout.RetailKeyNames.Describe —
// the same retail GetNameFromKey pipeline (DAT table
// override -> OS-localized name -> DIK spelling) Configure
// Keyboard's own key-button captions use
// (Layout/KeyboardConfigController.cs line 284:
// `_describe = new RetailKeyNames(resolveString).Describe`)
// — reused here rather than writing a second formatter.
_bindings.Options.DisplaySystemMessage(
PluginShelfHiddenMessage());
}
else
{
shelf.Show();
}
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel:
_bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable);
@ -3641,6 +3690,37 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail Configure Keyboard screen from gmKeyboardUI LayoutDesc 0x21000009.");
}
/// <summary>
/// NEW-5 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
/// Slice A): the plugin-shelf hide message must name whatever chord is
/// CURRENTLY bound to <see cref="AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager"/>
/// — Configure Keyboard lets the player rebind it — formatted through the
/// same <see cref="Layout.RetailKeyNames.Describe"/> pipeline Configure
/// Keyboard's own row captions use
/// (<c>Layout/KeyboardConfigController.cs</c> line 284), not a second
/// formatter. Falls back to an honest "no binding" message when the
/// action has been cleared, or when no live <see cref="InputDispatcher"/>
/// is wired (headless/no-window hosts) to ask.
/// </summary>
private string PluginShelfHiddenMessage()
{
InputDispatcher? dispatcher = _bindings.Keyboard?.Dispatcher;
Binding? bound = dispatcher?.Bindings
.ForAction(AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager)
.Cast<Binding?>()
.FirstOrDefault();
if (bound is not { } binding)
{
return "Plugin shelf hidden. Bind Toggle Plugin Manager in "
+ "Configure Keyboard to show it again.";
}
var strings = new DatStringResolver(_bindings.Assets.Dats);
string chordText = new Layout.RetailKeyNames((tableId, stringId) =>
strings.Resolve(tableId, stringId)).Describe(binding.Chord);
return $"Plugin shelf hidden. Press {chordText} to show it again.";
}
private static string UnmappedKeyBindingsPath(string keyBindingsFilePath)
{
string? dir = System.IO.Path.GetDirectoryName(keyBindingsFilePath);
@ -4637,6 +4717,18 @@ public sealed class RetailUiRuntime : IDisposable
private void MountPlugins()
{
if (_bindings.Plugins is null) return;
// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md item
// 7): one resolver, shared by every plugin panel mounted this pass,
// built from the SAME sprite resolve, IconComposer, and
// ClientObjectTable the rest of the retained UI already uses — see
// RetailMarkupIconResolver's own doc comment for exactly which
// existing bindings field supplies the object table.
IMarkupIconResolver iconResolver = new RetailMarkupIconResolver(
_bindings.Assets.Dats,
_bindings.Assets.Icons,
_bindings.Toolbar.Objects);
foreach (var panel in _bindings.Plugins.Drain())
{
try
@ -4648,7 +4740,8 @@ public sealed class RetailUiRuntime : IDisposable
panel.Binding,
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls,
_bindings.Assets.DefaultFont);
_bindings.Assets.DefaultFont,
iconResolver);
if (Host.WindowManager.TryGet(panel.WindowName, out _))
{
@ -4674,11 +4767,20 @@ public sealed class RetailUiRuntime : IDisposable
// later registration/sidepanel failure then rolls the mounted
// subtree back through FailMount instead of leaking it.
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
// #490 part 2: derive the authored-geometry revision from
// the panel's own authored extent instead of a hard-coded 0
// — see RetailWindowLayoutPersistence's class doc and
// RetailWindowManager.ComputeAuthoredGeometryRevision's own
// doc for why a plugin window can't use the built-in
// windows' manual-literal scheme.
int authoredGeometryRevision = RetailWindowManager.ComputeAuthoredGeometryRevision(
element.Width, element.Height, element.MinWidth, element.MinHeight, element.Resizable);
RetailWindowHandle handle = Host.WindowManager.Register(
panel.WindowName,
element,
element,
visibility);
visibility,
authoredGeometryRevision: authoredGeometryRevision);
_bindings.Plugins.CompleteWindowMount(
panel,
() => Host.WindowManager.Unregister(panel.WindowName));
@ -4687,11 +4789,38 @@ public sealed class RetailUiRuntime : IDisposable
{
if (_pluginSidePanel is null)
{
// Residual round finding N1 (2026-09-06): the shelf's
// icon resolve must go through the SAME
// IMarkupIconResolver.ResolveDid every markup icon
// sink uses, never _bindings.Assets.ResolveSprite
// (= ResolveChrome = TextureCache.GetOrUploadRenderSurface,
// which returns the 1x1 magenta placeholder for a
// missing id — see that method's own doc comment).
// ResolveDid returns (0,0,0) for an id that does not
// resolve to a real installed RenderSurface, which is
// what lets PluginShelfButton's initials fallback
// (_iconAvailable) actually fire for a bad descriptor
// id in production. iconResolver.ResolveDid already
// expects a normalized did (PluginShelfButton's own
// ctor normalizes via PluginIcons.Normalize before
// calling _resolve), so no double-normalize here.
_pluginSidePanel = new PluginSidePanel(
Host.WindowManager,
_bindings.Assets.ResolveSprite,
iconResolver.ResolveDid,
_bindings.Assets.DefaultFont);
Host.Root.AddChild(_pluginSidePanel);
// Slice A: the shelf is itself a retained window (stable
// key WindowNames.PluginShelf) so drag, the UI lock, and
// RetailWindowLayoutPersistence apply for free — the
// panel implements both IRetainedPanelController (the
// availability-vs-requested-visible split) and
// IRetainedWindowStateController (collapse + persisted
// show/hide intent) itself.
Host.WindowManager.Register(
WindowNames.PluginShelf,
_pluginSidePanel,
_pluginSidePanel,
controller: _pluginSidePanel);
}
_pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle);
}

View file

@ -57,6 +57,18 @@ public sealed class RetailWindowHandle
public event Action<RetailWindowHandle>? Moved;
public event Action<RetailWindowHandle>? Resized;
public event Action<RetailWindowHandle>? Closed;
/// <summary>
/// Review fix round (finding 3, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
/// Slice A): raised by a controller-owned state change that Moved/Resized/
/// Shown/Hidden do not cover — e.g. <c>PluginSidePanel</c>'s collapse toggle
/// or its own <see cref="Show"/>/<see cref="Hide"/>, which route through
/// <see cref="IRetainedWindowStateController"/> instead of the outer frame's
/// <c>Visible</c> property. <see cref="RetailWindowLayoutPersistence"/>
/// subscribes here (alongside Moved/Resized/Shown/Hidden) to trigger a save.
/// Internal: no consumer outside this assembly needs it today.
/// </summary>
internal event Action<RetailWindowHandle>? StateChanged;
public event Action<RetailWindowHandle, bool>? LockChanged;
public event Action<RetailWindowHandle, UiElement?>? DescendantFocusChanged;
public event Action<RetailWindowHandle, UiElement?>? DescendantCaptureChanged;
@ -111,6 +123,9 @@ public sealed class RetailWindowHandle
internal void NotifyMoved() => Moved?.Invoke(this);
internal void NotifyResized() => Resized?.Invoke(this);
/// <summary>Raises <see cref="StateChanged"/>. See that event's own doc.</summary>
internal void NotifyStateChanged() => StateChanged?.Invoke(this);
internal void NotifyClosed()
{
if (_closedSinceShown) return;

View file

@ -9,6 +9,31 @@ namespace AcDream.App.UI;
/// per-resolution settings. It deliberately ignores the temporary pre-login
/// <c>default</c> character key so startup layout cannot overwrite a real
/// character's state.
///
/// <para>
/// <b>Authored-geometry revision (#490 part 2).</b> Every registered window
/// carries an <c>authoredGeometryRevision</c> (see
/// <see cref="RetailWindowHandle.AuthoredGeometryRevision"/>); a restore
/// whose saved revision differs from the handle's current one replaces only
/// the saved WIDTH/HEIGHT with the current authored size
/// (<see cref="MigrateAuthoredGeometry"/>) — position, visibility, and
/// collapsed/maximized state are untouched, and the clamp in
/// <see cref="Apply"/> still re-fits the kept position to the live screen.
/// Built-in retail-imported windows hand-pick that revision as a small
/// incrementing literal at their <c>Register</c> call site (chat windows:
/// <c>authoredGeometryRevision = 1</c>) — a deliberate author decision each
/// time their authored size changes. Plugin windows have no such call site
/// an author remembers to touch, so <c>MountPlugins</c> instead derives the
/// revision automatically from the authored geometry tuple itself via
/// <see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>
/// (width, height, min width, min height, resizable): unchanged authored
/// geometry hashes to the same revision (a user's own resize survives
/// restore), and ANY authored geometry change hashes to a different one
/// (the stored size resets to the new default exactly once). Because a hash
/// is not an ordered counter, the comparison is for INEQUALITY — see
/// <see cref="MigrateAuthoredGeometry"/>'s own doc for why the original
/// "newer revision only" read was wrong for this case.
/// </para>
/// </summary>
public sealed class RetailWindowLayoutPersistence : IDisposable
{
@ -36,10 +61,22 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
? new HashSet<string>(StringComparer.Ordinal)
: new HashSet<string>(stateManagedVisibilityWindows, StringComparer.Ordinal);
// Review fix round finding 4: attach late registrations too. Before this
// fix, only windows already registered at CONSTRUCTION time ever got a
// save subscription — a plugin window (or the plugin shelf, on a path
// that somehow constructs persistence first) registered afterward was
// silently never persisted. WindowUnregistered detaches the mirror image
// so a stale handle is not held (and re-notified) forever.
_manager.WindowRegistered += OnWindowRegistered;
_manager.WindowUnregistered += OnWindowUnregistered;
foreach (RetailWindowHandle handle in manager.Windows)
Attach(handle);
}
private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle);
private void OnWindowUnregistered(RetailWindowHandle handle) => Detach(handle);
/// <summary>Restore all registered windows after character and screen are
/// known. <paramref name="saveBack"/> (#390): the login-time restore keeps
/// its lazy schema-migration save; the LIVE display-change reload passes
@ -79,7 +116,13 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
// NEW-4 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice A): _attached is now mutated mid-session by WindowRegistered/
// WindowUnregistered (a plugin window or the shelf can register/
// unregister from inside a callback this very loop invokes — e.g.
// Apply -> Show()/Hide() -> a controller reacting by unregistering
// another window), so every loop over it snapshots first.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
UiWindowLayout fallback = Capture(handle);
UiWindowLayout? saved = _store.LoadWindowLayout(
@ -135,7 +178,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
float maxX = MathF.Max(0f, screen.Width - handle.Width);
float maxY = MathF.Max(0f, screen.Height - handle.Height);
@ -159,7 +203,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
if (!CanPersist(character)) return;
var screen = ValidScreenSize();
string resolution = ResolutionKey(screen);
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
@ -168,7 +213,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(profileName);
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
_store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle));
}
@ -182,7 +228,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
UiWindowLayout? saved = _store.LoadNamedWindowLayout(
profileName, handle.Name, Capture(handle));
@ -206,9 +253,12 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
private void Attach(RetailWindowHandle handle)
{
if (_attached.Contains(handle))
return;
_attached.Add(handle);
handle.Moved += OnChanged;
handle.Resized += OnChanged;
handle.StateChanged += OnChanged;
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
{
handle.Shown += OnChanged;
@ -216,6 +266,20 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
}
}
private void Detach(RetailWindowHandle handle)
{
if (!_attached.Remove(handle))
return;
handle.Moved -= OnChanged;
handle.Resized -= OnChanged;
handle.StateChanged -= OnChanged;
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
{
handle.Shown -= OnChanged;
handle.Hidden -= OnChanged;
}
}
private void OnChanged(RetailWindowHandle handle)
{
if (_restoring || _disposed) return;
@ -245,17 +309,37 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
state.PersistedTop ?? handle.Top,
handle.Width,
state.PersistedHeight ?? handle.Height,
handle.IsVisible,
// Review fix round finding 3: persist the controller's own show/hide
// INTENT when it reports one, never the outer frame's derived
// IsVisible — a controller (e.g. PluginSidePanel) may fold in an
// availability gate on top of the user's actual request, and an
// availability-driven hide must never be captured as a user hide.
state.RequestedVisible ?? handle.IsVisible,
state.Collapsed,
state.Maximized,
handle.AuthoredGeometryRevision);
}
/// <summary>
/// #490 part 2: compares revisions for INEQUALITY, not ordering. Built-in
/// retail-imported windows hand-pick a small incrementing literal
/// (0, 1, 2…) that only ever grows, so the original "migrate only if
/// saved &lt; authored" read fine for them. Plugin windows instead derive
/// their revision from a hash of the authored geometry itself
/// (<see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>) so
/// their author never has to remember to bump a literal — but a hash is
/// not a counter, and two different authored sizes can hash in either
/// order. "The authored size changed" therefore means "the value
/// differs", not "the value went up"; treating it as ordered silently
/// dropped every size-decreasing (by hash value, not by pixels) plugin
/// update, which is exactly how MossTank's 856x236 -&gt; 984x271 bump got
/// stuck at the old size for every user with a stored layout.
/// </summary>
private static UiWindowLayout MigrateAuthoredGeometry(
UiWindowLayout saved,
UiWindowLayout authored)
{
if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision)
if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision)
return saved;
return saved with
@ -292,9 +376,17 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
float y = Math.Clamp(FiniteOr(layout.Y, handle.Top), 0f, maxY);
handle.MoveTo(x, y);
// Review fix round finding 3: always hand the saved intent to the
// controller (not only when restoreVisibility is true) — a
// state-managed window (restoreVisibility false) restores its own
// show/hide intent THIS way instead of through Show/Hide below, so
// login never fires an extra BringToFront or routes through the
// ordinary Shown/Hidden notification for a window whose visibility
// this persistence layer does not otherwise touch.
handle.StateController?.RestoreWindowState(new RetainedWindowState(
Collapsed: layout.Collapsed,
Maximized: layout.Maximized));
Maximized: layout.Maximized,
RequestedVisible: layout.Visible));
if (restoreVisibility)
{
@ -335,16 +427,10 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
{
if (_disposed) return;
_disposed = true;
foreach (RetailWindowHandle handle in _attached)
{
handle.Moved -= OnChanged;
handle.Resized -= OnChanged;
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
{
handle.Shown -= OnChanged;
handle.Hidden -= OnChanged;
}
}
_manager.WindowRegistered -= OnWindowRegistered;
_manager.WindowUnregistered -= OnWindowUnregistered;
foreach (RetailWindowHandle handle in _attached.ToArray())
Detach(handle);
_attached.Clear();
}
}

View file

@ -111,6 +111,45 @@ public sealed class RetailWindowManager : IDisposable
return handle;
}
/// <summary>
/// Derives a stable authored-geometry revision from a window's own
/// authored extent (width, height, min width, min height, resizable), so
/// a plugin window's <see cref="Register"/> call can invalidate an
/// obsolete saved size across an authored-size change WITHOUT the plugin
/// author remembering to bump an explicit revision literal the way
/// built-in retail-imported windows do (#490 part 2 — MossTank shipped
/// 856x236 -> 984x271 and every stored layout stayed at 856x236 forever).
/// Deliberately NOT <see cref="HashCode"/>: that type reseeds its
/// internal state once per process specifically to defeat hash-flooding
/// attacks, so the SAME geometry would hash to a DIFFERENT value on
/// every relaunch — every login would look like a fresh authored-geometry
/// revision and reset every plugin window's saved size, every time. This
/// instead combines the exact IEEE-754 bit patterns with a fixed FNV-1a-
/// style multiplier, which is stable across processes, machines, and
/// .NET versions. <see cref="RetailWindowLayoutPersistence.MigrateAuthoredGeometry"/>
/// compares revisions for INEQUALITY, not ordering — a hash is not a
/// counter, so "authored size changed" means "the value differs",
/// whichever direction it moved. The sign bit is masked off the result:
/// <see cref="Register"/> clamps a negative <c>authoredGeometryRevision</c>
/// up to 0 (its "no explicit revision" sentinel), and a hash landing
/// there would be indistinguishable from an old, pre-hash saved layout
/// that never had a revision at all.
/// </summary>
public static int ComputeAuthoredGeometryRevision(
float width, float height, float minWidth, float minHeight, bool resizable)
{
unchecked
{
int hash = 17;
hash = (hash * 31) + BitConverter.SingleToInt32Bits(width);
hash = (hash * 31) + BitConverter.SingleToInt32Bits(height);
hash = (hash * 31) + BitConverter.SingleToInt32Bits(minWidth);
hash = (hash * 31) + BitConverter.SingleToInt32Bits(minHeight);
hash = (hash * 31) + (resizable ? 1 : 0);
return hash & 0x7FFFFFFF;
}
}
public bool TryGet(string name, out RetailWindowHandle handle)
=> _byName.TryGetValue(name, out handle!);

View file

@ -0,0 +1,41 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Fix round item 8: the five-band lamp glyph checkbox primitive, promoted
/// out of <see cref="UiMarkupToggle"/> into its own shared static so BOTH
/// <see cref="UiMarkupToggle"/> (the standalone <c>&lt;toggle&gt;</c>
/// element) and <see cref="UiMarkupList"/>'s <c>&lt;column type="check"&gt;</c>
/// cell draw the IDENTICAL glyph from ONE definition rather than two
/// independently-maintained copies of the same five <c>DrawFill</c> calls
/// and four colors.
/// </summary>
internal static class UiCheckLamp
{
/// <summary>The lamp glyph's fixed on-screen size in px (both axes).</summary>
public const float LampSize = 11f;
public static readonly Vector4 CheckedOuter = new(0.36f, 0.58f, 0.12f, 1f);
public static readonly Vector4 CheckedInner = new(0.52f, 1f, 0.08f, 1f);
public static readonly Vector4 UncheckedOuter = new(0.26f, 0.22f, 0.13f, 1f);
public static readonly Vector4 UncheckedInner = new(0.38f, 0.34f, 0.23f, 1f);
/// <summary>
/// Draws the lamp with its top-left at <paramref name="x"/>,
/// <paramref name="y"/> — a <see cref="LampSize"/> x <see cref="LampSize"/>
/// footprint. Five bands form the small circular indicator without
/// introducing a plugin bitmap or a new renderer primitive.
/// </summary>
public static void Draw(UiRenderContext ctx, float x, float y, bool isChecked)
{
Vector4 outer = isChecked ? CheckedOuter : UncheckedOuter;
Vector4 inner = isChecked ? CheckedInner : UncheckedInner;
ctx.DrawFill(x + 3f, y, 5f, 1f, outer);
ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer);
ctx.DrawFill(x, y + 3f, 11f, 5f, outer);
ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer);
ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer);
ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner);
}
}

View file

@ -0,0 +1,48 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Plugin markup's <c>&lt;icon&gt;</c> element (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>). Draw-only: a
/// plugin never receives the resolved GL texture, only the resolved-or-not
/// outcome baked into <see cref="IconSource"/> by
/// <see cref="MarkupDocument.Build"/>. Aspect is preserved and the sprite is
/// centered inside the element's box, nearest-filtered (the same convention
/// every other 32x32 DAT icon in the client draws with).
/// </summary>
public sealed class UiMarkupIcon : UiElement
{
/// <summary>
/// Resolves to (GL texture, native width, native height) each draw.
/// <c>tex == 0</c> (or a non-positive extent) draws nothing — never
/// throws, matching every other markup binding's "unresolvable at
/// runtime is silent" rule (only a malformed literal throws, at Build).
/// </summary>
public Func<(uint tex, int w, int h)> IconSource { get; set; } =
static () => (0u, 0, 0);
/// <summary>
/// <see langword="true"/> unless the element has an authored tooltip —
/// a tooltip needs this element to be a real hit-test target (see
/// <see cref="UiElement.ClickThrough"/>'s doc: a click-through element
/// never becomes the hovered element a tooltip attaches to).
/// <see cref="MarkupDocument"/> flips this to <see langword="false"/>
/// when <c>tooltip=</c> is present.
/// </summary>
public UiMarkupIcon() => ClickThrough = true;
protected override void OnDraw(UiRenderContext ctx)
{
(uint tex, int w, int h) = IconSource();
if (tex == 0u || w <= 0 || h <= 0 || Width <= 0f || Height <= 0f)
return;
float scale = MathF.Min(Width / w, Height / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
float x = (Width - drawWidth) * 0.5f;
float y = (Height - drawHeight) * 0.5f;
ctx.DrawSprite(tex, x, y, drawWidth, drawHeight, 0f, 0f, 1f, 1f, Vector4.One);
}
}

View file

@ -14,8 +14,57 @@ public sealed class UiMarkupList : UiElement
static () => Array.Empty<string>();
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
static () => Array.Empty<uint>();
/// <summary>
/// Plugin markup's <c>&lt;list icons="..."&gt;</c> (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>): one icon
/// id per row, parallel to <see cref="ItemsSource"/>. Null (the default)
/// means no icon column at all — every list built without an <c>icons</c>
/// attribute behaves exactly as before. A row past the end of this list,
/// or an id that resolves to nothing, draws no icon (never a placeholder).
/// </summary>
public Func<IReadOnlyList<uint>>? IconIdsSource { get; set; }
/// <summary>
/// Resolves one <see cref="IconIdsSource"/> entry to a drawable icon.
/// <see cref="MarkupDocument"/> builds this from the same
/// <see cref="IMarkupIconResolver"/> every other markup icon sink uses,
/// selected by the element's <c>iconkind</c> attribute.
/// </summary>
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; set; }
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
public Action<int>? SelectionChanged { get; set; }
/// <summary>
/// Campaign VT slice 1 Part B (VVS <c>HudList</c> parity — multi-column
/// lists, <c>docs/research/vtank-kb/08-ui-views.md</c> §2-3). Null (the
/// default) keeps every list built without <c>&lt;column&gt;</c> children
/// byte-for-byte the original single-text-column widget below —
/// <see cref="OnDraw"/>/<see cref="OnEvent"/> only take the per-cell path
/// when this is non-null and non-empty, and every legacy
/// Items/IconIds/ItemColors field is then ignored (mutually exclusive by
/// construction: <see cref="MarkupDocument"/> never sets both).
/// </summary>
public IReadOnlyList<UiMarkupListColumn>? Columns
{
get => _columns;
set
{
_columns = value;
// Fix round item 6: the per-column sidecar arrays and the
// layout/scratch arrays are sized to Columns.Count exactly ONCE
// here (reset whenever a new Columns list is assigned) rather
// than freshly allocated every Draw/OnEvent call — see
// DrawColumns/ComputeColumnLayout/OnEventColumns below, none of
// which allocate an array of their own any more.
int count = value?.Count ?? 0;
_cachedTextRows = new IReadOnlyList<string>?[count];
_cachedColorRows = new IReadOnlyList<uint>?[count];
_cachedCheckRows = new IReadOnlyList<bool>?[count];
_cachedIconRows = new IReadOnlyList<uint>?[count];
_cachedLayout = new (float x, float w)[count];
_scratchIsAuto = new bool[count];
_scratchFixedWidth = new float[count];
_cachedRowCount = 0;
}
}
public UiDatFont? DatFont { get; set; }
public float RowHeight { get; set; } = 18f;
public float Padding { get; set; } = 3f;
@ -24,60 +73,207 @@ public sealed class UiMarkupList : UiElement
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
/// <summary>
/// Campaign VT slice 7 resemblance re-check (2026-09-07): real VVS lists
/// (VTank's own <c>HudList</c>) draw no persistent row-selection fill at
/// all — before this fix the column-less <c>items=</c> mode drew
/// <see cref="SelectedColor"/> under the selected row while the
/// <c>&lt;column&gt;</c> mode did the same, so a plugin's Buffs lists
/// highlighted a row while the Monsters/Meta grids happened not to (or
/// vice versa, depending on which mode a given list used) — same-looking
/// widgets, inconsistent behavior. Default false now suppresses the fill
/// in BOTH <see cref="OnDraw"/>'s legacy branch and
/// <see cref="DrawColumns"/>, matching VVS. <c>&lt;list
/// selectionband="true"&gt;</c> (parsed in <see cref="MarkupDocument"/>'s
/// <c>case "list"</c>) opts a single list back into a visible band for
/// plugins that want one. This gates ONLY the fill — <see cref="SelectedIndexSource"/>,
/// <see cref="SelectionChanged"/>, and the selected-row scroll-into-view
/// logic in <see cref="OnDraw"/>/<see cref="DrawColumns"/> are unchanged.
/// </summary>
public bool SelectionBandEnabled { get; set; }
/// <summary>
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the
/// meta window we use the same assets as we do in for example chat or
/// inventory window"): resolves the retail scrollbar chrome
/// (<see cref="RetailScrollbarChrome"/>) drawn at the list's right edge
/// once its rows overflow the viewport — the same resolver every other
/// markup sink (icons, DAT fonts) already uses. Null (a hand-built list
/// with no host resolver wired) keeps the list wheel-scrollable with no
/// visible bar at all, exactly as before this fix.
/// </summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Reserved width of the retail scrollbar column — VVS's own
/// convention (right edge, 16px), matching <see cref="UiMenu.ScrollbarWidth"/>'s
/// authored value and <see cref="UiScrollbar"/>'s vertical skin.</summary>
private const float ScrollbarWidth = 16f;
/// <summary>Button extent along the scrollbar's own axis — same authored
/// 16px convention <see cref="UiScrollbar.DecrementButtonExtent"/> uses.</summary>
private const float ScrollButtonExtent = 16f;
private int _topRow;
private IReadOnlyList<UiMarkupListColumn>? _columns;
/// <summary>
/// Owner live-client report 2026-09-07 ("Scrolling in advanced options
/// does not work... or it works sometimes"): the last
/// <see cref="SelectedIndexSource"/> value the "keep selection visible"
/// clamp below (<see cref="OnDraw"/>/<see cref="DrawColumns"/>) actually
/// reacted to. A real bound list (e.g. MossTank's Advanced Options,
/// <c>selected="{SelectedAdvancedOptionIndex}"</c>) keeps a STABLE
/// selected index while the user scrolls elsewhere with the scrollbar —
/// re-running the reveal clamp on EVERY frame regardless of whether
/// selection actually changed snapped <see cref="_topRow"/> straight back
/// to the (unchanged) selected row on the very next draw, undoing the
/// scrollbar arrow/track/thumb interaction the same frame it happened.
/// Gating the clamp on an observed CHANGE in the selected index — the
/// only time retail HudList-style lists scroll to reveal a row — lets a
/// stable selection coexist with the user scrolling away from it via the
/// bar, while still auto-revealing a genuinely NEW selection exactly
/// once. Sentinel <see cref="int.MinValue"/> so the very first draw with
/// ANY selected index (including the valid -1 "nothing selected") still
/// runs the clamp once.
/// </summary>
private int _lastRevealedSelected = int.MinValue;
/// <summary>
/// Pixel-based scroll projection used ONLY to feed
/// <see cref="UiScrollbar.ThumbRect"/>'s geometry math (thumb
/// size/position) from this list's own <see cref="_topRow"/> — the list
/// itself remains the single source of truth for scroll position (every
/// existing selection-follow/ClampTop/wheel path is unchanged); this is
/// re-synced from <see cref="_topRow"/> immediately before every draw or
/// scrollbar hit-test, never driven independently.
/// </summary>
private readonly UiScrollable _scroll = new();
private bool _draggingThumb;
private float _thumbDragOffset;
public UiMarkupList() { CapturesPointerDrag = true; }
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
// sized to Columns.Count by the Columns setter above) ────────────────────
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
private IReadOnlyList<uint>?[] _cachedColorRows = Array.Empty<IReadOnlyList<uint>?>();
private IReadOnlyList<bool>?[] _cachedCheckRows = Array.Empty<IReadOnlyList<bool>?>();
private IReadOnlyList<uint>?[] _cachedIconRows = Array.Empty<IReadOnlyList<uint>?>();
private (float x, float w)[] _cachedLayout = Array.Empty<(float, float)>();
private bool[] _scratchIsAuto = Array.Empty<bool>();
private float[] _scratchFixedWidth = Array.Empty<float>();
private int _cachedRowCount;
public override bool HandlesClick => true;
protected override void OnDraw(UiRenderContext context)
{
if (Columns is { Count: > 0 } columns)
{
DrawColumns(context, columns);
return;
}
IReadOnlyList<string> items = ItemsSource();
IReadOnlyList<uint> itemColors = ItemColorsSource();
IReadOnlyList<uint>? iconIds = IconIdsSource?.Invoke();
// Decal's IconColumn: a leading square per row, RowHeight - 2 wide,
// reserved only while the list actually carries an icons= binding —
// an ordinary text-only list keeps its full-width text column.
float iconColumn = iconIds is not null
? MathF.Max(0f, RowHeight - 2f)
: 0f;
int visibleRows = VisibleRows;
int selected = SelectedIndexSource();
if (selected >= 0 && selected < items.Count)
if (selected != _lastRevealedSelected)
{
if (selected < _topRow)
_topRow = selected;
else if (selected >= _topRow + visibleRows)
_topRow = selected - visibleRows + 1;
_lastRevealedSelected = selected;
if (selected >= 0 && selected < items.Count)
{
if (selected < _topRow)
_topRow = selected;
else if (selected >= _topRow + visibleRows)
_topRow = selected - visibleRows + 1;
}
}
ClampTop(items.Count, visibleRows);
bool showScrollbar = items.Count > visibleRows;
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
int end = Math.Min(items.Count, _topRow + visibleRows);
for (int index = _topRow; index < end; index++)
{
float y = (index - _topRow) * RowHeight;
if (index == selected)
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
if (index == selected && SelectionBandEnabled)
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
{
uint iconId = iconIds[index];
if (iconId != 0u)
{
(uint tex, int w, int h) = resolve(iconId);
if (tex != 0u && w > 0 && h > 0)
{
float extent = MathF.Max(0f, iconColumn - 2f);
float scale = MathF.Min(extent / w, extent / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
context.DrawSprite(
tex,
1f + (extent - drawWidth) * 0.5f,
y + (RowHeight - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
}
}
string text = items[index];
Vector4 textColor = index < itemColors.Count
? Rgb(itemColors[index])
: TextColor;
float textX = Padding + iconColumn;
float textY = y + MathF.Max(0f,
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
if (DatFont is { } font)
context.DrawStringDat(font, text, Padding, textY, textColor, true);
context.DrawStringDat(font, text, textX, textY, textColor, true);
else
context.DrawString(text, Padding, textY, textColor);
context.DrawString(text, textX, textY, textColor);
}
if (showScrollbar)
DrawScrollbar(context, contentWidth, items.Count, visibleRows);
}
public override bool OnEvent(in UiEvent e)
{
if (Columns is { Count: > 0 } columns)
return OnEventColumns(e, columns);
IReadOnlyList<string> items = ItemsSource();
int visibleRows = VisibleRows;
float contentWidth = items.Count > visibleRows
? MathF.Max(0f, Width - ScrollbarWidth)
: Width;
if (TryHandleScrollbarEvent(e, contentWidth, items.Count, visibleRows))
return true;
if (e.Type == UiEventType.Scroll)
{
_topRow -= Math.Sign(e.Data0);
ClampTop(items.Count, VisibleRows);
ClampTop(items.Count, visibleRows);
return true;
}
if (e.Type != UiEventType.MouseDown || !Enabled)
return false;
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
int index = _topRow + row;
if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count)
if (row >= 0 && row < visibleRows && index >= 0 && index < items.Count)
SelectionChanged?.Invoke(index);
return true;
}
@ -93,4 +289,510 @@ public sealed class UiMarkupList : UiElement
((value >> 8) & 0xFFu) / 255f,
(value & 0xFFu) / 255f,
1f);
// ── Multi-column mode (Campaign VT slice 1 Part B) ──────────────────────
/// <summary>
/// Per-column (x, width) in local space — computed fresh every call off
/// the list's live <see cref="UiElement.Width"/> rather than baked in at
/// Build, so a resized list re-flows its columns like every other
/// retained widget. Shared by <see cref="DrawColumns"/> and
/// <see cref="OnEventColumns"/> so the drawn cell boundaries and the
/// hit-test boundaries can never drift apart.
///
/// <para>
/// Fix round item 2 (width semantics): the LAST column is ALWAYS treated
/// as auto regardless of its own declared <see cref="UiMarkupListColumn.Width"/>
/// or <see cref="UiMarkupListColumn.IsAutoWidth"/> — this is the
/// pre-existing "last column absorbs the remainder" contract, now
/// generalized as "the last column is always a member of the auto set".
/// Any OTHER column marked <c>IsAutoWidth</c> (an authored
/// <c>width="*"</c>) joins that same auto set. Every non-auto column's
/// declared width is walked left to right and CLAMPED against whatever
/// room is actually left (a declared width that would overflow the
/// list's total width is cut down to what remains, and every column
/// after the overflow point gets 0 — "declared widths that exceed the
/// list width" degradation). The leftover width after every non-auto
/// column is then split EQUALLY among the auto set (VVS's "0-width
/// columns share the remainder" rule), with the last column absorbing
/// whatever integer-division remainder is left over — when the auto set
/// is just {last column} (the common case, no explicit <c>"*"</c>
/// anywhere), this reduces to exactly the original "last column gets
/// 100% of the remainder" behavior.
/// </para>
/// </summary>
private void ComputeColumnLayout(IReadOnlyList<UiMarkupListColumn> columns, float totalWidth)
{
int n = columns.Count;
float x = 0f;
float sumFixed = 0f;
int autoCount = 0;
for (int i = 0; i < n; i++)
{
bool last = i == n - 1;
bool auto = last || columns[i].IsAutoWidth;
_scratchIsAuto[i] = auto;
if (auto)
{
autoCount++;
continue;
}
float avail = MathF.Max(0f, totalWidth - x);
float w = MathF.Min(MathF.Max(0f, columns[i].Width), avail);
_scratchFixedWidth[i] = w;
x += w;
sumFixed += w;
}
float remaining = MathF.Max(0f, totalWidth - sumFixed);
float share = autoCount > 0 ? MathF.Floor(remaining / autoCount) : 0f;
float cursor = 0f;
for (int i = 0; i < n; i++)
{
float w;
if (_scratchIsAuto[i])
{
bool isLast = i == n - 1;
w = isLast
? MathF.Max(0f, remaining - share * (autoCount - 1))
: share;
}
else
{
w = _scratchFixedWidth[i];
}
_cachedLayout[i] = (cursor, w);
cursor += w;
}
}
private void DrawColumns(UiRenderContext context, IReadOnlyList<UiMarkupListColumn> columns)
{
// Materialize every column's row source exactly once for this frame —
// matches the single-column path's ItemsSource()/ItemColorsSource()
// calls above. Only one of the four cached arrays is populated at a
// given column index (per that column's Kind); the others stay null.
// Fix round item 6: these are the SAME instance-field arrays
// OnEventColumns reads (sized to Columns.Count by the Columns
// setter) — a click no longer re-invokes any of these Funcs.
int rowCount = 0;
for (int c = 0; c < columns.Count; c++)
{
var col = columns[c];
switch (col.Kind)
{
case UiMarkupListColumnKind.Text:
_cachedTextRows[c] = col.TextSource!();
_cachedColorRows[c] = col.ColorsSource?.Invoke();
rowCount = Math.Max(rowCount, _cachedTextRows[c]!.Count);
break;
case UiMarkupListColumnKind.Check:
_cachedCheckRows[c] = col.CheckSource!();
rowCount = Math.Max(rowCount, _cachedCheckRows[c]!.Count);
break;
case UiMarkupListColumnKind.Icon:
_cachedIconRows[c] = col.IconValuesSource!();
rowCount = Math.Max(rowCount, _cachedIconRows[c]!.Count);
break;
}
}
_cachedRowCount = rowCount;
int visibleRows = VisibleRows;
// The reserved scrollbar column only exists once rows actually
// overflow the viewport (owner directive: reserve 16px only when
// the bar is shown) — computed BEFORE ComputeColumnLayout so the
// last (always-auto) column's remainder already accounts for it.
bool showScrollbar = rowCount > visibleRows;
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
ComputeColumnLayout(columns, contentWidth);
int selected = SelectedIndexSource();
if (selected != _lastRevealedSelected)
{
_lastRevealedSelected = selected;
if (selected >= 0 && selected < rowCount)
{
if (selected < _topRow)
_topRow = selected;
else if (selected >= _topRow + visibleRows)
_topRow = selected - visibleRows + 1;
}
}
ClampTop(rowCount, visibleRows);
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
// Fix round B item 10 (owner/oracle: VVS's own HudList grids have NO
// row-selection highlight at all — Monsters/Meta/Route/Items and
// every other <list><column> grid). SelectedIndexSource above still
// drives scroll-into-view; the SelectedColor band draw uses the same
// SelectionBandEnabled gate as the legacy single-column path below
// and defaults to false, so plugin lists keep no band unless a
// caller opts in. Every per-cell onclick/onchange callback is
// unchanged — only the visual band is conditional.
int end = Math.Min(rowCount, _topRow + visibleRows);
for (int index = _topRow; index < end; index++)
{
float y = (index - _topRow) * RowHeight;
if (index == selected && SelectionBandEnabled)
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
for (int c = 0; c < columns.Count; c++)
{
(float cellX, float cellW) = _cachedLayout[c];
if (cellW <= 0f)
continue;
// Per-cell horizontal clipping: no column's content (an
// over-long text row above all) can bleed into its neighbor.
// Fix round item 4: try/finally around the whole per-cell
// draw (matching UiButton.cs/UiElement.cs's own clip
// discipline) — a cell draw that throws (a hostile/buggy
// plugin icon resolver, say) must still balance the clip
// stack rather than leaking this PushClip forever.
context.PushClip(cellX, y, cellW, RowHeight);
try
{
switch (columns[c].Kind)
{
case UiMarkupListColumnKind.Text:
DrawTextCell(context, _cachedTextRows[c], _cachedColorRows[c], index, cellX, y);
break;
case UiMarkupListColumnKind.Check:
DrawCheckCell(context, _cachedCheckRows[c], index, cellX, cellW, y);
break;
case UiMarkupListColumnKind.Icon:
DrawIconCell(context, columns[c], _cachedIconRows[c], index, cellX, cellW, y);
break;
}
}
finally
{
context.PopClip();
}
}
}
if (showScrollbar)
DrawScrollbar(context, contentWidth, rowCount, visibleRows);
}
private void DrawTextCell(
UiRenderContext context, IReadOnlyList<string>? texts, IReadOnlyList<uint>? colors,
int index, float cellX, float y)
{
if (texts is null || index >= texts.Count)
return;
string text = texts[index];
Vector4 color = colors is { } cc && index < cc.Count ? Rgb(cc[index]) : TextColor;
float textX = cellX + Padding;
float textY = y + MathF.Max(0f, (RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
if (DatFont is { } font)
context.DrawStringDat(font, text, textX, textY, color, true);
else
context.DrawString(text, textX, textY, color);
}
/// <summary>
/// Draws the same five-band lamp glyph <see cref="UiMarkupToggle"/> uses
/// (both now share <see cref="UiCheckLamp"/>'s one definition — fix round
/// item 8), so a check column reads exactly like every other checkbox in
/// the client (contract requirement: reuse the toggle's own primitive
/// rather than a bespoke box-and-tick). Centered horizontally in its
/// cell, matching how <see cref="DrawIconCell"/> already centers its
/// sprite — a check cell is not always as narrow as the glyph itself
/// (fix round item 11's PITCH-based authoring convention routinely
/// declares check columns wider than <see cref="UiCheckLamp.LampSize"/>).
///
/// <para>
/// Fix round item 7: unlike text/icon cells (which draw nothing past
/// their own column's row count — there is no sensible default string or
/// icon), a check cell past its own bound data still draws the
/// UNCHECKED lamp. VVS materializes every cell in the row regardless of
/// which columns actually have data for it; docs/plugin-ui-markup.md
/// already documented this ("short columns simply have nothing to draw"
/// was never meant to apply to check specifically) — this makes the code
/// agree.
/// </para>
/// </summary>
private void DrawCheckCell(
UiRenderContext context, IReadOnlyList<bool>? flags, int index, float cellX, float cellW, float y)
{
bool isChecked = flags is not null && index < flags.Count && flags[index];
float extent = MathF.Max(0f, cellW - 2f);
float lampX = cellX + 1f + MathF.Max(0f, extent - UiCheckLamp.LampSize) * 0.5f;
float lampY = y + MathF.Max(1f, (RowHeight - UiCheckLamp.LampSize) * 0.5f);
UiCheckLamp.Draw(context, lampX, lampY, isChecked);
}
private void DrawIconCell(
UiRenderContext context, UiMarkupListColumn column, IReadOnlyList<uint>? ids,
int index, float cellX, float cellW, float y)
{
if (ids is null || index >= ids.Count || column.IconResolve is not { } resolve)
return;
uint id = ids[index];
if (id == 0u)
return;
(uint tex, int w, int h) = resolve(id);
if (tex == 0u || w <= 0 || h <= 0)
return;
float extentW = MathF.Max(0f, cellW - 2f);
float extentH = MathF.Max(0f, RowHeight - 2f);
float scale = MathF.Min(extentW / w, extentH / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
context.DrawSprite(
tex,
cellX + 1f + (extentW - drawWidth) * 0.5f,
y + (RowHeight - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
private bool OnEventColumns(in UiEvent e, IReadOnlyList<UiMarkupListColumn> columns)
{
// Fix round item 6: rowCount/layout come from the LAST Draw call's
// materialization (_cachedRowCount/_cachedLayout, populated by
// DrawColumns/ComputeColumnLayout above) — no re-invoking every
// column's source Func or recomputing layout on every event. This
// mirrors normal frame order (draw, then handle input); before the
// first Draw these caches are all zeroed (sized but empty), so an
// event arriving before any Draw is a harmless no-op rather than a
// crash.
int rowCount = _cachedRowCount;
int visibleRows = VisibleRows;
float contentWidth = rowCount > visibleRows
? MathF.Max(0f, Width - ScrollbarWidth)
: Width;
if (TryHandleScrollbarEvent(e, contentWidth, rowCount, visibleRows))
return true;
if (e.Type == UiEventType.Scroll)
{
_topRow -= Math.Sign(e.Data0);
ClampTop(rowCount, visibleRows);
return true;
}
if (e.Type != UiEventType.MouseDown || !Enabled)
return false;
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
int index = _topRow + row;
if (row < 0 || row >= visibleRows || index < 0 || index >= rowCount)
return true; // swallow the press; clicks past the last row do nothing
float localX = e.Data1;
for (int c = 0; c < columns.Count; c++)
{
(float cellX, float cellW) = _cachedLayout[c];
if (localX < cellX || localX >= cellX + cellW)
continue;
switch (columns[c].Kind)
{
case UiMarkupListColumnKind.Text:
// Fix round finding 1: a text column with its own
// onclick fires THAT instead of selecting — none of
// VTank's eight lists actually uses row selection, every
// real text cell is an action target. A text column
// without onclick keeps the original select-the-row
// behavior (the list's own selected/onchange), which is
// NOT subject to the per-column row-bound guard below —
// selection is a list-level concept, already bounded by
// the overall rowCount check above.
if (columns[c].TextClicked is { } onTextClick)
{
// Fix round finding 3: a per-column row-bound guard
// — this column's own bound row count can be
// SHORTER than the overall (max-across-columns) row
// count the outer index check above allows, so a
// click past THIS column's own data must still fire
// nothing (matches the draw side, which already
// skips drawing a cell past its own column's rows).
// Reuses the SAME cached materialization Draw built.
if (index < (_cachedTextRows[c]?.Count ?? 0))
onTextClick(index);
}
else
{
SelectionChanged?.Invoke(index);
}
break;
case UiMarkupListColumnKind.Check:
// A click in a check/icon column fires that column's own
// callback and does NOT change selection. Same
// per-column row-bound guard as the text-onclick case.
if (index < (_cachedCheckRows[c]?.Count ?? 0))
columns[c].CheckChanged?.Invoke(index);
break;
case UiMarkupListColumnKind.Icon:
if (index < (_cachedIconRows[c]?.Count ?? 0))
columns[c].IconClicked?.Invoke(index);
break;
}
break;
}
return true;
}
// ── Retail scrollbar chrome (owner live-client report 2026-09-07) ───────
//
// "For scrollable dropdown or the meta window we use the same assets as
// we do in for example chat or inventory window": once a list's rows
// overflow its own viewport, it draws the SAME sprite ids
// (RetailScrollbarChrome's vertical skin) as the chat SpewBox and the
// inventory UiItemList, at VVS's own placement (right edge, 16px wide).
// Geometry reuses UiScrollbar.ThumbRect via a small UiScrollable
// projection kept in sync with this list's own _topRow — the list stays
// the single source of truth for scroll position; the projection only
// feeds the shared thumb-size/position math.
/// <summary>Re-syncs <see cref="_scroll"/>'s content/view extents and
/// offset from this list's own <see cref="_topRow"/> — call immediately
/// before reading its <see cref="UiScrollbar.ThumbRect"/> geometry.</summary>
private void ConfigureScroll(int rowCount, int visibleRows)
{
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
_scroll.LineHeight = lineHeight;
_scroll.SetExtents(rowCount * lineHeight, visibleRows * lineHeight);
_scroll.SetScrollY(_topRow * lineHeight);
}
private void DrawScrollbar(UiRenderContext ctx, float x, int rowCount, int visibleRows)
{
if (SpriteResolve is not { } resolve) return;
ConfigureScroll(rowCount, visibleRows);
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.Track, x, 0f, ScrollbarWidth, Height);
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.UpNormal, x, 0f, ScrollbarWidth, decExtent);
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.DownNormal, x, Height - incExtent, ScrollbarWidth, incExtent);
float trackTop = decExtent;
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
const float capH = 3f;
if (th >= 2f * capH)
{
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbTopNormal, x, ty, ScrollbarWidth, capH);
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty + capH, ScrollbarWidth, th - 2f * capH);
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbBotNormal, x, ty + th - capH, ScrollbarWidth, th <= 0f ? 0f : capH);
}
else
{
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty, ScrollbarWidth, th);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect — same
/// convention <see cref="UiMenu"/>/<see cref="UiScrollbar"/> use for
/// their own button/thumb-cap art.</summary>
private static void DrawFlatSprite(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
}
/// <summary>Draw a sprite tiled (UV-repeat at native size) to fill the
/// dest rect — same convention as the track/thumb-middle draws
/// elsewhere in the retail scrollbar chrome.</summary>
private static void DrawTiledSprite(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
/// <summary>
/// Scrollbar hit-testing shared by the legacy single-column
/// <see cref="OnEvent"/> and <see cref="OnEventColumns"/> — arrows, track
/// paging, and thumb drag all work exactly as they do in
/// <see cref="UiScrollbar"/>/the chat window's own docked bar, driving
/// this list's own <see cref="_topRow"/>. Returns false (never handled)
/// once the rows fit the viewport — a non-overflowing list has no bar
/// and its area is ordinary row/content space.
/// </summary>
private bool TryHandleScrollbarEvent(in UiEvent e, float contentWidth, int rowCount, int visibleRows)
{
if (_draggingThumb)
{
if (e.Type == UiEventType.MouseMove)
{
DragThumb(e.Data2, rowCount, visibleRows);
return true;
}
if (e.Type is UiEventType.MouseUp or UiEventType.CaptureChanged)
{
_draggingThumb = false;
return true;
}
}
if (rowCount <= visibleRows) return false;
if (e.Type != UiEventType.MouseDown || !Enabled) return false;
if (e.Data1 < contentWidth) return false; // click landed in row content, not the bar
ConfigureScroll(rowCount, visibleRows);
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
float ly = e.Data2;
if (ly < decExtent) { StepRow(-1, rowCount, visibleRows); return true; }
if (ly >= Height - incExtent) { StepRow(1, rowCount, visibleRows); return true; }
float trackTop = decExtent;
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
if (ly >= ty && ly <= ty + th)
{
_draggingThumb = true;
_thumbDragOffset = ly - ty;
}
else
{
PageRow(ly < ty ? -1 : 1, rowCount, visibleRows);
}
return true;
}
private void DragThumb(float ly, int rowCount, int visibleRows)
{
ConfigureScroll(rowCount, visibleRows);
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
float trackTop = decExtent;
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
var (_, thumbH) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
float travel = MathF.Max(1f, trackLen - thumbH);
float ratio = (ly - _thumbDragOffset - trackTop) / travel;
_scroll.SetPositionRatio(ratio);
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
_topRow = (int)MathF.Round((float)_scroll.ScrollY / lineHeight);
ClampTop(rowCount, visibleRows);
}
private void StepRow(int lines, int rowCount, int visibleRows)
{
_topRow += lines;
ClampTop(rowCount, visibleRows);
}
private void PageRow(int pages, int rowCount, int visibleRows)
{
_topRow += pages * visibleRows;
ClampTop(rowCount, visibleRows);
}
}

View file

@ -0,0 +1,171 @@
namespace AcDream.App.UI;
/// <summary>
/// The three column kinds a plugin markup <c>&lt;list&gt;&lt;column&gt;</c> can
/// declare (Campaign VT slice 1 Part B, VVS <c>HudList</c> parity —
/// <c>docs/research/vtank-kb/08-ui-views.md</c> §2-3). Mirrors VVS's
/// <c>TextColumn</c>/<c>CheckColumn</c>/<c>IconColumn</c> progids.
/// </summary>
public enum UiMarkupListColumnKind
{
Text,
Check,
Icon,
}
/// <summary>
/// One column of a multi-column <see cref="UiMarkupList"/>. Built once by
/// <see cref="MarkupDocument"/> from a <c>&lt;column&gt;</c> element and then
/// only read by the widget — the plugin binding remains the sole owner of
/// every row's data, exactly like the single-column list's own
/// <see cref="UiMarkupList.ItemsSource"/>.
///
/// <para>
/// <see cref="Width"/> is the column's DECLARED width in px, meaningful only
/// when <see cref="IsAutoWidth"/> is false. The LAST column in a list always
/// ignores its own declared width/auto-ness at layout time — it always
/// absorbs whatever room remains after every earlier column. Any OTHER
/// column marked <see cref="IsAutoWidth"/> (fix round item 2, <c>width="*"</c>
/// — VVS's own "0-width column auto-sizes" convention) shares that same
/// remaining room equally with every other auto column, the last column
/// absorbing the rounding slack — see <see cref="UiMarkupList"/>'s
/// column-layout helper, which recomputes this dynamically off the list's
/// live <c>Width</c> rather than baking it in at Build.
/// </para>
///
/// <para>
/// Fix round item 8: every settable member is <c>internal init</c> — this
/// type is constructible only through its <see cref="Text"/>/
/// <see cref="Check"/>/<see cref="Icon"/> factories. A plugin (an external
/// assembly with no <c>InternalsVisibleTo</c> grant) can never assemble an
/// inconsistent instance (e.g. <see cref="Kind"/> Text with
/// <see cref="CheckChanged"/> set) via object-initializer syntax; only the
/// three factories, which each set exactly the fields their own kind uses,
/// can construct one.
/// </para>
/// </summary>
public sealed class UiMarkupListColumn
{
// Fix round item 8: `required` cannot pair with a setter less visible
// than the type itself (CS9032) — since Kind/Width are now internal
// init, every factory sets both unconditionally instead (compiler
// enforcement moves from "required" to "the only three call sites all
// do it").
public UiMarkupListColumnKind Kind { get; internal init; }
public float Width { get; internal init; }
/// <summary>
/// Fix round item 2: <c>width="*"</c> — this column shares the list's
/// remaining width equally with every other auto column (the last
/// column in the list is ALWAYS treated as auto regardless of this flag
/// or its own declared <see cref="Width"/> — see the class doc above).
/// </summary>
public bool IsAutoWidth { get; internal init; }
// ── text ──────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="text" items="{IReadOnlyList&lt;string&gt;}"&gt;</c>.</summary>
public Func<IReadOnlyList<string>>? TextSource { get; internal init; }
/// <summary>
/// Optional per-row text color override, mirroring the single-column
/// list's own <c>colors</c> attribute. Null (the default, when the
/// column has no <c>colors</c> attribute at all) means every row in this
/// column draws with the list's <see cref="UiMarkupList.TextColor"/>.
/// </summary>
public Func<IReadOnlyList<uint>>? ColorsSource { get; internal init; }
/// <summary>
/// Fix round finding 1: optional <c>onclick="{Action&lt;int&gt;}"</c> on a
/// text column. Fired with the ROW INDEX on a click anywhere in the cell
/// INSTEAD of selecting the row, when present. Null (the default — no
/// <c>onclick</c> attribute at all) keeps today's original behavior: a
/// click in this cell selects the row and fires the list's own
/// <c>onchange</c>, exactly as before this fix. None of VTank's eight
/// lists actually uses row selection — every real text cell is an action
/// target — but the select-on-click default stays for any acdream markup
/// that already relies on it.
/// </summary>
public Action<int>? TextClicked { get; internal init; }
// ── check ─────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="check" values="{IReadOnlyList&lt;bool&gt;}"&gt;</c>.</summary>
public Func<IReadOnlyList<bool>>? CheckSource { get; internal init; }
/// <summary>
/// Fired with the ROW INDEX on a click anywhere in this cell — the
/// plugin flips its own bool; the column never mutates
/// <see cref="CheckSource"/>'s backing collection itself. Required (a
/// check column with no <c>onchange</c> throws at Build).
/// </summary>
public Action<int>? CheckChanged { get; internal init; }
// ── icon ──────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="icon" values="{IReadOnlyList&lt;uint&gt;}"&gt;</c> — one icon id per row.</summary>
public Func<IReadOnlyList<uint>>? IconValuesSource { get; internal init; }
/// <summary>
/// Resolves one <see cref="IconValuesSource"/> entry to a drawable icon,
/// dispatched by the column's own <c>iconkind</c> — built by
/// <see cref="MarkupDocument"/> from the shared
/// <see cref="IMarkupIconResolver"/>. Null when no resolver is wired on
/// the host (the column then draws no icons, matching every other
/// Slice-B icon sink's "no resolver → draws nothing" rule).
/// </summary>
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; internal init; }
/// <summary>
/// Fired with the ROW INDEX on a click anywhere in this cell. Required
/// (an icon column with no <c>onclick</c> throws at Build).
/// </summary>
public Action<int>? IconClicked { get; internal init; }
public static UiMarkupListColumn Text(
float width,
Func<IReadOnlyList<string>> textSource,
Func<IReadOnlyList<uint>>? colorsSource,
Action<int>? onClick = null,
bool isAutoWidth = false) => new()
{
Kind = UiMarkupListColumnKind.Text,
Width = width,
IsAutoWidth = isAutoWidth,
TextSource = textSource,
ColorsSource = colorsSource,
TextClicked = onClick,
};
public static UiMarkupListColumn Check(
float width,
Func<IReadOnlyList<bool>> checkSource,
Action<int> onChange,
bool isAutoWidth = false) => new()
{
Kind = UiMarkupListColumnKind.Check,
Width = width,
IsAutoWidth = isAutoWidth,
CheckSource = checkSource,
CheckChanged = onChange,
};
public static UiMarkupListColumn Icon(
float width,
Func<IReadOnlyList<uint>> valuesSource,
Func<uint, (uint tex, int w, int h)>? resolve,
Action<int> onClick,
bool isAutoWidth = false) => new()
{
Kind = UiMarkupListColumnKind.Icon,
Width = width,
IsAutoWidth = isAutoWidth,
IconValuesSource = valuesSource,
IconResolve = resolve,
IconClicked = onClick,
};
/// <summary>
/// This column's own row count — the widget takes
/// <c>Max</c> across every column in the list ("row count = the longest
/// bound column" per the slice's contract).
/// </summary>
public int RowCount() => Kind switch
{
UiMarkupListColumnKind.Text => TextSource?.Invoke().Count ?? 0,
UiMarkupListColumnKind.Check => CheckSource?.Invoke().Count ?? 0,
UiMarkupListColumnKind.Icon => IconValuesSource?.Invoke().Count ?? 0,
_ => 0,
};
}

View file

@ -8,15 +8,6 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiMarkupToggle : UiElement
{
private static readonly Vector4 CheckedOuter =
new(0.36f, 0.58f, 0.12f, 1f);
private static readonly Vector4 CheckedInner =
new(0.52f, 1f, 0.08f, 1f);
private static readonly Vector4 UncheckedOuter =
new(0.26f, 0.22f, 0.13f, 1f);
private static readonly Vector4 UncheckedInner =
new(0.38f, 0.34f, 0.23f, 1f);
public string Text { get; set; } = string.Empty;
public Func<string?>? TextSource { get; set; }
public Func<bool>? CheckedSource { get; set; }
@ -39,9 +30,10 @@ public sealed class UiMarkupToggle : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter;
Vector4 inner = IsChecked ? CheckedInner : UncheckedInner;
DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner);
// Fix round item 8: the lamp glyph is now the SHARED
// UiCheckLamp.Draw primitive — UiMarkupList's <column type="check">
// cell draws the exact same glyph from the same one definition.
UiCheckLamp.Draw(ctx, 1f, MathF.Max(1f, (Height - UiCheckLamp.LampSize) * 0.5f), IsChecked);
string caption = TextSource?.Invoke() ?? Text;
Vector4 color = Enabled
@ -55,21 +47,4 @@ public sealed class UiMarkupToggle : UiElement
else
ctx.DrawString(caption, 17f, y, color);
}
private static void DrawLamp(
UiRenderContext ctx,
float x,
float y,
Vector4 outer,
Vector4 inner)
{
// Five bands form the small circular indicator without introducing a
// plugin bitmap or a new renderer primitive.
ctx.DrawFill(x + 3f, y, 5f, 1f, outer);
ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer);
ctx.DrawFill(x, y + 3f, 11f, 5f, outer);
ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer);
ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer);
ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner);
}
}

View file

@ -160,6 +160,22 @@ public sealed class UiMenu : UiElement
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
/// <summary>Index into <see cref="Items"/> of the row under the pointer while
/// the plain popup is open, or -1. Presentation-only (see
/// <see cref="PlainHoverColor"/>'s doc) — retail's sprite popup has no
/// equivalent hover concept, so this never affects the retail draw path.</summary>
private int _hoveredPopupIndex = -1;
/// <summary>Test seam, same rationale as <see cref="CurrentFaceSpriteForTest"/>.</summary>
internal int HoveredPopupIndexForTest => _hoveredPopupIndex;
/// <summary>
/// The plain popup needs continuous MouseMove while open to keep its hover
/// highlight tracking the cursor (retail's sprite popup has no such state, so
/// this only matters when <see cref="RetailButtonArt"/> is false).
/// </summary>
public override bool ReceivesHoverMouseMove => _open && !RetailButtonArt;
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
// square; the label starts just past it (box width + small gap) so text aligns with
@ -304,6 +320,63 @@ public sealed class UiMenu : UiElement
/// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump.</summary>
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
/// <summary>
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): whether the CLOSED-state button
/// face draws retail's gold pushbutton art (<see cref="NormalSprite"/>/
/// <see cref="PressedSprite"/> + the arrow-cap overlay) or the plain flat
/// box below (<see cref="DrawPlainClosedState"/>) that reads as the same
/// widget family as <see cref="UiMarkupList"/>'s dark list boxes — the
/// VTank/Decal <c>HudCombo</c> shape (<c>docs/research/vtank-kb/08-ui-views.md</c>
/// §2: a flat box, left-aligned current value, small down-arrow at the
/// right edge). Default TRUE so every existing non-plugin <see cref="UiMenu"/>
/// user (chat's channel menu, vendor's category dropdown, the Config
/// option menus, the retail confirmation-dialog menu, and every generic
/// dat Type-6 element built by <c>DatWidgetFactory</c>) keeps its exact
/// retail look untouched; <see cref="MarkupDocument"/> flips this to
/// <c>false</c> for plugin <c>&lt;menu&gt;</c> markup by default, with
/// <c>style="retail"</c> as the opt-out back to this art.
/// </summary>
public bool RetailButtonArt { get; set; } = true;
// ── Plain closed-state chrome (RetailButtonArt = false). Colors mirror
// UiMarkupList's own list chrome (BackgroundColor/BorderColor/TextColor)
// so a plugin's dropdown reads as the same widget family as its lists.
public Vector4 PlainBackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f);
public Vector4 PlainBorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f);
/// <summary>Border tint while the popup is open or the face is physically
/// pressed — the only visual change those states make in plain mode (no
/// gold art swap, ever).</summary>
public Vector4 PlainOpenBorderColor { get; set; } = new(0.70f, 0.58f, 0.24f, 1f);
public Vector4 PlainTextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 PlainTriangleColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
/// <summary>Left-inset of the plain box's value text — matches
/// <see cref="UiMarkupList.Padding"/>'s default so a combo's text lines up
/// with the list rows beneath it.</summary>
public const float PlainPadding = 3f;
// ── Plain OPEN-popup chrome (RetailButtonArt = false). Owner live-client
// report 2026-09-07 ("Drop down menus look horrible, there is also a
// checkmark on the text there"): the S7 fix above only replaced the
// CLOSED-state button face — opening the dropdown still drew retail's
// tan/orange gradient panel (PopupBgSprite), the row-highlight sprites
// (whose art bakes a checkbox/checkmark glyph into the leftmost ~17px —
// see TextIndent's doc comment), and the ornate scrollbar chrome. VTank's
// own open combo (VVS HudCombo, docs/research/vtank-kb/08-ui-views.md §2)
// is a plain dark list — no gradient, no baked checkmark — so the plain
// popup below reuses UiMarkupList's own list palette (same rationale as
// PlainBackgroundColor/PlainBorderColor above) rather than inventing a
// third color scheme.
/// <summary>The current entry's row fill — identical value to
/// <see cref="UiMarkupList.SelectedColor"/> so a plugin's open dropdown
/// reads as the same widget family as its lists.</summary>
public Vector4 PlainSelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
/// <summary>A slightly lighter fill for the row under the pointer (no
/// separate glyph or sprite swap — fills only, mirroring
/// <see cref="PlainOpenBorderColor"/>'s "tint, never a sprite swap" rule
/// for the closed state).</summary>
public Vector4 PlainHoverColor { get; set; } = new(0.40f, 0.33f, 0.14f, 0.95f);
private bool _open;
/// <summary>
@ -342,6 +415,7 @@ public sealed class UiMenu : UiElement
OnOpen?.Invoke();
}
_open = value;
_hoveredPopupIndex = -1; // stale hover from the last time this popup was open
if (FindRoot() is not { } root) return;
if (value) root.SetActivePopup(this, () => SetOpen(false));
else root.ClearActivePopup(this);
@ -457,6 +531,12 @@ public sealed class UiMenu : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (!RetailButtonArt)
{
DrawPlainClosedState(ctx);
return;
}
var resolve = SpriteResolve;
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
@ -486,6 +566,52 @@ public sealed class UiMenu : UiElement
if (resolve is not null) DrawArrowCap(ctx, resolve);
}
/// <summary>
/// The VTank/Decal <c>HudCombo</c> closed-state shape (see
/// <see cref="RetailButtonArt"/>'s doc comment): flat fill, 1px border,
/// left-aligned value text at <see cref="PlainPadding"/>, and a small ▾
/// triangle right-aligned — no sprite or DAT quad at all, drawn entirely
/// with <see cref="UiRenderContext.DrawFill"/>/<see cref="UiRenderContext.DrawRectOutline"/>
/// (the same untextured sprite-bucket primitives <see cref="UiMarkupList"/>
/// already uses for its own chrome). Open/pressed only tints the border —
/// never a sprite swap.
/// </summary>
private void DrawPlainClosedState(UiRenderContext ctx)
{
ctx.DrawFill(0f, 0f, Width, Height, PlainBackgroundColor);
Vector4 border = (_open || _facePressed) ? PlainOpenBorderColor : PlainBorderColor;
ctx.DrawRectOutline(0f, 0f, Width, Height, border, 1f);
string caption = ButtonLabelProvider?.Invoke() ?? "";
UiDatFont? captionFont = ButtonDatFont ?? DatFont;
float captionLineH = captionFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float textY = (Height - captionLineH) * 0.5f;
if (captionFont is { } cf)
ctx.DrawStringDat(cf, caption, PlainPadding, textY, PlainTextColor, Outline, OutlineColor);
else
ctx.DrawString(caption, PlainPadding, textY, PlainTextColor, Font);
DrawPlainTriangle(ctx);
}
/// <summary>
/// A 7px-wide, 4px-tall ▾ glyph built from four stacked
/// <see cref="UiRenderContext.DrawFill"/> bands — the same "no DAT art,
/// just fills" technique <see cref="UiCheckLamp.Draw"/> uses for its lamp
/// glyph. Right-aligned with a small margin so it never crowds the box's
/// own border.
/// </summary>
private void DrawPlainTriangle(UiRenderContext ctx)
{
const float w = 7f, rightMargin = 6f;
float x = Width - rightMargin - w;
float y = (Height - 4f) * 0.5f;
ctx.DrawFill(x, y, w, 1f, PlainTriangleColor);
ctx.DrawFill(x + 1f, y + 1f, w - 2f, 1f, PlainTriangleColor);
ctx.DrawFill(x + 2f, y + 2f, w - 4f, 1f, PlainTriangleColor);
ctx.DrawFill(x + 3f, y + 3f, w - 6f, 1f, PlainTriangleColor);
}
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
// round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow
// point. Slicing keeps the LED + arrow undistorted when the button widens to its label.
@ -530,8 +656,29 @@ public sealed class UiMenu : UiElement
/// pass) greys out the part of the popup that overlaps it.</summary>
protected override void OnDrawOverlay(UiRenderContext ctx)
{
if (!_open) return;
// Owner live-client report 2026-09-07: the S7 closed-state fix left the
// OPEN popup drawing retail's gradient/checkmark art regardless of
// RetailButtonArt. Plain mode needs no SpriteResolve at all — it draws
// only untextured fills/outlines (see DrawGridPopupPlain/
// DrawScrollablePopupPlain's own doc comments).
if (!RetailButtonArt)
{
ctx.PushAlphaAbsolute(1f);
try
{
if (Scrollable)
DrawScrollablePopupPlain(ctx);
else
DrawGridPopupPlain(ctx);
}
finally { ctx.PopAlpha(); }
return;
}
var resolve = SpriteResolve;
if (!_open || resolve is null) return;
if (resolve is null) return;
// Force OPAQUE (a menu reads solid even though the chat window is translucent).
// Draw bevel → panel fill → row sprites → labels, all through the sprite bucket
@ -685,6 +832,171 @@ public sealed class UiMenu : UiElement
}
}
// ── Plain OPEN-popup drawing (RetailButtonArt = false) ──────────────────
//
// Owner live-client report 2026-09-07: no DAT art at all — a flat fill
// background, a 1px border, one row per entry in the list text color, the
// current entry filled like a list selection, the hovered entry a slightly
// lighter fill, and NO checkmark (retail's row-highlight sprites bake a
// checkbox/checkmark glyph into their leftmost ~17px — see TextIndent's
// doc comment — which a flat DrawFill simply cannot draw, so plain mode
// has none by construction). These mirror DrawGridPopup/DrawScrollablePopup's
// shape exactly (same column/row math, same VisibleTopRow/EnabledProvider
// rules) so hit-testing (OnHitTest/OnEvent, unchanged) stays byte-identical
// to what it already computes for the retail path.
/// <summary>Plain counterpart of <see cref="DrawGridPopup"/> — flat fill +
/// 1px outline instead of the bevel/panel sprites, per-row selected/hover
/// fills instead of highlight sprites, <see cref="PlainTextColor"/>/
/// <see cref="TextColorGhosted"/> labels left-aligned at
/// <see cref="PlainPadding"/> instead of the authored <see cref="TextIndent"/>/
/// <see cref="ItemTextCentered"/> justification (plain mode has no baked
/// checkbox glyph to align past, and no authored per-menu justification
/// convention — VTank's own list rows are always left-aligned).</summary>
private void DrawGridPopupPlain(UiRenderContext ctx)
{
float outerTop = PopupTop;
float inX = Border, inY = outerTop + Border;
ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor);
ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f);
for (int i = 0; i < Items.Count; i++)
{
int col = i / RowsPerColumn, row = i % RowsPerColumn;
float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
bool selected = Equals(Items[i].Payload, Selected);
if (selected)
ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainSelectedColor);
else if (i == _hoveredPopupIndex)
ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainHoverColor);
}
float textY = (RowHeight - LineH()) * 0.5f;
for (int i = 0; i < Items.Count; i++)
{
int col = i / RowsPerColumn, row = i % RowsPerColumn;
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + PlainPadding,
inY + row * RowHeight + textY,
avail ? PlainTextColor : TextColorGhosted);
}
}
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
/// <see cref="VisibleTopRow"/>-sliced single column and plain
/// selected/hover row fills, but the SCROLLBAR itself draws retail's own
/// chrome (owner live-client report 2026-09-07: "For scrollable dropdown
/// or the meta window we use the same assets as we do in for example
/// chat or inventory window") via the shared <see cref="DrawPopupScrollbar"/>
/// helper — the exact ids <see cref="RetailScrollbarChrome.ApplyToMenuPopup"/>
/// wires onto <see cref="ScrollTrackSprite"/> etc, the SAME sprite ids the
/// chat SpewBox/inventory <see cref="UiItemList"/> scrollbar uses. Rows
/// stay plain by design (the owner accepted the flat dark list; only the
/// bar was objectionable). A menu built with no <see cref="SpriteResolve"/>
/// at all (a hand-built test/legacy fixture) falls back to the fully
/// flat <see cref="DrawPopupScrollbarPlain"/> rather than silently
/// drawing nothing.</summary>
private void DrawScrollablePopupPlain(UiRenderContext ctx)
{
ConfigurePopupScroll();
float outerTop = PopupTop;
float inX = Border, inY = outerTop + Border;
ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor);
ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f);
int start = VisibleTopRow;
int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start);
float textY = (RowHeight - LineH()) * 0.5f;
for (int i = 0; i < count; i++)
{
int idx = start + i;
float y = inY + i * RowHeight;
bool selected = Equals(Items[idx].Payload, Selected);
if (selected)
ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainSelectedColor);
else if (idx == _hoveredPopupIndex)
ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainHoverColor);
}
for (int i = 0; i < count; i++)
{
int idx = start + i;
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
DrawLabel(ctx, Items[idx].Label, inX + PlainPadding, inY + i * RowHeight + textY,
avail ? PlainTextColor : TextColorGhosted);
}
if (SpriteResolve is { } resolve)
DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY);
else
DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY);
}
/// <summary>
/// NO-RESOLVER FALLBACK ONLY (see <see cref="DrawScrollablePopupPlain"/>'s
/// own doc comment — the 2026-09-07 owner directive moved the normal
/// plain-popup scrollbar to retail's own chrome via
/// <see cref="DrawPopupScrollbar"/>). This draws a 1px-bordered track and
/// a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all — for the rare case a
/// <see cref="UiMenu"/> is built with <see cref="Scrollable"/> true but no
/// <see cref="SpriteResolve"/> at all (a hand-built test/legacy fixture).
/// Shares the exact same <see cref="UiScrollbar.ThumbRect"/> geometry (so
/// the thumb's drawn position matches
/// <see cref="HandleScrollablePopupMouseDown"/>'s hit-test math), but
/// draws no separate up/down button glyphs — plain mode has no art for
/// them and the click regions already work through geometry alone
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
/// </summary>
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)
{
if (!IsPopupScrollbarPresentationVisible) return;
ctx.DrawFill(x, y, ScrollbarWidth, InteriorH, PlainBackgroundColor);
ctx.DrawRectOutline(x, y, ScrollbarWidth, InteriorH, PlainBorderColor, 1f);
if (!PopupScroll.HasOverflow) return;
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
float trackTop = decExtent;
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
ctx.DrawFill(x + 1f, y + ty, MathF.Max(0f, ScrollbarWidth - 2f), th, PlainBorderColor);
}
/// <summary>
/// Recomputes the hovered popup row from a MouseMove's local (lx,ly) —
/// same convention <see cref="OnEvent"/>'s MouseDown handling already uses
/// (<see cref="PopupTop"/>/<see cref="Border"/>-relative). Plain-mode-only:
/// see <see cref="ReceivesHoverMouseMove"/>'s doc comment for why this is
/// never invoked on the retail sprite-popup path.
/// </summary>
private void UpdatePlainPopupHover(float lx, float ly)
{
float ix = lx - Border, iy = ly - (PopupTop + Border);
_hoveredPopupIndex = Scrollable ? HoveredScrollableIndex(ix, iy) : HoveredGridIndex(ix, iy);
}
private int HoveredGridIndex(float ix, float iy)
{
if (ix < 0 || ix >= InteriorW || iy < 0 || iy >= InteriorH) return -1;
int col = (int)(ix / ColumnWidth);
int row = (int)(iy / RowHeight);
int idx = col * RowsPerColumn + row;
return row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count ? idx : -1;
}
private int HoveredScrollableIndex(float ix, float iy)
{
if (ix < 0 || ix >= ColumnWidth || iy < 0 || iy >= InteriorH) return -1;
int row = (int)(iy / RowHeight);
int idx = VisibleTopRow + row;
return row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count ? idx : -1;
}
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
/// <paramref name="w"/>,<paramref name="h"/>). Reuses the same geometry +
@ -759,11 +1071,25 @@ public sealed class UiMenu : UiElement
}
}
// Plain-mode hover tracking (see ReceivesHoverMouseMove's doc comment):
// continuous MouseMove while the plain popup is open recomputes the
// hovered row for DrawGridPopupPlain/DrawScrollablePopupPlain. Checked
// BEFORE the MouseUp/HoverLeave/MouseDown-only gates below since, like
// the Scrollable drag block above, it spans an event type none of them
// handle.
if (!RetailButtonArt && _open && e.Type == UiEventType.MouseMove)
{
UpdatePlainPopupHover(e.Data1, e.Data2);
return true;
}
if (e.Type is UiEventType.MouseUp
or UiEventType.HoverLeave
or UiEventType.CaptureChanged)
{
_facePressed = false; // the momentary face flick ends here
if (e.Type == UiEventType.HoverLeave)
_hoveredPopupIndex = -1;
return false;
}

View file

@ -176,6 +176,19 @@ public class UiSimpleButton : UiPanel
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true;
/// <summary>
/// Plugin markup's <c>&lt;button icon="..."&gt;</c> (Slice B,
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>). Null (the
/// default) draws no icon and behaves exactly as before — every existing
/// caller of this widget (the plugin shelf's own toggle/minimize buttons
/// included) leaves this unset. When set, the icon draws flush left and
/// the caption's centering region shifts right by the reserved icon
/// column, so an icon-and-text button never overlaps them; an empty
/// <see cref="Text"/>/<see cref="TextSource"/> with this set is a valid
/// icon-only button.
/// </summary>
public Func<(uint tex, int w, int h)>? IconSource { get; set; }
public event System.Action? Click;
/// <summary>
@ -208,22 +221,56 @@ public class UiSimpleButton : UiPanel
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
// Slice B icon column: review fix round finding 7 — the column is
// reserved whenever IconSource is SET (non-null), regardless of
// whether the icon actually resolved THIS frame. MarkupDocument only
// ever assigns IconSource when a real IMarkupIconResolver is wired
// (see its <button icon> call site), so a set-but-momentarily-
// unresolved id (a bound value that just went to 0, a DAT lookup
// still warming up) no longer slides the caption back and forth
// frame to frame — only the sprite draw itself is conditional on a
// successful resolve.
float iconColumn = 0f;
if (IconSource is { } iconSource)
{
float extent = MathF.Max(0f, MathF.Min(Width, Height) - 6f);
iconColumn = extent + 6f;
(uint tex, int w, int h) = iconSource();
if (tex != 0u && w > 0 && h > 0)
{
float scale = MathF.Min(extent / w, extent / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
ctx.DrawSprite(
tex,
3f + (extent - drawWidth) * 0.5f,
(Height - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
}
string caption = TextSource?.Invoke() ?? Text;
if (caption.Length == 0) return;
float captionAreaX = iconColumn;
float captionAreaWidth = MathF.Max(0f, Width - iconColumn);
if (DatFont is { } dat)
{
float datW = dat.MeasureWidth(caption);
ctx.DrawStringDat(
dat, caption,
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
captionAreaX + (captionAreaWidth - datW) * 0.5f,
(Height - dat.LineHeight) * 0.5f,
TextColor, Outline);
return;
}
if (ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(caption);
float tx = (Width - textW) * 0.5f;
float tx = captionAreaX + (captionAreaWidth - textW) * 0.5f;
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
ctx.DrawString(caption, tx, ty, TextColor);
}

View file

@ -115,6 +115,16 @@ public sealed class UiRenderContext
_clipStack.RemoveAt(_clipStack.Count - 1);
}
/// <summary>
/// Test-only: the number of <see cref="PushClip"/> calls not yet matched
/// by a <see cref="PopClip"/>. Used to prove a per-cell draw that throws
/// mid-draw (e.g. a plugin's icon resolver) still leaves the clip stack
/// balanced — see <see cref="UiMarkupList"/>'s per-cell
/// <c>try</c>/<c>finally</c> around <c>PushClip</c>/<c>PopClip</c> (fix
/// round item 4). <c>InternalsVisibleTo</c> to <c>AcDream.App.Tests</c>.
/// </summary>
internal int ClipStackDepth => _clipStack.Count;
/// <summary>
/// True when the current accumulated clip is non-null and has zero (or negative)
/// area — CT-GF1 fix-round subtree cull, porting retail's
@ -153,18 +163,27 @@ public sealed class UiRenderContext
// ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
x += _current.X;
y += _current.Y;
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
TextRenderer.DrawRect(x, y, w, h, ApplyAlpha(color));
}
/// <summary>
/// Solid-colour fill/outline-segment drawn in the SPRITE bucket (painter/submission
/// order with every other sprite and dat-font glyph). Root-cause fix (owner report,
/// "MossTank border draws over the inventory paperdoll"): this used to forward into
/// <see cref="TextRenderer.DrawRect"/>'s separate untextured-rect bucket, which
/// <see cref="TextRenderer.Flush"/> always composited AFTER every sprite segment in
/// the layer, regardless of submission order — so a <see cref="DrawRectOutline"/>
/// border (every <c>BorderColor</c> outline in the retained UI: <see cref="UiPanel"/>,
/// <see cref="UiMarkupList"/>) always won against whatever window happened to draw
/// after it, instead of only the windows actually painted before it. Routing through
/// the same untextured segment <see cref="DrawFill"/> uses makes painter order the
/// ONLY thing that decides who's on top, exactly like sprites and text.
/// See <see cref="TextRenderer.DrawRect"/>'s own doc comment for what (if anything)
/// still uses that bucket.
/// </summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color) => DrawFill(x, y, w, h, color);
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
/// all sprites and would cover the text — use this for backgrounds, that for foreground
/// fills (carets, vital bars).</summary>
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter/submission order with
/// every other sprite, outline segment, and dat-font glyph) — see <see cref="DrawRect"/>'s
/// doc comment; the two are now the same operation under different names (background vs.
/// foreground callers), both routing through <see cref="TextRenderer.DrawFill"/>.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
{
x += _current.X;
@ -173,6 +192,9 @@ public sealed class UiRenderContext
TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color));
}
/// <summary>Four-quad border, each side drawn through <see cref="DrawRect"/> — and so,
/// since that fix, in painter/submission order with everything else rather than always
/// on top of every window drawn after it. See <see cref="DrawRect"/>'s doc comment.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
if (thickness <= 0f || w <= 0f || h <= 0f) return;

View file

@ -45,6 +45,25 @@ public sealed class UiScrollbar : UiElement
public Func<float?>? ScalarPositionSource { get; set; }
public bool Horizontal { get; set; }
/// <summary>
/// Fix round B item 11: mirrors <see cref="UiMenu.RetailButtonArt"/> —
/// true (the default, preserving every existing retail scrollbar/slider
/// byte-for-byte) draws the sprite chrome below; false draws a plain
/// track/nub instead (<see cref="DrawPlainScalar"/>), with no
/// <see cref="SpriteResolve"/> dependency at all. Only plugin markup's
/// <c>&lt;slider&gt;</c> (MarkupDocument) ever sets this false by
/// default — every other caller of this widget (retail LayoutDesc
/// import, the chat opacity sliders, etc.) leaves it at the default
/// true and is completely unaffected.
/// </summary>
public bool RetailArt { get; set; } = true;
// ── Plain scalar chrome (RetailArt = false). Colors mirror UiMenu's own
// Plain* palette for a consistent "no DAT art" look across widgets. ──
public Vector4 PlainTrackColor { get; set; } = new(0f, 0f, 0f, 0.6f);
public Vector4 PlainBorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f);
public Vector4 PlainNubColor { get; set; } = new(0.72f, 0.62f, 0.34f, 1f);
/// <summary>True while a thumb drag is in progress (between a thumb-hit
/// <c>MouseDown</c>/drag-start and the matching <c>MouseUp</c>). OP5 review
/// fix S1, 2026-08-11: lets a consumer distinguish a per-tick drag edit
@ -274,6 +293,11 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (!IsPresentationVisible) return;
if (!RetailArt)
{
DrawPlainScalar(ctx);
return;
}
if (SpriteResolve is not { } resolve) return;
if (Horizontal)
{
@ -470,6 +494,40 @@ public sealed class UiScrollbar : UiElement
DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight);
}
/// <summary>
/// Fix round B item 11: <see cref="RetailArt"/> = false counterpart of
/// the horizontal/vertical scalar sprite paths above — a flat
/// <see cref="PlainTrackColor"/> fill, a 1px <see cref="PlainBorderColor"/>
/// outline, and a small flat <see cref="PlainNubColor"/> nub at the
/// current <see cref="ScalarPosition"/>. No <see cref="SpriteResolve"/>
/// dependency at all — a plain slider with no icon resolver wired up
/// still draws. Only handles the scalar shape plugin markup's
/// <c>&lt;slider&gt;</c> actually builds (Horizontal true or false,
/// <see cref="ScalarChanged"/> set); the paged <see cref="Model"/>-driven
/// scrollbar shape is unreached by <c>&lt;slider&gt;</c> and keeps its
/// existing sprite-only rendering regardless of this flag.
/// </summary>
private void DrawPlainScalar(UiRenderContext ctx)
{
ctx.DrawFill(0f, 0f, Width, Height, PlainTrackColor);
ctx.DrawRectOutline(0f, 0f, Width, Height, PlainBorderColor, 1f);
if (Horizontal)
{
float nubWidth = MathF.Min(6f, Width);
float travel = MathF.Max(0f, Width - nubWidth);
float x = travel * ScalarPosition;
ctx.DrawFill(x, 0f, nubWidth, Height, PlainNubColor);
}
else
{
float nubHeight = MathF.Min(6f, Height);
float travel = MathF.Max(0f, Height - nubHeight);
float y = travel * ScalarPosition;
ctx.DrawFill(0f, y, Width, nubHeight, PlainNubColor);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)

View file

@ -48,4 +48,9 @@ public static class WindowNames
/// <summary>Campaign QT slice QT5: the three-tab Contracts/Journal/Page
/// List panel (<see cref="RetailPanelCatalog.Journal"/>).</summary>
public const string Journal = "journal";
/// <summary>Slice A (2026-09-06,
/// docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md): the movable,
/// collapsible plugin shelf (<see cref="PluginSidePanel"/>).</summary>
public const string PluginShelf = "plugin-shelf";
}

View file

@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
/// <summary>
/// Forwarded, not scoped, unlike <see cref="Storage"/>: 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.
/// </summary>
public IPluginStorage VtankProfiles => _inner.VtankProfiles;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;

View file

@ -4,7 +4,8 @@ internal sealed record HeadlessCommandLine(
string Command,
string ConfigurationPath,
HeadlessPathOverrides Paths,
HeadlessDirectCredentials? DirectCredentials)
HeadlessDirectCredentials? DirectCredentials,
bool ConsoleEnabled = false)
{
internal static HeadlessCommandLine Parse(
IReadOnlyList<string> arguments)
@ -23,15 +24,26 @@ internal sealed record HeadlessCommandLine(
string? cacheDirectory = null;
string? user = null;
string? password = null;
for (int index = 1; index < arguments.Count; index += 2)
bool console = false;
int index = 1;
while (index < arguments.Count)
{
string name = arguments[index];
// --console is a bare flag (no value token) — the interactive
// console for the run command (see HeadlessConsoleOptions).
if (name == "--console")
{
console = true;
index += 1;
continue;
}
if (index + 1 >= arguments.Count)
{
throw new HeadlessCommandLineException(
"Every command option requires a value.");
}
string name = arguments[index];
string value = arguments[index + 1];
if (string.IsNullOrWhiteSpace(value))
{
@ -65,6 +77,7 @@ internal sealed record HeadlessCommandLine(
throw new HeadlessCommandLineException(
"Unknown command option.");
}
index += 2;
}
if (configurationPath is null)
@ -82,6 +95,14 @@ internal sealed record HeadlessCommandLine(
throw new HeadlessCommandLineException(
"Direct credentials are valid only for run mode.");
}
// N3: reject rather than silently ignore --console for validate mode
// — validate never starts a session, so there is nothing for the
// console to attach to.
if (console && command != "run")
{
throw new HeadlessCommandLineException(
"--console is valid only for run mode.");
}
return new HeadlessCommandLine(
command,
@ -92,7 +113,8 @@ internal sealed record HeadlessCommandLine(
cacheDirectory),
user is null
? null
: new HeadlessDirectCredentials(user, password!));
: new HeadlessDirectCredentials(user, password!),
console);
}
private static void SetOnce(ref string? destination, string value)

View file

@ -0,0 +1,53 @@
namespace AcDream.Headless.Configuration;
/// <summary>
/// Typed resolution for the headless interactive console (docs/plans/
/// 2026-09-07-headless-console.md). Three inputs, first match wins:
/// the <c>--console</c> command-line flag, the
/// <c>ACDREAM_HEADLESS_CONSOLE</c> environment variable, and finally a
/// terminal-shaped default — on when stdin is a real console (an operator
/// typing at a keyboard), off when it is redirected (a script, CI runner, or
/// piped fixture, where a background reader thread blocked on
/// <c>ReadLine</c> would never see input and would just sit idle). See
/// docs/launch-options.md for the documented row this owns.
/// </summary>
/// <remarks>
/// S1 fix (2026-09-07 review round): the environment variable is a
/// default-on override once it is SET at all, not a bare "equals 1" test —
/// <c>ACDREAM_HEADLESS_CONSOLE=0</c> must disable the console even when
/// stdin is a real terminal, matching the
/// <c>ACDREAM_RETAIL_CLOSE_DEGRADES</c> / <c>ACDREAM_RETAIL_UI</c>
/// convention (any value other than the literal string <c>"0"</c> enables).
/// An UNSET variable still falls through to the terminal-shaped default —
/// this flag's "default on" is conditional on stdin, unlike those two, but
/// once set at all it behaves identically.
/// </remarks>
internal static class HeadlessConsoleOptions
{
internal const string EnvironmentVariable = "ACDREAM_HEADLESS_CONSOLE";
internal static bool Resolve(
bool commandLineFlag,
bool standardInputIsTerminal) =>
Resolve(
commandLineFlag,
Environment.GetEnvironmentVariable,
standardInputIsTerminal);
internal static bool Resolve(
bool commandLineFlag,
Func<string, string?> env,
bool standardInputIsTerminal)
{
ArgumentNullException.ThrowIfNull(env);
if (commandLineFlag)
return true;
if (env(EnvironmentVariable) is null)
return standardInputIsTerminal;
// Default-on once the flag is set at all: any value other than the
// literal string "0" enables the console — the same
// ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI idiom.
return !string.Equals(
env("ACDREAM_HEADLESS_CONSOLE"), "0", StringComparison.Ordinal);
}
}

View file

@ -46,7 +46,9 @@ internal static class HeadlessEntryPoint
TextReader standardInput,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool standardInputIsTerminal = false,
bool standardOutputIsTerminal = false)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(standardInput);
@ -74,13 +76,18 @@ internal static class HeadlessEntryPoint
configuredPaths.Merge(commandLine.Paths));
if (commandLine.Command == "run")
{
bool consoleEnabled = HeadlessConsoleOptions.Resolve(
commandLine.ConsoleEnabled,
standardInputIsTerminal);
using var host = new HeadlessProcessHost(
configuration,
paths,
standardInput,
output,
directCredentials:
commandLine.DirectCredentials);
commandLine.DirectCredentials,
consoleEnabled: consoleEnabled,
standardOutputIsTerminal: standardOutputIsTerminal);
return (int)host.RunAsync(cancellationToken)
.GetAwaiter()
.GetResult();

View file

@ -0,0 +1,58 @@
using AcDream.Core.Chat;
using AcDream.Runtime;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Presentation for the console's rendered chat lines. A distinct, terminal-
/// shaped format from the graphical <c>ChatVM.FormatEntry</c> retail prose
/// (Headless cannot reference <c>AcDream.UI.Abstractions</c> — see the
/// dependency-boundary test — and a script piping console output wants a
/// stable, greppable "[Label] Sender: text" shape more than retail's exact
/// sentence). It uses the SAME channel-name strings the graphical SpewBox
/// shows (<see cref="RuntimeChatEntry.ChannelName"/>, "Tell", "Local") per
/// the plan's requirement, just not the same sentence template.
/// </summary>
internal static class HeadlessConsoleChatFormatter
{
/// <summary>Formats one chat event for the console, or
/// <see langword="null"/> when this kind renders nothing (there are
/// none today — kept for forward compatibility with a future silent
/// kind).</summary>
internal static string? Format(in RuntimeChatEntry entry)
{
var kind = (ChatKind)entry.Kind;
return kind switch
{
ChatKind.LocalSpeech or ChatKind.RangedSpeech =>
$"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}",
ChatKind.Channel =>
$"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}",
ChatKind.Tell => FormatTell(entry),
ChatKind.Emote or ChatKind.SoulEmote =>
$"* {entry.Sender} {entry.Text}",
ChatKind.Popup => $"[Popup] {entry.Text}",
// System/Combat lines arrive pre-formatted (system messages,
// combat translator output) — render bare, matching retail's own
// no-prefix system-chat convention (Campaign CH user-gate round
// 1, item B).
_ => entry.Text,
};
}
private static string FormatTell(in RuntimeChatEntry entry) =>
// SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived);
// == 0 is our own outbound echo, where Sender carries the target
// name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label
// the plan asks for; the "You -> " marker is what disambiguates an
// outgoing tell from an incoming one in the bracket-label shape.
entry.SenderGuid != 0
? $"[Tell] {entry.Sender}: {entry.Text}"
: $"[Tell] You -> {entry.Sender}: {entry.Text}";
private static string SpeakerLabel(string sender) =>
string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender;
private static string ChannelLabel(in RuntimeChatEntry entry) =>
string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName;
}

View file

@ -0,0 +1,116 @@
using AcDream.Runtime.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// The console's own orchestration: owns the background reader
/// (<see cref="HeadlessConsoleInputReader"/>) and, once per session tick
/// (<see cref="DrainDue"/>), drains every line queued since the last call
/// and dispatches each one IN ORDER, on the calling thread — never the
/// reader thread (Slice K's monotonic scheduler contract; see
/// <see cref="HeadlessConsoleInputReader"/>'s own doc).
/// </summary>
/// <remarks>
/// <c>/quit</c> and <c>/status</c> are console-only controls (the plan's
/// "Control" section) — they never reach <see cref="ChatCommandRouter"/>,
/// matching retail's own client-local commands. Every other line goes
/// through <paramref name="submit"/>, which a production caller binds to
/// <c>HeadlessSessionHost.SubmitConsoleLine</c> — the exact
/// <see cref="ChatCommandRouter.Submit"/> pipeline (retail's client-command
/// catalog first, then local <c>/help</c>, then the plugin-verb registry,
/// then the retail unregistered-channel-tag fallback, then an explicit
/// server command, then plain chat) <c>LoginCommandSequence</c> and the
/// graphical chat box both already use.
/// </remarks>
internal sealed class HeadlessConsoleController : IDisposable
{
private readonly HeadlessConsoleInputReader _reader;
private readonly TextWriter _output;
private readonly Func<string, SubmitOutcome> _submit;
private readonly Func<string> _statusText;
private readonly CancellationTokenSource _quitRequested;
internal HeadlessConsoleController(
TextReader input,
TextWriter output,
Func<string, SubmitOutcome> submit,
Func<string> statusText,
CancellationTokenSource quitRequested)
{
ArgumentNullException.ThrowIfNull(input);
_output = output ?? throw new ArgumentNullException(nameof(output));
_submit = submit ?? throw new ArgumentNullException(nameof(submit));
_statusText = statusText ?? throw new ArgumentNullException(nameof(statusText));
_quitRequested = quitRequested
?? throw new ArgumentNullException(nameof(quitRequested));
_reader = new HeadlessConsoleInputReader(input);
}
/// <summary>Number of lines handled by the most recent
/// <see cref="DrainDue"/> call — a test seam for the reader-thread
/// ordering assertion.</summary>
internal int LastDrainCount { get; private set; }
/// <summary>Test seam: lets a bounded-fixture test wait for the
/// background reader thread to reach EOF before calling
/// <see cref="DrainDue"/>, instead of sleeping or polling.</summary>
internal HeadlessConsoleInputReader Reader => _reader;
internal void DrainDue()
{
int count = 0;
while (_reader.TryDequeue(out string line))
{
Handle(line);
count++;
}
LastDrainCount = count;
}
private void Handle(string rawLine)
{
string trimmed = rawLine.Trim();
if (trimmed.Length == 0)
return;
if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase))
{
WriteLine("quitting (graceful logout)");
_quitRequested.Cancel();
return;
}
if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase))
{
WriteLine(_statusText());
return;
}
// S4 (2026-09-07 review round): mirrors
// LoginCommandSequence.DrainDue's own try/catch and
// UnknownCommand/Dropped reporting — a console typo (a bad line, a
// downstream bug in a plugin verb handler) must never escape to the
// scheduler's per-session quarantine catch and fault the whole
// session, and the operator deserves the same "this line did
// nothing" signal LoginCommandSequence already gives a login-line
// failure.
try
{
SubmitOutcome outcome = _submit(rawLine);
if (outcome is SubmitOutcome.UnknownCommand or SubmitOutcome.Dropped)
WriteLine($"not handled ({outcome}): {rawLine}");
}
catch (Exception error)
{
WriteLine($"command failed: {error.GetBaseException().Message}");
}
}
private void WriteLine(string text)
{
_output.WriteLine(text);
_output.Flush();
}
public void Dispose() => _reader.Dispose();
}

View file

@ -0,0 +1,94 @@
using System.Collections.Concurrent;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Reads lines from a <see cref="TextReader"/> on one dedicated background
/// thread and hands them to whoever drains <see cref="TryDequeue"/>. Slice K's
/// scheduler contract binds every mutating call to one thread for a session's
/// whole lifetime (#368 — collision generations refuse migration), so console
/// input can never be executed from this thread: it only ever enqueues, and
/// the session tick is the sole reader of <see cref="TryDequeue"/>.
/// </summary>
/// <remarks>
/// <see cref="TextReader.ReadLine"/> has no cancellable overload, so a real
/// <c>Console.In</c> reader can be blocked on it when the process wants to
/// exit. The thread is a background thread (does not keep the process alive)
/// and <see cref="Dispose"/> only requests the loop stop at its next
/// opportunity — it does not abort a pending read. A closed/EOF input (a
/// piped fixture reaching its last line, or the real console's stdin handle
/// closing) ends the loop on its own; <see cref="EndOfInput"/> lets a test
/// wait for that deterministically instead of polling or sleeping.
/// </remarks>
internal sealed class HeadlessConsoleInputReader : IDisposable
{
private readonly TextReader _input;
private readonly ConcurrentQueue<string> _queue = new();
private readonly Thread _thread;
private volatile bool _stopRequested;
internal HeadlessConsoleInputReader(TextReader input)
{
_input = input ?? throw new ArgumentNullException(nameof(input));
_thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "acdream-headless-console-reader",
};
_thread.Start();
}
/// <summary>Set once the reader loop has returned (EOF or stop request).
/// Tests wait on this instead of sleeping/polling for a deterministic
/// "every line the fixture will ever produce has been enqueued" signal.
/// </summary>
internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false);
/// <summary>Dequeues the next queued line in FIFO order, or returns
/// <see langword="false"/> if none is queued yet. Never blocks.</summary>
internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!);
private void ReadLoop()
{
try
{
while (!_stopRequested)
{
string? line = _input.ReadLine();
if (line is null)
return;
_queue.Enqueue(line);
}
}
catch (ObjectDisposedException)
{
// The input was disposed out from under a pending read (process
// teardown racing the reader thread) — end the loop quietly,
// same as EOF.
}
catch (IOException)
{
// A redirected stream can fail mid-read (e.g. a broken pipe).
// Treat it the same as EOF rather than crashing the process.
}
finally
{
EndOfInput.Set();
}
}
/// <summary>Requests the read loop stop at its next opportunity. Does
/// not abort a <see cref="TextReader.ReadLine"/> already in progress —
/// the thread is background, so it cannot block process exit.
/// Deliberately does NOT dispose <see cref="EndOfInput"/>: the read
/// loop's own <c>finally</c> sets it from the reader thread, and racing
/// that against a Dispose() here (an unhandled
/// <see cref="ObjectDisposedException"/> on a background thread
/// terminates the process) is worse than leaking one small
/// synchronization handle for the process's remaining lifetime.
/// </summary>
public void Dispose()
{
_stopRequested = true;
}
}

View file

@ -0,0 +1,111 @@
using AcDream.Runtime;
namespace AcDream.Headless.Hosting;
/// <summary>
/// One presentation over the K2 bot event stream
/// (<see cref="IRuntimeEventObserver"/>) — the SAME typed events a headless
/// bot policy observes (<c>HeadlessBotPolicy.cs</c>) — rendered as plain
/// lines. Every write goes through <see cref="WriteLine"/>, so a test can
/// assert on exactly what a real console would have printed without a
/// terminal.
/// </summary>
internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver
{
private const string Reset = "";
private const string Dim = "";
private readonly TextWriter _output;
private readonly bool _useColor;
internal HeadlessConsoleRenderer(TextWriter output, bool useColor)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
_useColor = useColor;
}
public void OnChat(in RuntimeChatDelta delta)
{
string? line = HeadlessConsoleChatFormatter.Format(delta.Entry);
if (!string.IsNullOrEmpty(line))
WriteLine(line, dim: false);
}
/// <summary>
/// Retail's transient "interface text" (SpewBox, <c>ClientLocal</c>
/// type) never touches <see cref="RuntimeCommunicationState.Chat"/> —
/// see <c>RuntimeCommunicationState.AddText</c> — so it never reaches
/// <see cref="OnChat"/>. <c>HeadlessConsoleSpewBoxPump</c> calls this
/// directly, once per console tick, for whatever text is newly visible
/// in the polled <see cref="AcDream.Core.Chat.SpewBoxState"/> — the
/// SAME seam the graphical overlay's own SpewBox controller reads, so
/// server- and plugin-driven interface text prints here too, not only
/// the console's own submissions. Default weight (N5) — this is
/// player-visible interface text, not scheduling noise.
/// </summary>
internal void WriteInterfaceText(string text) => WriteLine(text, dim: false);
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
switch (delta.Current)
{
case RuntimeLifecycleState.InWorld:
WriteLine("entered world", dim: true);
break;
case RuntimeLifecycleState.Stopping:
WriteLine("disconnecting", dim: true);
break;
case RuntimeLifecycleState.Faulted:
WriteLine("session faulted", dim: true);
break;
}
}
public void OnCommand(in RuntimeCommandDelta delta)
{
if (delta.Status == RuntimeCommandStatus.Rejected)
{
WriteLine(
$"command rejected: {delta.Domain} {delta.Text}".TrimEnd(),
dim: true);
}
}
public void OnPortal(in RuntimePortalDelta delta)
{
if (delta.Portal.IsMaterialized)
{
WriteLine(
$"portal -> cell 0x{delta.Portal.DestinationCell:X8}",
dim: true);
}
}
public void OnEntity(in RuntimeEntityDelta delta)
{
}
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnCombat(in RuntimeCombatDelta delta)
{
}
/// <summary>
/// N5 (2026-09-07 review round): only lifecycle/command/portal lines are
/// dimmed — scheduling and session-status noise, not player-visible
/// content. Chat and interface text print at the terminal's default
/// weight.
/// </summary>
private void WriteLine(string text, bool dim)
{
_output.WriteLine(_useColor && dim ? Dim + text + Reset : text);
_output.Flush();
}
}

View file

@ -0,0 +1,63 @@
using AcDream.Core.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
/// polls <see cref="SpewBoxState"/> on the console's own per-tick pump — the
/// SAME seam <c>AcDream.App.UI.SpewBoxController.Tick</c> drives for the
/// graphical overlay. Retail's transient "interface text"
/// (<see cref="RetailLogTextType.ClientLocal"/>, routed by
/// <c>RuntimeCommunicationState.AddText</c>) never touches
/// <c>RuntimeCommunicationState.Chat</c>/<c>RuntimeChatDelta</c>, so it is
/// otherwise invisible to a console that only observes the chat event
/// stream — this is true for EVERY producer of that text (a bad-args
/// refusal from the console's own submit, but also a server-driven refusal
/// or a plugin's own interface-text write), not just the console's own
/// submissions. This replaces the earlier per-call
/// <c>HeadlessConsoleChatFeedback</c> decorator, which only ever saw text
/// produced by the console's own <c>SubmitConsoleLine</c> calls.
/// </summary>
internal sealed class HeadlessConsoleSpewBoxPump
{
private readonly SpewBoxState _spewBox;
private readonly Func<double> _nowSeconds;
private readonly Action<string> _writeInterfaceText;
private SpewBoxEntry[] _lastSeen = [];
internal HeadlessConsoleSpewBoxPump(
SpewBoxState spewBox,
Func<double> nowSeconds,
Action<string> writeInterfaceText)
{
_spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
_nowSeconds = nowSeconds
?? throw new ArgumentNullException(nameof(nowSeconds));
_writeInterfaceText = writeInterfaceText
?? throw new ArgumentNullException(nameof(writeInterfaceText));
}
/// <summary>
/// Drains any pending SpewBox text into the visible set (exactly
/// <see cref="SpewBoxState.Tick"/>'s contract — the same drain
/// <c>SpewBoxVM.Lines</c> performs for the graphical overlay) and prints
/// any entry that was not part of the previous call's visible snapshot.
/// </summary>
/// <remarks>
/// <see cref="SpewBoxState.Snapshot"/> is newest-first
/// (retail's <c>InsertItem(item, 0)</c>); this walks it back-to-front so
/// newly-visible entries print in the order they were actually
/// enqueued, not newest-first.
/// </remarks>
internal void Pump()
{
_spewBox.Tick(_nowSeconds());
SpewBoxEntry[] current = _spewBox.Snapshot();
for (int i = current.Length - 1; i >= 0; i--)
{
if (Array.IndexOf(_lastSeen, current[i]) < 0)
_writeInterfaceText(current[i].Text);
}
_lastSeen = current;
}
}

View file

@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable
private readonly HeadlessDiagnosticWriter _diagnostics;
private readonly HeadlessProcessContentOwner? _content;
private readonly HeadlessProcessResourceSampler _resources;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): always
/// created, cancelled only by <c>/quit</c> — linking it into the
/// scheduler's run token below costs nothing when the console is
/// disabled (it simply never fires) and keeps <see cref="RunOnUpdateThread"/>
/// free of a console-shaped branch.
/// </summary>
private readonly CancellationTokenSource _consoleQuitRequested = new();
private readonly HeadlessConsoleController? _console;
private readonly IDisposable? _consoleRendererSubscription;
private int _disposeIndex;
private bool _disposed;
@ -26,7 +36,9 @@ internal sealed class HeadlessProcessHost : IDisposable
ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null,
IHeadlessProcessContentFactory? contentFactory = null,
HeadlessDirectCredentials? directCredentials = null)
HeadlessDirectCredentials? directCredentials = null,
bool consoleEnabled = false,
bool standardOutputIsTerminal = false)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths);
@ -61,8 +73,12 @@ 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;
HeadlessConsoleController? console = null;
IDisposable? consoleRendererSubscription = null;
// FA6: constructed unconditionally — cheap, and every non-gate
// session simply never reads or writes it (see the coordinator's
// own class doc).
@ -110,7 +126,8 @@ internal sealed class HeadlessProcessHost : IDisposable
timeProvider,
contentLease: contentLease,
gateCoordinator: gateCoordinator,
pluginRoots: pluginRoots));
pluginRoots: pluginRoots,
vtankProfiles: vtankProfiles));
}
catch
{
@ -134,9 +151,62 @@ internal sealed class HeadlessProcessHost : IDisposable
_resources = resources;
_content = content;
_disposeIndex = _sessions.Length - 1;
// Headless console (docs/plans/2026-09-07-headless-console.md):
// "Multi-session. Out of scope for the first cut" — attach only
// to a single-session process. Constructed AFTER every
// session's credential resolution above (which may itself read
// a line from standardInput for a StandardInput-provider
// credential) so the console's own reader thread never races a
// password prompt for the same stream.
if (consoleEnabled && _sessions.Length == 1)
{
HeadlessSessionHost session = _sessions[0];
var renderer = new HeadlessConsoleRenderer(
diagnostics,
useColor: standardOutputIsTerminal);
consoleRendererSubscription =
session.Runtime.Subscribe(renderer);
HeadlessConsoleController controller = new(
standardInput,
diagnostics,
session.SubmitConsoleLine,
() => BuildStatusText(session),
_consoleQuitRequested);
// S5 (2026-09-07 review round): poll the SAME SpewBoxState
// seam the graphical overlay's SpewBoxController.Tick reads
// (RuntimeCommunicationState.AddText's ClientLocal branch —
// it never touches Chat/RuntimeChatDelta) so server- and
// plugin-driven interface text prints too, not only the
// console's own submissions. Replaces the earlier per-call
// HeadlessConsoleChatFeedback decorator, which only saw text
// produced by THIS console's own SubmitConsoleLine calls.
var spewPump = new HeadlessConsoleSpewBoxPump(
session.Runtime.CommunicationOwner.SpewBox,
() => session.Runtime.Clock.SimulationTimeSeconds,
renderer.WriteInterfaceText);
session.ConsolePump = () =>
{
controller.DrainDue();
spewPump.Pump();
};
console = controller;
}
else if (consoleEnabled)
{
// S7 (2026-09-07 review round): a silent skip here read as
// "--console worked" to an operator with no way to tell
// otherwise — the launcher's multi-session mode is a
// legitimate, common configuration, so say so explicitly.
_diagnostics.Message("console", "single-session only");
}
_console = console;
_consoleRendererSubscription = consoleRendererSubscription;
}
catch
{
console?.Dispose();
consoleRendererSubscription?.Dispose();
resources?.Dispose();
for (int index = sessions.Count - 1; index >= 0; index--)
sessions[index].Dispose();
@ -145,6 +215,29 @@ internal sealed class HeadlessProcessHost : IDisposable
}
}
/// <summary>
/// <c>/status</c>: generation, position (or "unknown" without a live
/// movement controller — a content-less host, or before the first
/// accepted placement), and the plugin-visible macro state this host
/// can actually observe today (loaded-plugin count — no plugin
/// currently reports a richer status string; see the plan's "if the
/// plugin reports one").
/// </summary>
private static string BuildStatusText(HeadlessSessionHost session)
{
RuntimeMovementSnapshot movement =
session.Runtime.MovementOwner.Snapshot;
string position = movement.HasController
? $"cell=0x{movement.Position.ObjCellId:X8} "
+ $"local=({movement.Position.Frame.Origin.X:F2},"
+ $"{movement.Position.Frame.Origin.Y:F2},"
+ $"{movement.Position.Frame.Origin.Z:F2})"
: "unknown";
return $"generation={session.Runtime.Generation.Value} "
+ $"position={position} "
+ $"plugins={session.Plugins.LoadedCount} loaded";
}
internal HeadlessSessionHost Session => _sessions.Length == 1
? _sessions[0]
: throw new InvalidOperationException(
@ -246,12 +339,21 @@ internal sealed class HeadlessProcessHost : IDisposable
_scheduler.CaptureSnapshot(),
_content);
// Headless console: /quit cancels _consoleQuitRequested, which this
// linked token propagates into the scheduler's own wait loop —
// Run() returns normally (its loop condition simply goes false),
// the SAME graceful-exit path an external Ctrl+C/SIGTERM already
// takes. Linking costs nothing when the console never fires.
using CancellationTokenSource linkedQuit =
CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_consoleQuitRequested.Token);
try
{
_scheduler.Run(cancellationToken);
_scheduler.Run(linkedQuit.Token);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
when (linkedQuit.IsCancellationRequested)
{
}
catch (Exception error)
@ -274,6 +376,9 @@ internal sealed class HeadlessProcessHost : IDisposable
{
if (_disposed)
return;
_console?.Dispose();
_consoleRendererSubscription?.Dispose();
_consoleQuitRequested.Dispose();
while (_disposeIndex >= 0)
{
_sessions[_disposeIndex].Dispose();

View file

@ -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;
@ -182,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription;
private readonly HeadlessPluginSession _pluginSession;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): the
/// SAME plugin-verb registry <see cref="_chatCommandSurface"/>'s bus
/// forwards to (via <c>TryHandlePluginCommand</c>) and
/// <see cref="HeadlessPluginSession.Create"/> hands to every loaded
/// plugin. Exposed only so a test can register a verb directly without
/// loading a real plugin assembly — production callers reach it
/// exclusively through <see cref="SubmitConsoleLine"/> /
/// <see cref="LoginCommandSequence"/>, never this field.
/// </summary>
private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands;
/// <summary>
/// Headless console: the SAME retained bus <c>LoginCommandSequence</c>
/// submits through — see <see cref="SubmitConsoleLine"/>. One instance
/// for the host's whole lifetime; <see cref="CreateEventRoute"/>
/// attaches/detaches a fresh <see cref="LiveChatCommandRoute"/> to it on
/// every (re)connect, exactly as it does today for login commands.
/// </summary>
private readonly LiveChatCommandSurface _chatCommandSurface;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
@ -266,7 +286,8 @@ internal sealed class HeadlessSessionHost : IDisposable
IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null,
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
IEnumerable<string>? pluginRoots = null)
IEnumerable<string>? pluginRoots = null,
IPluginStorage? vtankProfiles = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@ -367,7 +388,8 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins,
pluginCommands);
pluginCommands,
vtankProfiles);
var liveSession = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(
@ -450,6 +472,8 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime = runtime;
Commands = commands;
_liveSession = liveSession;
_pluginCommands = pluginCommands;
_chatCommandSurface = chatCommandSurface;
_statusWriter = statusWriter;
_localPlayerFrame =
runtime.CreateLocalPlayerFrameController(
@ -518,7 +542,20 @@ internal sealed class HeadlessSessionHost : IDisposable
/// </summary>
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
internal HeadlessPluginSession Plugins => _pluginSession;
/// <summary>Test seam (mirrors <see cref="OptionsSeeder"/>'s own
/// pattern): registers a plugin verb directly against the SAME registry
/// a real loaded plugin would use, without loading a plugin assembly.
/// </summary>
internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands =>
_pluginCommands;
internal string SessionId => _descriptor.Id;
/// <summary>
/// Headless console: invoked at the end of every <see cref="Tick"/> so
/// console input drains ON the session tick, in order, never on the
/// reader thread. <see langword="null"/> (every non-console host) costs
/// nothing extra per tick.
/// </summary>
internal Action? ConsolePump { get; set; }
internal string ActiveCharacterName { get; private set; } =
string.Empty;
internal bool IsPolicyComplete =>
@ -560,6 +597,31 @@ internal sealed class HeadlessSessionHost : IDisposable
_pendingConfirmation = null;
}
/// <summary>
/// The headless console's ONE entry point for a typed line — the exact
/// pipeline <see cref="LoginCommandSequence"/> already submits through:
/// <see cref="ChatCommandRouter.Submit"/> against this host's retained
/// <see cref="_chatCommandSurface"/>. Dispatch order (matching
/// <see cref="ChatCommandRouter"/>'s own class doc): retail's client-
/// command catalog first, then the local <c>/help</c> presentation
/// command, then the plugin-verb registry, then the retail unregistered-
/// channel-tag fallback, then an explicit server command, then plain
/// chat. Retail's transient interface text (bad-args refusals, unknown-
/// command text — never routed through
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/>, see
/// <c>RuntimeCommunicationState.AddText</c>'s <c>ClientLocal</c> branch)
/// lands in the shared <see cref="RuntimeCommunicationState.SpewBox"/>
/// exactly like every other producer of that text; the console's own
/// per-tick pump polls it (see <c>HeadlessConsoleSpewBoxPump</c>)
/// instead of this call decorating its own feedback.
/// </summary>
internal SubmitOutcome SubmitConsoleLine(string line) =>
ChatCommandRouter.Submit(
line,
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
_chatCommandSurface,
ChatChannelKind.Say);
internal RuntimeSessionStartResult Start()
{
// Campaign LA slice LA1: "started" = session host start — the
@ -604,6 +666,10 @@ internal sealed class HeadlessSessionHost : IDisposable
_localPlayerFrame.RunPostNetworkCommandPhase();
Runtime.ActionOwner.CombatAttack.Tick();
_policy.Tick(Runtime, Commands);
// Headless console: drain any input queued by the background reader
// thread since the last tick, in order, on THIS thread — never the
// reader thread (see HeadlessConsoleInputReader's own doc).
ConsolePump?.Invoke();
}
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")

View file

@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet(
internal string PluginsDirectory =>
Path.Combine(DataDirectory, "plugins");
/// <summary>
/// Default root for <see cref="AcDream.Plugin.Abstractions.IPluginHost.VtankProfiles"/>
/// (Campaign VT slice-1 fix round, item F). No override mechanism of its
/// own yet — unlike the graphical host's <c>ACDREAM_VTANK_PROFILE_DIR</c>,
/// which only exists on <c>AcDream.App.RuntimeOptions</c> — because
/// headless path overrides already go through <c>HeadlessPathOverrides</c>
/// (config file / <c>--data-dir</c>), not environment variables.
/// </summary>
internal string VtankProfilesDirectory =>
Path.Combine(DataDirectory, "vtank");
internal static HeadlessPathSet Resolve(
HeadlessPathOverrides overrides,
IHeadlessPlatformEnvironment? platform = null)

View file

@ -0,0 +1,99 @@
using System.Text;
using AcDream.Plugin.Abstractions;
namespace AcDream.Headless.Plugins;
/// <summary>
/// Crash-safe filesystem <see cref="IPluginStorage"/> — byte-identical
/// contract to <c>AcDream.App.Plugins.FilePluginStorage</c>. Duplicated
/// rather than shared: <c>AcDream.Headless</c> does not (and per the
/// no-window/graphical layer split should not) reference <c>AcDream.App</c>,
/// 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.
/// </summary>
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<string> 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<string>();
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;
}
}

View file

@ -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;

View file

@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable
string sessionId,
IEnumerable<string> roots,
IReadOnlyList<string>? 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),

View file

@ -27,7 +27,9 @@ try
Console.In,
Console.Out,
Console.Error,
cancellation.Token);
cancellation.Token,
standardInputIsTerminal: !Console.IsInputRedirected,
standardOutputIsTerminal: !Console.IsOutputRedirected);
}
finally
{

View file

@ -84,6 +84,17 @@ public readonly record struct PluginSpellInfo(
/// </summary>
public int? QualityOverride { get; init; }
public int Quality => QualityOverride ?? Difficulty;
/// <summary>
/// Raw retail SpellTable (portal.dat <c>0x0E00000E</c>) icon RenderSurface
/// DID — Decal's <c>SpellTable.GetById(id).Icon</c>. This is the spell's
/// OWN art asset id, distinct from the composited icon a plugin markup
/// <c>&lt;icon spell="..."&gt;</c>/<c>iconkind="spell"</c> draws (which
/// layers power-level backing + tint + self/fellow overlay on top of it,
/// matching retail's <c>ClientMagicSystem::CompositeSpellIcon</c>) — a
/// plugin that wants the plain art tile rather than the composited badge
/// draws this id directly through <c>iconkind="did"</c>. 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>One enchantment currently in force on the local player.</summary>
@ -115,6 +126,19 @@ public readonly record struct PluginSkillInfo(
{
/// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
public uint Base { get; init; } = Current;
/// <summary>
/// Retail SkillTable (portal.dat <c>0x0E000004</c>) icon RenderSurface
/// DID — the <c>SkillBase.IconId</c> field (Chorizite.DatReaderWriter
/// <c>DatReaderWriter.Types.SkillBase</c>; verified via reflection over
/// the installed package, since the XML doc comments don't cover it: the
/// type carries public fields <c>Description</c>, <c>Name</c>,
/// <c>IconId</c> (uint), <c>TrainedCost</c>, <c>SpecializedCost</c>,
/// <c>Category</c>, <c>ChargenUse</c>, <c>MinLevel</c>, <c>Formula</c>,
/// <c>UpperBound</c>, <c>LowerBound</c>, <c>LearnMod</c> — no separate
/// XML-doc member exists because these are Pack/Unpack-generated public
/// fields, not properties). 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>
@ -278,10 +302,20 @@ public interface IPluginChat
Array.Empty<PluginChatMessage>();
/// <summary>
/// Post a client-local system line, the channel retail uses for the
/// client's own notices. It is local to this client: nothing is sent to the
/// server and no other player sees it.
/// Post a plugin-originated system line into the chat window. It is
/// local to this client: nothing is sent to the server and no other
/// player sees it.
/// </summary>
/// <remarks>
/// Owner direction 2026-09-07 (register row AD-124): this used to route
/// through retail's <c>ClientLocal</c> (0x1A) channel — the SpewBox
/// overlay every <c>ChatInterface</c> window's default filter excludes.
/// The owner explicitly overrode that for plugin text, matching Decal's
/// own <c>AddChatText</c> behavior: plugin output now lands in the chat
/// transcript (retail <c>Default</c>/0x00) so it is actually visible and
/// scrolls back, never the transient overlay. See
/// <c>AppAutomationSurface.PostSystemMessage</c> for the implementation.
/// </remarks>
void PostSystemMessage(string text);
/// <summary>

View file

@ -42,4 +42,22 @@ public interface IPluginHost
/// host kind.
/// </summary>
IAutomationSurface Automation { get; }
/// <summary>
/// Storage rooted at the VTank profile folder (real <c>.usd</c>/
/// <c>.ast</c>/<c>.af</c> files, VTank's own naming rules) rather than
/// this plugin's own scoped <see cref="Storage"/> directory — see
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>, which
/// enumerates through this property exclusively (no <c>System.IO</c>,
/// no per-OS portable-default fallback of its own) so directory
/// discovery stays entirely host-composed. Defaults to the inert
/// <see cref="NoOpPluginStorage"/> (<c>IsAvailable</c> 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
/// <c>ApplicationPathSet.DataDirectory</c>; that discovery belongs
/// entirely to the host composing this property, never to the plugin
/// reading it.
/// </summary>
IPluginStorage VtankProfiles => NoOpPluginStorage.Instance;
}

View file

@ -24,6 +24,13 @@ public sealed record PluginPanelDescriptor(string WindowId, string Title)
/// <see cref="IconText"/> instead. Plugins never receive the resulting GPU
/// resource and remain BCL-only.
/// </summary>
/// <remarks>
/// Accepts a bare Decal/VirindiViewService-style portal.dat index as well
/// as a full RenderSurface DID — the host normalizes every value through
/// <see cref="PluginIcons.Normalize"/> before drawing it, the same
/// grammar applied at plugin markup's <c>&lt;icon did&gt;</c>/
/// <c>&lt;button icon&gt;</c>/<c>&lt;list icons&gt;</c> sinks.
/// </remarks>
public uint IconSurfaceId { get; init; }
/// <summary>

View file

@ -91,6 +91,13 @@ public readonly record struct PluginInventoryItem(
public PluginObjectClass ObjectClass { get; init; }
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
Array.Empty<PluginPaletteInfo>();
/// <summary>
/// Retail <c>ClientObject.IconId</c> RenderSurface DID (base icon, before
/// the type-default underlay/custom-underlay/overlay/effect compositing
/// a plugin markup <c>iconkind="item"</c> draws through
/// <c>IconComposer.GetIcon</c>). 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>

View file

@ -0,0 +1,60 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// The one icon-id grammar every host icon sink normalizes through
/// (descriptor <see cref="PluginPanelDescriptor.IconSurfaceId"/>, plugin
/// markup's <c>&lt;icon did&gt;</c>, <c>&lt;button icon&gt;</c>, and
/// <c>&lt;list icons&gt;</c>). Decal/VirindiViewService plugins (the
/// reference usage: MosswartMassacre's <c>HudPictureBox.Image</c> assignments)
/// hand out bare portal.dat indices — small integers below <c>0x01000000</c>,
/// the same numbers Decal's <c>FileService.SpellTable</c>/<c>SkillTable</c>
/// icon columns return. A host that draws those literally as RenderSurface
/// DIDs (the <c>0x06xxxxxx</c> block) resolves nothing.
/// </summary>
/// <remarks>
/// This grammar is deliberately host-side and applied at the SINK, not at
/// every plugin-facing record: <see cref="PluginSpellInfo.IconId"/>,
/// <see cref="PluginSkillInfo.IconId"/>, <see cref="PluginInventoryItem.IconId"/>
/// and <see cref="PluginWorldObject.IconId"/> already carry full retail
/// RenderSurface DIDs (read straight from the client's SpellTable/SkillTable/
/// object state), so normalizing them again would be a no-op — but a plugin
/// author who only has a Decal-style bare index (typed literally in markup,
/// or echoed from an external metadata source) still needs the same
/// conversion, which is exactly what happens at the markup <c>did</c> sink.
/// </remarks>
public static class PluginIcons
{
/// <summary>
/// The bare-index/DID boundary: <c>0x01000000</c>. Decal/VVS icon indices
/// (portal.dat's SpellTable/SkillTable icon columns, and the raw
/// integers a plugin author types by hand) are always well below this —
/// portal.dat's own id space reserves everything from <c>0x01000000</c>
/// up for named DBTYPE blocks (RenderSurface's own block starts at
/// <c>0x06000000</c>, comfortably above it). A value below the boundary
/// is treated as a bare index needing the RenderSurface block prefix
/// added; a value at or above it is assumed to already be a resolvable
/// DID (RenderSurface or otherwise) and is returned unchanged.
/// </summary>
private const uint BareIndexBoundary = 0x01000000u;
/// <summary>
/// The RenderSurface DID block prefix added to a bare index below
/// <see cref="BareIndexBoundary"/>.
/// </summary>
private const uint RenderSurfaceBlock = 0x06000000u;
/// <summary>
/// Normalizes one plugin-supplied icon id: <c>0</c> stays <c>0</c> (no
/// icon); a bare index below <c>0x01000000</c> becomes
/// <c>0x06000000 + value</c> (Decal/VVS convention); a value at or above
/// <c>0x01000000</c> — including <c>0x01000000</c> itself — is returned
/// unchanged (already a full DID, of whatever DBTYPE the caller intends —
/// the host resolves it against the space its own sink expects).
/// </summary>
public static uint Normalize(uint idOrIndex) =>
idOrIndex == 0u
? 0u
: idOrIndex < BareIndexBoundary
? RenderSurfaceBlock + idOrIndex
: idOrIndex;
}

View file

@ -83,6 +83,12 @@ public readonly record struct PluginWorldObject(
public int ContainersCapacity { get; init; }
public IReadOnlyList<uint> SpellIds { get; init; } = Array.Empty<uint>();
public IReadOnlyList<uint> ActiveSpellIds { get; init; } = Array.Empty<uint>();
/// <summary>
/// Retail <c>ClientObject.IconId</c> RenderSurface DID (base icon, before
/// compositing). Same id a plugin markup <c>iconkind="item"</c> resolves
/// through <c>IconComposer.GetIcon</c>. 0 when unknown.
/// </summary>
public uint IconId { get; init; }
}
/// <summary>

View file

@ -11,6 +11,14 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Plugins.MossTank.Tests" />
<!-- Fix round B item 15: AcDream.App.Tests constructs a real
MossTankPanel directly (MossTankMarkupBuildOverRealFilesTests) so
MarkupDocument.Build validates every mosstank*.xml's actual
attribute/binding grammar, not just reflection-visible property
names/types. A test-only exception to the plugin/host compile-time
boundary (AcDream.App itself never links this assembly — see
AcDream.App.csproj's ReferenceOutputAssembly=false copy target). -->
<InternalsVisibleTo Include="AcDream.App.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
@ -19,10 +27,17 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Update="mosstank.xml">
<!-- Fix round B item 13: glob every mosstank*.xml plugin-panel file
instead of naming each one — a new popup (mosstank-metaeditor.xml,
item 5) used to need a matching manual edit here, in
AcDream.App.csproj's two Copy targets, and in the test project's
Link list; missing one silently dropped a panel at mount instead
of failing the build. -->
<None Update="mosstank*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
<EmbeddedResource Include="VtankDefaultSettings.usd" />
</ItemGroup>
</Project>

View file

@ -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
};
}
/// <summary>
/// VTank's real 3-way UseArcs switch (<c>refs/vtank/decompiled/hi.cs:515-538</c>):
/// <c>No</c> never arcs, <c>Yes</c> always arcs, <c>AtRange</c> arcs only
/// once the target is at or beyond <see cref="CombatSettings.ArcRange"/>.
/// </summary>
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,

View file

@ -0,0 +1,274 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Gets the character ready to cast before a buff pass's first
/// <c>TryCast</c>: resolve which caster to buff with, wield it if it is not
/// already equipped, and enter Magic mode. Retail drops any cast that
/// arrives outside Magic mode (ACE <c>Player_Magic.cs:84-95</c>), and Magic
/// mode itself requires a wielded caster (ACE
/// <c>Player_Combat.cs:778+</c>, <c>GetEquippedWand</c>) — the buff pass
/// used to assume both were already true. This is the single owner of
/// "which caster do we buff with and how do we get into Magic mode"; see
/// <c>docs/plans/2026-09-06-mosstank-mode-arbitration.md</c> Design A.
/// </summary>
internal sealed class BuffCasterPreparer
{
/// <summary>
/// Retail's caster ITEM_TYPE bit (VTank's <c>CasterItemType</c>,
/// <see cref="VitalRecharge"/>'s recovery-caster predicate, and
/// <see cref="CombatController.SelectRecoveryCaster"/> all use the same
/// constant).
/// </summary>
private const uint CasterItemType = 0x00008000u;
/// <summary>
/// Mirrors <see cref="CombatController.TryEquipIfNeeded"/>'s retry
/// budget in spirit (same <see cref="VitalSettings.DropToPeaceModeRetryCount"/>
/// constant), but on a fixed wall-clock cadence rather than once per
/// combat tick — a buff pass is not scanning for targets every 0.25 s,
/// so re-issuing a stuck mode request needs its own pacing.
/// </summary>
private const double ModeRetrySeconds = 2.0;
private readonly IPluginHost _host;
private readonly CombatSettings _settings;
private readonly VitalSettings _vitalSettings;
private PluginCombatMode? _pendingRequestedMode;
private double _modeWaitElapsed;
private int _modeRetryCount;
/// <summary>
/// Posting VTank's "no wand" notice is latched so an automatic scan that
/// keeps finding no caster does not spam chat every scan interval. The
/// latch clears only on <see cref="Reset"/> (Stop, session end, or macro
/// stop) — the simplest honest rule: it re-announces once per fresh
/// attempt at running the macro, not on every failed scan in between.
/// </summary>
private bool _noCasterNoticePosted;
public BuffCasterPreparer(
IPluginHost host,
CombatSettings settings,
VitalSettings? vitalSettings = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_vitalSettings = vitalSettings ?? new VitalSettings();
}
/// <summary>The buff queue may run once this is true.</summary>
public bool Ready { get; private set; }
/// <summary>
/// The pass must stop (no caster, an equip/mode refusal, or an
/// exhausted retry budget). <see cref="Status"/> names why.
/// </summary>
public bool Stopped { get; private set; }
public string Status { get; private set; } = string.Empty;
/// <summary>
/// Ticked every frame while the buff pass runs, before its first
/// <c>TryCast</c>. A no-op once <see cref="Ready"/> or
/// <see cref="Stopped"/>.
/// </summary>
public void Tick(double elapsedSeconds)
{
if (Ready || Stopped)
return;
IAutomationSurface automation = _host.Automation;
IEquipmentAutomation equipment = automation.Equipment;
// Older/no-window hosts report unavailable rather than modelling
// equipment at all (the same convention as
// CombatController.TickEquipment); do not turn a missing optional
// projection into a permanent deadlock for every buff pass.
if (equipment.IsAvailable)
{
if (equipment.IsBusy)
{
Status = "Equipping caster";
return;
}
if (!TryResolveCaster(equipment.CaptureOwnedEquipment(), out PluginEquipmentItem caster))
{
StopWithNoCasterNotice();
return;
}
if (!caster.IsEquipped)
{
PluginCombatMode wieldMode = automation.Combat.Snapshot.Mode;
if (wieldMode != PluginCombatMode.Peace)
{
RequestMode(
PluginCombatMode.Peace,
elapsedSeconds,
"could not enter peace mode to equip a caster");
return;
}
ResetModeWait();
PluginEquipmentCommandResult equip = equipment.Equip(caster.ObjectId);
if (equip.Status == PluginEquipmentCommandStatus.Refused)
{
Stop(equip.Notice ?? $"Cannot equip {caster.Name}.");
return;
}
Status = $"Equipping {caster.Name}";
return;
}
}
PluginCombatMode currentMode = automation.Combat.Snapshot.Mode;
if (currentMode != PluginCombatMode.Magic)
{
RequestMode(PluginCombatMode.Magic, elapsedSeconds, "could not enter magic mode");
return;
}
Ready = true;
Status = "Ready to buff";
}
/// <summary>Reset on Stop, on session end, and when the macro stops.</summary>
public void Reset()
{
Ready = false;
Stopped = false;
Status = string.Empty;
_pendingRequestedMode = null;
_modeWaitElapsed = 0d;
_modeRetryCount = 0;
_noCasterNoticePosted = false;
}
/// <summary>
/// Wielded caster first (retail's actual equipped wand); else the first
/// profiled caster, ordered by name then object id for a deterministic
/// choice. <c>_noBuffItemNames</c> (VTank's "do not cast item
/// enchantments on this weapon") is not a caster exclusion — that flag
/// is about weapon procs, not who may buff.
/// </summary>
private bool TryResolveCaster(
IReadOnlyList<PluginEquipmentItem> items,
out PluginEquipmentItem caster)
{
foreach (PluginEquipmentItem item in items)
{
if ((item.ItemType & CasterItemType) != 0u && item.IsEquipped)
{
caster = item;
return true;
}
}
PluginEquipmentItem? best = null;
foreach (PluginEquipmentItem item in items)
{
if ((item.ItemType & CasterItemType) == 0u)
continue;
if (!_settings.CombatItemObjectIds.Contains(item.ObjectId)
&& !_settings.CombatItemNames.Contains(item.Name))
{
continue;
}
if (best is null
|| string.CompareOrdinal(item.Name, best.Value.Name) < 0
|| (string.Equals(item.Name, best.Value.Name, StringComparison.Ordinal)
&& item.ObjectId < best.Value.ObjectId))
{
best = item;
}
}
if (best is { } selected)
{
caster = selected;
return true;
}
caster = default;
return false;
}
private void StopWithNoCasterNotice()
{
const string notice = "You must add at least one wand to your Items profile.";
Stopped = true;
Status = notice;
if (_noCasterNoticePosted)
return;
_host.Automation.Chat.PostSystemMessage("[MossTank] " + notice);
_noCasterNoticePosted = true;
}
private void Stop(string status)
{
Stopped = true;
Status = status;
}
private void ResetModeWait()
{
_pendingRequestedMode = null;
_modeWaitElapsed = 0d;
_modeRetryCount = 0;
}
/// <summary>
/// Issue (or re-issue) one mode request, gated to at most once per
/// <see cref="ModeRetrySeconds"/> and at most
/// <see cref="VitalSettings.DropToPeaceModeRetryCount"/> times; the
/// exhausted budget stops the pass naming the stage.
/// </summary>
private void RequestMode(PluginCombatMode mode, double elapsedSeconds, string exhaustedStatus)
{
if (_pendingRequestedMode != mode)
{
_pendingRequestedMode = mode;
_modeWaitElapsed = 0d;
_modeRetryCount = 1;
IssueModeRequest(mode);
return;
}
_modeWaitElapsed += Math.Max(0d, elapsedSeconds);
if (_modeWaitElapsed < ModeRetrySeconds)
{
Status = $"Entering {mode} mode";
return;
}
_modeWaitElapsed = 0d;
_modeRetryCount++;
if (_modeRetryCount > _vitalSettings.DropToPeaceModeRetryCount)
{
Stop(exhaustedStatus);
return;
}
IssueModeRequest(mode);
}
private void IssueModeRequest(PluginCombatMode mode)
{
PluginCombatCommandResult result = _host.Automation.Combat.EnterMode(mode);
if (result.Status == PluginCombatCommandStatus.Unavailable)
{
// The host does not model combat-mode automation at all (an
// older/no-window host projects only NoOpAutomationSurface
// here). Do not turn a missing optional projection into a
// permanent deadlock for every buff pass; proceed as ready.
Ready = true;
Status = "Ready to buff";
return;
}
Status = result.Status == PluginCombatCommandStatus.Refused
? result.Notice ?? $"Cannot enter {mode} mode"
: $"Entering {mode} mode";
}
}

View file

@ -28,7 +28,11 @@ public sealed class BuffSettings
/// <summary>
/// How far the casting skill must exceed a spell's difficulty before the
/// tier is considered reliable — VTank's
/// <c>SpellDiffExcessThreshold-Buff</c>.
/// <c>SpellDiffExcessThreshold-Buff</c>, editable through Advanced
/// Options (VtankOptionCatalog). Signed on purpose — the wiki is
/// explicit that "a positive number raises the skill necessary to cast
/// spells, a negative number lowers it", so a lower-level character can
/// reach for higher tiers by going negative.
/// </summary>
public int SkillExcessOverDifficulty { get; set; } = 5;
@ -84,6 +88,30 @@ public sealed class BuffSettings
public int BuffWithUntrainedItemSkill { get; set; } = 80;
public int BuffWithUntrainedCreatureSkill { get; set; } = 80;
public int BuffWithUntrainedLifeSkill { get; set; } = 80;
/// <summary>
/// VTank's ExtraBuffSpells table (single-field ExemplarId list,
/// defaultsettings.usd:21-25) — named spell exemplars to cast "best
/// similar" beyond the school-driven picks above. Campaign VT slice 7
/// S7.4 adds the storage and the Buffs-tab UI (list + the
/// SelfBuffChoiceView-style picker) but does NOT wire this set into
/// <see cref="BuffPlan.Build"/>'s cast selection — resolving an
/// exemplar name to a real castable line/tier is genuine casting-
/// algorithm behavior, out of this UI-parity slice's scope. Tracked as
/// issue #491 ("shown and persisted, not consumed by BuffPlan.Build
/// until slice 4"), owned by a future Campaign VT behavior slice.
/// </summary>
public ISet<string> ExtraBuffSpellNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// VTank's AntiExtraBuffSpells table (defaultsettings.usd:1-6) — named
/// buff families to never cast even if the school-driven rules above
/// would otherwise want them. Same storage-only scope note as
/// <see cref="ExtraBuffSpellNames"/> — issue #491.
/// </summary>
public ISet<string> BlacklistedBuffFamilyNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
}
/// <summary>

View file

@ -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,
@ -269,19 +269,12 @@ internal sealed class CombatController
if (_targetId == 0u)
{
// Peace-Mode-When-Idle is owned solely by MacroIdleModeArbiter,
// which ticks after every controller (including this one) and so
// also covers the case this controller cannot see: combat policy
// disabled but the macro still running. See
// docs/plans/2026-09-06-mosstank-mode-arbitration.md Design B.
StopApproachMovement();
PluginCombatSnapshot idle = _host.Automation.Combat.Snapshot;
if (_settings.IdlePeaceMode
&& idle.Mode is not (PluginCombatMode.Unknown
or PluginCombatMode.Peace))
{
PluginCombatCommandResult result =
_host.Automation.Combat.EnterMode(PluginCombatMode.Peace);
Status = result.Status == PluginCombatCommandStatus.Refused
? result.Notice ?? "Cannot enter peace mode"
: "Entering peace mode";
return;
}
Status = "Waiting for a target";
return;
}
@ -595,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);
@ -1094,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)
{

View file

@ -22,6 +22,23 @@ internal enum DebuffSelectionMethod
Skill = 2,
}
/// <summary>
/// VTank's real 3-way "UseArcs" enum
/// (<c>refs/vtank/decompiled/hi.cs:515-538</c>, switch on
/// <c>f3.f("UseArcs")</c>): <c>No</c> always picks the direct-shape spell,
/// <c>AtRange</c> picks the arc shape only once the target is at or beyond
/// <c>ArcRange</c>, and <c>Yes</c> 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 <see cref="AttackSpellCatalog"/>.
/// </summary>
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;
/// <summary>VTank's hunt-cast skill margin.</summary>
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;
/// <summary>
/// Monsters nearer than this are not valid attack targets. VTank applies
/// this before priority and angle/range ranking.
/// </summary>
public float MinimumRange { get; set; }
public double MinimumRange { get; set; }
/// <summary>
/// VTank's Approach Distance. Zero disables monster approach; otherwise
/// navigation may close a selected target from this range down to
/// <see cref="MaximumRange"/>.
/// </summary>
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;

Some files were not shown because too many files have changed in this diff Show more