merge(vt): slice 1 Part A — VTank .usd/.utl drop-in and metaf .af for metas and routes (review-closed)

Campaign VT slice 1 Part A: the .usd document model + 137-setting
serializer with declared type tags and exact compare, metaf .af reader/
writer for metas and nav routes with real byte identity against the
owner's fixtures, .utl gate fixes, the VtankProfiles host storage
(ACDREAM_VTANK_PROFILE_DIR), and the cutover of all four profile stores
to real VTank files with one-time JSON migration. Two Opus lenses, three
fix rounds, two narrow re-reviews, final re-check: MERGE-READY.
Contract-doc ledger conflict resolved by keeping the campaign branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 02:25:28 +02:00
commit f680bf234a
108 changed files with 62970 additions and 1860 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

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

@ -238,6 +238,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's own `.cdf` writes name its Nav/Meta profiles with the `.af` (metaf text) extension, not real VTank's native binary `.nav`/`.met` — pointed at a real installed VTank folder, that same character's Nav/Meta profiles become unloadable by the real VTank client sharing the directory (Settings `.usd` and Loot `.utl` stay real/binary-compatible; see register row AD-122). | unset → `<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

@ -538,6 +538,23 @@ and `PetDeviceCatalog.cs` — but was not compared step-by-step against
`CombatController.cs`. Effect: minor DPS/time loss from unnecessary
re-wields when several adjacent monsters need different weapons,
lower impact than 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

View file

@ -514,14 +514,20 @@ tests (`tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs`,
**Can a real `.nav` file load today? Yes.** `VtankNavRouteSerializer.TryLoad`
implements the exact header/route-type/waypoint-record grammar in §1,
including the correct discard of the placeholder line, the correct
per-type payload for all ten waypoint types, and — notably — correctly
special-cases Portal2/UseNPC to overwrite the (meaningless) outer header
coordinate with the embedded `d`-record's own coordinate (`VtankNavRouteSerializer.cs:151-157`).
It is wired to actual import via `MossTankRouteProfileStore.TryImportLegacy`,
which reads a `.nav` text file from plugin storage by filename. Manual
verification against `bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips
cleanly against the documented grammar.
including the correct discard of the placeholder line, and the correct
per-type payload for all ten waypoint types. Portal2/UseNPC now keep both
coordinate triples on `RouteWaypoint` — the (meaningless, per §1.2) outer
header coordinate in `Position` and the embedded `d`-record's own real
target coordinate in `ReferencePosition` (`VtankNavRouteSerializer.cs:127-145`;
Campaign VT slice-1 Part A fix round — a prior port overwrote `Position`
with the `d`-record instead of keeping both, which made a `.af` round trip
of the same waypoint lossy). `Navigation.TickUse` searches for the live
world object using `ReferencePosition`, matching retail's "real target
coordinate" role for that field. It is wired to actual import via
`MossTankRouteProfileStore.TryImportLegacy`, which reads a `.nav` text file
from plugin storage by filename. Manual verification against
`bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips cleanly against
the documented grammar.
Ranked by impact (highest first):
@ -535,6 +541,7 @@ Ranked by impact (highest first):
| 6 | **Portal2/UseNPC candidate filter omits the ground truth's `item.c()==0` gate.** | `e9.g()`/`fa.g()` only consider candidates where `item.c() == 0` — i.e. the object is neither contained nor equipped (`fu.c()` returns its Container or Wielder instance id, `fu.cs:66-87`) in addition to name+class+proximity. | `TryFindObject` is opaque from this file (defined elsewhere in the plugin abstraction); could not confirm whether an equivalent filter exists. | **Low** — flagged for follow-up rather than asserted as missing. |
| 7 | **Chat-color gate on UseNPC's "got a response" detection is dropped.** | `fa.a(ChatTextInterceptEventArgs)` only accepts color-3 "tells you" or color-0 "gives you" lines (`fa.cs:160-177`). | `HasNpcResponse` (`Navigation.cs:829-848`) matches on text content and sender name only, with no color/channel check, plus an extra `Sender.Equals(npcName)` branch not present in ground truth. | **Low** — small false-positive risk (any channel's text matching the phrase would complete the node), unlikely to matter in practice given the fairly specific phrase match. |
| 8 | **Door frame-count debounce vs. time-based retry.** | Portal2's post-use verification waits `PluginCore.@do >= startFrame + 2` — at least two *rendered frames*, not a duration — before checking arrival (`e9.cs:126-139`). | acdream's equivalents are all elapsed-seconds based (`UseRetrySeconds`, etc., `Navigation.cs:181` and throughout). | **Low** — a frame-based debounce doesn't map cleanly onto acdream's tick model in the first place; noted for completeness, not actionable. |
| 9 | **`.af` (metaf) cannot represent a strafe jump's direction at all.** | The binary `.nav` format's Jump record ends with one combined line encoding both charge-ms and a direction digit ∈ {3=Forward,4=StrafeLeft,5=StrafeRight} (§1.2 row 9, `di.cs:116-172`). | metaf's `NJump` class (`metaf_monolithic.py:11708-11821`, both `ImportFromMetAF`/`ExportToMetAF`) has no direction field whatsoever — only x/y/z, heading, holdShift, and delay-ms. `RouteWaypoint.JumpDirection` survives a `.nav`⇄model round trip exactly, but a route saved to `.af` and reloaded always comes back `Forward` regardless of what it held before the save, because the `.af` text itself never carried the value. | **Medium** — a real, unavoidable format limitation (not a porting gap): confirmed by reading metaf's own class end to end, not inferred. `MetafSerializer`'s `.af` writer does not claim otherwise and the reader does not force-assign `Forward` (it leaves the model's own default), but the value is still lost across a save-as-`.af`/reload cycle for StrafeLeft/StrafeRight waypoints. |
Correctly and precisely ported (confirmed, not a gap — listed since they
were non-obvious and worth recording as verified rather than re-litigated):

View file

@ -727,6 +727,7 @@ line counts). This is an unusually precise reverse-engineering result — the
| 3 | **`MonsterNameCountWithinDistance`'s name pattern is case-insensitive in MossTank, case-sensitive in retail** | `d7.c()` compiles with `RegexOptions.Compiled` only (`d7.cs:26`) — case-sensitive | `Meta.cs`'s `MonsterCount` helper (`Meta.cs:536-552`) compiles with `RegexOptions.IgnoreCase \| RegexOptions.CultureInvariant` | **Medium-high.** A pattern authored against retail's case-sensitive matching (e.g. deliberately excluding a differently-cased variant name) will over-match in MossTank. Note `ChatMessage`/`ChatMessageCapture` do **not** have this divergence — MossTank's `ChatMatch` (`Meta.cs:463-513`) is correctly case-sensitive (`RegexOptions.CultureInvariant` only), matching `hl.cs`/`c5.cs`. |
| 4 | **`;` sequence-operator value convention is inverted vs. retail (but matches UtilityBelt, and was chosen deliberately)** | Retail VTank: `a;b` evaluates to **`a`** (the left/first operand), discarding `b`'s value, while still executing `b` for side effects (`ExpressionEvaluator.cs:787-790`); `;` is an ordinary operator that can nest anywhere via normal precedence | `ExpressionProgram.Evaluate` (`ExpressionEngine.cs:20-27`) treats top-level `;`-separated statements as a `Node[]` program and returns the value of the **last** statement — this matches UtilityBelt's own audited grammar ("multiple `;`-separated statements, returning the final result", `2026-08-26-mosstank-vtank-utilitybelt-research.md:237`), which the campaign explicitly chose as MossTank's baseline dialect (§3.1 there) | **Medium, and by design, not an oversight.** Retail VTank and UtilityBelt already disagree with each other on `;`'s return value; MossTank correctly implements UtilityBelt's convention. The compat risk is narrower than a plain bug: only a `.met` authored *against retail's own* semantics (chaining `sideeffect[]; realcheck[]` and relying on the *first* value) evaluates to the opposite result once imported. |
| 5 | **Watchdog ring pre-fill differs (far sentinel vs. arm-time position)** | All 10 position-history slots start as a sentinel far from any real coordinate (`(1000,1000,1000)`, `h7.cs:45-49`), guaranteeing expiry cannot even be *reachable* until one full `timeSpan` window has elapsed and every slot has been overwritten with a real sample | `SetWatchdog` (`Meta.cs:585-596`) pre-fills all 10 slots with the **current** position at arm time | **Lower-medium.** Both converge on "earliest possible expiry is ~one `TimeSpanSeconds` after arming" in the common case, but they diverge for a watchdog that is armed, then the player leaves and returns to very near the arm point before the window closes — retail's sentinel-seeded ring cannot spuriously read the arm-time position as one of its 10 samples, MossTank's can. |
| 6 | **`MetaRule.Enabled` (a per-rule disable-without-delete toggle) has no metaf-compatible representation at all** | Real VTank/metaf has no such concept — `metaf_monolithic.py` has zero occurrences of "enabled"/"disabled" anywhere; a rule in a `.met`/`.af` is simply present or absent | `MetafSerializer.SaveMeta` (Campaign VT slice-1 item H) **refuses to save** a profile containing any disabled rule by default (throws `InvalidOperationException` naming the count) rather than silently dropping it — the caller must opt in via `SaveMeta(profile, dropDisabledRules: true)` to accept the loss explicitly. Round 2 (Campaign VT slice 1 Part A) made `.af` the SOLE authoritative Meta store — `MossTankMetaProfileStore` has no separate JSON storage or `.af` "convenience mirror" any more, so this refusal now blocks the save of the profile itself: a disabled rule makes that profile file genuinely **unsaveable** until the user re-enables or deletes the rule (the file on disk keeps its last successfully-saved content in the meantime; round 3 item 12 corrected this row, which previously described the pre-cutover "legacy-export mirror" design). | **Low** (by design, not a bug): this is a MossTank-only UI extension with no VTank equivalent to diverge from, and the refusal is loud (a user-visible notice) rather than a silent data loss. |
### 5.3 Confirmed non-gaps (the format/engine is otherwise unusually faithful)

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

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

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

@ -61,6 +61,8 @@ internal sealed class HeadlessProcessHost : IDisposable
Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory,
];
var vtankProfiles = new AcDream.Headless.Plugins.FilePluginStorage(
paths.VtankProfilesDirectory);
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate
@ -110,7 +112,8 @@ internal sealed class HeadlessProcessHost : IDisposable
timeProvider,
contentLease: contentLease,
gateCoordinator: gateCoordinator,
pluginRoots: pluginRoots));
pluginRoots: pluginRoots,
vtankProfiles: vtankProfiles));
}
catch
{

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;
@ -266,7 +267,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 +369,8 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins,
pluginCommands);
pluginCommands,
vtankProfiles);
var liveSession = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(

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

@ -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,5 +24,6 @@
</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

@ -243,11 +243,11 @@ internal sealed class CombatController
_untilScan -= Math.Max(0d, elapsedSeconds);
if (_untilScan <= 0d)
{
float acquisitionRange = navigationEnabled
double acquisitionRange = navigationEnabled
? Math.Max(_settings.MaximumRange, _settings.ApproachDistance)
: _settings.MaximumRange;
_targets = _host.Automation.Combat.CaptureHostileTargets(
acquisitionRange);
(float)acquisitionRange);
foreach (uint ghost in _failures.ObserveTargets(
_targets,
_now,
@ -588,7 +588,7 @@ internal sealed class CombatController
float maximumRange = spell.BaseRangeConstant
+ (spell.BaseRangeModifier * skill.Current)
- _settings.SpellRangeFudge;
- (float)_settings.SpellRangeFudge;
return maximumRange <= 0f
|| target.ObjectId == 0u
|| target.Distance <= MathF.Min(75f, maximumRange);
@ -1087,15 +1087,15 @@ internal sealed class CombatController
targetObjectId,
kind,
height,
_settings.CollisionProjectileRadius,
_settings.CollisionStepDistance,
(float)_settings.CollisionProjectileRadius,
(float)_settings.CollisionStepDistance,
_settings.MaximumCollisionChecksPerTick)
: _host.Automation.Projectiles.EvaluatePath(
targetObjectId,
kind,
height,
_settings.CollisionProjectileRadius,
_settings.CollisionStepDistance,
(float)_settings.CollisionProjectileRadius,
(float)_settings.CollisionStepDistance,
_settings.MaximumCollisionChecksPerTick);
if (_settings.ShowCollisionDebug && result.DebugSamples.Count > 0)
{

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;

View file

@ -89,8 +89,10 @@ internal sealed class LootSettings
public bool CombineSalvage { get; set; } = true;
public int ManaStoneLootCount { get; set; } = 4;
public int ManaTankMinimumMana { get; set; } = 1000;
public float CorpseApproachRange { get; set; } = 40f;
public float CorpseMinimumApproachRange { get; set; } = 3.36f;
// Declared tDouble in VTank's own Settings table (item I, slice-1 fix
// round) — see CombatSettings.MaximumRange's doc comment for why.
public double CorpseApproachRange { get; set; } = 40d;
public double CorpseMinimumApproachRange { get; set; } = 3.36d;
public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d;
public double CorpseItemAppearanceTimeoutSeconds { get; set; } = 6d;
public double CorpseItemIdentifyTimeoutSeconds { get; set; } = 60d;
@ -524,7 +526,7 @@ internal sealed class LootController
_scanRemaining = Math.Clamp(_settings.ScanIntervalSeconds, 0.05d, 5d);
IReadOnlyList<PluginLootContainer> corpses = loot.CaptureCorpses(
Math.Clamp(_settings.CorpseApproachRange, 2f, 100f));
(float)Math.Clamp(_settings.CorpseApproachRange, 2d, 100d));
PruneCorpseCache();
foreach (PluginLootContainer seen in corpses)
_corpseFirstSeen.TryAdd(seen.ObjectId, _lifetime);

View file

@ -77,6 +77,17 @@ internal sealed class MetaAction
public double Number { get; set; }
public double SecondaryNumber { get; set; }
public List<MetaAction> Children { get; set; } = [];
/// <summary>
/// The parsed route for a <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
/// action. Replaces a prior port's binary "uTank2 NAV 1.2" text blob
/// carried in <see cref="Text"/>: both the <c>.af</c> importer
/// (<c>MetafSerializer.ResolveEmbeddedNavs</c>) and the <c>.met</c>
/// importer (<c>VtankMetaProfileSerializer.ReadEmbeddedNavigation</c>)
/// now produce this typed model directly, and
/// <c>MetaEngine.LoadEmbeddedNavigationRoute</c> consumes it without a
/// re-parse. Null only for an unresolved/never-defined tag.
/// </summary>
public NavigationSettings? EmbeddedRoute { get; set; }
}
internal sealed class MetaRule
@ -102,7 +113,7 @@ internal sealed class MetaServices
static () => double.PositiveInfinity;
public Func<int, double, int> CountMonstersByPriority { get; init; } =
static (_, _) => 0;
public Action<string> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Action<NavigationSettings?> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Func<string, ExpressionValue> GetOption { get; init; } =
static _ => ExpressionValue.Zero;
public Func<string, ExpressionValue, bool> SetOption { get; init; } =
@ -379,7 +390,7 @@ internal sealed class MetaEngine
}
return true;
case MetaActionKind.LoadEmbeddedNavigationRoute:
_services.LoadEmbeddedNavigationRoute(action.Text);
_services.LoadEmbeddedNavigationRoute(action.EmbeddedRoute);
return true;
case MetaActionKind.CallMetaState:
if (_callStack.Count >= MaximumCallDepth)

File diff suppressed because it is too large Load diff

View file

@ -241,10 +241,7 @@ internal sealed partial class MossTankPanel
if (_profiles.Create(
name,
copyCurrent: true,
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_allSettings,
_noBuffItemNames,
out string notice))
{
@ -271,7 +268,17 @@ internal sealed partial class MossTankPanel
WriteVtank("Usage: /vt nav [save/load] [filename]");
return;
}
name = StripExtension(name, ".nav");
// Item J (slice-1 fix round): .af is the only VTank-compatible
// storage format now (Campaign VT slice 1 Part A); ".nav" is
// stripped for legacy-typed names, ".af" for names copy-pasted
// from a real .af file (the VtankProfiles directory, or an
// imports/ drop-in for TryImportLegacy below) — without both,
// "/vt nav save Foo.af" would have produced a doubled "Foo.af.af"
// file. Round 3 item 12: this comment previously cited an
// "exports/nav/" mirror directory that no longer exists — route
// profiles have written directly to their real .af file since the
// round 2 step 2-3 cutover, with no separate export copy.
name = StripExtension(name, ".nav", ".af");
if (operation == "save")
{
_routeProfiles.Create(
@ -353,7 +360,9 @@ internal sealed partial class MossTankPanel
WriteVtank("Usage: /vt meta [save/load] [filename]");
return;
}
name = StripExtension(name, ".met", ".json");
// Item J (slice-1 fix round): .af is the only VTank-compatible
// storage format now — see the analogous nav-command comment above.
name = StripExtension(name, ".met", ".json", ".af");
if (operation == "save")
{
_metaProfiles.Create(
@ -420,19 +429,28 @@ internal sealed partial class MossTankPanel
WriteVtank("Option set: Invalid option specified.");
return;
}
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value))
canonical = VtankOptionCatalog.Canonical(name);
VtankSettingValueType declaredType = VtankOptionCatalog.DeclaredType(canonical);
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, declaredType, out ExpressionValue value))
{
WriteVtank("Option set: Invalid value specified.");
// Round 3 item 6: retail's exact failure text
// (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612)
// names the option and the CLR type its Settings row
// actually declares (gy's own m_a, ToString()'d).
WriteVtank(
"Option set: Invalid value specified. Proper type of "
+ canonical + " is " + DeclaredClrTypeName(declaredType) + ".");
return;
}
canonical = VtankOptionCatalog.Canonical(name);
SetMetaOption(canonical, value);
if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase))
{
int count = _profiles.SetOptionInAll(
canonical,
ToMonsterValue(value));
WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}");
// Round 3 item 6: retail's exact bk.a text
// (refs/vtank/decompiled/bk.cs:32) — no "= value" suffix,
// and the count is every .usd file scanned, not just the
// ones that already had a row for this name.
int count = _profiles.SetOptionInAll(canonical, _allSettings);
WriteVtank($"Done saving setting {canonical} to all profiles. (Changed {count} profiles)");
}
else
{
@ -754,9 +772,9 @@ internal sealed partial class MossTankPanel
private void TestPet()
{
IReadOnlyList<PluginCombatTarget> targets = _host.Automation.Combat
.CaptureHostileTargets(_combatSettings.PetRangeMode == PetRangeMode.Custom
.CaptureHostileTargets((float)(_combatSettings.PetRangeMode == PetRangeMode.Custom
? _combatSettings.PetCustomRange
: _combatSettings.MaximumRange);
: _combatSettings.MaximumRange));
PetAutomationChoice choice = PetAutomation.Select(
_host.Automation.Items.CaptureOwnedItems(),
targets,
@ -962,22 +980,107 @@ internal sealed partial class MossTankPanel
return result;
}
private static bool TryParseOptionValue(string source, out ExpressionValue value)
/// <summary>
/// Free-form parse (no catalog type to check against) for callers like
/// the Advanced Options editor, which can target names outside the 137-
/// row catalog: best-effort bool, then number, then string.
/// </summary>
private static bool TryParseOptionValue(string source, out ExpressionValue value) =>
TryParseOptionValue(source, declaredType: null, out value);
/// <summary>
/// Round 3 item 6: <c>/vt opt set</c>/<c>setinall</c> validate the typed
/// value against the catalog's OWN declared <see cref="VtankSettingValueType"/>
/// (retail's <c>eSettingValueType</c>) rather than accepting anything —
/// a value that does not parse as that type fails with retail's exact
/// "Proper type of X is Y." text (<see cref="HandleOptionCommand"/>).
/// <paramref name="declaredType"/> is <see langword="null"/> only for
/// the free-form Advanced Options editor, which keeps the original lax
/// best-effort behavior.
/// </summary>
private static bool TryParseOptionValue(
string source,
VtankSettingValueType? declaredType,
out ExpressionValue value)
{
if (bool.TryParse(source, out bool boolean))
if (source.Length == 0)
{
value = ExpressionValue.Boolean(boolean);
return true;
value = default;
return false;
}
if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
switch (declaredType)
{
value = ExpressionValue.Number(number);
return true;
case VtankSettingValueType.Bool:
if (bool.TryParse(source, out bool boolean))
{
value = ExpressionValue.Boolean(boolean);
return true;
}
value = default;
return false;
case VtankSettingValueType.Int:
case VtankSettingValueType.Enum:
if (int.TryParse(
source, NumberStyles.Integer, CultureInfo.InvariantCulture, out int integer))
{
value = ExpressionValue.Number(integer);
return true;
}
value = default;
return false;
case VtankSettingValueType.Double:
case VtankSettingValueType.Single:
if (double.TryParse(
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
{
value = ExpressionValue.Number(number);
return true;
}
value = default;
return false;
case VtankSettingValueType.String:
value = ExpressionValue.String(source);
return true;
default:
// No declared type (free-form editor) or VtankSettingValueType.Custom
// (the one non-scalar row, RechargeHandlerSet, never reached
// through /vt opt set): keep the original lax parse.
if (bool.TryParse(source, out bool freeBoolean))
{
value = ExpressionValue.Boolean(freeBoolean);
return true;
}
if (double.TryParse(
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double freeNumber))
{
value = ExpressionValue.Number(freeNumber);
return true;
}
value = ExpressionValue.String(source);
return true;
}
value = ExpressionValue.String(source);
return source.Length != 0;
}
/// <summary>
/// The CLR type name retail's exact failure text names
/// (<c>refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612</c>:
/// <c>cw2[1].b().ToString()</c> — the Settings row's underlying
/// <c>gy</c> cell type, <see cref="Type.ToString"/>'d). Retail stores an
/// Enum-declared row as a plain int cell (confirmed against the
/// UseArcs row in the shipped defaultsettings.usd fixture), so it also
/// reads "System.Int32".
/// </summary>
private static string DeclaredClrTypeName(VtankSettingValueType type) => type switch
{
VtankSettingValueType.Bool => "System.Boolean",
VtankSettingValueType.Double => "System.Double",
VtankSettingValueType.Int => "System.Int32",
VtankSettingValueType.Single => "System.Single",
VtankSettingValueType.String => "System.String",
VtankSettingValueType.Enum => "System.Int32",
_ => "System.Object",
};
private bool TryParseCoordinates(string source, out PluginNavigationPosition position)
{
position = default;

View file

@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -6,13 +7,32 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank loot-profile lifecycle. Macro settings select this
/// profile by character, but its ordered rules live in their own document.
/// VTank-compatible loot profile lifecycle. <c>.utl</c> (VTClassic's real
/// public format, <see cref="VtankLootProfileSerializer"/>) is the ONLY
/// storage/authoring format — round 3 item 9 cutover, matching the
/// Settings/.usd, Route/.af, and Meta/.af cutovers already landed. Real
/// VTank's own loot picker (<c>aa()</c>, <c>uTank2/PluginCore.cs:7127-7154</c>)
/// seeds ONLY <c>[None]</c> — there is no per-character auto loot file and
/// no "mine only" filter for loot at all
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3).
/// MossTank keeps its own established <see cref="ByCharacter"/> convention
/// (already load-bearing for the other three stores) as a recorded
/// adaptation rather than a retail behavior.
/// </summary>
internal sealed class MossTankLootProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/loot/index.json";
// The pre-cutover roster (round 3 item 9): before this cutover, this
// key was the LIVE index (Names + SelectedByCharacter) — unlike Meta/
// Route's already-abandoned pre-round-2 rosters, loot's roster was
// still actively read/written until this exact commit. Read-only now,
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded (which also
// converts the SELECTED profile, so there is no separate
// MigrateLegacyIfNeeded here the way Settings/Meta/Route each have —
// the sweep already covers every named profile, selected or not).
private const string LegacyRosterKey = "profiles/loot/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
@ -20,62 +40,89 @@ internal sealed class MossTankLootProfileStore
};
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
private bool _rosterSwept;
public MossTankLootProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
/// <summary>
/// The bare name a user typed to select/create this profile (the file
/// name minus its <c>.utl</c> extension) — internal identity
/// (<see cref="_selected"/>) always carries the real, on-disk file
/// name; only display strips it, matching
/// <see cref="MossTankMetaProfileStore.Selected"/>'s own convention.
/// </summary>
public string Selected => StripUtl(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private static string StripUtl(string name) => name.Equals(
ByCharacter, StringComparison.OrdinalIgnoreCase)
? name
: name.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
? name[..^4]
: name;
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListLootProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/MossTank's "[By char]" sentinels.
names.Add(StripUtl(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
string normalized = string.IsNullOrWhiteSpace(characterName)
? string.Empty
: characterName.Trim();
if (string.Equals(
normalized,
_characterName,
StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
}
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
: null;
_selected = binding is { LootFileName.Length: > 0 } bound
? bound.LootFileName
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
WriteBinding();
return true;
}
string candidate = ToFileName(normalized);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
{
_selected = candidate;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -97,32 +144,17 @@ internal sealed class MossTankLootProfileStore
return false;
}
LootProfileDocument? currentDocument = copyCurrent
? Read<LootProfileDocument>(CurrentKey())
: null;
var document = new LootProfileDocument
string fileName = ToFileName(normalized);
var profile = new VtankLootProfile
{
Rules = copyCurrent
? current.Select(LootRuleDocument.From).ToArray()
: [],
Rules = copyCurrent ? current.ToList() : [],
SalvageCombine = copyCurrent
? (settings?.SalvageCombine.Clone()
?? currentDocument?.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings())
? settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings()
: new VtankSalvageCombineSettings(),
UnknownBlocks = copyCurrent
? currentDocument?.UnknownBlocks ?? []
: [],
};
Write(ProfileKey(normalized, byCharacter: false), document);
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, document);
WriteUtl(fileName, profile);
_selected = fileName;
WriteBinding();
notice = copyCurrent
? $"Copied loot rules to {_selected}."
: $"Created loot profile {_selected}.";
@ -133,18 +165,23 @@ internal sealed class MossTankLootProfileStore
public bool LoadCurrent(List<LootRule> target, LootSettings? settings = null)
{
ArgumentNullException.ThrowIfNull(target);
LootProfileDocument? document = Read<LootProfileDocument>(CurrentKey());
if (document is null)
SweepLegacyRosterIfNeeded();
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return false;
target.Clear();
foreach (LootRuleDocument rule in document.Rules ?? [])
target.Add(rule.ToRule());
if (settings is not null)
if (!VtankLootProfileSerializer.TryRead(text, out VtankLootProfile profile, out string error))
{
settings.SalvageCombine =
document.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings();
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "loot", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return false;
}
ApplyMossTankExpressions(profile);
target.Clear();
target.AddRange(profile.Rules);
if (settings is not null)
settings.SalvageCombine = profile.SalvageCombine.Clone();
return true;
}
@ -160,20 +197,22 @@ internal sealed class MossTankLootProfileStore
string normalized = name?.Trim() ?? string.Empty;
if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^4];
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
string canonical = CanonicalName(normalized);
string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(canonical, byCharacter: false);
LootProfileDocument? document = Read<LootProfileDocument>(key);
if (document is null)
string fileName = normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? CurrentFileName()
: ToFileName(normalized);
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null || !VtankLootProfileSerializer.TryRead(
text, out VtankLootProfile profile, out _))
{
return false;
}
ApplyMossTankExpressions(profile);
target.Clear();
foreach (LootRuleDocument rule in document.Rules ?? [])
target.Add(rule.ToRule());
target.AddRange(profile.Rules);
return true;
}
@ -181,17 +220,45 @@ internal sealed class MossTankLootProfileStore
IReadOnlyList<LootRule> rules,
LootSettings? settings = null)
{
LootProfileDocument? existing = Read<LootProfileDocument>(CurrentKey());
var document = new LootProfileDocument
string fileName = CurrentFileName();
var profile = new VtankLootProfile
{
Rules = rules.Select(LootRuleDocument.From).ToArray(),
SalvageCombine = settings?.SalvageCombine.Clone()
?? existing?.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings(),
UnknownBlocks = existing?.UnknownBlocks ?? [],
Rules = rules.ToList(),
SalvageCombine = settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings(),
};
Write(CurrentKey(), document);
WriteLegacyExport(LegacyProfileName(), document);
// Preserve UnknownBlocks/SourceVersion from whatever is already on
// disk (a drop-in real .utl may carry blocks MossTank does not
// understand yet) rather than discarding them on every save.
string? existingText = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (existingText is not null
&& VtankLootProfileSerializer.TryRead(existingText, out VtankLootProfile existing, out _))
{
profile.UnknownBlocks = existing.UnknownBlocks;
}
WriteUtl(fileName, profile);
}
/// <summary>
/// Round 3 item 10: deletes the currently selected named loot profile's
/// real <c>.utl</c> file, then falls back to <see cref="ByCharacter"/>.
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
/// </summary>
public bool Delete(out string notice)
{
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in loot profile and cannot be deleted.";
return false;
}
string fileName = _selected;
if (VtankStorage.IsAvailable)
VtankStorage.Delete(fileName);
_selected = ByCharacter;
WriteBinding();
notice = $"Deleted loot profile {StripUtl(fileName)}.";
return true;
}
public void ClearCurrent(List<LootRule> target, LootSettings? settings = null)
@ -239,47 +306,271 @@ internal sealed class MossTankLootProfileStore
return false;
}
var document = new LootProfileDocument
{
Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(),
SalvageCombine = imported.SalvageCombine.Clone(),
UnknownBlocks = imported.UnknownBlocks.Select(
VtankLootExtraBlockDocument.From).ToArray(),
};
Write(ProfileKey(normalized, byCharacter: false), document);
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
string fileName = ToFileName(normalized);
WriteUtl(fileName, imported);
_selected = fileName;
WriteBinding();
target.Clear();
target.AddRange(imported.Rules);
if (settings is not null)
settings.SalvageCombine = imported.SalvageCombine.Clone();
WriteLegacyExport(_selected, document);
notice = $"Imported VTClassic loot profile {_selected}.";
return true;
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
// ------------------------------------------------------------------
// Legacy JSON -> .utl migration (round 3 item 9).
// ------------------------------------------------------------------
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Round 3 item 9: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// Settings/Meta/Route's own per-selection migration (which only ever
/// converted whichever ONE profile happened to be selected), this
/// single pass converts EVERY named profile the roster lists AND this
/// character's own "By char" JSON document, since the roster was still
/// the live mechanism right up to this cutover — there is no separate
/// "already migrated one, sweep the rest" split here.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
// This character's own "By char" JSON document, if not already a
// real .utl file.
string byCharacterFileName = CurrentFileName();
if (VtankStorage.ReadText(byCharacterFileName) is null)
{
LootProfileDocument? byCharacter = ReadLegacyJson(
ProfileKey(_characterName, byCharacter: true));
if (byCharacter is not null)
{
WriteUtl(byCharacterFileName, byCharacter.ToVtankProfile());
_host.Storage.Delete(ProfileKey(_characterName, byCharacter: true));
}
}
LegacyRosterDocument? roster = ReadLegacyRoster();
if (roster?.Names is not { Count: > 0 } names)
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = ProfileKey(name, byCharacter: false);
LootProfileDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = ToFileName(name);
if (VtankStorage.ReadText(fileName) is null)
WriteUtl(fileName, legacy.ToVtankProfile());
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, Options));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named loot profile(s) from the old roster.");
}
private LegacyRosterDocument? ReadLegacyRoster()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "loot", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private LootProfileDocument? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LootProfileDocument>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "loot", key, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "utl")
: _selected;
private static string ToFileName(string bareName) =>
bareName.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
? bareName
: bareName + ".utl";
private void WriteBinding()
{
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, CurrentFileName(), string.Empty, null);
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_characterName,
Server,
existing with { LootFileName = CurrentFileName() });
}
private void WriteUtl(string fileName, VtankLootProfile profile)
{
if (!VtankStorage.IsAvailable)
return;
AttachMossTankExpressions(profile);
try
{
VtankStorage.WriteText(fileName, VtankLootProfileSerializer.Write(profile));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
}
}
// ------------------------------------------------------------------
// MossTank rule-expression preservation (round 3 item 9): VTClassic's
// real .utl requirement grammar cannot express an arbitrary MossTank
// Expression string at all (VtankLootProfileSerializer.ExportRequirements
// replaces an empty VtankRequirements list with a "safely disabled"
// placeholder requirement for VTClassic compatibility) — fine when
// .utl was only ever a courtesy export mirror alongside the
// authoritative JSON store, but silently DESTRUCTIVE now that .utl is
// the SOLE store: every MossTank-authored rule's Expression would be
// permanently lost on its very first save/reload cycle. Preserved
// instead in a MossTank-owned length-delimited block
// (VtankLootProfileSerializer's own UnknownBlocks mechanism already
// round-trips any block type it doesn't recognize as "SalvageCombine"
// verbatim — exactly the "a foreign reader keeps what it doesn't
// understand" contract this needs) so a real VTClassic reading the
// same file simply ignores it as an unknown block, the same as any
// other extension block.
// ------------------------------------------------------------------
private const string MossTankRulesBlockType = "MossTankRuleExpressions";
private static void AttachMossTankExpressions(VtankLootProfile profile)
{
profile.UnknownBlocks.RemoveAll(static block =>
string.Equals(block.Type, MossTankRulesBlockType, StringComparison.Ordinal));
var payload = new StringBuilder();
payload.Append(profile.Rules.Count.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
foreach (LootRule rule in profile.Rules)
{
// A rule with real VtankRequirements (imported from a genuine
// VTClassic file, never touched by MossTank's own editor) has
// nothing of ours to preserve — record an empty slot so load
// leaves its VtankRequirements-derived state alone.
string expression = rule.VtankRequirements.Count > 0 ? string.Empty : rule.Expression;
payload.Append(expression.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
payload.Append(expression);
}
profile.UnknownBlocks.Add(new VtankLootExtraBlock
{
Type = MossTankRulesBlockType,
Payload = payload.ToString(),
});
}
private static void ApplyMossTankExpressions(VtankLootProfile profile)
{
VtankLootExtraBlock? block = profile.UnknownBlocks.FirstOrDefault(candidate =>
string.Equals(candidate.Type, MossTankRulesBlockType, StringComparison.Ordinal));
if (block is null)
return;
profile.UnknownBlocks.Remove(block);
string payload = block.Payload ?? string.Empty;
int position = 0;
if (!TryReadPayloadLine(payload, ref position, out string countText)
|| !int.TryParse(countText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count))
{
return; // Corrupt/foreign block reusing our type name; ignore rather than throw.
}
int limit = Math.Min(count, profile.Rules.Count);
for (int index = 0; index < limit; index++)
{
if (!TryReadPayloadLine(payload, ref position, out string lengthText)
|| !int.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int length)
|| length < 0
|| length > payload.Length - position)
{
return; // Truncated/corrupt; stop applying rather than throw.
}
string expression = payload.Substring(position, length);
position += length;
if (expression.Length == 0)
continue;
profile.Rules[index].Expression = expression;
profile.Rules[index].VtankRequirements.Clear();
}
}
private static bool TryReadPayloadLine(string text, ref int position, out string line)
{
if (position > text.Length)
{
line = string.Empty;
return false;
}
int start = position;
while (position < text.Length && text[position] is not ('\r' or '\n'))
position++;
line = text[start..position];
if (position < text.Length && text[position] == '\r')
position++;
if (position < text.Length && text[position] == '\n')
position++;
return true;
}
private static string ProfileKey(string value, bool byCharacter)
{
@ -290,90 +581,13 @@ internal sealed class MossTankLootProfileStore
return $"profiles/loot/{hash}.json";
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
// ------------------------------------------------------------------
// Migration-only shapes: the OLD JSON roster and per-profile document,
// kept solely so SweepLegacyRosterIfNeeded can recover them once.
// Nothing else in this file writes either shape again.
// ------------------------------------------------------------------
private T? Read<T>(string key) where T : class
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"loot",
key,
json,
error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
}
}
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, LootProfileDocument document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.utl",
VtankLootProfileSerializer.Write(document.ToVtankProfile()));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTClassic loot export could not be saved: {error.Message}");
}
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected;
private static string LegacyFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Loot" : result.ToString();
}
private sealed class IndexDocument
private sealed class LegacyRosterDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];

View file

@ -5,47 +5,76 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Independent VTank-style By-char/named Meta profile lifetime.</summary>
/// <summary>
/// VTank-compatible Meta profile lifecycle. <c>.af</c> (metaf's human-
/// readable grammar, see <see cref="MetafSerializer"/>) is the ONLY storage
/// and authoring format — real VTank's own <c>ac()</c> meta-profile picker
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3)
/// seeds <c>[None]</c>/<c>[By char]</c> then every non-<c>--</c> file: named
/// Meta profiles are plain shared files (unlike the Settings picker, there
/// is no per-character <c>[Char] suffix</c> sub-profile carve-out here),
/// while the per-character auto file uses VTank's <c>--Name_Server.af</c>
/// naming and is therefore hidden from that listing by construction.
/// </summary>
internal sealed class MossTankMetaProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/meta/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
// The pre-cutover roster (round 3 item 2): before the .af cutover this
// store kept a flat Names list here (unlike the settings roster, named
// Meta profiles were never owner-scoped — one shared, globally-hashed
// key per name). The cutover stopped reading this key at all, orphaning
// every named profile except whichever one a character had selected
// (that one alone still converts, via MigrateLegacyIfNeeded). Read-only:
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded.
private const string LegacyRosterKey = "profiles/meta/index.json";
private readonly IPluginHost _host;
private IndexDocument _index;
private string _character = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
public MossTankMetaProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? [],
StringComparer.OrdinalIgnoreCase);
}
private List<string> Names => _index.Names ??= [];
private Dictionary<string, string> SelectedByCharacter =>
_index.SelectedByCharacter ??= new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
public string Selected => _selected;
/// <summary>
/// The bare name a user typed to select/create this profile (the file
/// name minus its <c>.af</c> extension) — internal identity (<see cref="_selected"/>)
/// always carries the real, on-disk file name; only display strips it.
/// </summary>
public string Selected => StripAf(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
public string? SaveNotice { get; private set; }
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _character.Length > 0 && Server.Length > 0;
private static string StripAf(string name) => name.Equals(
ByCharacter, StringComparison.OrdinalIgnoreCase)
? name
: name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name[..^3]
: name;
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListMetaProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/"[By char]" sentinels.
names.Add(StripAf(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
@ -55,33 +84,106 @@ internal sealed class MossTankMetaProfileStore
if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase))
return false;
_character = normalized;
_selected = SelectedByCharacter.TryGetValue(
CharacterKey(),
out string? selected)
&& IsKnown(selected)
? Canonical(selected)
: ByCharacter;
_pendingLegacyBareName = null;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
: null;
_selected = binding is { MetaFileName.Length: > 0 } bound
? bound.MetaFileName
: ByCharacter;
return true;
}
public MetaProfile LoadCurrent() =>
Read<MetaProfile>(CurrentKey()) ?? new MetaProfile();
public void SaveCurrent(MetaProfile profile)
public MetaProfile LoadCurrent()
{
Write(CurrentKey(), profile);
WriteLegacyExport(LegacyProfileName(), profile);
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded();
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return new MetaProfile();
if (!MetafSerializer.TryLoadMeta(text, _host.Automation.Spells, out MetaProfile profile, out string error))
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "meta", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return new MetaProfile();
}
return profile;
}
/// <summary>
/// Saves <paramref name="profile"/> as this selection's <c>.af</c> file.
/// Real VTank/metaf has no way to represent <see cref="MetaRule.Enabled"/>
/// <see langword="false"/> (a MossTank-only extension) — rather than
/// silently dropping a disabled rule, the save is refused entirely (the
/// file on disk keeps its last good content) and <see cref="SaveNotice"/>
/// carries a user-visible reason. Returns <see langword="true"/> only
/// when the file was actually written.
/// </summary>
public bool SaveCurrent(MetaProfile profile)
{
string fileName = CurrentFileName();
string text;
try
{
text = MetafSerializer.SaveMeta(profile);
}
catch (InvalidOperationException error)
{
SaveNotice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
_host.Log.Warn(SaveNotice);
return false;
}
if (!VtankStorage.IsAvailable)
return false;
try
{
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
SaveNotice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
_host.Log.Warn(SaveNotice);
return false;
}
SaveNotice = null;
return true;
}
public bool Select(string? name)
{
string normalized = Normalize(name);
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
_selected = Canonical(normalized);
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
{
_selected = plain;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
if (_host.Storage.IsAvailable
&& _host.Storage.ReadText(LegacyNamedKey(normalized)) is not null)
{
_selected = plain;
_pendingLegacyBareName = normalized;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -97,19 +199,18 @@ internal sealed class MossTankMetaProfileStore
notice = "Enter a unique Meta profile name (1-64 characters).";
return false;
}
MetaProfile document = copyCurrent
? Clone(current)
: new MetaProfile();
Write(NamedKey(normalized), document);
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = normalized;
SelectedByCharacter[CharacterKey()] = normalized;
SaveIndex();
WriteLegacyExport(normalized, document);
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
if (!SaveTo(fileName, document, out notice))
return false;
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied Meta profile to {normalized}."
: $"Created Meta profile {normalized}.";
? $"Copied Meta profile to {fileName}."
: $"Created Meta profile {fileName}.";
return true;
}
@ -139,20 +240,48 @@ internal sealed class MossTankMetaProfileStore
notice = $"VTank Meta file '{normalized}.met' was not found in imports.";
return false;
}
if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error))
if (!VtankMetaProfileSerializer.TryLoad(
source, _host.Automation.Spells, out profile, out string error))
{
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = Names.First(existing => existing.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
SaveCurrent(profile);
notice = $"Imported VTank Meta profile {_selected}.";
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
if (!SaveTo(fileName, profile, out string saveNotice))
{
notice = saveNotice;
return false;
}
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank Meta profile {fileName}.";
return true;
}
/// <summary>
/// Round 3 item 10: deletes the currently selected named Meta profile's
/// real <c>.af</c> file, then falls back to <see cref="ByCharacter"/>.
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
/// </summary>
public bool Delete(out string notice)
{
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in Meta profile and cannot be deleted.";
return false;
}
string fileName = _selected;
if (VtankStorage.IsAvailable)
VtankStorage.Delete(fileName);
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Deleted Meta profile {fileName}.";
return true;
}
@ -163,124 +292,237 @@ internal sealed class MossTankMetaProfileStore
return empty;
}
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? $"profiles/meta/by-character/{Hash(_character)}.json"
: NamedKey(_selected);
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
private static string NamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private string CharacterKey() =>
string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character;
private bool IsKnown(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
|| Names.Contains(name, StringComparer.OrdinalIgnoreCase);
private string Canonical(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: Names.First(existing => existing.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, MetaProfile profile)
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected profile — this converts every OTHER named
/// Meta profile the old roster still lists. Named Meta profiles were
/// never owner-scoped (one shared, globally-hashed key per name), so —
/// unlike the settings roster — every entry converts regardless of
/// which character is currently bound.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
try
List<string>? names = ReadRosterNames();
if (names is not { Count: > 0 })
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = LegacyNamedKey(name);
MetaProfile? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name
: name + ".af";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
if (!SaveTo(fileName, legacy, out string notice))
{
// Representational loss (a disabled rule) — same gate as
// MigrateLegacyIfNeeded: keep the row so this can be
// retried once the user resolves it.
_host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}");
remaining.Add(name);
continue;
}
}
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.met",
VtankMetaProfileSerializer.Save(profile));
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster.");
}
private List<string>? ReadRosterNames()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank Meta export could not be saved: {error.Message}");
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "meta", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character
: _selected;
private static string LegacyFileName(string name)
private void MigrateLegacyIfNeeded()
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
string fileName = CurrentFileName();
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
_pendingLegacyBareName = null;
return;
}
return result.Length == 0 ? "Meta" : result.ToString();
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
? LegacyNamedKey(bareName)
: LegacyByCharacterKey();
MetaProfile? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
{
_pendingLegacyBareName = null;
return;
}
if (!SaveTo(fileName, legacy, out string notice))
{
// Representational loss (a disabled rule) blocks the migration
// outright rather than silently dropping it: the legacy JSON
// stays put (still fully readable) until the user resolves it.
_host.Log.Warn($"MossTank could not migrate legacy Meta profile: {notice}");
_pendingLegacyBareName = null;
return;
}
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
_pendingLegacyBareName = null;
_host.Log.Warn($"Migrated legacy MossTank Meta profile '{legacyKey}' to '{fileName}'.");
}
private T? Read<T>(string key)
private MetaProfile? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return default;
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? default
: JsonSerializer.Deserialize<T>(json, Options);
? null
: JsonSerializer.Deserialize<MetaProfile>(json, JsonOptions);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"meta",
key,
json,
error);
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "meta", key, json, error);
_host.Log.Error(RecoveryNotice, error);
return default;
return null;
}
}
private void Write<T>(string key, T value)
private bool SaveTo(string fileName, MetaProfile profile, out string notice)
{
if (!_host.Storage.IsAvailable)
return;
string text;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options));
text = MetafSerializer.SaveMeta(profile);
}
catch (InvalidOperationException error)
{
notice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
SaveNotice = notice;
_host.Log.Warn(notice);
return false;
}
if (!VtankStorage.IsAvailable)
{
notice = "VTank Meta storage is unavailable.";
return false;
}
try
{
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
_host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error);
notice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
SaveNotice = notice;
_host.Log.Warn(notice);
return false;
}
SaveNotice = null;
notice = string.Empty;
return true;
}
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
: _selected;
private void WriteBinding()
{
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, string.Empty, string.Empty, CurrentFileName());
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_character,
Server,
existing with { MetaFileName = CurrentFileName() });
}
private static MetaProfile Clone(MetaProfile profile) =>
JsonSerializer.Deserialize<MetaProfile>(
JsonSerializer.Serialize(profile, Options),
Options) ?? new MetaProfile();
JsonSerializer.Serialize(profile, JsonOptions),
JsonOptions) ?? new MetaProfile();
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
private string LegacyByCharacterKey() =>
$"profiles/meta/by-character/{Hash(_character)}.json";
private static string LegacyNamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private static string Hash(string value)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant();
}
private sealed class IndexDocument
private static readonly JsonSerializerOptions JsonOptions = new()
{
public List<string>? Names { get; set; } = [];
public Dictionary<string, string>? SelectedByCharacter { get; set; } = [];
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once.
private sealed class LegacyRosterDocument
{
public List<string> Names { get; set; } = [];
}
}

View file

@ -48,6 +48,14 @@ internal sealed partial class MossTankPanel
private readonly CombatSettings _combatSettings = new();
private readonly InventorySettings _inventorySettings = new();
private readonly NavigationSettings _navigationSettings = new();
/// <summary>
/// One stable bundle of every live settings object the VTank-catalog
/// Settings profile (<c>.usd</c>) covers, built once so
/// <see cref="MossTankProfileStore"/>'s Load/Save/Create/ClearCurrent/
/// SetOptionInAll surface never has to repeat five constructor
/// parameters at every call site.
/// </summary>
private readonly VtankSettingsProfileSerializer.AllSettings _allSettings;
private readonly MossTankProfileStore _profiles;
private readonly MossTankLootProfileStore _lootProfiles;
private readonly MossTankRouteProfileStore _routeProfiles;
@ -182,14 +190,17 @@ internal sealed partial class MossTankPanel
{
_host = host;
_firstRunGuidancePending = NeedsFirstRunGuidance(host);
_allSettings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = _combatSettings,
Buffs = _buffSettings,
Vitals = _vitalSettings,
Inventory = _inventorySettings,
Navigation = _navigationSettings,
};
_profiles = new MossTankProfileStore(host);
_profiles.BindCharacter(host.Automation.Character.Name);
_profiles.LoadCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
_lootProfiles = new MossTankLootProfileStore(host);
_lootProfiles.BindCharacter(host.Automation.Character.Name);
if (!_lootProfiles.LoadCurrent(
@ -202,7 +213,7 @@ internal sealed partial class MossTankPanel
}
_routeProfiles = new MossTankRouteProfileStore(host);
_routeProfiles.BindCharacter(host.Automation.Character.Name);
if (!_routeProfiles.LoadCurrent(_navigationSettings))
if (!_routeProfiles.LoadCurrent(_navigationSettings, host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
_combat = new CombatController(host, _combatSettings, _vitalSettings);
_buffCasterPreparer = new BuffCasterPreparer(
@ -583,6 +594,7 @@ internal sealed partial class MossTankPanel
public Action CopyLootProfile => () =>
CreateLootProfileCore(copyCurrent: true);
public Action ClearLootProfile => ClearLootProfileCore;
public Action DeleteLootProfile => DeleteLootProfileCore;
public Action CloseLootEditor => () => _lootEditorVisible = false;
public Action<int> SelectLootRule => SelectLootRuleCore;
public Action<string> SetLootExpressionDraft => value =>
@ -745,6 +757,7 @@ internal sealed partial class MossTankPanel
public Action CopyRouteProfile => () =>
CreateRouteProfileCore(copyCurrent: true);
public Action ClearRouteProfile => ClearRouteProfileCore;
public Action DeleteRouteProfile => DeleteRouteProfileCore;
public Action SetFollowTarget => CaptureFollowTarget;
// ── Meta profile / editor ────────────────────────────────────────────
@ -815,6 +828,7 @@ internal sealed partial class MossTankPanel
public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false);
public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true);
public Action ClearMetaProfile => ClearMetaProfileCore;
public Action DeleteMetaProfile => DeleteMetaProfileCore;
// ── Profiles tab ─────────────────────────────────────────────────────
public IReadOnlyList<string> MacroProfileNames => _profiles.AvailableNames;
@ -839,6 +853,7 @@ internal sealed partial class MossTankPanel
public Action CreateProfile => () => CreateProfileCore(copyCurrent: false);
public Action CopyProfile => () => CreateProfileCore(copyCurrent: true);
public Action ClearProfile => ClearProfileCore;
public Action DeleteProfile => DeleteProfileCore;
public Action ToggleMineOnly => () =>
{
string before = _profiles.Selected;
@ -1413,6 +1428,17 @@ internal sealed partial class MossTankPanel
_lootEditorNotice = $"Cleared {_lootProfiles.Selected}.";
}
private void DeleteLootProfileCore()
{
if (!_lootProfiles.Delete(out string notice))
{
_lootEditorNotice = notice;
return;
}
LoadLootProfile();
_lootEditorNotice = notice;
}
private void LoadLootProfile()
{
if (!_lootProfiles.LoadCurrent(
@ -1587,7 +1613,15 @@ internal sealed partial class MossTankPanel
AddRouteWaypoint(new RouteWaypoint
{
Type = type,
Position = target.Position,
// Retail's own header x/y/z for Portal2/UseNPC is dead weight —
// e9/fa extend `at`, whose position accessor is the player's
// OWN live position, never read back into anything meaningful
// (docs/research/vtank-kb/06-navigation-and-nav.md section 1.2).
// ReferencePosition is the embedded d-record: the real target
// coordinate matched against live world objects by name+class+
// proximity (Navigation.cs's TickUse).
Position = _host.Automation.Navigation.Snapshot.Position,
ReferencePosition = target.Position,
ObjectId = target.ObjectId,
ObjectName = target.Name,
});
@ -1764,9 +1798,20 @@ internal sealed partial class MossTankPanel
_routeNotice = $"Cleared {_routeProfiles.Selected}.";
}
private void DeleteRouteProfileCore()
{
if (!_routeProfiles.Delete(out string notice))
{
_routeNotice = notice;
return;
}
LoadRouteProfile();
_routeNotice = notice;
}
private void LoadRouteProfile()
{
if (!_routeProfiles.LoadCurrent(_navigationSettings))
if (!_routeProfiles.LoadCurrent(_navigationSettings, _host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
if (_initialized)
ApplyPersistedOptionOverrides();
@ -2225,9 +2270,9 @@ internal sealed partial class MossTankPanel
}
_metaProfile.Rules.Add(rule);
_selectedMetaRule = _metaProfile.Rules.Count - 1;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Added rule in {rule.State}.";
if (SaveMetaProfile())
_metaNotice = $"Added rule in {rule.State}.";
}
private void ApplyMetaRuleCore()
@ -2244,9 +2289,9 @@ internal sealed partial class MossTankPanel
}
replacement.Id = current.Id;
_metaProfile.Rules[_selectedMetaRule] = replacement;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Updated rule in {replacement.State}.";
if (SaveMetaProfile())
_metaNotice = $"Updated rule in {replacement.State}.";
}
private bool TryBuildMetaRule(out MetaRule rule, out string error)
@ -2301,9 +2346,9 @@ internal sealed partial class MossTankPanel
_selectedMetaRule = Math.Min(
_selectedMetaRule,
Math.Max(0, _metaProfile.Rules.Count - 1));
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Removed rule from {selected.State}.";
if (SaveMetaProfile())
_metaNotice = $"Removed rule from {selected.State}.";
}
private void MoveMetaRule(int direction)
@ -2318,9 +2363,9 @@ internal sealed partial class MossTankPanel
_metaProfile.Rules.RemoveAt(_selectedMetaRule);
_metaProfile.Rules.Insert(destination, rule);
_selectedMetaRule = destination;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Moved {rule.State} rule.";
if (SaveMetaProfile())
_metaNotice = $"Moved {rule.State} rule.";
}
private void SelectMetaProfileCore(string name)
@ -2360,6 +2405,17 @@ internal sealed partial class MossTankPanel
_metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}.";
}
private void DeleteMetaProfileCore()
{
if (!_metaProfiles.Delete(out string notice))
{
_metaNotice = notice;
return;
}
LoadMetaProfile();
_metaNotice = notice;
}
private void LoadMetaProfile()
{
_metaProfile = _metaProfiles.LoadCurrent();
@ -2370,7 +2426,22 @@ internal sealed partial class MossTankPanel
RefreshMetaEditor();
}
private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile);
/// <summary>
/// Saves the current Meta profile as <c>.af</c>. Returns
/// <see langword="false"/> when the save was refused (a disabled rule
/// has no metaf-compatible representation) — callers must not overwrite
/// <see cref="_metaNotice"/> with a generic success message in that
/// case, since <see cref="MossTankMetaProfileStore.SaveNotice"/> already
/// carries the user-visible reason.
/// </summary>
private bool SaveMetaProfile()
{
if (_metaProfiles.SaveCurrent(_metaProfile))
return true;
if (_metaProfiles.SaveNotice is { } notice)
_metaNotice = notice;
return false;
}
private static string DescribeMetaCondition(MetaCondition condition) =>
condition.Kind switch
@ -2408,19 +2479,16 @@ internal sealed partial class MossTankPanel
return nearest;
}
private void LoadEmbeddedNavigationRoute(string source)
private void LoadEmbeddedNavigationRoute(NavigationSettings? route)
{
_navigation.Reset();
if (!VtankNavRouteSerializer.TryLoad(
source,
_navigationSettings,
_host.Automation.Spells,
out string error))
if (route is null)
{
_routeNotice = $"Embedded route rejected: {error}";
_host.Log.Warn($"MossTank Meta embedded route rejected: {error}");
_routeNotice = "Embedded route rejected: unresolved Nav tag.";
_host.Log.Warn("MossTank Meta embedded route rejected: unresolved Nav tag.");
return;
}
VtankNavRouteSerializer.Apply(route, _navigationSettings);
_routeProfiles.SaveCurrent(_navigationSettings);
_selectedRouteWaypoint = 0;
RefreshRouteEditor();
@ -2528,7 +2596,7 @@ internal sealed partial class MossTankPanel
_combatSettings.DebuffPrecastSeconds),
"switchwandstodebuff" => ExpressionValue.Boolean(
_combatSettings.SwitchWandsToDebuff),
"usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs),
"usearcs" => ExpressionValue.Number((int)_combatSettings.UseArcs),
"deleteghostmonsters" => ExpressionValue.Boolean(
_combatSettings.DeleteGhostMonsters),
"ghostmonsterspellattemptcount" => ExpressionValue.Number(
@ -2998,7 +3066,8 @@ internal sealed partial class MossTankPanel
_combatSettings.SwitchWandsToDebuff = value.IsTruthy;
break;
case "usearcs":
_combatSettings.UseArcs = value.IsTruthy;
_combatSettings.UseArcs = (UseArcsMode)Math.Clamp(
value.AsInt32("UseArcs"), 1, 3);
break;
case "deleteghostmonsters":
_combatSettings.DeleteGhostMonsters = value.IsTruthy;
@ -3414,10 +3483,7 @@ internal sealed partial class MossTankPanel
if (!_profiles.Create(
_profileNameDraft,
copyCurrent,
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_allSettings,
_noBuffItemNames,
out string notice))
{
@ -3431,24 +3497,25 @@ internal sealed partial class MossTankPanel
private void ClearProfileCore()
{
_profiles.ClearCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.ClearCurrent(_allSettings, _noBuffItemNames);
_profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults.";
ResetProfileConsumers();
}
private void DeleteProfileCore()
{
if (!_profiles.Delete(out string notice))
{
_profileLifecycleNotice = notice;
return;
}
LoadSelectedProfile();
_profileLifecycleNotice = notice;
}
private void LoadSelectedProfile()
{
_profiles.LoadCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
LoadLootProfile();
LoadRouteProfile();
ApplyPersistedOptionOverrides();
@ -3532,12 +3599,7 @@ internal sealed partial class MossTankPanel
private void SaveProfile()
{
_profiles.SaveCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.SaveCurrent(_allSettings, _noBuffItemNames);
_lootProfiles.SaveCurrent(
_inventorySettings.Loot.Rules,
_inventorySettings.Loot);

File diff suppressed because it is too large Load diff

View file

@ -6,45 +6,86 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank navigation-profile lifecycle. The selected route is
/// remembered per character; "By char" is a private route document and named
/// profiles are reusable copies.
/// VTank-compatible navigation-route lifecycle. <c>.af</c> is the ONLY
/// storage/authoring format — but only for the fields metaf's own
/// <c>NAV:</c> grammar actually represents (<see cref="NavigationSettings.Mode"/>,
/// <see cref="NavigationSettings.Waypoints"/>,
/// <see cref="NavigationSettings.FollowTargetObjectId"/>/
/// <see cref="NavigationSettings.FollowTargetName"/> — see
/// <see cref="MetafSerializer.SaveNav"/>/<see cref="MetafSerializer.TryLoadNav"/>).
/// Every other <see cref="NavigationSettings"/> field
/// (Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/OpenDoors/
/// Door*) is a real VTank Settings-table row with its own catalog name
/// (<c>EnableNav</c>, <c>NavPriorityBoost</c>, <c>NavCloseStopRange</c>,
/// …) already owned end-to-end by <see cref="MossTankProfileStore"/>'s
/// <c>.usd</c> profile — this store never touches them, matching real
/// VTank's own split between the Settings-scoped nav prefs and the
/// per-route file.
///
/// Named route files carry a <c>nav_</c> prefix (metaf's own observed
/// convention for a stand-alone nav-only <c>.af</c>, see the committed
/// <c>nav_*.af</c> fixtures) so a route and a Meta profile sharing the same
/// user-typed name never collide in the shared <see cref="IPluginHost.VtankProfiles"/>
/// directory.
/// </summary>
internal sealed class MossTankRouteProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/route/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private const string NavPrefix = "nav_";
// The pre-cutover roster (round 3 item 2): before the .af cutover this
// store kept a flat Names list here (named routes were never
// owner-scoped — one shared, globally-hashed key per name). The
// cutover stopped reading this key at all, orphaning every named route
// except whichever one a character had selected (that one alone still
// converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by
// the one-time SweepLegacyRosterIfNeeded.
private const string LegacyRosterKey = "profiles/route/index.json";
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
public MossTankRouteProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
public string Selected => Strip(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
private static string Strip(string fileName)
{
if (fileName.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
return fileName;
string value = fileName;
if (value.StartsWith(NavPrefix, StringComparison.Ordinal))
value = value[NavPrefix.Length..];
if (value.EndsWith(".af", StringComparison.OrdinalIgnoreCase))
value = value[..^3];
return value;
}
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListNavigationProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/"[By char]" sentinels.
names.Add(Strip(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
@ -54,24 +95,46 @@ internal sealed class MossTankRouteProfileStore
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
_pendingLegacyBareName = null;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
: null;
_selected = binding is { NavFileName.Length: > 0 } bound
? bound.NavFileName
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
string normalized = Normalize(name);
if (normalized.Length == 0)
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
string candidate = ToFileName(normalized);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
{
_selected = candidate;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
if (_host.Storage.IsAvailable
&& _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null)
{
_selected = candidate;
_pendingLegacyBareName = normalized;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -80,7 +143,7 @@ internal sealed class MossTankRouteProfileStore
NavigationSettings current,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
string normalized = Normalize(name);
if (normalized.Length is < 1 or > 64)
{
notice = "Enter a route profile name (1-64 characters).";
@ -91,46 +154,40 @@ internal sealed class MossTankRouteProfileStore
notice = "'By char' is the built-in route profile.";
return false;
}
Write(
ProfileKey(normalized, byCharacter: false),
copyCurrent
? RouteDocument.Capture(current)
: new RouteDocument());
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings());
string fileName = ToFileName(normalized);
NavigationSettings source = copyCurrent ? current : new NavigationSettings();
WriteAf(fileName, MetafSerializer.SaveNav(source));
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied route to {_selected}."
: $"Created route profile {_selected}.";
? $"Copied route to {Strip(fileName)}."
: $"Created route profile {Strip(fileName)}.";
return true;
}
public bool LoadCurrent(NavigationSettings target)
public bool LoadCurrent(NavigationSettings target, ISpellCatalog spells)
{
ArgumentNullException.ThrowIfNull(target);
RouteDocument? document = Read<RouteDocument>(CurrentKey());
if (document is null)
ArgumentNullException.ThrowIfNull(spells);
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded(target, spells);
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return false;
document.Apply(target);
if (!MetafSerializer.TryLoadNav(text, target, spells, out string error))
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "route", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return false;
}
return true;
}
public void SaveCurrent(NavigationSettings settings)
{
Write(CurrentKey(), RouteDocument.Capture(settings));
WriteLegacyExport(
_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected,
settings);
}
public void SaveCurrent(NavigationSettings settings) =>
WriteAf(CurrentFileName(), MetafSerializer.SaveNav(settings));
public bool TryImportLegacy(
string? name,
@ -138,7 +195,7 @@ internal sealed class MossTankRouteProfileStore
ISpellCatalog spells,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
string normalized = Normalize(name);
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
notice = "Legacy navigation storage is unavailable.";
@ -162,64 +219,163 @@ internal sealed class MossTankRouteProfileStore
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
SaveCurrent(target);
notice = $"Imported VTank navigation profile {_selected}.";
string fileName = ToFileName(normalized);
WriteAf(fileName, MetafSerializer.SaveNav(target));
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank navigation profile {Strip(fileName)}.";
return true;
}
/// <summary>
/// Round 3 item 10: deletes the currently selected named route's real
/// <c>.af</c> file, then falls back to <see cref="ByCharacter"/>.
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
/// </summary>
public bool Delete(out string notice)
{
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in route profile and cannot be deleted.";
return false;
}
string fileName = _selected;
if (VtankStorage.IsAvailable)
VtankStorage.Delete(fileName);
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Deleted route profile {Strip(fileName)}.";
return true;
}
/// <summary>
/// Resets only the fields this store owns (Mode/Waypoints/FollowTarget)
/// — Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/
/// OpenDoors/Door* belong to the Settings profile and are left alone.
/// </summary>
public void ClearCurrent(NavigationSettings target)
{
target.Enabled = false;
target.Priority = false;
target.Mode = RouteMode.Circular;
target.MinimumDistanceMeters = 2d;
target.FollowTargetObjectId = 0u;
target.FollowTargetName = string.Empty;
target.FollowAroundCorners = true;
target.OpenDoors = false;
target.Waypoints.Clear();
SaveCurrent(target);
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
private static string ProfileKey(string value, bool byCharacter)
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected route — this converts every OTHER named route
/// the old roster still lists, using a fresh <see cref="NavigationSettings"/>
/// per entry so the live/selected route is never touched. Named routes
/// were never owner-scoped (one shared, globally-hashed key per name),
/// so every entry converts regardless of which character is currently
/// bound.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
string identity = (byCharacter ? "char:" : "named:")
+ value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
List<string>? names = ReadRosterNames();
if (names is not { Count: > 0 })
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = LegacyProfileKey(name, byCharacter: false);
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = ToFileName(name);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
var scratch = new NavigationSettings();
legacy.ApplyRouteOnly(scratch);
WriteAf(fileName, MetafSerializer.SaveNav(scratch));
}
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster.");
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
private List<string>? ReadRosterNames()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private T? Read<T>(string key) where T : class
private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
{
string fileName = CurrentFileName();
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
{
_pendingLegacyBareName = null;
return;
}
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
? LegacyProfileKey(bareName, byCharacter: false)
: LegacyProfileKey(_characterName, byCharacter: true);
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
{
_pendingLegacyBareName = null;
return;
}
legacy.ApplyRouteOnly(target);
WriteAf(fileName, MetafSerializer.SaveNav(target));
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
_pendingLegacyBareName = null;
_host.Log.Warn($"Migrated legacy MossTank route profile '{legacyKey}' to '{fileName}'.");
}
private LegacyRouteDocument? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return null;
@ -229,28 +385,56 @@ internal sealed class MossTankRouteProfileStore
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
: JsonSerializer.Deserialize<LegacyRouteDocument>(json, JsonOptions);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"route",
key,
json,
error);
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", key, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
// Round 3 item 3: the hidden "--" prefix MUST come before the "nav_"
// kind marker (--nav_Name_Server.af) — putting the marker first
// (nav_--Name_Server.af, the pre-fix shape) means the filename does not
// start with "--" at all, defeating the StartsWith("--") hidden-file
// check both ListNavigationProfiles and ListMetaProfiles rely on and
// leaking this character's private per-character route to every other
// character's picker.
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix)
: _selected;
private static string ToFileName(string bareName) =>
NavPrefix + bareName + ".af";
private void WriteBinding()
{
if (!_host.Storage.IsAvailable)
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, string.Empty, CurrentFileName(), null);
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_characterName,
Server,
existing with { NavFileName = CurrentFileName() });
}
private void WriteAf(string fileName, string text)
{
if (!VtankStorage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
@ -258,47 +442,35 @@ internal sealed class MossTankRouteProfileStore
}
}
private void SaveIndex() => Write(IndexKey, _index);
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
private void WriteLegacyExport(string name, NavigationSettings settings)
private static string LegacyProfileKey(string value, bool byCharacter)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.nav",
VtankNavRouteSerializer.Save(settings));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank navigation export could not be saved: {error.Message}");
}
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
}
private static string LegacyFileName(string name)
private static readonly JsonSerializerOptions JsonOptions = new()
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Route" : result.ToString();
}
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private sealed class IndexDocument
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once.
private sealed class LegacyRosterDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];
public Dictionary<string, string> SelectedByCharacter { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
private sealed class RouteDocument
// ------------------------------------------------------------------
// Migration-only: the OLD full route document shape, kept solely so a
// not-yet-migrated JSON profile can still be read once and converted.
// Only the fields this store still owns (Mode/Waypoints/FollowTarget)
// are applied — the rest belonged to the Settings profile even before
// the cutover and are left to whatever that profile already holds.
private sealed class LegacyRouteDocument
{
public int Version { get; set; } = 1;
public bool Enabled { get; set; }
@ -312,50 +484,20 @@ internal sealed class MossTankRouteProfileStore
public double DoorIdentifyRangeMeters { get; set; } = 20d;
public double DoorOpenRangeMeters { get; set; } = 4d;
public int DoorLockpickExcessThreshold { get; set; } = -50;
public WaypointDocument[] Waypoints { get; set; } = [];
public LegacyWaypointDocument[] Waypoints { get; set; } = [];
public static RouteDocument Capture(NavigationSettings value) => new()
public void ApplyRouteOnly(NavigationSettings value)
{
Enabled = value.Enabled,
Priority = value.Priority,
Mode = value.Mode,
MinimumDistanceMeters = value.MinimumDistanceMeters,
FollowTargetObjectId = value.FollowTargetObjectId,
FollowTargetName = value.FollowTargetName,
FollowAroundCorners = value.FollowAroundCorners,
OpenDoors = value.OpenDoors,
DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters,
DoorOpenRangeMeters = value.DoorOpenRangeMeters,
DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold,
Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(),
};
public void Apply(NavigationSettings value)
{
value.Enabled = Enabled;
value.Priority = Priority;
value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular;
value.MinimumDistanceMeters = Math.Clamp(
MinimumDistanceMeters,
0.5d,
50d);
value.FollowTargetObjectId = FollowTargetObjectId;
value.FollowTargetName = FollowTargetName ?? string.Empty;
value.FollowAroundCorners = FollowAroundCorners;
value.OpenDoors = OpenDoors;
value.DoorIdentifyRangeMeters = Math.Clamp(
DoorIdentifyRangeMeters, 1d, 100d);
value.DoorOpenRangeMeters = Math.Clamp(
DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters);
value.DoorLockpickExcessThreshold = Math.Clamp(
DoorLockpickExcessThreshold, -500, 500);
value.Waypoints.Clear();
foreach (WaypointDocument waypoint in Waypoints ?? [])
foreach (LegacyWaypointDocument waypoint in Waypoints ?? [])
value.Waypoints.Add(waypoint.ToWaypoint());
}
}
private sealed class WaypointDocument
private sealed class LegacyWaypointDocument
{
public RouteWaypointType Type { get; set; }
public uint CellId { get; set; }
@ -364,6 +506,12 @@ internal sealed class MossTankRouteProfileStore
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
public uint ReferenceCellId { get; set; }
public double ReferenceEastWest { get; set; }
public double ReferenceNorthSouth { get; set; }
public double ReferenceElevation { get; set; }
public float ReferenceHeadingDegrees { get; set; }
public bool ReferenceIsOutdoor { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
public int LegacyObjectClass { get; set; }
@ -378,40 +526,18 @@ internal sealed class MossTankRouteProfileStore
public int JumpChargeMilliseconds { get; set; } = 1000;
public RouteJumpDirection JumpDirection { get; set; }
public static WaypointDocument From(RouteWaypoint value) => new()
{
Type = value.Type,
CellId = value.Position.CellId,
EastWest = value.Position.EastWest,
NorthSouth = value.Position.NorthSouth,
Elevation = value.Position.Elevation,
HeadingDegrees = value.Position.HeadingDegrees,
IsOutdoor = value.Position.IsOutdoor,
ObjectId = value.ObjectId,
ObjectName = value.ObjectName,
LegacyObjectClass = value.LegacyObjectClass,
LegacyReferenceValid = value.LegacyReferenceValid,
Text = value.Text,
DurationMilliseconds = value.DurationMilliseconds,
Recall = value.Recall,
RecallSpellId = value.RecallSpellId,
RecallSpellName = value.RecallSpellName,
JumpHeadingDegrees = value.JumpHeadingDegrees,
JumpRun = value.JumpRun,
JumpChargeMilliseconds = value.JumpChargeMilliseconds,
JumpDirection = value.JumpDirection,
};
public RouteWaypoint ToWaypoint() => new()
{
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
Position = new PluginNavigationPosition(
CellId,
EastWest,
NorthSouth,
Elevation,
HeadingDegrees,
IsOutdoor),
CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
ReferencePosition = new PluginNavigationPosition(
ReferenceCellId,
ReferenceEastWest,
ReferenceNorthSouth,
ReferenceElevation,
ReferenceHeadingDegrees,
ReferenceIsOutdoor),
ObjectId = ObjectId,
ObjectName = ObjectName ?? string.Empty,
LegacyObjectClass = LegacyObjectClass,
@ -421,17 +547,15 @@ internal sealed class MossTankRouteProfileStore
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
? JumpHeadingDegrees
: 0f,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
JumpRun = JumpRun,
JumpChargeMilliseconds = Math.Clamp(
JumpChargeMilliseconds,
0,
10_000),
JumpDirection = Enum.IsDefined(JumpDirection)
? JumpDirection
: RouteJumpDirection.Forward,
// Round 3 item 5: no load-time clamp here either — the real
// bi.a 2000 ms ceiling is retail's EXECUTION-time behavior
// (NavigationController.TickJump), not a storage-format limit;
// this legacy migration path preserves whatever the pre-cutover
// JSON authored, exactly like the .af load path now does.
JumpChargeMilliseconds = JumpChargeMilliseconds,
JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
};
}
}

View file

@ -44,6 +44,17 @@ internal sealed class RouteWaypoint
{
public RouteWaypointType Type { get; set; }
public PluginNavigationPosition Position { get; set; }
/// <summary>
/// The second of the two coordinate triples VTank's own Portal2/UseNPC
/// waypoint records carry (metaf's "ptl"/"tlk" nodes: "myx myy myz tgtx
/// tgty tgtz" — <c>metaf_monolithic.py:356-357,11482,11618</c>; the
/// binary <c>.nav</c> record's own trailing xyz). <see cref="Position"/>
/// is where the bot stands to use the object ("myxyz"); this is the
/// portal/NPC object's own recorded position ("objxyz"), used to
/// disambiguate which object at that name to interact with. Unused for
/// every other waypoint type.
/// </summary>
public PluginNavigationPosition ReferencePosition { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
/// <summary>Decal ObjectClass retained for exact VTank NAV interchange.</summary>
@ -64,6 +75,7 @@ internal sealed class RouteWaypoint
{
Type = Type,
Position = Position,
ReferencePosition = ReferencePosition,
ObjectId = ObjectId,
ObjectName = ObjectName,
LegacyObjectClass = LegacyObjectClass,
@ -185,6 +197,13 @@ internal sealed class NavigationController
private const double RecallExitDistanceMeters = 2.4d;
private const double JumpLaunchGraceSeconds = 0.25d;
private const double JumpCompletionTimeoutSeconds = 3d;
// Round 3 item 5: retail's real jump-charge ceiling
// (refs/vtank/decompiled/bi.cs:502-505, bi.a — "if (A_2 > 2000.0) A_2 =
// 2000.0" at the moment the jump starts charging) belongs HERE, at
// execution, not at .af/legacy-JSON load — a waypoint's authored
// JumpChargeMilliseconds round-trips through storage unclamped; only
// the actual in-game charge duration is capped.
private const int JumpChargeCeilingMilliseconds = 2000;
private const double CheckpointRetrySeconds = 15d;
private const double FollowBreadcrumbSpacingMeters = 0.096d;
private const double FollowPathCaptureRangeMeters = 240d;
@ -711,9 +730,15 @@ internal sealed class NavigationController
StringComparison.OrdinalIgnoreCase));
if (!currentStillExists)
{
// Search near the object's own recorded position ("tgtxyz"
// in metaf's ptl/tlk grammar), not where the bot was
// standing when the waypoint was authored ("myxyz" —
// waypoint.Position): the object can be meaningfully far
// from the approach point (e.g. a portal at the far end of
// a room).
if (!_host.Automation.Navigation.TryFindObject(
waypoint.ObjectName,
waypoint.Position,
waypoint.ReferencePosition,
ObjectReacquireRadiusMeters,
out PluginNavigationObject reacquired))
{
@ -941,15 +966,16 @@ internal sealed class NavigationController
}
_jumpChargeElapsed += elapsedSeconds;
bool hold = _jumpChargeElapsed * 1000d
< Math.Max(0, waypoint.JumpChargeMilliseconds);
int effectiveChargeMilliseconds = Math.Clamp(
waypoint.JumpChargeMilliseconds, 0, JumpChargeCeilingMilliseconds);
bool hold = _jumpChargeElapsed * 1000d < effectiveChargeMilliseconds;
if (hold)
{
PluginMovementIntent intent = JumpIntent(waypoint, jump: true);
_hadMovementIntent = _host.Automation.Navigation
.SetMovementIntent(intent)
== PluginNavigationCommandStatus.Accepted;
_status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms.";
_status = $"Charging jump: {effectiveChargeMilliseconds}ms.";
return true;
}
PluginMovementIntent release = JumpIntent(waypoint, jump: false);

View file

@ -117,9 +117,9 @@ internal sealed class PetAutomation
if (!settings.SummonPets || activeOwnedPetCount > 0)
return PetAutomationChoice.None;
float range = settings.PetRangeMode == PetRangeMode.Custom
float range = (float)(settings.PetRangeMode == PetRangeMode.Custom
? settings.PetCustomRange
: settings.MaximumRange;
: settings.MaximumRange);
int density = Math.Max(1, settings.PetMonsterDensity);
var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>();
foreach (PluginCombatTarget target in targets)

View file

@ -35,9 +35,11 @@ public sealed class VitalSettings
public double HelperHealth { get; set; } = 0.20;
public double HelperStamina { get; set; } = 0.01;
public double HelperMana { get; set; } = 0.01;
public float HelperHealthDistance { get; set; } = 59.6f;
public float HelperStaminaDistance { get; set; } = 59.6f;
public float HelperManaDistance { get; set; } = 32f;
// Declared tDouble in VTank's own Settings table (item I, slice-1 fix
// round) — see CombatSettings.MaximumRange's doc comment for why.
public double HelperHealthDistance { get; set; } = 59.6d;
public double HelperStaminaDistance { get; set; } = 59.6d;
public double HelperManaDistance { get; set; } = 32d;
public bool HelpOthers { get; set; } = true;
public bool UseHealersHeart { get; set; } = true;
@ -46,6 +48,19 @@ public sealed class VitalSettings
public bool ClearLevelBoostFlagOnCast { get; set; } = true;
public int DropToPeaceModeRetryCount { get; set; } = 34;
public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet";
/// <summary>
/// The real VTank <c>RechargeHandlerSet</c> nested table (5 columns:
/// Vital/HandlerString/MinPercent/MaxPercent/Stance). Defaults to VTank's
/// own shipped 26-row table (<see cref="VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows"/>),
/// not an independently hand-typed replica; loading a real <c>.usd</c>
/// profile via <see cref="VtankSettingsProfileSerializer"/> replaces it
/// with that profile's own table. <see cref="VitalRechargePlanner"/>'s
/// hand-ported switch-based ordering is retained only as a defensive
/// fallback for the case where every row happens to have been cleared
/// explicitly and none match a given (vital, stance, percent) band.
/// </summary>
public IReadOnlyList<RechargeHandlerRow> RechargeHandlerRows { get; set; } =
VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows;
public bool UseKitsInMagicMode { get; set; } = true;
public bool GoToPeaceModeToUseKits { get; set; }
public int MinimumHealKitSuccessChance { get; set; } = 95;

View file

@ -33,6 +33,43 @@ internal enum VitalRechargeMethod
Food,
}
/// <summary>
/// One row of VTank's real <c>RechargeHandlerSet</c> nested table
/// (<c>refs/vtank/uTank2.Resources.defaultsettings.usd</c>, Settings row
/// 137's Value cell — a 5-column TABLE: Vital/HandlerString/MinPercent/
/// MaxPercent/Stance, 26 seed rows verified against the live fixture).
/// <c>Vital</c> uses VTank's own raw encoding (1=Health, 2=Stamina,
/// 3=Mana — distinct from this port's <see cref="VitalKind"/> which reuses
/// AC property-id values); <c>Stance</c> is 1=Magic, 2=Melee/Missile
/// (matches this planner's <c>magicMode</c> bool exactly, confirmed by
/// reproducing every existing hand-ported default list from the parsed
/// table). <c>MinPercent</c>/<c>MaxPercent</c> bound the current-vital
/// percentage this row applies at.
/// </summary>
public readonly record struct RechargeHandlerRow(
int Vital,
string HandlerString,
int MinPercent,
int MaxPercent,
int Stance)
{
internal static VitalKind? ToVitalKind(int vital) => vital switch
{
1 => VitalKind.Health,
2 => VitalKind.Stamina,
3 => VitalKind.Mana,
_ => null,
};
internal static int FromVitalKind(VitalKind vital) => vital switch
{
VitalKind.Health => 1,
VitalKind.Stamina => 2,
VitalKind.Mana => 3,
_ => 0,
};
}
/// <summary>
/// VTank's default <c>RechargeHandlerSet</c>, including its stance- and
/// current-percentage-dependent order. The host supplies raw inventory and
@ -60,7 +97,10 @@ internal static class VitalRechargePlanner
vital,
mode == PluginCombatMode.Magic,
percent,
settings.RechargeHandlerSet);
settings.RechargeHandlerSet,
settings.RechargeHandlerRows.Count != 0
? settings.RechargeHandlerRows
: null);
IReadOnlyList<PluginInventoryItem> items =
automation.Items.CaptureOwnedItems();
@ -107,7 +147,7 @@ internal static class VitalRechargePlanner
IReadOnlyList<PluginFellowMember> members =
automation.Fellowship.CaptureMembers();
foreach ((VitalKind vital, double threshold, float distance, uint baseSpell)
foreach ((VitalKind vital, double threshold, double distance, uint baseSpell)
in new[]
{
(VitalKind.Health, settings.HelperHealth,
@ -118,7 +158,7 @@ internal static class VitalRechargePlanner
settings.HelperManaDistance, (uint)SpellId.GiftOfEssence),
})
{
PluginFellowMember? target = Lowest(members, vital, threshold, distance);
PluginFellowMember? target = Lowest(members, vital, threshold, (float)distance);
if (target is not { } fellow)
continue;
if (vital == VitalKind.Health
@ -205,12 +245,36 @@ internal static class VitalRechargePlanner
return true;
}
/// <summary>
/// <paramref name="handlerRows"/> is <see cref="VitalSettings.RechargeHandlerRows"/>,
/// which defaults to VTank's own shipped 26-row table
/// (<see cref="VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows"/>)
/// rather than an empty list, so <see cref="TryHandlersFromRows"/> is the
/// path every ordinary call takes. The hardcoded <c>defaults</c> switch
/// below is retained ONLY as a defensive net for a caller that
/// deliberately passes an empty/null row set or an unrecognized
/// (vital, stance, percent) band the real table happens not to cover —
/// not as the primary source of truth it used to be before the real
/// table became parseable.
/// </summary>
internal static IReadOnlyList<VitalRechargeMethod> Handlers(
VitalKind vital,
bool magicMode,
int currentPercent,
string? handlerSet = null)
string? handlerSet = null,
IReadOnlyList<RechargeHandlerRow>? handlerRows = null)
{
if (handlerRows is { Count: > 0 }
&& TryHandlersFromRows(
vital,
magicMode,
currentPercent,
handlerRows,
out VitalRechargeMethod[] fromRows))
{
return fromRows;
}
IReadOnlyList<VitalRechargeMethod> defaults;
if (magicMode)
{
@ -288,6 +352,81 @@ internal static class VitalRechargePlanner
: defaults;
}
/// <summary>
/// VTank's real table-driven handler-order lookup: collect every row
/// matching (Vital, Stance) whose [MinPercent,MaxPercent] band contains
/// the current percentage, IN FILE ORDER (duplicates included — VTank's
/// own shipped table has them, e.g. row 17 of the default table repeats
/// "Recharge With Food" for the non-magic Health band; a duplicate is
/// harmless, since re-trying the same handler a second time just fails
/// identically). Confirmed against every hand-ported default list this
/// planner shipped before this table was parseable — see
/// <c>docs/research/vtank-kb/01-settings-and-profiles.md</c> row 137 and
/// the RechargeHandlerSet fixture. Unknown HandlerString tokens are
/// skipped (forward-compatible with a future VTank build's new method).
/// </summary>
private static bool TryHandlersFromRows(
VitalKind vital,
bool magicMode,
int currentPercent,
IReadOnlyList<RechargeHandlerRow> rows,
out VitalRechargeMethod[] handlers)
{
int vitalCode = RechargeHandlerRow.FromVitalKind(vital);
int stance = magicMode ? 1 : 2;
var matched = new List<VitalRechargeMethod>();
foreach (RechargeHandlerRow row in rows)
{
if (row.Vital != vitalCode
|| row.Stance != stance
|| currentPercent < row.MinPercent
|| currentPercent > row.MaxPercent)
{
continue;
}
if (TryParseHandlerToken(row.HandlerString, out VitalRechargeMethod method))
matched.Add(method);
}
handlers = [.. matched];
return handlers.Length != 0;
}
private static bool TryParseHandlerToken(string source, out VitalRechargeMethod method)
{
string normalized = source.Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal)
.ToLowerInvariant();
switch (normalized)
{
case "regularspell":
method = VitalRechargeMethod.RegularSpell;
return true;
case "staminatohealth":
method = VitalRechargeMethod.StaminaToHealth;
return true;
case "manatohealth":
method = VitalRechargeMethod.ManaToHealth;
return true;
case "healthtostamina":
method = VitalRechargeMethod.HealthToStamina;
return true;
case "healthtomana":
method = VitalRechargeMethod.HealthToMana;
return true;
case "kit":
case "kitrecharge":
method = VitalRechargeMethod.Kit;
return true;
case "food":
case "rechargewithfood":
method = VitalRechargeMethod.Food;
return true;
default:
method = default;
return false;
}
}
private static string HandlerContext(
VitalKind vital,
bool magicMode,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
using System.Reflection;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The complete <c>.usd</c> shipped by VTank as
/// <c>uTank2.Resources.defaultsettings.usd</c> — every table (Settings,
/// MyMonsters, GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells,
/// ItemUseSpecifiers, SettingsCategories, SettingsEnumInfo, AssistItems,
/// BuffedItems, RechargeHandlerSet, …), byte-for-byte, not a hand-typed
/// C# replica. <see cref="VtankSettingsProfileSerializer.CreateNew"/> seeds
/// a brand-new profile from this document (every table, every row's real
/// <c>Description</c>/<c>SettingType</c>) and
/// <see cref="VitalSettings"/>'s default <c>RechargeHandlerRows</c> is
/// parsed from its <c>RechargeHandlerSet</c> table — both replace a prior
/// port's independently hand-maintained defaults, which could (and did:
/// see the 8fa70c3e4 "26 rows, not 24" fix) silently drift from what VTank
/// itself ships.
/// </summary>
internal static class VtankDefaultSettingsDatabase
{
private const string ResourceSuffix = ".VtankDefaultSettings.usd";
private static readonly Lazy<string> RawText = new(LoadText);
private static readonly Lazy<RechargeHandlerRow[]> DefaultRows = new(LoadDefaultRows);
/// <summary>A fresh, independently mutable parse of the embedded document — every call gets its own object graph.</summary>
public static VtankDatabase Parse() => VtankDatabase.Parse(RawText.Value);
/// <summary>VTank's own shipped <c>RechargeHandlerSet</c> rows (26, in file order).</summary>
public static IReadOnlyList<RechargeHandlerRow> DefaultRechargeHandlerRows => DefaultRows.Value;
private static string LoadText()
{
Assembly assembly = typeof(VtankDefaultSettingsDatabase).Assembly;
string resource = assembly.GetManifestResourceNames().Single(
static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal));
using Stream stream = assembly.GetManifestResourceStream(resource)
?? throw new InvalidOperationException(
"The embedded VTank default settings (.usd) document is missing.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static RechargeHandlerRow[] LoadDefaultRows()
{
VtankDatabase database = VtankDatabase.Parse(RawText.Value);
VtankTable? settings = database.Find("Settings");
if (settings is null)
return [];
int nameColumn = settings.ColumnIndex("Setting");
int valueColumn = settings.ColumnIndex("Value");
if (nameColumn < 0 || valueColumn < 0)
return [];
foreach (VtankRow row in settings.Rows)
{
if (!row.Cells[nameColumn].AsString().Equals(
"RechargeHandlerSet", StringComparison.OrdinalIgnoreCase))
{
continue;
}
VtankCell cell = row.Cells[valueColumn];
if (cell.Tag == "TABLE" && cell.Table is { } table)
return VtankSettingsProfileSerializer.ParseRechargeHandlerSet(table);
}
return [];
}
}

View file

@ -23,20 +23,33 @@ internal static class VtankLootRequirementEvaluator
[6095] = (28, 80),
};
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus)>
DoubleSpellBonuses = new Dictionary<uint, (uint, double)>
/// <summary>
/// VTClassic's static double-spell-bonus table (<c>ComputedItemInfo.cs:88-139</c>).
/// <c>Change</c> is the real per-row selector KB doc 05 section 2.2 names
/// ("additive unless the static table's <c>Change==1</c>, in which case
/// multiplicative", <c>ComputedItemInfo.cs:244</c>) — carried explicitly
/// here rather than inferred from whether <c>Bonus</c>'s truncated
/// integer part happens to equal 1 (a prior port's proxy, which this
/// item replaced: it only worked because every multiplicative bonus in
/// this table happens to be in [1.0, 2.0) and every additive one happens
/// to be under 1.0 — a coincidence of the current 19 rows, not a rule,
/// and it would have silently mis-branched on a future row like an
/// additive 1.5 or a multiplicative 2.0+).
/// </summary>
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus, bool Change)>
DoubleSpellBonuses = new Dictionary<uint, (uint, double, bool)>
{
[3251] = (152, .01), [3250] = (152, .03),
[4670] = (152, .05), [6098] = (152, .07),
[2603] = (VtankDoubleBase + 12, .03),
[2591] = (VtankDoubleBase + 12, .05),
[4666] = (VtankDoubleBase + 12, .07),
[6094] = (VtankDoubleBase + 12, .09),
[2600] = (29, .03), [3985] = (29, .04),
[2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09),
[3201] = (144, 1.05), [3199] = (144, 1.10),
[3202] = (144, 1.15), [3200] = (144, 1.20),
[6086] = (144, 1.25), [6087] = (144, 1.30),
[3251] = (152, .01, false), [3250] = (152, .03, false),
[4670] = (152, .05, false), [6098] = (152, .07, false),
[2603] = (VtankDoubleBase + 12, .03, false),
[2591] = (VtankDoubleBase + 12, .05, false),
[4666] = (VtankDoubleBase + 12, .07, false),
[6094] = (VtankDoubleBase + 12, .09, false),
[2600] = (29, .03, false), [3985] = (29, .04, false),
[2588] = (29, .05, false), [4663] = (29, .07, false), [6091] = (29, .09, false),
[3201] = (144, 1.05, true), [3199] = (144, 1.10, true),
[3202] = (144, 1.15, true), [3200] = (144, 1.20, true),
[6086] = (144, 1.25, true), [6087] = (144, 1.30, true),
};
private static readonly IReadOnlyDictionary<string, int[]> ArmorColorSlots =
@ -363,54 +376,103 @@ internal static class VtankLootRequirementEvaluator
: string.Empty,
};
/// <summary>
/// The single source of truth for both "what is this int-typed key's
/// value" (<see cref="IntValue"/>) and "does this item actually carry a
/// base value for this key at all" (<see cref="IntKeyExists"/>) — item J
/// (slice-1 fix round) consolidated these from two independently
/// hand-maintained key lists that had to be kept in exact sync by hand:
/// a named field added to one switch and forgotten in the other would
/// silently let <c>BuffedInt</c>'s KeyExists gate treat a real,
/// always-present field as "raw property bag only", which for a key the
/// item's bag never happens to carry would wrongly refuse a spell bonus
/// it should apply. Returns <see langword="false"/> only when
/// <paramref name="key"/> is not one of the named
/// <see cref="PluginInventoryItem"/> fields AND the raw property bag
/// does not carry it either.
/// </summary>
private static bool TryIntValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties,
out int value)
{
switch (key)
{
case 5: value = item.Burden; return true;
case 19: value = item.Value; return true;
case 105: value = checked((int)item.Workmanship); return true;
case 107: value = item.ItemCurrentMana; return true;
case 108: value = item.ItemMaximumMana; return true;
case 131: value = checked((int)item.MaterialType); return true;
case VtankIntBase + 0: value = checked((int)item.WeenieClassId); return true;
case VtankIntBase + 2: value = checked((int)item.ContainerObjectId); return true;
case VtankIntBase + 4: value = item.ItemsCapacity; return true;
case VtankIntBase + 5: value = item.ContainersCapacity; return true;
case VtankIntBase + 6: value = item.StackSize; return true;
case VtankIntBase + 7: value = item.MaximumStackSize; return true;
case VtankIntBase + 8: value = checked((int)item.SpellId); return true;
case VtankIntBase + 9: value = item.ContainerSlot; return true;
case VtankIntBase + 10: value = checked((int)item.WielderObjectId); return true;
case VtankIntBase + 11: value = checked((int)item.EquippedLocation); return true;
case VtankIntBase + 14: value = checked((int)item.ValidLocations); return true;
case VtankIntBase + 18: value = checked((int)item.Useability); return true;
case VtankIntBase + 23: value = checked((int)item.PublicFlags); return true;
case VtankIntBase + 31: value = item.CombatUse; return true;
case VtankIntBase + 32: value = item.WeaponSkill; return true;
case VtankIntBase + 33: value = item.DamageType; return true;
case VtankIntBase + 34: value = item.Damage; return true;
case VtankIntBase + 38: value = item.AppraisedSpellIds.Count; return true;
default:
value = 0;
return properties.Ints?.TryGetValue(key, out value) == true;
}
}
private static int IntValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
in PluginItemProperties properties) =>
TryIntValue(key, item, properties, out int value) ? value : 0;
/// <summary>See <see cref="TryDoubleValue"/> — the double-side equivalent of <see cref="TryIntValue"/>'s consolidation.</summary>
private static bool TryDoubleValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties,
out double value)
{
5 => item.Burden,
19 => item.Value,
105 => checked((int)item.Workmanship),
107 => item.ItemCurrentMana,
108 => item.ItemMaximumMana,
131 => checked((int)item.MaterialType),
VtankIntBase + 0 => checked((int)item.WeenieClassId),
VtankIntBase + 2 => checked((int)item.ContainerObjectId),
VtankIntBase + 4 => item.ItemsCapacity,
VtankIntBase + 5 => item.ContainersCapacity,
VtankIntBase + 6 => item.StackSize,
VtankIntBase + 7 => item.MaximumStackSize,
VtankIntBase + 8 => checked((int)item.SpellId),
VtankIntBase + 9 => item.ContainerSlot,
VtankIntBase + 10 => checked((int)item.WielderObjectId),
VtankIntBase + 11 => checked((int)item.EquippedLocation),
VtankIntBase + 14 => checked((int)item.ValidLocations),
VtankIntBase + 18 => checked((int)item.Useability),
VtankIntBase + 23 => checked((int)item.PublicFlags),
VtankIntBase + 31 => item.CombatUse,
VtankIntBase + 32 => item.WeaponSkill,
VtankIntBase + 33 => item.DamageType,
VtankIntBase + 34 => item.Damage,
VtankIntBase + 38 => item.AppraisedSpellIds.Count,
_ => properties.Ints?.TryGetValue(key, out int value) == true
? value
: 0,
};
switch (key)
{
case VtankDoubleBase + 9: value = item.Workmanship; return true;
case VtankDoubleBase + 11: value = item.DamageVariance; return true;
// These two are named/virtual fields (like the int side's
// VtankIntBase+N cases) that always "exist", regardless of
// whether the underlying remapped raw key (62/63) happens to
// be present in the property bag — matching the prior
// DoubleKeyExists switch's explicit "=> true" for both.
case VtankDoubleBase + 12:
TryRawFloat(properties, 62, out value);
return true;
case VtankDoubleBase + 14:
TryRawFloat(properties, 63, out value);
return true;
default:
return TryRawFloat(properties, key, out value);
}
}
private static double DoubleValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
VtankDoubleBase + 9 => item.Workmanship,
VtankDoubleBase + 11 => item.DamageVariance,
VtankDoubleBase + 12 => RawFloat(properties, 62),
VtankDoubleBase + 14 => RawFloat(properties, 63),
_ => RawFloat(properties, key),
};
in PluginItemProperties properties) =>
TryDoubleValue(key, item, properties, out double value) ? value : 0d;
private static double RawFloat(in PluginItemProperties properties, uint key) =>
properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d;
private static bool TryRawFloat(in PluginItemProperties properties, uint key, out double value)
{
value = 0d;
return properties.Floats?.TryGetValue(key, out value) == true;
}
private static int BuffedInt(
uint key,
@ -418,6 +480,15 @@ internal static class VtankLootRequirementEvaluator
in PluginItemProperties properties)
{
int value = IntValue(key, item, properties);
// VTClassic's ComputedItemInfo.GetBuffedLogValueKey only adds the
// spell bonus if the item already carries the base key at all
// (ComputedItemInfo.cs:205, the KeyExistsInt gate — KB doc 05
// section 2.2 and the section-5 gap-4 finding). Without this gate,
// an item that lacks the key entirely (falls through to the
// zero-defaulted "_ =>" branch below) would still receive a spell
// bonus it never had a base value to buff.
if (!IntKeyExists(key, item, properties))
return value;
foreach (uint spellId in item.AppraisedSpellIds)
{
if (IntSpellBonuses.TryGetValue(spellId, out var bonus)
@ -435,6 +506,8 @@ internal static class VtankLootRequirementEvaluator
in PluginItemProperties properties)
{
double value = DoubleValue(key, item, properties);
if (!DoubleKeyExists(key, item, properties))
return value;
foreach (uint spellId in item.AppraisedSpellIds)
{
if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus)
@ -442,11 +515,28 @@ internal static class VtankLootRequirementEvaluator
{
continue;
}
value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus;
value = bonus.Change ? value * bonus.Bonus : value + bonus.Bonus;
}
return value;
}
/// <summary>
/// Now driven by <see cref="TryIntValue"/> (item J, slice-1 fix round)
/// instead of an independently hand-maintained duplicate of its key
/// list — see that method's doc for why the duplication was a hazard.
/// </summary>
private static bool IntKeyExists(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) =>
TryIntValue(key, item, properties, out _);
private static bool DoubleKeyExists(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) =>
TryDoubleValue(key, item, properties, out _);
private static double MinimumDamage(in PluginInventoryItem item) =>
item.Damage - (item.DamageVariance * item.Damage);

View file

@ -1,11 +1,17 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Reads and writes VTank's exact line-encoded <c>CondAct</c> Meta database.
/// The format is the public interchange contract used by legacy <c>.met</c>
/// profiles; it is deliberately independent from MossTank's native JSON store.
/// Reads VTank's exact line-encoded <c>CondAct</c> Meta database — a
/// one-shot import path only. Per the owner's 2026-09-06 "MossTank does not
/// implement <c>.met</c>" direction (Campaign VT slice 1 Part A), the
/// writer that used to live here was deleted: <c>.af</c>
/// (<see cref="MetafSerializer"/>) is the only storage/authoring format for
/// meta profiles now. This class only converts a legacy binary <c>.met</c>
/// file into a <see cref="MetaProfile"/> so it can be saved straight back
/// out as <c>.af</c>.
/// </summary>
internal static class VtankMetaProfileSerializer
{
@ -20,8 +26,16 @@ internal static class VtankMetaProfileSerializer
private const int MaximumRules = 100_000;
private const int MaximumNesting = 256;
public static bool TryLoad(string source, out MetaProfile profile, out string error)
public static bool TryLoad(string source, out MetaProfile profile, out string error) =>
TryLoad(source, MetafSerializer.NoOpSpells.Instance, out profile, out error);
public static bool TryLoad(
string source,
ISpellCatalog spells,
out MetaProfile profile,
out string error)
{
ArgumentNullException.ThrowIfNull(spells);
try
{
var reader = new LineReader(source);
@ -38,7 +52,7 @@ internal static class VtankMetaProfileSerializer
reader.Expect("i");
int actionType = reader.ReadInt();
MetaCondition condition = ReadCondition(reader, conditionType, 0);
MetaAction action = ReadAction(reader, actionType, 0);
MetaAction action = ReadAction(reader, actionType, 0, spells);
reader.Expect("s");
parsed.Rules.Add(new MetaRule
{
@ -62,23 +76,6 @@ internal static class VtankMetaProfileSerializer
}
}
public static string Save(MetaProfile source)
{
ArgumentNullException.ThrowIfNull(source);
var writer = new LineWriter();
writer.Add(Header);
MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray();
writer.Add(rules.Length);
foreach (MetaRule rule in rules)
{
writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind));
WriteCondition(writer, rule.Condition, 0);
WriteAction(writer, rule.Action, 0);
writer.Add("s", rule.State ?? string.Empty);
}
return writer.Finish();
}
private static MetaCondition ReadCondition(LineReader reader, int type, int depth)
{
CheckDepth(reader, depth);
@ -172,7 +169,7 @@ internal static class VtankMetaProfileSerializer
}
}
private static MetaAction ReadAction(LineReader reader, int type, int depth)
private static MetaAction ReadAction(LineReader reader, int type, int depth, ISpellCatalog spells)
{
CheckDepth(reader, depth);
var value = new MetaAction { Kind = ActionKind(type) };
@ -191,11 +188,11 @@ internal static class VtankMetaProfileSerializer
for (int index = 0; index < count; index++)
{
reader.Expect("i");
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1));
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1, spells));
}
break;
case 4:
ReadEmbeddedNavigation(reader, value);
ReadEmbeddedNavigation(reader, value, spells);
break;
case 5:
reader.Expect(TablePrefix, "2", "s", "st", "s");
@ -247,7 +244,7 @@ internal static class VtankMetaProfileSerializer
return value;
}
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target)
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target, ISpellCatalog spells)
{
reader.Expect("ba");
int serializedCharacters = reader.ReadCount();
@ -255,7 +252,7 @@ internal static class VtankMetaProfileSerializer
int statedNodeCount = reader.ReadCount();
if (serializedCharacters <= 5)
{
target.Text = EmptyNavigation();
target.EmbeddedRoute = new NavigationSettings();
return;
}
@ -282,7 +279,15 @@ internal static class VtankMetaProfileSerializer
}
if (actualNodeCount != statedNodeCount)
throw reader.Error("Embedded VTank navigation node counts do not match.");
target.Text = string.Join("\r\n", lines) + "\r\n";
// Parse the reassembled "uTank2 NAV 1.2" text through the same
// reader a top-level binary .nav import uses (item B: MetaAction
// carries a typed NavigationSettings, not a re-parse-me blob).
string blob = string.Join("\r\n", lines) + "\r\n";
var route = new NavigationSettings();
if (!VtankNavRouteSerializer.TryLoad(blob, route, spells, out string navError))
throw reader.Error($"Embedded VTank navigation route is invalid: {navError}");
target.EmbeddedRoute = route;
}
private static void ReadNavigationNode(LineReader reader, List<string> lines)
@ -304,195 +309,10 @@ internal static class VtankMetaProfileSerializer
lines.Add(reader.Read());
}
private static void WriteCondition(LineWriter writer, MetaCondition value, int depth)
{
CheckDepth(depth);
int type = ConditionType(value.Kind);
switch (type)
{
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
writer.Add("i", "0");
break;
case 2 or 3:
writer.Add(RecursiveTablePrefix);
writer.Add(value.Children.Count);
foreach (MetaCondition child in value.Children)
{
writer.Add("i", ConditionType(child.Kind));
WriteCondition(writer, child, depth + 1);
}
break;
case 4:
writer.Add("s", value.Text);
break;
case 5 or 6 or 17 or 18 or 22 or 24:
writer.Add("i", IntValue(value.Number));
break;
case 11 or 12:
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
"s", "c", "i", IntValue(value.Number));
break;
case 13:
writer.Add(TablePrefix, "3", "s", "n", "s", value.Text,
"s", "c", "i", IntValue(value.Number),
"s", "r", "d", Number(value.SecondaryNumber));
break;
case 14:
writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber),
"s", "c", "i", IntValue(value.Number),
"s", "r", "d", Number(value.SecondaryNumber));
break;
case 16:
writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number));
break;
case 21:
if (value.Children.Count != 1)
throw new InvalidOperationException("VTank Meta Not requires exactly one condition.");
writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind));
WriteCondition(writer, value.Children[0], depth + 1);
break;
case 23:
writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number),
"s", "sec", "i", IntValue(value.SecondaryNumber));
break;
case 25:
writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number));
break;
case 26:
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
break;
case 28:
writer.Add(TablePrefix, "2", "s", "p", "s", value.Text,
"s", "c", "s", value.SecondaryText);
break;
default:
throw new InvalidOperationException($"Unknown VTank Meta condition type {type}.");
}
}
private static void WriteAction(LineWriter writer, MetaAction value, int depth)
{
CheckDepth(depth);
int type = ActionType(value.Kind);
switch (type)
{
case 0 or 6:
writer.Add("i", "0");
break;
case 1 or 2:
writer.Add("s", value.Text);
break;
case 3:
writer.Add(RecursiveTablePrefix);
writer.Add(value.Children.Count);
foreach (MetaAction child in value.Children)
{
writer.Add("i", ActionType(child.Kind));
WriteAction(writer, child, depth + 1);
}
break;
case 4:
WriteEmbeddedNavigation(writer, value);
break;
case 5:
writer.Add(TablePrefix, "2", "s", "st", "s", value.Text,
"s", "ret", "s", value.SecondaryText);
break;
case 7 or 8:
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
break;
case 9:
writer.Add(TablePrefix, "3", "s", "s", "s", value.Text,
"s", "r", "d", Number(value.Number),
"s", "t", "d", Number(value.SecondaryNumber));
break;
case 10 or 15:
writer.Add(TablePrefix, "0");
break;
case 11 or 12:
writer.Add(TablePrefix, "2", "s", "o", "s", value.Text,
"s", "v", "s", value.SecondaryText);
break;
case 13:
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
"s", "x", "ba", value.SecondaryText.Length);
writer.AddBuggedByteArray(value.SecondaryText);
break;
case 14:
writer.Add(TablePrefix, "1", "s", "n", "s", value.Text);
break;
default:
throw new InvalidOperationException($"Unknown VTank Meta action type {type}.");
}
}
private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value)
{
string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text;
string normalized = NormalizeNewlines(nav);
string[] navLines = normalized.Split('\n', StringSplitOptions.None);
if (navLines.Length != 0 && navLines[^1].Length == 0)
navLines = navLines[..^1];
int nodes = NavigationNodeCount(navLines);
string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText;
int characters = name.Length + 2
+ nodes.ToString(CultureInfo.InvariantCulture).Length + 2
+ navLines.Sum(static line => line.Length + 2);
writer.Add("ba", characters, name, nodes);
writer.Add(navLines);
}
private static int NavigationNodeCount(string[] lines)
{
if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal))
throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data.");
int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture);
if (mode == 3)
return 1;
if (mode is not (1 or 2 or 4) || lines.Length < 3)
throw new InvalidOperationException("Embedded Meta route has an invalid navigation type.");
return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture);
}
private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n";
private static string NormalizeNewlines(string value) => value
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
private static int ConditionType(MetaConditionKind kind) => kind switch
{
MetaConditionKind.Never => 0,
MetaConditionKind.Always => 1,
MetaConditionKind.All => 2,
MetaConditionKind.Any => 3,
MetaConditionKind.ChatMessage => 4,
MetaConditionKind.PackSlotsLessThanOrEqual => 5,
MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6,
MetaConditionKind.NavigationRouteEmpty => 7,
MetaConditionKind.CharacterDeath => 8,
MetaConditionKind.AnyVendorOpen => 9,
MetaConditionKind.VendorClosed => 10,
MetaConditionKind.InventoryItemCountLessThanOrEqual => 11,
MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12,
MetaConditionKind.MonsterNameCountWithinDistance => 13,
MetaConditionKind.MonsterPriorityCountWithinDistance => 14,
MetaConditionKind.NeedToBuff => 15,
MetaConditionKind.NoMonstersWithinDistance => 16,
MetaConditionKind.LandblockEquals => 17,
MetaConditionKind.LandcellEquals => 18,
MetaConditionKind.PortalspaceEntered => 19,
MetaConditionKind.PortalspaceExited => 20,
MetaConditionKind.Not => 21,
MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22,
MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23,
MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24,
MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25,
MetaConditionKind.Expression => 26,
MetaConditionKind.ChatMessageCapture => 28,
_ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."),
};
private static MetaConditionKind ConditionKind(int type) => type switch
{
0 => MetaConditionKind.Never,
@ -526,27 +346,6 @@ internal static class VtankMetaProfileSerializer
_ => throw new FormatException($"Unknown VTank Meta condition type {type}."),
};
private static int ActionType(MetaActionKind kind) => kind switch
{
MetaActionKind.None => 0,
MetaActionKind.SetMetaState => 1,
MetaActionKind.ChatCommand => 2,
MetaActionKind.All => 3,
MetaActionKind.LoadEmbeddedNavigationRoute => 4,
MetaActionKind.CallMetaState => 5,
MetaActionKind.ReturnFromCall => 6,
MetaActionKind.ExpressionAction => 7,
MetaActionKind.ChatExpression => 8,
MetaActionKind.SetWatchdog => 9,
MetaActionKind.ClearWatchdog => 10,
MetaActionKind.GetVtankOption => 11,
MetaActionKind.SetVtankOption => 12,
MetaActionKind.CreateView => 13,
MetaActionKind.DestroyView => 14,
MetaActionKind.DestroyAllViews => 15,
_ => throw new InvalidOperationException($"Unsupported Meta action {kind}."),
};
private static MetaActionKind ActionKind(int type) => type switch
{
0 => MetaActionKind.None,
@ -568,32 +367,12 @@ internal static class VtankMetaProfileSerializer
_ => throw new FormatException($"Unknown VTank Meta action type {type}."),
};
private static string Number(double value)
{
if (!double.IsFinite(value))
throw new InvalidOperationException("VTank Meta numbers must be finite.");
return value.ToString("R", CultureInfo.InvariantCulture);
}
private static int IntValue(double value)
{
if (!double.IsFinite(value) || value != Math.Truncate(value))
throw new InvalidOperationException("VTank Meta integer fields require whole numbers.");
return checked((int)value);
}
private static void CheckDepth(LineReader reader, int depth)
{
if (depth > MaximumNesting)
throw reader.Error("VTank Meta nesting is too deep.");
}
private static void CheckDepth(int depth)
{
if (depth > MaximumNesting)
throw new InvalidOperationException("VTank Meta nesting is too deep.");
}
private sealed class LineReader
{
private readonly List<string> _lines;
@ -703,40 +482,4 @@ internal static class VtankMetaProfileSerializer
public FormatException Error(string message) =>
new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}");
}
private sealed class LineWriter
{
private readonly List<string> _lines = [];
private readonly List<int> _buggedByteArrays = [];
public void Add(params object?[] values)
{
foreach (object? value in values)
_lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty);
}
public void Add(string[] first, params object?[] rest)
{
Add(first.Cast<object?>().ToArray());
Add(rest);
}
public void AddBuggedByteArray(string value)
{
_buggedByteArrays.Add(_lines.Count);
_lines.Add(value ?? string.Empty);
}
public string Finish()
{
foreach (int index in _buggedByteArrays.OrderDescending())
{
if (index + 1 >= _lines.Count)
throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record.");
_lines[index] += _lines[index + 1];
_lines.RemoveAt(index + 1);
}
return string.Join("\r\n", _lines) + "\r\n";
}
}
}

View file

@ -3,40 +3,27 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format.</summary>
/// <summary>
/// Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format — a one-shot
/// import path only. Per the owner's 2026-09-06 "MossTank does not author
/// <c>.nav</c>" direction (Campaign VT slice 1 Part A), the writer that
/// used to live here was deleted: <c>.af</c> (<see cref="MetafSerializer"/>)
/// is the only storage/authoring format for navigation routes now.
/// <see cref="MetaEngine"/>'s embedded-navigation contract (a Meta rule's
/// <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/> action,
/// <c>.af</c> tag <c>EmbedNav</c>) is a typed
/// <see cref="MetaAction.EmbeddedRoute"/> <see cref="NavigationSettings"/>
/// now (round 2 step B, replacing an earlier binary-blob shape), saved and
/// loaded through the SAME <see cref="MetafSerializer.SaveNav"/>/
/// <see cref="MetafSerializer.TryLoadNav"/> grammar this class no longer
/// owns any writer for — round 3 item 12 cleanup: this doc comment
/// previously cited a "<c>WriteBinaryNavBlob</c>" method that does not
/// exist anywhere in the codebase.
/// </summary>
internal static class VtankNavRouteSerializer
{
private const string Header = "uTank2 NAV 1.2";
public static string Save(NavigationSettings source)
{
ArgumentNullException.ThrowIfNull(source);
var writer = new StringWriter(CultureInfo.InvariantCulture)
{
NewLine = "\r\n",
};
writer.WriteLine(Header);
writer.WriteLine(source.Mode switch
{
RouteMode.Circular => 1,
RouteMode.Linear => 2,
RouteMode.Target => 3,
RouteMode.Once => 4,
_ => throw new InvalidOperationException("Unknown navigation type."),
});
if (source.Mode == RouteMode.Target)
{
writer.WriteLine(source.FollowTargetName ?? string.Empty);
writer.WriteLine(unchecked((int)source.FollowTargetObjectId));
return writer.ToString();
}
writer.WriteLine(source.Waypoints.Count);
foreach (RouteWaypoint waypoint in source.Waypoints)
WriteWaypoint(writer, waypoint);
return writer.ToString();
}
public static bool TryLoad(
string source,
NavigationSettings target,
@ -145,13 +132,23 @@ internal static class VtankNavRouteSerializer
break;
case 6:
case 7:
// The record's leading eastWest/northSouth/elevation (read
// above into waypoint.Position) is "myxyz" — where the
// character stood when the waypoint was authored. This
// trailing triple is the portal/NPC object's own recorded
// position ("tgtxyz" in metaf's parallel ptl/tlk grammar —
// see MetafSerializer.ReadNavNode), kept separately so a
// reload can still disambiguate which object at that name
// to interact with, matching the record shape exactly:
// the pre-existing single-Position port used to overwrite
// "myxyz" with this value instead of keeping both.
waypoint.ObjectName = ReadLine(reader);
waypoint.LegacyObjectClass = ReadInt(reader);
waypoint.LegacyReferenceValid = ReadBoolean(reader);
double referenceEastWest = ReadDouble(reader);
double referenceNorthSouth = ReadDouble(reader);
double referenceElevation = ReadDouble(reader);
waypoint.Position = Position(
waypoint.ReferencePosition = Position(
referenceEastWest,
referenceNorthSouth,
referenceElevation);
@ -165,76 +162,6 @@ internal static class VtankNavRouteSerializer
return waypoint;
}
private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint)
{
int type = (int)waypoint.Type;
writer.WriteLine(type.ToString(CultureInfo.InvariantCulture));
WriteDouble(writer, waypoint.Position.EastWest);
WriteDouble(writer, waypoint.Position.NorthSouth);
WriteDouble(writer, waypoint.Position.Elevation);
writer.WriteLine("0");
switch (waypoint.Type)
{
case RouteWaypointType.Point:
case RouteWaypointType.Checkpoint:
break;
case RouteWaypointType.Portal:
writer.WriteLine(unchecked((int)waypoint.ObjectId)
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Recall:
writer.WriteLine(waypoint.RecallSpellId
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Pause:
writer.WriteLine(waypoint.DurationMilliseconds
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.ChatCommand:
writer.WriteLine(waypoint.Text ?? string.Empty);
break;
case RouteWaypointType.OpenVendor:
writer.WriteLine(unchecked((int)waypoint.ObjectId)
.ToString(CultureInfo.InvariantCulture));
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
break;
case RouteWaypointType.PortalByName:
case RouteWaypointType.UseNpc:
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
int objectClass = waypoint.LegacyObjectClass != 0
? waypoint.LegacyObjectClass
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture));
writer.WriteLine(waypoint.LegacyReferenceValid
.ToString(CultureInfo.InvariantCulture));
WriteDouble(writer, waypoint.Position.EastWest);
WriteDouble(writer, waypoint.Position.NorthSouth);
WriteDouble(writer, waypoint.Position.Elevation);
break;
case RouteWaypointType.Jump:
WriteDouble(writer, waypoint.JumpHeadingDegrees);
writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture));
string suffix = waypoint.JumpDirection switch
{
RouteJumpDirection.StrafeLeft => "4",
RouteJumpDirection.StrafeRight => "5",
_ => "3",
};
writer.WriteLine(
waypoint.JumpChargeMilliseconds.ToString(
"0.0000",
CultureInfo.InvariantCulture)
+ suffix);
break;
default:
throw new InvalidOperationException(
$"Unknown waypoint type {waypoint.Type}.");
}
}
private static void WriteDouble(TextWriter writer, double value) =>
writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture));
private static void ParseJump(string source, RouteWaypoint target)
{
string value = source.Trim();
@ -277,7 +204,14 @@ internal static class VtankNavRouteSerializer
0f,
IsOutdoor: true);
private static void Apply(NavigationSettings source, NavigationSettings target)
/// <summary>
/// Copies every field <see cref="NavigationSettings"/> owns from
/// <paramref name="source"/> into the live <paramref name="target"/>
/// instance (internal, not private, so <c>MetaEngine</c>'s embedded-
/// route consumer can reuse the exact same copy the top-level import
/// path uses instead of re-parsing already-typed data).
/// </summary>
internal static void Apply(NavigationSettings source, NavigationSettings target)
{
target.Mode = source.Mode;
target.FollowTargetObjectId = source.FollowTargetObjectId;

View file

@ -5,6 +5,26 @@ namespace AcDream.Plugins.MossTank;
/// <c>uTank2.Resources.defaultsettings.usd</c>. Order is retained because
/// <c>/vt opt list</c> presents the database order four entries per line.
/// </summary>
/// <summary>
/// VTank's <c>uTank2.eSettingValueType</c> enum
/// (<c>refs/vtank/decompiled/uTank2/eSettingValueType.cs</c>) — the value
/// each Settings row's own <c>SettingType</c> cell declares. This is the
/// authority for which <see cref="VtankCell"/> type tag a Settings row's
/// Value cell must use; it is independent of the CLR type of the C# field
/// the live setting is stored in (e.g. every Recharge-* percentage is a
/// <c>tInt</c> row even though the live field is a <c>double</c> fraction).
/// </summary>
internal enum VtankSettingValueType
{
Bool = 1,
Double = 2,
Int = 3,
Single = 4,
String = 5,
Enum = 6,
Custom = 7,
}
internal static class VtankOptionCatalog
{
internal static readonly string[] Names =
@ -208,6 +228,151 @@ internal static class VtankOptionCatalog
["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"),
};
// Exact per-row SettingType from the shipped defaultsettings.usd
// Settings table (verified 2026-09-06 against the committed
// Fixtures/vtank/defaultsettings.usd, all 137 rows).
private static readonly IReadOnlyDictionary<string, VtankSettingValueType> DeclaredTypes =
new Dictionary<string, VtankSettingValueType>(StringComparer.OrdinalIgnoreCase)
{
["EnableLooting"] = VtankSettingValueType.Bool,
["EnableNav"] = VtankSettingValueType.Bool,
["EnableBuffing"] = VtankSettingValueType.Bool,
["EnableCombat"] = VtankSettingValueType.Bool,
["SpellDiffExcessThreshold-Hunt"] = VtankSettingValueType.Int,
["SpellDiffExcessThreshold-Buff"] = VtankSettingValueType.Int,
["ArrowheadFletchDiffExcessThreshold"] = VtankSettingValueType.Int,
["Recharge-Norm-HitP"] = VtankSettingValueType.Int,
["Recharge-Norm-Stam"] = VtankSettingValueType.Int,
["Recharge-Norm-Mana"] = VtankSettingValueType.Int,
["Recharge-NoTarg-HitP"] = VtankSettingValueType.Int,
["Recharge-NoTarg-Stam"] = VtankSettingValueType.Int,
["Recharge-NoTarg-Mana"] = VtankSettingValueType.Int,
["Recharge-Helper-HitP"] = VtankSettingValueType.Int,
["Recharge-Helper-Stam"] = VtankSettingValueType.Int,
["Recharge-Helper-Mana"] = VtankSettingValueType.Int,
["DoHelp"] = VtankSettingValueType.Bool,
["AttackDistance"] = VtankSettingValueType.Double,
["AttackMinimumDistance"] = VtankSettingValueType.Double,
["ApproachDistance"] = VtankSettingValueType.Double,
["RingDistance"] = VtankSettingValueType.Double,
["CorpseApproachRange-Max"] = VtankSettingValueType.Double,
["CorpseApproachRange-Min"] = VtankSettingValueType.Double,
["NavCloseStopRange"] = VtankSettingValueType.Double,
["NavFarStopRange"] = VtankSettingValueType.Double,
["UsePortalDistance"] = VtankSettingValueType.Double,
["HelperDistanceHitP"] = VtankSettingValueType.Double,
["HelperDistanceStam"] = VtankSettingValueType.Double,
["HelperDistanceMana"] = VtankSettingValueType.Double,
["MinimumRingTargets"] = VtankSettingValueType.Int,
["DefaultMeleeAttackHeight"] = VtankSettingValueType.Int,
["CastDispelSelf"] = VtankSettingValueType.Bool,
["UseDispelItems"] = VtankSettingValueType.Bool,
["AutoCram"] = VtankSettingValueType.Bool,
["AutoStack"] = VtankSettingValueType.Bool,
["ReadUnknownScrolls"] = VtankSettingValueType.Bool,
["UseDispelDrum"] = VtankSettingValueType.Bool,
["SwitchWandsToDebuff"] = VtankSettingValueType.Bool,
["AutoCraftItems"] = VtankSettingValueType.Bool,
["UseHealersHeart"] = VtankSettingValueType.Bool,
["JumpOutWandCasting"] = VtankSettingValueType.Bool,
["LootAllCorpses"] = VtankSettingValueType.Bool,
["LootFellowCorpses"] = VtankSettingValueType.Bool,
["DoJiggle"] = VtankSettingValueType.Bool,
["RandomHelperBuffs"] = VtankSettingValueType.Bool,
["RandomHelperIntervalSeconds"] = VtankSettingValueType.Double,
["IdlePeaceMode"] = VtankSettingValueType.Bool,
["TargetLock"] = VtankSettingValueType.Bool,
["StopMacroOnDeath"] = VtankSettingValueType.Bool,
["UseArcs"] = VtankSettingValueType.Enum,
["ArcRange"] = VtankSettingValueType.Double,
["TargetSelectMethod"] = VtankSettingValueType.Enum,
["TargetSelectAngleRange"] = VtankSettingValueType.Double,
["IdleBuffTopoff"] = VtankSettingValueType.Bool,
["IdleBuffTopoffTimeSeconds"] = VtankSettingValueType.Int,
["RebuffTimeRemainingSeconds"] = VtankSettingValueType.Int,
["RefillWornMana"] = VtankSettingValueType.Bool,
["RefillWornMana-Item-ManaPercent"] = VtankSettingValueType.Int,
["BuffProfile-Prots"] = VtankSettingValueType.String,
["BuffProfile-Banes"] = VtankSettingValueType.String,
["BuffProfile_Prots"] = VtankSettingValueType.Enum,
["BuffProfile_Banes"] = VtankSettingValueType.Enum,
["DebuffEachFirst"] = VtankSettingValueType.Enum,
["AutoAttackPower"] = VtankSettingValueType.Bool,
["LootPriorityBoost"] = VtankSettingValueType.Bool,
["CorpseCacheTimeoutMinutes"] = VtankSettingValueType.Double,
["CorpseItemAppearanceTimeoutSeconds"] = VtankSettingValueType.Double,
["CorpseItemIDTimeoutSeconds"] = VtankSettingValueType.Double,
["DebuffSelectionMethod"] = VtankSettingValueType.Enum,
["ManaStoneLootCount"] = VtankSettingValueType.Int,
["ManaTankMinimumMana"] = VtankSettingValueType.Int,
["SplitPeas"] = VtankSettingValueType.Bool,
["SpellCompMin-Critical"] = VtankSettingValueType.Int,
["SpellCompMin-Normal"] = VtankSettingValueType.Int,
["SpellCompMin-Idle"] = VtankSettingValueType.Int,
["RechargeBoostTimeSeconds"] = VtankSettingValueType.Double,
["RechargeBoostAmount"] = VtankSettingValueType.Int,
["UseSpecialAmmo"] = VtankSettingValueType.Enum,
["OpenDoors"] = VtankSettingValueType.Bool,
["DoorIDRange"] = VtankSettingValueType.Double,
["DoorOpenRange"] = VtankSettingValueType.Double,
["DoorLockpickDiffExcessThreshold"] = VtankSettingValueType.Int,
["ManaChargesWhenOff"] = VtankSettingValueType.Bool,
["AutoFellowManagement"] = VtankSettingValueType.Bool,
["MinimumHealKitSuccessChance"] = VtankSettingValueType.Int,
["UseKitsInMagicMode"] = VtankSettingValueType.Bool,
["StaminaToHealthMultiplier"] = VtankSettingValueType.Double,
["ManaToHealthMultiplier"] = VtankSettingValueType.Double,
["NavPriorityBoost"] = VtankSettingValueType.Bool,
["DeleteGhostMonsters"] = VtankSettingValueType.Bool,
["GhostMonsterSpellAttemptCount"] = VtankSettingValueType.Int,
["WhoYouGonnaCall"] = VtankSettingValueType.Bool,
["BlacklistMonsterAttemptCount"] = VtankSettingValueType.Int,
["BlacklistMonsterTimeoutSeconds"] = VtankSettingValueType.Int,
["CombineSalvage"] = VtankSettingValueType.Bool,
["LootOnlyRareCorpses"] = VtankSettingValueType.Bool,
["DeleteGhostMonstersByHPTracker"] = VtankSettingValueType.Bool,
["GhostDeleteHPTrackerSeconds"] = VtankSettingValueType.Int,
["GoToPeaceModeToUseKits"] = VtankSettingValueType.Bool,
["UseRecklessness"] = VtankSettingValueType.Bool,
["DebuffPrecastSeconds"] = VtankSettingValueType.Int,
["ClearLevelBoostFlagOnCast"] = VtankSettingValueType.Bool,
["IdleCraftCount_HealthKits"] = VtankSettingValueType.Int,
["IdleCraftCount_StamKits"] = VtankSettingValueType.Int,
["IdleCraftCount_ManaKits"] = VtankSettingValueType.Int,
["IdleCraftCount_HealthFood"] = VtankSettingValueType.Int,
["IdleCraftCount_StamFood"] = VtankSettingValueType.Int,
["IdleCraftCount_ManaFood"] = VtankSettingValueType.Int,
["BuffCastRecast_Seconds"] = VtankSettingValueType.Int,
["BuffCastRecastReset_Seconds"] = VtankSettingValueType.Int,
["EnableMeta"] = VtankSettingValueType.Bool,
["BlacklistedSpellComps"] = VtankSettingValueType.String,
["DropToPeaceModeRetryCount"] = VtankSettingValueType.Int,
["FollowAroundCorners"] = VtankSettingValueType.Bool,
["BlacklistCorpseOpenAttemptCount"] = VtankSettingValueType.Int,
["BlacklistCorpseOpenTimeoutSeconds"] = VtankSettingValueType.Int,
["SummonPets"] = VtankSettingValueType.Bool,
["PetRangeMode"] = VtankSettingValueType.Enum,
["PetCustomRange"] = VtankSettingValueType.Double,
["PetRefillCount-Idle"] = VtankSettingValueType.Int,
["PetRefillCount-Normal"] = VtankSettingValueType.Int,
["CorpseOpenTimeoutSeconds"] = VtankSettingValueType.Double,
["PetMonsterDensity"] = VtankSettingValueType.Int,
["CorpseLootItemMaxAttempts"] = VtankSettingValueType.Int,
["FastCastBuffs"] = VtankSettingValueType.Bool,
["UseBreakableTurnTo"] = VtankSettingValueType.Bool,
["UseProjectileAwareness"] = VtankSettingValueType.Bool,
["CollisionProjectileRadius"] = VtankSettingValueType.Double,
["CollisionStepDistance"] = VtankSettingValueType.Double,
["ShowCollisionDebug"] = VtankSettingValueType.Bool,
["MaximumCollisionChecksPerTick"] = VtankSettingValueType.Int,
["SpellRangeFudge"] = VtankSettingValueType.Double,
["BuffWithUntrained-Item"] = VtankSettingValueType.Int,
["BuffWithUntrained-Creature"] = VtankSettingValueType.Int,
["BuffWithUntrained-Life"] = VtankSettingValueType.Int,
["AllowDebuffFallback"] = VtankSettingValueType.Bool,
["RechargeHandlerSet"] = VtankSettingValueType.Custom,
};
internal static bool IsKnown(string name) =>
Names.Contains(name, StringComparer.OrdinalIgnoreCase);
@ -218,4 +383,10 @@ internal static class VtankOptionCatalog
Defaults.TryGetValue(name, out MonsterValue value)
? value
: MonsterValue.FromNumber(0d);
/// <summary>The <c>eSettingValueType</c> VTank's own Settings row declares for this name.</summary>
internal static VtankSettingValueType DeclaredType(string name) =>
DeclaredTypes.TryGetValue(name, out VtankSettingValueType type)
? type
: VtankSettingValueType.Double;
}

View file

@ -0,0 +1,404 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Implements VTank's real profile-directory naming/selection rules
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3):
/// the per-character auto file, the longer <c>--Name_Server_</c>
/// sub-profile prefix, and which filenames a given character can see.
///
/// The directory itself is never resolved here: every listing method reads
/// through <see cref="IPluginHost.VtankProfiles"/> (an <see cref="IPluginStorage"/>
/// the App/Headless host composes — real installed VTank's own profile
/// folder for direct interop, or a portable per-OS default under
/// <c>ApplicationPathSet.DataDirectory</c>). This class has no
/// <c>System.IO</c> dependency and no portable-default fallback of its
/// own: when the host has no opinion, <see cref="IPluginHost.VtankProfiles"/>
/// defaults to the inert <c>NoOpPluginStorage</c> (<c>IsAvailable</c>
/// false), and every listing here degrades to just its built-in entries,
/// exactly like a missing directory used to.
/// </summary>
internal static class VtankProfileDirectory
{
/// <summary>The real VTank "--" reserved-prefix marker (section 3).</summary>
internal const string HiddenPrefix = "--";
/// <summary>
/// The nav-profile-only "~~" reserved prefix (section 3) — VTank's own
/// producer/purpose was not determined by the KB research pass either;
/// this only reproduces the filter.
/// </summary>
internal const string NavHiddenPrefix = "~~";
internal const string ByCharacterLabel = "[By char]";
internal const string DefaultLabel = "[Default]";
internal const string NoneLabel = "[None]";
/// <summary>
/// The <c>nav_</c> marker (round 3 items 3/4) that distinguishes a
/// stand-alone route <c>.af</c> from a Meta profile sharing the same
/// flat <see cref="IPluginHost.VtankProfiles"/> directory and extension
/// — metaf's own observed convention for a nav-only <c>.af</c> (see the
/// committed <c>nav_*.af</c> fixtures). MUST be checked AFTER stripping
/// <see cref="HiddenPrefix"/> when the marker is combined with it (see
/// <see cref="AutoCharacterFileName(string,string,string,string)"/>'s
/// <c>marker</c> parameter): a hidden per-character route file is named
/// <c>--nav_Name_Server.af</c> — hidden prefix FIRST, marker SECOND —
/// never <c>nav_--Name_Server.af</c>, which does not start with
/// <see cref="HiddenPrefix"/> at all and so defeats every
/// <c>StartsWith("--")</c> hidden-file check in this class.
/// </summary>
internal const string NavMarker = "nav_";
/// <summary>
/// VTank's single per-character default filename
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
string server,
string extension) =>
$"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}";
/// <summary>
/// Overload for a per-character auto file that ALSO carries a kind
/// marker (currently only <see cref="NavMarker"/>, for
/// <see cref="MossTankRouteProfileStore"/>'s route <c>.af</c>): the
/// hidden prefix always comes first so the file stays hidden from every
/// other character's picker exactly like the unmarked overload above —
/// <c>--{marker}Name_Server.ext</c>, e.g. <c>--nav_Name_Server.af</c>.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
string server,
string extension,
string marker) =>
$"{HiddenPrefix}{marker}{characterName}_{server}.{extension.TrimStart('.')}";
/// <summary>
/// The longer, trailing-underscore prefix
/// (<c>uTank2/PluginCore.cs:933,3866</c>, field <c>dw</c>) that marks a
/// *named sub-profile* belonging to one character, distinct from that
/// character's single auto file above.
/// </summary>
public static string SubProfilePrefix(string characterName, string server) =>
$"{HiddenPrefix}{characterName}_{server}_";
/// <summary>
/// True when <paramref name="fileName"/> must be hidden from the
/// cross-character profile picker for <paramref name="characterName"/>/
/// <paramref name="server"/>: any <c>--</c>-prefixed name that is not
/// this character's own sub-profile family
/// (<c>uTank2/PluginCore.cs:7020,7072,7144</c>).
/// </summary>
public static bool IsHiddenFromOtherCharacters(
string fileName,
string characterName,
string server) =>
fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
&& !fileName.StartsWith(
SubProfilePrefix(characterName, server),
StringComparison.Ordinal);
/// <summary>
/// The "[Char] suffix" display form for one of this character's own
/// sub-profiles (<c>uTank2/PluginCore.cs:7016-7046</c>): the
/// <c>--Name_Server_</c> prefix and the file extension are both
/// stripped. Returns <see langword="null"/> for a filename that is not
/// one of this character's sub-profiles.
/// </summary>
public static string? TryDisplayName(
string fileName,
string characterName,
string server)
{
string prefix = SubProfilePrefix(characterName, server);
if (!fileName.StartsWith(prefix, StringComparison.Ordinal))
return null;
string withoutPrefix = fileName[prefix.Length..];
int dot = withoutPrefix.LastIndexOf('.');
string suffix = dot >= 0 ? withoutPrefix[..dot] : withoutPrefix;
return suffix.Length == 0 ? null : $"[Char] {suffix}";
}
/// <summary>
/// One entry in a profile picker: the real on-disk file name, and the
/// label VTank would show for it.
/// </summary>
public readonly record struct ProfileEntry(string FileName, string DisplayName);
/// <summary>
/// VTank's settings-profile list (<c>a0()</c>,
/// <c>uTank2/PluginCore.cs:7057-7115</c>): seeds
/// <see cref="DefaultLabel"/> and <see cref="ByCharacterLabel"/> first,
/// then every non-<c>--</c> <c>.usd</c> file, plus every one of this
/// character's own <c>--Name_Server_*</c> sub-profiles shown as
/// <c>[Char] suffix</c>.
/// </summary>
/// <param name="mineOnly">
/// The "Mine only" checkbox (<c>cSettingsShowAll</c>/field <c>a9</c>).
/// VTank's own predicate (<c>uTank2/PluginCore.cs:7020-7024</c>) is NOT
/// "hide every shared file" — a shared (non-sub-profile) file is hidden
/// only when it is neither unchecked NOR the currently selected file,
/// so the profile actually in use never vanishes out from under the
/// user just because they ticked the box afterward.
/// </param>
/// <param name="currentFileName">
/// The file name currently assigned to <paramref name="characterName"/>
/// (if any), exempted from <paramref name="mineOnly"/> filtering per the
/// rule above. Comparison is ordinal (VTank's own file names are
/// case-sensitive on the filesystems it ships for).
/// </param>
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
IPluginStorage storage,
string characterName,
string server,
bool mineOnly,
string? currentFileName = null)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, DefaultLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".usd"))
{
string? subProfileDisplay = TryDisplayName(fileName, characterName, server);
if (subProfileDisplay is not null)
{
entries.Add(new ProfileEntry(fileName, subProfileDisplay));
continue;
}
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue; // someone else's --Name_Server(.usd|_*) family.
if (mineOnly
&& !fileName.Equals(currentFileName, StringComparison.Ordinal))
{
continue;
}
entries.Add(new ProfileEntry(fileName, fileName));
}
return entries;
}
/// <summary>
/// VTank's navigation-profile list (<c>l()</c>,
/// <c>uTank2/PluginCore.cs:7156-7185</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// <see cref="NavMarker"/>-marked <c>.af</c> file that starts with
/// neither <c>--</c> nor <c>~~</c>. Round 3 item 4: a Meta profile and a
/// route share this same flat, single-extension directory — without
/// the marker check this returned every Meta <c>.af</c> too.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
{
continue;
}
if (!fileName.StartsWith(NavMarker, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
}
return entries;
}
/// <summary>
/// VTank's meta-profile list (<c>ac()</c>,
/// <c>uTank2/PluginCore.cs:7187+</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// non-<c>--</c>, non-<see cref="NavMarker"/>-marked file (a meta and a
/// nav profile share the same directory and extension here —
/// <c>.af</c> — so the marker is what VTank's own separate
/// <c>.met</c>/<c>.nav</c> extensions used to provide; round 3 item 4:
/// without excluding <see cref="NavMarker"/> files this returned every
/// route <c>.af</c> too).
/// </summary>
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue;
if (fileName.StartsWith(NavMarker, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
}
return entries;
}
/// <summary>
/// VTank's real loot-profile list (<c>aa()</c>,
/// <c>uTank2/PluginCore.cs:7127-7154</c>) seeds ONLY
/// <see cref="NoneLabel"/> — retail has no per-character auto loot
/// file and no "mine only" filter for loot at all (docs/research/vtank-kb/01-settings-and-profiles.md
/// section 3: "the loot default has no equivalent auto-name; loot
/// profiles default to none") — then every non-<c>--</c> file across
/// whatever loot-profile extension family that build understands.
/// Round 3 item 9: MossTank keeps its own established
/// <see cref="ByCharacterLabel"/> convention (already load-bearing for
/// Settings/Nav/Meta) as a SECOND seed entry for internal consistency
/// across all four stores — a recorded adaptation (register), not a
/// retail behavior — while still seeding retail's own
/// <see cref="NoneLabel"/> first.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListLootProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".utl"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
}
return entries;
}
/// <summary>
/// The per-character spell-tracking cache filename (<c>dm</c> class,
/// <c>refs/vtank/decompiled/dm.cs:391</c>): always
/// <c>CharacterName_Server.ast</c> — no <c>"--"</c> prefix (unlike the
/// auto settings/nav/meta files), not user-selectable, and note the
/// concatenation ORDER (Name first, then Server) is the reverse of
/// <see cref="CdfFileName"/>'s Server-then-Name order.
/// </summary>
public static string AstFileName(string characterName, string server) =>
$"{characterName}_{server}.ast";
/// <summary>
/// The per-character binding file's own name (<c>da</c> class,
/// <c>refs/vtank/decompiled/da.cs:110</c>): always
/// <c>Server_CharacterName.cdf</c> — Server first, then Name (the
/// reverse of <see cref="AstFileName"/> and of the auto
/// <c>--Name_Server.usd</c> naming).
/// </summary>
public static string CdfFileName(string characterName, string server) =>
$"{server}_{characterName}.cdf";
/// <summary>The literal version header a valid <c>.cdf</c> starts with (<c>da.cs:15</c>).</summary>
internal const string CdfHeader = "uTank2 CDF 1.0";
/// <summary>
/// One character's currently-assigned profile filenames, read from its
/// <c>.cdf</c> (<c>da.e()</c>, <c>da.cs:105-154</c>). <c>Meta</c> is
/// <see langword="null"/> when the file is short (line 5 is present
/// only when the stream isn't already at EOF, <c>da.cs:125-128</c> —
/// an older <c>.cdf</c> predating meta support has no line 5 at all).
/// </summary>
public readonly record struct VtankCharacterBinding(
string SettingsFileName,
string LootFileName,
string NavFileName,
string? MetaFileName);
/// <summary>
/// Reads <paramref name="characterName"/>/<paramref name="server"/>'s
/// <c>.cdf</c> through <paramref name="storage"/>. Returns
/// <see langword="null"/> when the storage is unavailable, the file is
/// missing, or the file does not start with <see cref="CdfHeader"/> —
/// real VTank treats a version mismatch identically to a missing file
/// and falls back to that character's auto-created per-character
/// defaults (<c>da.cs:113-121</c>), which this method leaves entirely
/// to the caller rather than fabricating a default binding itself. The
/// legacy <c>.uts</c>→<c>.usd</c> settings-filename rewrite
/// (<c>da.cs:130-141</c>) is applied here so callers never see a
/// <c>.uts</c> name.
/// </summary>
public static VtankCharacterBinding? TryReadCharacterBinding(
IPluginStorage storage,
string characterName,
string server)
{
ArgumentNullException.ThrowIfNull(storage);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ArgumentException.ThrowIfNullOrWhiteSpace(server);
if (!storage.IsAvailable)
return null;
string? text = storage.ReadText(CdfFileName(characterName, server));
if (text is null)
return null;
string[] lines = text
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n');
if (lines.Length < 4 || !string.Equals(lines[0], CdfHeader, StringComparison.Ordinal))
return null;
string settings = lines[1].EndsWith(".uts", StringComparison.OrdinalIgnoreCase)
? string.Concat(lines[1].AsSpan(0, lines[1].Length - 4), ".usd")
: lines[1];
string? meta = lines.Length >= 5 && lines[4].Length > 0 ? lines[4] : null;
return new VtankCharacterBinding(settings, lines[2], lines[3], meta);
}
/// <summary>
/// Writes <paramref name="binding"/> as <paramref name="characterName"/>/
/// <paramref name="server"/>'s <c>.cdf</c> (<c>da.q()</c>,
/// <c>refs/vtank/decompiled/da.cs:156-164</c>): the version header, then
/// settings/loot/nav on lines 2-4, then the meta filename on line 5 only
/// when <see cref="VtankCharacterBinding.MetaFileName"/> is non-empty —
/// matching <see cref="TryReadCharacterBinding"/>'s own line-5-is-optional
/// parsing exactly. No-ops when the storage is unavailable.
/// </summary>
public static void WriteCharacterBinding(
IPluginStorage storage,
string characterName,
string server,
VtankCharacterBinding binding)
{
ArgumentNullException.ThrowIfNull(storage);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ArgumentException.ThrowIfNullOrWhiteSpace(server);
if (!storage.IsAvailable)
return;
var lines = new List<string>
{
CdfHeader,
binding.SettingsFileName,
binding.LootFileName,
binding.NavFileName,
};
if (!string.IsNullOrEmpty(binding.MetaFileName))
lines.Add(binding.MetaFileName);
storage.WriteText(
CdfFileName(characterName, server),
string.Join("\r\n", lines) + "\r\n");
}
/// <summary>
/// Lists root-level file names matching <paramref name="extension"/>
/// through <see cref="IPluginStorage.List"/> alone — no
/// <c>System.IO</c>. VTank's own profile directory is flat, so a key
/// containing '/' (meaning it lives in some deeper storage
/// implementation's sub-directory) is not one of these files and is
/// skipped rather than surfaced as a bogus profile name.
/// </summary>
private static IEnumerable<string> EnumerateFileNames(IPluginStorage storage, string extension)
{
if (!storage.IsAvailable)
yield break;
foreach (string key in storage.List(string.Empty)
.Where(key => !key.Contains('/', StringComparison.Ordinal))
.Where(key => key.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase))
{
yield return key;
}
}
}

View file

@ -0,0 +1,542 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Loads/saves VTank's real <c>.usd</c> settings profile (the self-describing
/// <c>gy</c>/<c>bd</c>/<c>y</c> grammar in <see cref="VtankDatabase"/>),
/// mapping every one of the 137 <c>Settings</c> table rows
/// (<see cref="VtankOptionCatalog.Names"/>) onto the live
/// <see cref="CombatSettings"/>/<see cref="BuffSettings"/>/
/// <see cref="VitalSettings"/>/<see cref="InventorySettings"/>/
/// <see cref="NavigationSettings"/> fields by VTank name.
///
/// The value transforms (unit scaling, enum offsets) are the exact ones
/// already verified in <c>MossTankPanel.SetMetaOption</c>/<c>GetMetaOption</c>
/// (<c>MossTankPanel.cs:2442-3377</c>) against the decompiled VTank source
/// per <c>docs/research/vtank-kb/01-settings-and-profiles.md</c> — this
/// class duplicates those transforms rather than calling into
/// <c>MossTankPanel</c> because the panel's methods operate on a live
/// session (chat, expression state) that a file-format serializer must not
/// depend on.
///
/// Every table other than <c>Settings</c> (MyMonsters, GemFoodItems,
/// ExtraBuffSpells, AntiExtraBuffSpells, ItemUseSpecifiers,
/// SettingsCategories, SettingsEnumInfo, AssistItems, BuffedItems, …) is
/// preserved byte-for-byte: <see cref="Save"/> mutates the exact
/// <see cref="VtankDatabase"/> object <see cref="Load"/> returned, and only
/// replaces a <c>Settings</c> row's Value cell when the live setting's
/// current value differs from what was parsed (a value-equality compare,
/// not a text compare, so an untouched profile round-trips byte-for-byte
/// even if VTank's own float formatting differs subtly from ours).
/// </summary>
internal static class VtankSettingsProfileSerializer
{
private const string SettingsTable = "Settings";
internal sealed class AllSettings
{
public required CombatSettings Combat { get; init; }
public required BuffSettings Buffs { get; init; }
public required VitalSettings Vitals { get; init; }
public required InventorySettings Inventory { get; init; }
public required NavigationSettings Navigation { get; init; }
}
/// <summary>
/// Parses <paramref name="text"/> and applies every recognized Settings
/// row onto <paramref name="target"/>. Returns the parsed
/// <see cref="VtankDatabase"/> so a later <see cref="Save"/> can
/// preserve every other table (and every unrecognized Settings row)
/// byte-for-byte. Throws <see cref="FormatException"/> with a
/// <c>line N:</c>-prefixed message on the first structural error.
/// </summary>
public static VtankDatabase Load(string text, AllSettings target)
{
ArgumentNullException.ThrowIfNull(target);
VtankDatabase database = VtankDatabase.Parse(text);
VtankTable? settings = database.Find(SettingsTable);
if (settings is null)
throw new FormatException("missing required 'Settings' table.");
int nameColumn = settings.ColumnIndex("Setting");
int valueColumn = settings.ColumnIndex("Value");
if (nameColumn < 0 || valueColumn < 0)
throw new FormatException("'Settings' table is missing Setting/Value columns.");
foreach (VtankRow row in settings.Rows)
{
string name = row.Cells[nameColumn].AsString();
Apply(name, row.Cells[valueColumn], target);
}
return database;
}
/// <summary>
/// Regenerates the <c>Settings</c> table's Value cells from
/// <paramref name="source"/>'s current values (skipping any row whose
/// value is unchanged from what <paramref name="document"/> already
/// holds) and re-renders the whole database. Every other table, and any
/// Settings row this serializer does not recognize, is untouched.
/// </summary>
public static string Save(VtankDatabase document, AllSettings source)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(source);
VtankTable? settings = document.Find(SettingsTable);
if (settings is null)
throw new FormatException("missing required 'Settings' table.");
int nameColumn = settings.ColumnIndex("Setting");
int valueColumn = settings.ColumnIndex("Value");
foreach (VtankRow row in settings.Rows)
{
string name = row.Cells[nameColumn].AsString();
VtankCell? captured = Capture(name, source);
if (captured is null)
continue;
VtankCell current = row.Cells[valueColumn];
if (ValuesEqual(current, captured))
continue;
row.Cells[valueColumn] = captured;
}
return document.Render();
}
/// <summary>
/// Builds a brand-new database seeded from VTank's own shipped
/// <c>defaultsettings.usd</c> (<see cref="VtankDefaultSettingsDatabase"/>)
/// — every table (MyMonsters, GemFoodItems, ExtraBuffSpells,
/// AntiExtraBuffSpells, ItemUseSpecifiers, SettingsCategories,
/// SettingsEnumInfo, AssistItems, BuffedItems, RechargeHandlerSet, …),
/// not a hand-typed 4-column Settings-only replica: a prior version of
/// this method built only the Settings table, with an empty
/// Description and a hardcoded SettingType of 1 (Bool) for every row
/// regardless of the setting's real declared type. <see cref="Save"/>
/// then rewrites every Settings row's Value cell from
/// <paramref name="source"/>'s current values, exactly as it would for
/// an existing profile being re-saved untouched.
/// </summary>
public static VtankDatabase CreateNew(AllSettings source)
{
ArgumentNullException.ThrowIfNull(source);
VtankDatabase database = VtankDefaultSettingsDatabase.Parse();
_ = Save(database, source);
return database;
}
/// <summary>
/// Converts a live numeric setting value to the exact
/// <see cref="VtankCell"/> shape its declared <c>SettingType</c>
/// requires — the fix for the fidelity defect where 17 <c>tInt</c>
/// settings were written as <c>d</c> and 3 <c>tSingle</c> settings were
/// written as <c>f</c> (VTank's <c>gy.e</c>/<c>gy.f</c> unbox helpers
/// throw <see cref="InvalidCastException"/> on a tag/CLR-type mismatch).
/// The tag is driven by <see cref="VtankOptionCatalog.DeclaredType"/>,
/// never by the CLR type of the C# field the caller happens to store
/// the value in.
/// </summary>
private static VtankCell Num(string name, double value) =>
VtankOptionCatalog.DeclaredType(name) switch
{
VtankSettingValueType.Bool => VtankCell.Bool(value != 0d),
VtankSettingValueType.Int or VtankSettingValueType.Enum =>
VtankCell.Int((int)Math.Round(value, MidpointRounding.AwayFromZero)),
VtankSettingValueType.Single => VtankCell.Float((float)value),
VtankSettingValueType.String => VtankCell.String(value.ToString(CultureInfo.InvariantCulture)),
_ => VtankCell.Double(value),
};
/// <summary>
/// Exact compare on the cell's own type, with no tolerance anywhere —
/// a <see cref="Capture"/> tag mismatch is a real bug, not something to
/// paper over. Item I (slice-1 fix round) removed the one narrow
/// exception this used to carry: every <c>tDouble</c>-declared distance
/// setting (AttackDistance, ArcRange, …) that used to round-trip
/// through a <c>float</c>-typed live field (CombatSettings.MaximumRange
/// etc., costing precision below float's ~7-digit guarantee) now stores
/// that value as a real <c>double</c> — the field widened to match its
/// declared type instead of the compare being loosened to match the
/// field. Physics/combat consumers that want a <c>float</c> cast it at
/// their own use site.
/// </summary>
private static bool ValuesEqual(VtankCell a, VtankCell b)
{
if (a.Tag != b.Tag)
return false;
return a.Tag switch
{
"b" => a.AsBool() == b.AsBool(),
"s" => a.AsString() == b.AsString(),
"i" => a.AsInt() == b.AsInt(),
"u" => a.AsUInt() == b.AsUInt(),
"d" or "f" => a.AsDouble() == b.AsDouble(),
_ => ReferenceEquals(a, b),
};
}
// ------------------------------------------------------------------
// Apply: .usd cell -> live setting.
// ------------------------------------------------------------------
/// <summary>Internal (not private) so the profile store can seed live settings directly from a parsed database (fresh/default profiles, migration).</summary>
internal static void Apply(string rawName, VtankCell cell, AllSettings s)
{
if (!VtankOptionCatalog.IsKnown(rawName))
return;
string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant();
CombatSettings c = s.Combat;
BuffSettings b = s.Buffs;
VitalSettings v = s.Vitals;
InventorySettings i = s.Inventory;
NavigationSettings n = s.Navigation;
switch (name)
{
case "enablelooting": i.Loot.Enabled = cell.AsBool(); break;
case "enablenav": n.Enabled = cell.AsBool(); break;
case "enablebuffing": b.Enabled = cell.AsBool(); break;
case "enablecombat": c.Enabled = cell.AsBool(); break;
case "spelldiffexcessthreshold-hunt": c.HuntSkillExcessOverDifficulty = cell.AsInt(); break;
case "spelldiffexcessthreshold-buff": b.SkillExcessOverDifficulty = cell.AsInt(); break;
case "arrowheadfletchdiffexcessthreshold": i.ArrowheadFletchDifficultyExcess = cell.AsInt(); break;
case "recharge-norm-hitp": v.NormalHealth = cell.AsDouble() / 100d; break;
case "recharge-norm-stam": v.NormalStamina = cell.AsDouble() / 100d; break;
case "recharge-norm-mana": v.NormalMana = cell.AsDouble() / 100d; break;
case "recharge-notarg-hitp": v.NoTargetHealth = cell.AsDouble() / 100d; break;
case "recharge-notarg-stam": v.NoTargetStamina = cell.AsDouble() / 100d; break;
case "recharge-notarg-mana": v.NoTargetMana = cell.AsDouble() / 100d; break;
case "recharge-helper-hitp": v.HelperHealth = cell.AsDouble() / 100d; break;
case "recharge-helper-stam": v.HelperStamina = cell.AsDouble() / 100d; break;
case "recharge-helper-mana": v.HelperMana = cell.AsDouble() / 100d; break;
case "dohelp": v.HelpOthers = cell.AsBool(); break;
case "attackdistance": c.MaximumRange = cell.AsDouble() * 240d; break;
case "attackminimumdistance": c.MinimumRange = cell.AsDouble() * 240d; break;
case "approachdistance": c.ApproachDistance = cell.AsDouble() * 240d; break;
case "ringdistance": c.RingDistance = cell.AsDouble() * 240d; break;
case "corpseapproachrange-max": i.Loot.CorpseApproachRange = cell.AsDouble() * 240d; break;
case "corpseapproachrange-min": i.Loot.CorpseMinimumApproachRange = cell.AsDouble() * 240d; break;
case "navclosestoprange": n.MinimumDistanceMeters = cell.AsDouble() * 240d; break;
case "navfarstoprange": n.MaximumDistanceMeters = cell.AsDouble() * 240d; break;
case "useportaldistance": n.PortalUseDistanceMeters = cell.AsDouble() * 240d; break;
case "helperdistancehitp": v.HelperHealthDistance = cell.AsDouble() * 240d; break;
case "helperdistancestam": v.HelperStaminaDistance = cell.AsDouble() * 240d; break;
case "helperdistancemana": v.HelperManaDistance = cell.AsDouble() * 240d; break;
case "minimumringtargets": c.MinimumRingTargets = cell.AsInt(); break;
case "defaultmeleeattackheight": c.AttackHeight = (PluginAttackHeight)cell.AsInt(); break;
case "castdispelself": v.CastDispelSelf = cell.AsBool(); break;
case "usedispelitems": v.UseDispelItems = cell.AsBool(); break;
case "autocram": i.AutoCram = cell.AsBool(); break;
case "autostack": i.AutoStack = cell.AsBool(); break;
case "readunknownscrolls": i.Loot.ReadUnknownScrolls = cell.AsBool(); break;
case "usedispeldrum": v.UseDispelDrum = cell.AsBool(); break;
case "switchwandstodebuff": c.SwitchWandsToDebuff = cell.AsBool(); break;
case "autocraftitems": i.AutoCraftItems = cell.AsBool(); break;
case "usehealersheart": v.UseHealersHeart = cell.AsBool(); break;
case "jumpoutwandcasting": c.JumpOutWandCasting = cell.AsBool(); break;
case "lootallcorpses": i.Loot.LootAllCorpses = cell.AsBool(); break;
case "lootfellowcorpses": i.Loot.LootFellowCorpses = cell.AsBool(); break;
case "dojiggle": c.DoJiggle = cell.AsBool(); break;
case "randomhelperbuffs": b.RandomHelperBuffs = cell.AsBool(); break;
case "randomhelperintervalseconds": b.RandomHelperIntervalSeconds = cell.AsDouble(); break;
case "idlepeacemode": c.IdlePeaceMode = cell.AsBool(); break;
case "targetlock": c.TargetLock = cell.AsBool(); break;
case "stopmacroondeath": c.StopMacroOnDeath = cell.AsBool(); break;
case "usearcs": c.UseArcs = (UseArcsMode)cell.AsInt(); break;
case "arcrange": c.ArcRange = cell.AsDouble() * 240d; break;
case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break;
case "targetselectanglerange": c.TargetSelectAngleRange = cell.AsDouble() * 240d; break;
case "idlebufftopoff": b.IdleBuffTopoff = cell.AsBool(); break;
case "idlebufftopofftimeseconds": b.IdleBuffTopoffSeconds = cell.AsDouble(); break;
case "rebufftimeremainingseconds": b.RebuffWhenUnderSeconds = cell.AsDouble(); break;
case "refillwornmana": i.RefillWornMana = cell.AsBool(); break;
case "refillwornmana-item-manapercent": i.RefillWornManaPercent = cell.AsInt(); break;
case "buffprofile-prots": b.ProtectionElements = cell.AsString(); break;
case "buffprofile-banes": b.BaneElements = cell.AsString(); break;
case "buffprofile_prots": b.ProtectionProfileMode = cell.AsInt(); break;
case "buffprofile_banes": b.BaneProfileMode = cell.AsInt(); break;
case "debuffeachfirst": c.DebuffEachFirst = (DebuffEachFirst)cell.AsInt(); break;
case "autoattackpower": c.AutoAttackPower = cell.AsBool(); break;
case "lootpriorityboost": i.Loot.PriorityBoost = cell.AsBool(); break;
case "corpsecachetimeoutminutes": i.Loot.CorpseCacheTimeoutMinutes = cell.AsDouble(); break;
case "corpseitemappearancetimeoutseconds": i.Loot.CorpseItemAppearanceTimeoutSeconds = cell.AsDouble(); break;
case "corpseitemidtimeoutseconds": i.Loot.CorpseItemIdentifyTimeoutSeconds = cell.AsDouble(); break;
case "debuffselectionmethod": c.DebuffSelectionMethod = (DebuffSelectionMethod)cell.AsInt(); break;
case "manastonelootcount": i.Loot.ManaStoneLootCount = cell.AsInt(); break;
case "manatankminimummana": i.Loot.ManaTankMinimumMana = cell.AsInt(); break;
case "splitpeas": i.SplitPeas = cell.AsBool(); break;
case "spellcompmin-critical": i.CriticalComponentMinimum = cell.AsInt(); break;
case "spellcompmin-normal": i.NormalComponentMinimum = cell.AsInt(); break;
case "spellcompmin-idle": i.IdleComponentMinimum = cell.AsInt(); break;
case "rechargeboosttimeseconds": v.RechargeBoostTimeSeconds = cell.AsDouble(); break;
case "rechargeboostamount": v.RechargeBoostAmount = cell.AsInt(); break;
case "usespecialammo": c.UseSpecialAmmo = cell.AsInt(); break;
case "opendoors": n.OpenDoors = cell.AsBool(); break;
case "dooridrange": n.DoorIdentifyRangeMeters = cell.AsDouble() * 240d; break;
case "dooropenrange": n.DoorOpenRangeMeters = cell.AsDouble() * 240d; break;
case "doorlockpickdiffexcessthreshold": n.DoorLockpickExcessThreshold = cell.AsInt(); break;
case "manachargeswhenoff": i.ManaChargesWhenOff = cell.AsBool(); break;
case "autofellowmanagement": c.AutoFellowManagement = cell.AsBool(); break;
case "minimumhealkitsuccesschance": v.MinimumHealKitSuccessChance = cell.AsInt(); break;
case "usekitsinmagicmode": v.UseKitsInMagicMode = cell.AsBool(); break;
case "staminatohealthmultiplier": v.StaminaToHealthMultiplier = cell.AsDouble(); break;
case "manatohealthmultiplier": v.ManaToHealthMultiplier = cell.AsDouble(); break;
case "navpriorityboost": n.Priority = cell.AsBool(); break;
case "deleteghostmonsters": c.DeleteGhostMonsters = cell.AsBool(); break;
case "ghostmonsterspellattemptcount": c.GhostMonsterSpellAttemptCount = cell.AsInt(); break;
case "whoyougonnacall": c.WhoYouGonnaCall = cell.AsBool(); break;
case "blacklistmonsterattemptcount": c.BlacklistMonsterAttemptCount = cell.AsInt(); break;
case "blacklistmonstertimeoutseconds": c.BlacklistMonsterTimeoutSeconds = cell.AsDouble(); break;
case "combinesalvage": i.Loot.CombineSalvage = cell.AsBool(); break;
case "lootonlyrarecorpses": i.Loot.LootOnlyRareCorpses = cell.AsBool(); break;
case "deleteghostmonstersbyhptracker": c.DeleteGhostMonstersByHealthTracker = cell.AsBool(); break;
case "ghostdeletehptrackerseconds": c.GhostDeleteHealthTrackerSeconds = cell.AsDouble(); break;
case "gotopeacemodetousekits": v.GoToPeaceModeToUseKits = cell.AsBool(); break;
case "userecklessness": c.UseRecklessness = cell.AsBool(); break;
case "debuffprecastseconds": c.DebuffPrecastSeconds = cell.AsDouble(); break;
case "clearlevelboostflagoncast": v.ClearLevelBoostFlagOnCast = cell.AsBool(); break;
case "idlecraftcount_healthkits": i.IdleHealthKitCount = cell.AsInt(); break;
case "idlecraftcount_stamkits": i.IdleStaminaKitCount = cell.AsInt(); break;
case "idlecraftcount_manakits": i.IdleManaKitCount = cell.AsInt(); break;
case "idlecraftcount_healthfood": i.IdleHealthFoodCount = cell.AsInt(); break;
case "idlecraftcount_stamfood": i.IdleStaminaFoodCount = cell.AsInt(); break;
case "idlecraftcount_manafood": i.IdleManaFoodCount = cell.AsInt(); break;
case "buffcastrecast_seconds": b.BuffCastRecastSeconds = cell.AsDouble(); break;
case "buffcastrecastreset_seconds": b.BuffCastRecastResetSeconds = cell.AsDouble(); break;
case "enablemeta": break; // live MetaEngine.Enabled, not a stored settings field.
case "blacklistedspellcomps":
b.BlacklistedSpellComponents = cell.AsString();
c.BlacklistedSpellComponents = cell.AsString();
break;
case "droptopeacemoderetrycount": v.DropToPeaceModeRetryCount = cell.AsInt(); break;
case "followaroundcorners": n.FollowAroundCorners = cell.AsBool(); break;
case "blacklistcorpseopenattemptcount": i.Loot.BlacklistCorpseOpenAttemptCount = cell.AsInt(); break;
case "blacklistcorpseopentimeoutseconds": i.Loot.BlacklistCorpseOpenTimeoutSeconds = cell.AsDouble(); break;
case "summonpets": c.SummonPets = cell.AsBool(); break;
case "petrangemode": c.PetRangeMode = (PetRangeMode)cell.AsInt(); break;
case "petcustomrange": c.PetCustomRange = cell.AsDouble() * 240d; break;
case "petrefillcount-idle": c.PetRefillCountIdle = cell.AsInt(); break;
case "petrefillcount-normal": c.PetRefillCountNormal = cell.AsInt(); break;
case "corpseopentimeoutseconds": i.Loot.CorpseOpenTimeoutSeconds = cell.AsDouble(); break;
case "petmonsterdensity": c.PetMonsterDensity = cell.AsInt(); break;
case "corpselootitemmaxattempts": i.Loot.CorpseLootItemMaxAttempts = cell.AsInt(); break;
case "fastcastbuffs": b.FastCastBuffs = cell.AsBool(); break;
case "usebreakableturnto": c.UseBreakableTurnTo = cell.AsBool(); break;
case "useprojectileawareness": c.UseProjectileAwareness = cell.AsBool(); break;
case "collisionprojectileradius": c.CollisionProjectileRadius = cell.AsDouble(); break;
case "collisionstepdistance": c.CollisionStepDistance = cell.AsDouble(); break;
case "showcollisiondebug": c.ShowCollisionDebug = cell.AsBool(); break;
case "maximumcollisioncheckspertick": c.MaximumCollisionChecksPerTick = cell.AsInt(); break;
case "spellrangefudge": c.SpellRangeFudge = cell.AsDouble(); break;
case "buffwithuntrained-item": b.BuffWithUntrainedItemSkill = cell.AsInt(); break;
case "buffwithuntrained-creature": b.BuffWithUntrainedCreatureSkill = cell.AsInt(); break;
case "buffwithuntrained-life": b.BuffWithUntrainedLifeSkill = cell.AsInt(); break;
case "allowdebufffallback": c.AllowDebuffFallback = cell.AsBool(); break;
case "rechargehandlerset":
if (cell.Tag == "TABLE" && cell.Table is { } table)
v.RechargeHandlerRows = ParseRechargeHandlerSet(table);
break;
default: break;
}
}
// ------------------------------------------------------------------
// Capture: live setting -> .usd cell. Mirror of Apply above; a name
// Apply ignores (enablemeta, rechargehandlerset) also returns null here
// so Save leaves that row's original cell untouched.
// ------------------------------------------------------------------
/// <summary>Internal (not private) solely so the 137-setting coverage test can call it directly.</summary>
internal static VtankCell? Capture(string rawName, AllSettings s)
{
if (!VtankOptionCatalog.IsKnown(rawName))
return null;
string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant();
CombatSettings c = s.Combat;
BuffSettings b = s.Buffs;
VitalSettings v = s.Vitals;
InventorySettings i = s.Inventory;
NavigationSettings n = s.Navigation;
return name switch
{
"enablelooting" => VtankCell.Bool(i.Loot.Enabled),
"enablenav" => VtankCell.Bool(n.Enabled),
"enablebuffing" => VtankCell.Bool(b.Enabled),
"enablecombat" => VtankCell.Bool(c.Enabled),
"spelldiffexcessthreshold-hunt" => Num(name, c.HuntSkillExcessOverDifficulty),
"spelldiffexcessthreshold-buff" => Num(name, b.SkillExcessOverDifficulty),
"arrowheadfletchdiffexcessthreshold" => Num(name, i.ArrowheadFletchDifficultyExcess),
"recharge-norm-hitp" => Num(name, v.NormalHealth * 100d),
"recharge-norm-stam" => Num(name, v.NormalStamina * 100d),
"recharge-norm-mana" => Num(name, v.NormalMana * 100d),
"recharge-notarg-hitp" => Num(name, v.NoTargetHealth * 100d),
"recharge-notarg-stam" => Num(name, v.NoTargetStamina * 100d),
"recharge-notarg-mana" => Num(name, v.NoTargetMana * 100d),
"recharge-helper-hitp" => Num(name, v.HelperHealth * 100d),
"recharge-helper-stam" => Num(name, v.HelperStamina * 100d),
"recharge-helper-mana" => Num(name, v.HelperMana * 100d),
"dohelp" => VtankCell.Bool(v.HelpOthers),
"attackdistance" => Num(name, c.MaximumRange / 240d),
"attackminimumdistance" => Num(name, c.MinimumRange / 240d),
"approachdistance" => Num(name, c.ApproachDistance / 240d),
"ringdistance" => Num(name, c.RingDistance / 240d),
"corpseapproachrange-max" => Num(name, i.Loot.CorpseApproachRange / 240d),
"corpseapproachrange-min" => Num(name, i.Loot.CorpseMinimumApproachRange / 240d),
"navclosestoprange" => Num(name, n.MinimumDistanceMeters / 240d),
"navfarstoprange" => Num(name, n.MaximumDistanceMeters / 240d),
"useportaldistance" => Num(name, n.PortalUseDistanceMeters / 240d),
"helperdistancehitp" => Num(name, v.HelperHealthDistance / 240d),
"helperdistancestam" => Num(name, v.HelperStaminaDistance / 240d),
"helperdistancemana" => Num(name, v.HelperManaDistance / 240d),
"minimumringtargets" => Num(name, c.MinimumRingTargets),
"defaultmeleeattackheight" => Num(name, (int)c.AttackHeight),
"castdispelself" => VtankCell.Bool(v.CastDispelSelf),
"usedispelitems" => VtankCell.Bool(v.UseDispelItems),
"autocram" => VtankCell.Bool(i.AutoCram),
"autostack" => VtankCell.Bool(i.AutoStack),
"readunknownscrolls" => VtankCell.Bool(i.Loot.ReadUnknownScrolls),
"usedispeldrum" => VtankCell.Bool(v.UseDispelDrum),
"switchwandstodebuff" => VtankCell.Bool(c.SwitchWandsToDebuff),
"autocraftitems" => VtankCell.Bool(i.AutoCraftItems),
"usehealersheart" => VtankCell.Bool(v.UseHealersHeart),
"jumpoutwandcasting" => VtankCell.Bool(c.JumpOutWandCasting),
"lootallcorpses" => VtankCell.Bool(i.Loot.LootAllCorpses),
"lootfellowcorpses" => VtankCell.Bool(i.Loot.LootFellowCorpses),
"dojiggle" => VtankCell.Bool(c.DoJiggle),
"randomhelperbuffs" => VtankCell.Bool(b.RandomHelperBuffs),
"randomhelperintervalseconds" => Num(name, b.RandomHelperIntervalSeconds),
"idlepeacemode" => VtankCell.Bool(c.IdlePeaceMode),
"targetlock" => VtankCell.Bool(c.TargetLock),
"stopmacroondeath" => VtankCell.Bool(c.StopMacroOnDeath),
"usearcs" => Num(name, (int)c.UseArcs),
"arcrange" => Num(name, c.ArcRange / 240d),
"targetselectmethod" => Num(name, (int)c.SelectionMethod + 1),
"targetselectanglerange" => Num(name, c.TargetSelectAngleRange / 240d),
"idlebufftopoff" => VtankCell.Bool(b.IdleBuffTopoff),
"idlebufftopofftimeseconds" => Num(name, b.IdleBuffTopoffSeconds),
"rebufftimeremainingseconds" => Num(name, b.RebuffWhenUnderSeconds),
"refillwornmana" => VtankCell.Bool(i.RefillWornMana),
"refillwornmana-item-manapercent" => Num(name, i.RefillWornManaPercent),
"buffprofile-prots" => VtankCell.String(b.ProtectionElements),
"buffprofile-banes" => VtankCell.String(b.BaneElements),
"buffprofile_prots" => Num(name, b.ProtectionProfileMode),
"buffprofile_banes" => Num(name, b.BaneProfileMode),
"debuffeachfirst" => Num(name, (int)c.DebuffEachFirst),
"autoattackpower" => VtankCell.Bool(c.AutoAttackPower),
"lootpriorityboost" => VtankCell.Bool(i.Loot.PriorityBoost),
"corpsecachetimeoutminutes" => Num(name, i.Loot.CorpseCacheTimeoutMinutes),
"corpseitemappearancetimeoutseconds" => Num(name, i.Loot.CorpseItemAppearanceTimeoutSeconds),
"corpseitemidtimeoutseconds" => Num(name, i.Loot.CorpseItemIdentifyTimeoutSeconds),
"debuffselectionmethod" => Num(name, (int)c.DebuffSelectionMethod),
"manastonelootcount" => Num(name, i.Loot.ManaStoneLootCount),
"manatankminimummana" => Num(name, i.Loot.ManaTankMinimumMana),
"splitpeas" => VtankCell.Bool(i.SplitPeas),
"spellcompmin-critical" => Num(name, i.CriticalComponentMinimum),
"spellcompmin-normal" => Num(name, i.NormalComponentMinimum),
"spellcompmin-idle" => Num(name, i.IdleComponentMinimum),
"rechargeboosttimeseconds" => Num(name, v.RechargeBoostTimeSeconds),
"rechargeboostamount" => Num(name, v.RechargeBoostAmount),
"usespecialammo" => Num(name, c.UseSpecialAmmo),
"opendoors" => VtankCell.Bool(n.OpenDoors),
"dooridrange" => Num(name, n.DoorIdentifyRangeMeters / 240d),
"dooropenrange" => Num(name, n.DoorOpenRangeMeters / 240d),
"doorlockpickdiffexcessthreshold" => Num(name, n.DoorLockpickExcessThreshold),
"manachargeswhenoff" => VtankCell.Bool(i.ManaChargesWhenOff),
"autofellowmanagement" => VtankCell.Bool(c.AutoFellowManagement),
"minimumhealkitsuccesschance" => Num(name, v.MinimumHealKitSuccessChance),
"usekitsinmagicmode" => VtankCell.Bool(v.UseKitsInMagicMode),
"staminatohealthmultiplier" => Num(name, v.StaminaToHealthMultiplier),
"manatohealthmultiplier" => Num(name, v.ManaToHealthMultiplier),
"navpriorityboost" => VtankCell.Bool(n.Priority),
"deleteghostmonsters" => VtankCell.Bool(c.DeleteGhostMonsters),
"ghostmonsterspellattemptcount" => Num(name, c.GhostMonsterSpellAttemptCount),
"whoyougonnacall" => VtankCell.Bool(c.WhoYouGonnaCall),
"blacklistmonsterattemptcount" => Num(name, c.BlacklistMonsterAttemptCount),
"blacklistmonstertimeoutseconds" => Num(name, c.BlacklistMonsterTimeoutSeconds),
"combinesalvage" => VtankCell.Bool(i.Loot.CombineSalvage),
"lootonlyrarecorpses" => VtankCell.Bool(i.Loot.LootOnlyRareCorpses),
"deleteghostmonstersbyhptracker" => VtankCell.Bool(c.DeleteGhostMonstersByHealthTracker),
"ghostdeletehptrackerseconds" => Num(name, c.GhostDeleteHealthTrackerSeconds),
"gotopeacemodetousekits" => VtankCell.Bool(v.GoToPeaceModeToUseKits),
"userecklessness" => VtankCell.Bool(c.UseRecklessness),
"debuffprecastseconds" => Num(name, c.DebuffPrecastSeconds),
"clearlevelboostflagoncast" => VtankCell.Bool(v.ClearLevelBoostFlagOnCast),
"idlecraftcount_healthkits" => Num(name, i.IdleHealthKitCount),
"idlecraftcount_stamkits" => Num(name, i.IdleStaminaKitCount),
"idlecraftcount_manakits" => Num(name, i.IdleManaKitCount),
"idlecraftcount_healthfood" => Num(name, i.IdleHealthFoodCount),
"idlecraftcount_stamfood" => Num(name, i.IdleStaminaFoodCount),
"idlecraftcount_manafood" => Num(name, i.IdleManaFoodCount),
"buffcastrecast_seconds" => Num(name, b.BuffCastRecastSeconds),
"buffcastrecastreset_seconds" => Num(name, b.BuffCastRecastResetSeconds),
"blacklistedspellcomps" => VtankCell.String(b.BlacklistedSpellComponents),
"droptopeacemoderetrycount" => Num(name, v.DropToPeaceModeRetryCount),
"followaroundcorners" => VtankCell.Bool(n.FollowAroundCorners),
"blacklistcorpseopenattemptcount" => Num(name, i.Loot.BlacklistCorpseOpenAttemptCount),
"blacklistcorpseopentimeoutseconds" => Num(name, i.Loot.BlacklistCorpseOpenTimeoutSeconds),
"summonpets" => VtankCell.Bool(c.SummonPets),
"petrangemode" => Num(name, (int)c.PetRangeMode),
"petcustomrange" => Num(name, c.PetCustomRange / 240d),
"petrefillcount-idle" => Num(name, c.PetRefillCountIdle),
"petrefillcount-normal" => Num(name, c.PetRefillCountNormal),
"corpseopentimeoutseconds" => Num(name, i.Loot.CorpseOpenTimeoutSeconds),
"petmonsterdensity" => Num(name, c.PetMonsterDensity),
"corpselootitemmaxattempts" => Num(name, i.Loot.CorpseLootItemMaxAttempts),
"fastcastbuffs" => VtankCell.Bool(b.FastCastBuffs),
"usebreakableturnto" => VtankCell.Bool(c.UseBreakableTurnTo),
"useprojectileawareness" => VtankCell.Bool(c.UseProjectileAwareness),
"collisionprojectileradius" => Num(name, c.CollisionProjectileRadius),
"collisionstepdistance" => Num(name, c.CollisionStepDistance),
"showcollisiondebug" => VtankCell.Bool(c.ShowCollisionDebug),
"maximumcollisioncheckspertick" => Num(name, c.MaximumCollisionChecksPerTick),
"spellrangefudge" => Num(name, c.SpellRangeFudge),
"buffwithuntrained-item" => Num(name, b.BuffWithUntrainedItemSkill),
"buffwithuntrained-creature" => Num(name, b.BuffWithUntrainedCreatureSkill),
"buffwithuntrained-life" => Num(name, b.BuffWithUntrainedLifeSkill),
"allowdebufffallback" => VtankCell.Bool(c.AllowDebuffFallback),
_ => null, // "enablemeta" (live engine state) and "rechargehandlerset"
// (no write path exists in real VTank either, section 2 row 137)
// are deliberately left untouched.
};
}
/// <summary>
/// Parses the RechargeHandlerSet nested TABLE (5 columns:
/// Vital/HandlerString/MinPercent/MaxPercent/Stance — verified against
/// the live <c>defaultsettings.usd</c> fixture, 26 rows).
/// </summary>
internal static RechargeHandlerRow[] ParseRechargeHandlerSet(VtankTable table)
{
int vitalColumn = table.ColumnIndex("Vital");
int handlerColumn = table.ColumnIndex("HandlerString");
int minColumn = table.ColumnIndex("MinPercent");
int maxColumn = table.ColumnIndex("MaxPercent");
int stanceColumn = table.ColumnIndex("Stance");
if (vitalColumn < 0 || handlerColumn < 0 || minColumn < 0
|| maxColumn < 0 || stanceColumn < 0)
{
return [];
}
var rows = new RechargeHandlerRow[table.Rows.Count];
for (int i = 0; i < table.Rows.Count; i++)
{
VtankRow row = table.Rows[i];
rows[i] = new RechargeHandlerRow(
row.Cells[vitalColumn].AsInt(),
row.Cells[handlerColumn].AsString(),
row.Cells[minColumn].AsInt(),
row.Cells[maxColumn].AsInt(),
row.Cells[stanceColumn].AsInt());
}
return rows;
}
// No RenderRechargeHandlerSet/write-back helper exists here deliberately:
// real VTank has NO save path for this table at all (docs/research/
// vtank-kb/01-settings-and-profiles.md section 2, row 137 — tCustom has
// no case in the Advanced Options editor's display/edit switch or in
// "/vt opt set"; the table is owned by a dedicated cRechargeManager
// object, not any control that persists through the generic Settings
// save path). Writing it back would be a MossTank invention, not a
// retail port. A prior version of this file had an unused
// RenderRechargeHandlerSet helper for exactly that invention; it was
// deleted rather than wired in for this reason.
}

View file

@ -0,0 +1,438 @@
using System.Globalization;
using System.Linq;
using System.Text;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The self-describing recursive text-database grammar VTank uses for
/// <c>.usd</c> settings profiles and <c>.ast</c> per-character caches. Ported
/// from the decompiled <c>gy</c>/<c>cw</c>/<c>bd</c>/<c>y</c> classes
/// documented in <c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
/// section 1: <c>refs/vtank/decompiled/gy.cs:20-56</c> (typed cell),
/// <c>cw.cs:9-40</c> (row), <c>bd.cs:364-395</c> (table), <c>y.cs:87-104</c>
/// (whole database). Plain text, CRLF line endings, no compression, no
/// checksum, no top-level version header.
///
/// One cell (<c>gy</c>) is a type-tag line followed by zero or more value
/// lines: <c>d</c>=double, <c>i</c>=int, <c>u</c>=uint, <c>f</c>=float,
/// <c>s</c>=string (the raw next line, possibly empty), <c>b</c>=bool
/// (<c>"True"</c>/<c>"False"</c>), <c>TABLE</c>=a nested table (recurse into
/// <see cref="VtankTable"/>), <c>ba</c>=a length-prefixed raw-character blob
/// (an int line for the character count, then that many raw characters,
/// not lines, so embedded newlines survive — see <see cref="VtankLineCursor.ReadBlob"/>).
/// Any other tag (<c>y.cs:28-41</c> only ever registers <c>TABLE</c> and
/// <c>ba</c> as named custom types) is genuinely unrecognized to VTank
/// itself: <c>gy.cs:50-55</c> (read) consumes ONLY the tag line and
/// <c>gy.cs:98-101</c> (write) emits ONLY a tag line ("0" for a void cell)
/// — no value line either way. This is preserved verbatim so an
/// unrecognized custom type still round-trips byte-for-byte.
/// </summary>
internal sealed class VtankCell
{
// The only tags VTank's gy.a(TextReader)/gy.a(TextWriter) treat as a
// tag+value pair (gy.cs:20-56, gy.cs:60-92). TABLE and ba are each
// handled by their own dedicated branch; everything else is an
// unrecognized/void tag that carries no value line at all.
private static readonly HashSet<string> KnownScalarTags = ["d", "i", "u", "f", "s", "b"];
internal VtankCell() { }
/// <summary>The raw type tag: <c>d</c>/<c>i</c>/<c>u</c>/<c>f</c>/<c>s</c>/<c>b</c>/<c>TABLE</c>/<c>ba</c>/other.</summary>
public required string Tag { get; init; }
/// <summary>Raw text of the single value line, for every scalar tag except <c>ba</c>.</summary>
public string? ScalarText { get; set; }
/// <summary>The nested table payload, only when <see cref="Tag"/> is <c>TABLE</c>.</summary>
public VtankTable? Table { get; set; }
/// <summary>The raw character blob, only when <see cref="Tag"/> is <c>ba</c>.</summary>
public string? BlobText { get; set; }
public static VtankCell Double(double value) => new()
{
Tag = "d",
ScalarText = FormatDouble(value),
};
public static VtankCell Int(int value) => new()
{
Tag = "i",
ScalarText = value.ToString(CultureInfo.InvariantCulture),
};
public static VtankCell UInt(uint value) => new()
{
Tag = "u",
ScalarText = value.ToString(CultureInfo.InvariantCulture),
};
public static VtankCell Float(float value) => new()
{
Tag = "f",
ScalarText = FormatDouble(value),
};
public static VtankCell String(string value) => new()
{
Tag = "s",
// VTank strips embedded newlines from a string cell on write
// (gy.cs:84: `text.Replace("\n", "")`) — a string cell is always
// exactly one line; a blob that needs embedded newlines uses "ba".
ScalarText = value.Replace("\n", string.Empty, StringComparison.Ordinal),
};
public static VtankCell Bool(bool value) => new()
{
Tag = "b",
ScalarText = value ? "True" : "False",
};
public static VtankCell NestedTable(VtankTable table) => new()
{
Tag = "TABLE",
Table = table,
};
public double AsDouble() => double.Parse(
ScalarText ?? "0",
NumberStyles.Float,
CultureInfo.InvariantCulture);
public int AsInt() => int.Parse(
ScalarText ?? "0",
NumberStyles.Integer,
CultureInfo.InvariantCulture);
public uint AsUInt() => uint.Parse(
ScalarText ?? "0",
NumberStyles.Integer,
CultureInfo.InvariantCulture);
public float AsFloat() => float.Parse(
ScalarText ?? "0",
NumberStyles.Float,
CultureInfo.InvariantCulture);
public string AsString() => ScalarText ?? string.Empty;
/// <summary>
/// VTank's <c>gy</c> bool tag stores the literal text <c>"True"</c> or
/// <c>"False"</c> (C#'s default <see cref="bool"/> formatting).
/// </summary>
public bool AsBool() => string.Equals(ScalarText, "True", StringComparison.Ordinal);
/// <summary>
/// Formats a double/float the way VTank's own (older .NET Framework)
/// build does: <c>Convert.ToString(double, CultureInfo)</c>
/// (<c>gy.cs:60-61</c>) used the CLR's classic 15-significant-digit
/// general format, not .NET Core's shortest-round-trippable default.
/// Verified against every <c>d</c>-tagged value in the real
/// <c>defaultsettings.usd</c> fixture (e.g. 5/240 renders as
/// <c>0.0208333333333333</c> — 15 significant digits).
/// </summary>
internal static string FormatDouble(double value) =>
value.ToString("G15", CultureInfo.InvariantCulture);
internal void WriteTo(StringBuilder sb)
{
VtankWriter.AppendLine(sb, Tag);
if (Tag == "TABLE")
{
Table!.WriteTo(sb);
}
else if (Tag == "ba")
{
// A "ba" blob is a raw character count, not a line count: the
// blob may contain embedded newlines. VTank's own writer
// (f6.cs:20-24) WriteLine()s only the length, then Write()s the
// raw characters with NO trailing line terminator — whatever
// structural token follows continues immediately after the
// blob's last character, exactly like the real client's output.
string blob = BlobText ?? string.Empty;
VtankWriter.AppendLine(sb, blob.Length.ToString(CultureInfo.InvariantCulture));
sb.Append(blob);
}
else if (KnownScalarTags.Contains(Tag))
{
VtankWriter.AppendLine(sb, ScalarText ?? string.Empty);
}
// else: an unrecognized/void tag is the WHOLE cell (gy.cs:98-101
// writes only "0", no value line) — nothing further to emit.
}
}
/// <summary>Shared line-terminator helper for every <c>WriteTo</c> in this file (CRLF, matching VTank's own writer).</summary>
internal static class VtankWriter
{
internal static void AppendLine(StringBuilder sb, string text) =>
sb.Append(text).Append("\r\n");
}
internal sealed class VtankRow
{
public List<VtankCell> Cells { get; } = [];
internal void WriteTo(StringBuilder sb)
{
foreach (VtankCell cell in Cells)
cell.WriteTo(sb);
}
}
/// <summary>
/// One <c>bd</c> table: a column-name list, a hash-index flag per column,
/// and a row list. Row length is NOT fixed at parse time — a <c>TABLE</c> or
/// <c>ba</c> cell recurses, so the reader must walk cell-by-cell rather than
/// assume a fixed line stride per row (<c>bd.cs:364-395</c>).
/// </summary>
internal sealed class VtankTable
{
public List<string> ColumnNames { get; } = [];
public List<bool> IndexFlags { get; } = [];
public List<VtankRow> Rows { get; } = [];
public int ColumnIndex(string name) => ColumnNames.FindIndex(
column => column.Equals(name, StringComparison.Ordinal));
internal static VtankTable Read(VtankLineCursor cursor)
{
var table = new VtankTable();
int columnCount = cursor.ReadInt();
for (int i = 0; i < columnCount; i++)
table.ColumnNames.Add(cursor.ReadLine());
for (int i = 0; i < columnCount; i++)
table.IndexFlags.Add(cursor.ReadLine() == "y");
int rowCount = cursor.ReadInt();
for (int r = 0; r < rowCount; r++)
{
var row = new VtankRow();
for (int c = 0; c < columnCount; c++)
row.Cells.Add(VtankDatabaseReader.ReadCell(cursor));
table.Rows.Add(row);
}
return table;
}
internal void WriteTo(StringBuilder sb)
{
VtankWriter.AppendLine(sb, ColumnNames.Count.ToString(CultureInfo.InvariantCulture));
foreach (string column in ColumnNames)
VtankWriter.AppendLine(sb, column);
foreach (bool flag in IndexFlags)
VtankWriter.AppendLine(sb, flag ? "y" : "n");
VtankWriter.AppendLine(sb, Rows.Count.ToString(CultureInfo.InvariantCulture));
foreach (VtankRow row in Rows)
row.WriteTo(sb);
}
}
/// <summary>The whole <c>y</c> database: an ordered set of named tables.</summary>
internal sealed class VtankDatabase
{
public List<(string Name, VtankTable Table)> Tables { get; } = [];
public VtankTable? Find(string name) => Tables
.Where(entry => entry.Name.Equals(name, StringComparison.Ordinal))
.Select(static entry => entry.Table)
.FirstOrDefault();
public static VtankDatabase Parse(string text)
{
var cursor = new VtankLineCursor(text);
var database = new VtankDatabase();
int tableCount = cursor.ReadInt();
for (int i = 0; i < tableCount; i++)
{
string name = cursor.ReadLine();
VtankTable table = VtankTable.Read(cursor);
database.Tables.Add((name, table));
}
return database;
}
/// <summary>
/// VTank's own <c>y</c> class holds its tables in a
/// <c>SortedDictionary&lt;string, bd&gt;</c> (item J, slice-1 fix
/// round), so a real <c>.usd</c>/<c>.ast</c> always emits its tables in
/// table-name order — confirmed against the committed
/// <c>defaultsettings.usd</c> fixture, whose first several tables
/// (AntiExtraBuffSpells, AssistItems, BuffedItems, ExtraBuffSpells,
/// GemFoodItems, …) are already alphabetical. This port's own
/// <see cref="Tables"/> is an insertion-ordered list rather than a
/// sorted structure, so every committed real fixture happens to
/// round-trip in order today purely because it was ALREADY sorted the
/// last time real VTank wrote it — this explicit sort on render makes
/// that invariant a property of the writer itself rather than an
/// accident of whatever order the source data happened to arrive in
/// (no observable behavior change for any fixture today; it only
/// matters the day a future code path adds or reorders a table).
///
/// Round 3 item 12: <see cref="StringComparer.Ordinal"/> is used here
/// as a stand-in for .NET Framework's <c>SortedDictionary&lt;string, bd&gt;</c>
/// default key order — that equivalence is confirmed only for the
/// plain-ASCII, single-case table names VTank itself ships
/// (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
/// claim that ordinal and .NET's culture-aware default string
/// comparer agree for every possible string; a hypothetical future
/// table name using non-ASCII characters or mixed-width comparisons
/// could sort differently under the two. No behavior change is
/// implied or made by this note.
/// </summary>
public string Render()
{
var sb = new StringBuilder();
var ordered = Tables
.OrderBy(static entry => entry.Name, StringComparer.Ordinal)
.ToArray();
VtankWriter.AppendLine(sb, ordered.Length.ToString(CultureInfo.InvariantCulture));
foreach ((string name, VtankTable table) in ordered)
{
VtankWriter.AppendLine(sb, name);
table.WriteTo(sb);
}
return sb.ToString();
}
}
/// <summary>Reads one <see cref="VtankCell"/>, recursing into nested tables and blobs.</summary>
internal static class VtankDatabaseReader
{
public static VtankCell ReadCell(VtankLineCursor cursor)
{
string tag = cursor.ReadLine();
switch (tag)
{
case "TABLE":
return VtankCell.NestedTable(VtankTable.Read(cursor));
case "ba":
int length = cursor.ReadInt();
return new VtankCellBuilder(tag) { BlobText = cursor.ReadBlob(length) }
.Build();
case "d" or "i" or "u" or "f" or "s" or "b":
return new VtankCellBuilder(tag) { ScalarText = cursor.ReadLine() }.Build();
default:
// Unrecognized/void tag (refs/vtank/decompiled/y.cs:28-41
// only ever registers TABLE and ba as named custom types):
// VTank's own reader (gy.cs:50-55) consumes ONLY the tag
// line here — no value line follows.
return new VtankCellBuilder(tag).Build();
}
}
// Small builder so VtankCell's constructor can stay private to the file
// (its public factory methods are the intended typed-construction path)
// while the reader still needs to synthesize an arbitrary/unknown tag.
private readonly struct VtankCellBuilder(string tag)
{
public string? ScalarText { get; init; }
public string? BlobText { get; init; }
public VtankCell Build() => VtankCellFactory.Create(tag, ScalarText, BlobText);
}
}
/// <summary>Internal factory seam so the reader can build cells with an arbitrary tag.</summary>
internal static class VtankCellFactory
{
public static VtankCell Create(string tag, string? scalarText, string? blobText) => new()
{
Tag = tag,
ScalarText = scalarText,
BlobText = blobText,
};
}
/// <summary>
/// Character-oriented cursor over the exact (unnormalized) text of a
/// <c>.usd</c>/<c>.ast</c> file. VTank's own reader is a strict token stream
/// (<c>StreamReader.ReadLine()</c>/<c>TextReader.Read(char[], int, int)</c>
/// calls in a fixed order) — there is no way to resynchronize after a parse
/// error, so every read here throws immediately with a file:line-shaped
/// message on the first unexpected token.
///
/// The cursor deliberately keeps the ORIGINAL string, never pre-splitting on
/// <c>'\n'</c>: a <c>ba</c> blob (<see cref="ReadBlob"/>) reads exactly N raw
/// characters straight out of that string, embedded <c>\r</c>/<c>\n</c>
/// included, matching <c>f6.cs:10-17</c>'s <c>TextReader.Read(array, 0, num)</c>
/// exactly — a CRLF-terminated line INSIDE a blob counts as 2 characters
/// toward that length, same as it would for VTank itself.
/// </summary>
internal sealed class VtankLineCursor
{
private readonly string _text;
private int _position;
private int _lineNumber = 1;
public VtankLineCursor(string text)
{
ArgumentNullException.ThrowIfNull(text);
_text = text;
}
public int LineNumber => _lineNumber;
public string ReadLine()
{
if (_position >= _text.Length)
{
throw new FormatException(
$"line {LineNumber}: unexpected end of file (expected another line).");
}
int newlineIndex = _text.IndexOf('\n', _position);
string line;
if (newlineIndex < 0)
{
line = _text[_position..];
_position = _text.Length;
}
else
{
line = _text[_position..newlineIndex];
_position = newlineIndex + 1;
}
if (line.EndsWith('\r'))
line = line[..^1];
_lineNumber++;
return line;
}
public int ReadInt()
{
string line = ReadLine();
if (!int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
{
throw new FormatException(
$"line {LineNumber - 1}: expected an integer, got '{line}'.");
}
return value;
}
/// <summary>
/// Reads a <c>ba</c> blob's exact character count straight out of the
/// underlying text with no line-oriented interpretation whatsoever
/// (<c>f6.cs:10-17</c>: <c>TextReader.Read(array, 0, num)</c> starting
/// immediately after the length line's terminator) — embedded
/// <c>\r</c>/<c>\n</c> characters are counted and returned verbatim, not
/// folded or re-joined.
/// </summary>
public string ReadBlob(int length)
{
if (length <= 0)
return string.Empty;
if (_position + length > _text.Length)
{
throw new FormatException(
$"line {LineNumber}: blob of {length} characters exceeds remaining input.");
}
string blob = _text.Substring(_position, length);
_position += length;
foreach (char c in blob)
{
if (c == '\n')
_lineNumber++;
}
return blob;
}
}

View file

@ -136,6 +136,9 @@
onclick="{CreateProfile}" />
<button x="448" y="36" w="112" h="23" text="Clear profile!"
onclick="{ClearProfile}" />
<button x="568" y="36" w="76" h="23" text="Delete"
onclick="{DeleteProfile}"
tooltip="Delete the selected named profile's real .usd file. Not available for By char." />
<toggle x="580" y="4" w="120" h="20" text="Mine only"
checked="{MineOnlyEnabled}" onclick="{ToggleMineOnly}" />
@ -155,6 +158,9 @@
onclick="{CreateRouteProfile}" />
<button x="560" y="64" w="84" h="22" text="Clear route"
onclick="{ClearRouteProfile}" />
<button x="650" y="64" w="70" h="22" text="Delete"
onclick="{DeleteRouteProfile}"
tooltip="Delete the selected named route's real .af file. Not available for By char." />
<toggle x="4" y="92" w="150" h="20" text="Enable Looting"
checked="{LootEnabled}" onclick="{ToggleLooting}" />
<menu x="156" y="90" w="116" h="22" items="{LootProfileNames}"
@ -454,9 +460,12 @@
onclick="{CopyMetaProfile}" />
<button x="378" y="0" w="48" h="21" text="Clear"
onclick="{ClearMetaProfile}" />
<toggle x="446" y="2" w="112" h="20" text="Enable Meta"
<button x="428" y="0" w="66" h="21" text="Delete"
onclick="{DeleteMetaProfile}"
tooltip="Delete the selected named Meta profile's real .af file. Not available for By char." />
<toggle x="500" y="2" w="112" h="20" text="Enable Meta"
checked="{MetaEnabled}" onclick="{ToggleMeta}" />
<label x="566" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
<label x="620" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
<list x="4" y="26" w="776" h="68" rowheight="17"
items="{MetaRows}" selected="{SelectedMetaRuleIndex}"
@ -530,6 +539,9 @@
onclick="{CopyLootProfile}" />
<button x="494" y="0" w="58" h="21" text="Clear"
onclick="{ClearLootProfile}" />
<button x="560" y="0" w="64" h="21" text="Delete"
onclick="{DeleteLootProfile}"
tooltip="Delete the selected named loot profile's real .utl file. Not available for By char." />
<button x="684" y="0" w="92" h="21" text="Back"
onclick="{CloseLootEditor}" />
<list x="4" y="24" w="520" h="82" rowheight="17"

View file

@ -0,0 +1,37 @@
using AcDream.App.Plugins;
namespace AcDream.App.Tests.Plugins;
/// <summary>
/// Item H (Campaign VT slice-1 fix round): a genuinely failable Linux-path
/// guarantee for <see cref="VtankProfilesDefault.Resolve"/> — re-derives the
/// expected value with <see cref="Path.Combine(string, string)"/> from an
/// injected root and compares, so a future hard-coded
/// <c>dataDirectory + "\vtank"</c> (or any other literal-backslash
/// regression) fails this test on any OS, not just by inspection.
/// </summary>
public sealed class VtankProfilesDefaultTests
{
[Fact]
public void ResolveIsBuiltWithPathCombineOnly()
{
const string injectedRoot = "C:/some/injected/data-dir";
string expected = Path.Combine(injectedRoot, "vtank");
string actual = VtankProfilesDefault.Resolve(injectedRoot);
Assert.Equal(expected, actual);
Assert.DoesNotContain(
injectedRoot + "\\vtank",
actual.Replace(Path.DirectorySeparatorChar, '/'),
StringComparison.Ordinal);
}
[Fact]
public void ResolveAppendsVtankBeneathWhicheverRootIsInjected()
{
Assert.Equal(
Path.Combine("/home/user/.local/share/acdream", "vtank"),
VtankProfilesDefault.Resolve("/home/user/.local/share/acdream"));
}
}

View file

@ -169,6 +169,7 @@ public sealed class RuntimeOptionsTests
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
Assert.Empty(opts.PluginTags);
Assert.Null(opts.VtankProfileDirectoryOverride);
}
[Fact]
@ -186,6 +187,26 @@ public sealed class RuntimeOptionsTests
Assert.Equal(["healer", "Leader", "scout"], options.PluginTags);
}
[Fact]
public void VtankProfileDirectoryOverrideIsNullUnlessSet()
{
RuntimeOptions blank = RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VTANK_PROFILE_DIR"] = "" }));
Assert.Null(blank.VtankProfileDirectoryOverride);
RuntimeOptions set = RuntimeOptions.Parse(
AnyDatDir,
Env(new()
{
["ACDREAM_VTANK_PROFILE_DIR"] =
"C:/Games/VirindiPlugins/VirindiTank",
}));
Assert.Equal(
"C:/Games/VirindiPlugins/VirindiTank",
set.VtankProfileDirectoryOverride);
}
[Fact]
public void PreparedAssetPath_DefaultsBesideDats_AndAllowsOneOverride()
{

View file

@ -35,6 +35,26 @@ public sealed class PluginSessionTests
alpha.Storage.WriteText("../escape.json", "bad"));
}
// Item F (Campaign VT slice-1 fix round): VtankProfiles must be
// forwarded UNSCOPED, unlike Storage — it names one shared external
// location (a real VTank profile folder, or a host-composed portable
// default), not per-plugin data, so prefixing it by manifest id would
// defeat the point of pointing it at a real installed VTank directory.
[Fact]
public void ScopedHostForwardsVtankProfilesUnscoped()
{
var vtankProfiles = new MemoryStorage();
using var scope = new ScopedPluginHost(
new StubHost(vtankProfiles: vtankProfiles),
"acdream.alpha",
"Alpha");
scope.VtankProfiles.WriteText("Shared.usd", "content");
Assert.Same(vtankProfiles, scope.VtankProfiles);
Assert.Equal("content", vtankProfiles.Text["Shared.usd"]);
}
[Fact]
public void ScopedHostNamespacesAndUnregistersLootClassifierOnDispose()
{
@ -364,7 +384,8 @@ public sealed class PluginSessionTests
private sealed class StubHost(
IPluginStorage? storage = null,
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
@ -377,6 +398,8 @@ public sealed class PluginSessionTests
storage ?? NoOpPluginStorage.Instance;
public IPluginLootClassifierRegistry LootClassifiers { get; } =
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
public IPluginStorage VtankProfiles { get; } =
vtankProfiles ?? NoOpPluginStorage.Instance;
}
private sealed class KeepClassifier : IPluginLootClassifier

View file

@ -24,4 +24,7 @@
Link="mosstank.xml"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\vtank\**\*.*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -58,7 +58,7 @@ public sealed class AttackSpellCatalogTests
IsProjectile = true,
},
]);
var settings = new CombatSettings { UseArcs = true, ArcRange = 15 };
var settings = new CombatSettings { UseArcs = UseArcsMode.AtRange, ArcRange = 15 };
var actions = new MonsterRuleActions
{
DamageType = MonsterDamageType.Fire,
@ -215,7 +215,7 @@ public sealed class AttackSpellCatalogTests
IReadOnlyList<AttackSpellChoice> choices = catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Auto },
new CombatSettings { UseArcs = false },
new CombatSettings { UseArcs = UseArcsMode.No },
target,
0,
Character.Instance);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,559 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
STATE: {Default} ~~ {
IF: Always
DO: DestroyAllViews
IF: Always
DO: CreateView {main} {<?xml version="1.0"?><view width="370" height="200" title="XP Augmentation Categories"><control type="layout"><control type="button" name="btnBurdenPackSlots" left="10" top="10" width="170" height="15" text="Burden and Pack Slots" setstate="stateBurdenPackSlots" /><control type="button" name="btnDeathPenalties" left="10" top="40" width="170" height="15" text="Death Penalties" setstate="stateDeathPenalties" /><control type="button" name="btnExperienceBonus" left="10" top="70" width="170" height="15" text="Experience Bonus" setstate="stateExperienceBonus" /><control type="button" name="btnHealthArmorRegen" left="10" top="100" width="170" height="15" text="Health, Armor, and Regeneration" setstate="stateHealthArmorRegen" /><control type="button" name="btnInnateAttribute" left="10" top="130" width="170" height="15" text="Innate Attribute" setstate="stateInnateAttribute" /><control type="button" name="btnStipend" left="10" top="160" width="170" height="15" text="Stipend" setstate="stateStipend" /><control type="button" name="btnInnateResistance" left="190" top="10" width="170" height="15" text="Innate Resistance" setstate="stateInnateResistance" /><control type="button" name="btnRating" left="190" top="40" width="170" height="15" text="Rating" setstate="stateRatings" /><control type="button" name="btnSalvagingTinkering" left="190" top="70" width="170" height="15" text="Salvaging and Tinkering" setstate="stateSalvagingTinkering" /><control type="button" name="btnSkills" left="190" top="100" width="170" height="15" text="Skills" setstate="stateSkills" /><control type="button" name="btnSpellDuration" left="190" top="130" width="170" height="15" text="Spell Duration" setstate="stateSpellDuration" /><control type="button" name="btnBella" left="190" top="160" width="170" height="15" text="Bella" setstate="stateBella" /></control></view>}
~~ }
STATE: {dummy} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {state} {}
~~ }
STATE: {stateBurdenPackSlots} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateBurdenPackSlots} {<?xml version="1.0"?><view width="300" height="260" title="Burden and Pack Slots"><control type="layout"><control type="button" name="btnMightSeventhMule" left="10" top="10" width="250" height="25" text="Might of the Seventh Mule" actionexpr="setvar[Augment,`stateMightSeventhMule`]" setstate="nav_zaikhal" /><control type="button" name="btnShadowSeventhMule" left="10" top="40" width="250" height="25" text="Shadow of the Seventh Mule" actionexpr="setvar[Augment,`stateShadowSeventhMule`]" setstate="nav_zaikhal" /><control type="button" name="btnInfusedWarMagic" left="10" top="70" width="250" height="25" text="Infused War Magic" actionexpr="setvar[Augment,`stateInfusedWarMagic`]" setstate="nav_silyun" /><control type="button" name="btnInfusedLifeMagic" left="10" top="100" width="250" height="25" text="Infused Life Magic" actionexpr="setvar[Augment,`stateInfusedLifeMagic`]" setstate="nav_hebianto" /><control type="button" name="btnInfusedItemMagic" left="10" top="130" width="250" height="25" text="Infused Item Magic" actionexpr="setvar[Augment,`stateInfusedItemMagic`]" setstate="nav_hebianto" /><control type="button" name="btnInfusedCreatureMagic" left="10" top="160" width="250" height="25" text="Infused Creature Magic" actionexpr="setvar[Augment,`stateInfusedCreatureMagic`]" setstate="nav_cragstone" /><control type="button" name="btnInfusedVoidMagic" left="10" top="190" width="250" height="25" text="Infused Void Magic" actionexpr="setvar[Augment,`stateInfusedVoidMagic`]" setstate="nav_candeth" /><control type="button" name="btnMain" left="10" top="220" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateDeathPenalties} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateDeathPenalties} {<?xml version="1.0"?><view width="300" height="110" title="Death Penalties"><control type="layout"><control type="button" name="btnClutchMiser" left="10" top="10" width="250" height="25" text="Clutch of the Miser" actionexpr="setvar[Augment,`stateClutchMiser`]" setstate="nav_ab" /><control type="button" name="btnEnduringEnchantment" left="10" top="40" width="250" height="25" text="Enduring Enchantment" actionexpr="setvar[Augment,`stateEnduringEnchantment`]" setstate="nav_ab" /><control type="button" name="btnMain" left="10" top="70" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateExperienceBonus} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateExperienceBonus} {<?xml version="1.0"?><view width="300" height="110" title="Experience Bonus"><control type="layout"><control type="button" name="btnQuickLearner" left="10" top="10" width="250" height="25" text="Quick Learner" actionexpr="setvar[Augment,`stateQuickLearner`]" setstate="nav_silyun" /><control type="button" name="btnMain" left="10" top="40" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateHealthArmorRegen} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateHealthArmorRegen} {<?xml version="1.0"?><view width="300" height="110" title="Health, Armor, and Regeneration"><control type="layout"><control type="button" name="btnAsheronsLesserBenediction" left="10" top="10" width="250" height="25" text="Asheron's Lesser Benediction" actionexpr="setvar[Augment,`stateAsheronsLesserBenediction`]" setstate="nav_silyun" /><control type="button" name="btnInnateRenewal" left="10" top="40" width="250" height="25" text="Innate Renewal" actionexpr="setvar[Augment,`stateInnateRenewal`]" setstate="nav_bandit" /><control type="button" name="btnMain" left="10" top="70" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateInnateAttribute} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateInnateAttribute} {<?xml version="1.0"?><view width="300" height="220" title="Innate Attribute"><control type="layout"><control type="button" name="btnReinforcementLugians" left="10" top="10" width="250" height="25" text="Reinforcement of the Lugians" actionexpr="setvar[Augment,`stateReinforcementLugians`]" setstate="nav_fiun" /><control type="button" name="btnBleearghFortitude" left="10" top="40" width="250" height="25" text="Bleeargh's Fortitude" actionexpr="setvar[Augment,`stateBleearghFortitude`]" setstate="nav_fiun" /><control type="button" name="btnOswaldEnhancement" left="10" top="70" width="250" height="25" text="Oswald's Enhancement" actionexpr="setvar[Augment,`stateOswaldEnhancement`]" setstate="nav_fiun" /><control type="button" name="btnSiraluunBlessing" left="10" top="100" width="250" height="25" text="Siraluun's Blessing" actionexpr="setvar[Augment,`stateSiraluunBlessing`]" setstate="nav_fiun" /><control type="button" name="btnEnduringCalm" left="10" top="130" width="250" height="25" text="Enduring Calm" actionexpr="setvar[Augment,`stateEnduringCalm`]" setstate="nav_fiun" /><control type="button" name="btnSteadfastWill" left="10" top="160" width="250" height="25" text="Steadfast Will" actionexpr="setvar[Augment,`stateSteadfastWill`]" setstate="nav_fiun" /><control type="button" name="btnMain" left="10" top="190" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateInnateResistance} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateInnateResistance} {<?xml version="1.0"?><view width="300" height="250" title="Innate Resistance"><control type="layout"><control type="button" name="btnEnhancementMaceTurner" left="10" top="10" width="250" height="25" text="Enhancement of the Mace Turner" actionexpr="setvar[Augment,`stateEnhancementMaceTurner`]" setstate="nav_hebianto" /><control type="button" name="btnEnhancementBladeTurner" left="10" top="40" width="250" height="25" text="Enhancement of the Blade Turner" actionexpr="setvar[Augment,`stateEnhancementBladeTurner`]" setstate="nav_hebianto" /><control type="button" name="btnEnhancementArrowTurner" left="10" top="70" width="250" height="25" text="Enhancement of the Arrow Turner" actionexpr="setvar[Augment,`stateEnhancementArrowTurner`]" setstate="nav_hebianto" /><control type="button" name="btnStormEnhancement" left="10" top="100" width="250" height="25" text="Storm's Enhancement" actionexpr="setvar[Augment,`stateStormEnhancement`]" setstate="nav_hebianto" /><control type="button" name="btnFieryEnhancement" left="10" top="130" width="250" height="25" text="Fiery Enhancement" actionexpr="setvar[Augment,`stateFieryEnhancement`]" setstate="nav_hebianto" /><control type="button" name="btnIcyEnhancement" left="10" top="160" width="250" height="25" text="Icy Enhancement" actionexpr="setvar[Augment,`stateIcyEnhancement`]" setstate="nav_hebianto" /><control type="button" name="btnCausticEnhancement" left="10" top="190" width="250" height="25" text="Caustic Enhancement" actionexpr="setvar[Augment,`stateCausticEnhancement`]" setstate="nav_hebianto" /><control type="button" name="btnMain" left="10" top="220" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateRatings} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateRatings} {<?xml version="1.0"?><view width="300" height="180" title="Ratings"><control type="layout"><control type="button" name="btnCriticalProtection" left="10" top="10" width="250" height="25" text="Critical Protection" actionexpr="setvar[Augment,`stateCriticalProtection`]" setstate="nav_sanamar" /><control type="button" name="btnFrenzySlayer" left="10" top="40" width="250" height="25" text="Frenzy of the Slayer" actionexpr="setvar[Augment,`stateFrenzySlayer`]" setstate="nav_bandit" /><control type="button" name="btnIronSkin" left="10" top="70" width="250" height="25" text="Iron Skin of the Invincible" actionexpr="setvar[Augment,`stateIronSkin`]" setstate="nav_cragstone" /><control type="button" name="btnEyeRemorseless" left="10" top="100" width="250" height="25" text="Eye of the Remorseless" actionexpr="setvar[Augment,`stateEyeRemorseless`]" setstate="nav_ab" /><control type="button" name="btnHandRemorseless" left="10" top="130" width="250" height="25" text="Hand of the Remorseless" actionexpr="setvar[Augment,`stateHandRemorseless`]" setstate="nav_ab" /><control type="button" name="btnMain" left="10" top="160" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateSalvagingTinkering} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateSalvagingTinkering} {<?xml version="1.0"?><view width="300" height="310" title="Salvaging and Tinkering"><control type="layout"><control type="button" name="btnCiandrasFortune" left="10" top="10" width="250" height="25" text="Ciandra's Fortune" actionexpr="setvar[Augment,`stateCiandrasFortune`]" setstate="nav_cragstone" /><control type="button" name="btnCharmedSmith" left="10" top="40" width="250" height="25" text="Charmed Smith" actionexpr="setvar[Augment,`stateCharmedSmith`]" setstate="nav_oolutangasrefuge" /><control type="button" name="btnJibrilsEssence" left="10" top="70" width="250" height="25" text="Jibril's Essence" actionexpr="setvar[Augment,`stateJibrilsEssence`]" setstate="nav_cragstone" /><control type="button" name="btnYoshisEssence" left="10" top="100" width="250" height="25" text="Yoshi's Essence" actionexpr="setvar[Augment,`stateYoshisEssence`]" setstate="nav_cragstone" /><control type="button" name="btnCeldisethsEssence" left="10" top="130" width="250" height="25" text="Celdiseth's Essence" actionexpr="setvar[Augment,`stateCeldisethsEssence`]" setstate="nav_cragstone" /><control type="button" name="btnKogasEssence" left="10" top="160" width="250" height="25" text="Koga's Essence" actionexpr="setvar[Augment,`stateKogasEssence`]" setstate="nav_cragstone" /><control type="button" name="btnCiandrasEssence" left="10" top="190" width="250" height="25" text="Ciandra's Essence" actionexpr="setvar[Augment,`stateCiandrasEssence`]" setstate="nav_cragstone" /><control type="button" name="btnMain" left="10" top="220" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateSkills} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateSkills} {<?xml version="1.0"?><view width="300" height="180" title="Skills"><control type="layout"><control type="button" name="btnMasterSteelCircle" left="10" top="10" width="250" height="25" text="Master of the Steel Circle" actionexpr="setvar[Augment,`stateMasterSteelCircle`]" setstate="nav_silyun" /><control type="button" name="btnMasterFiveFoldPath" left="10" top="40" width="250" height="25" text="Master of the Five Fold Path" actionexpr="setvar[Augment,`stateMasterFiveFoldPath`]" setstate="nav_zaikhal" /><control type="button" name="btnMasterFocusedEye" left="10" top="70" width="250" height="25" text="Master of the Focused Eye" actionexpr="setvar[Augment,`stateMasterFocusedEye`]" setstate="nav_zaikhal" /><control type="button" name="btnJackTrades" left="10" top="100" width="250" height="25" text="Jack of All Trades" actionexpr="setvar[Augment,`stateJackTrades`]" setstate="nav_bandit" /><control type="button" name="btnMain" left="10" top="130" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateSpellDuration} ~~ {
IF: Always
DO: DoAll
DestroyView {main}
IF: Always
DO: CreateView {stateSpellDuration} {<?xml version="1.0"?><view width="300" height="110" title="Spell Duration"><control type="layout"><control type="button" name="btnArchmageEndurance" left="10" top="10" width="250" height="25" text="Archmage's Endurance" actionexpr="setvar[Augment,`stateArchmageEndurance`]" setstate="nav_mayoi" /><control type="button" name="btnMain" left="10" top="40" width="250" height="25" text="Main" setstate="Default" /></control></view>}
~~ }
STATE: {stateMightSeventhMule} ~~ {
IF: Always
DO: Chat {/vt nav load nav_husoon}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateShadowSeventhMule} ~~ {
IF: Always
DO: Chat {/vt nav load nav_dumidabintruminre}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateInfusedWarMagic} ~~ {
IF: Always
DO: Chat {/vt nav load nav_rapheldetante}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateArchmageEndurance} ~~ {
IF: Always
DO: Chat {/vt nav load nav_nawamaraujio}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateInfusedLifeMagic} ~~ {
IF: Always
DO: Chat {/vt nav load nav_akemifei}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateInfusedItemMagic} ~~ {
IF: Always
DO: Chat {/vt nav load nav_ganfo}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateInfusedCreatureMagic} ~~ {
IF: Always
DO: Chat {/vt nav load nav_gustuvlansdown}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateInfusedVoidMagic} ~~ {
IF: Always
DO: Chat {/vt nav load nav_morathe}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateClutchMiser} ~~ {
IF: Always
DO: Chat {/vt nav load nav_rohulabintludun}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateEnduringEnchantment} ~~ {
IF: Always
DO: Chat {/vt nav load nav_erikfestus}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateQuickLearner} ~~ {
IF: Always
DO: Chat {/vt nav load nav_rickarddumalia}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateAsheronsLesserBenediction} ~~ {
IF: Always
DO: Chat {/vt nav load nav_donatellolinante}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateInnateRenewal} ~~ {
IF: Always
DO: Chat {/vt nav load nav_alisondulane}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateReinforcementLugians} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiunluunere}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateBleearghFortitude} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiunruun}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateOswaldEnhancement} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiunbayaas}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateSiraluunBlessing} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiunriish}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateEnduringCalm} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiunvasherr}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateSteadfastWill} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiunnoress}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateCriticalProtection} ~~ {
IF: Always
DO: Chat {/vt nav load nav_piersantilinante}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateFrenzySlayer} ~~ {
IF: Always
DO: Chat {/vt nav load nav_neelanashua}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateIronSkin} ~~ {
IF: Always
DO: Chat {/vt nav load nav_emilyyarow}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateEyeRemorseless} ~~ {
IF: Always
DO: Chat {/vt nav load nav_anframmellow}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateHandRemorseless} ~~ {
IF: Always
DO: Chat {/vt nav load nav_alishiabintaldan}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateEnhancementMaceTurner} ~~ {
IF: Always
DO: Chat {/vt nav load nav_nawamaradia}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateEnhancementBladeTurner} ~~ {
IF: Always
DO: Chat {/vt nav load nav_ilinwis}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateEnhancementArrowTurner} ~~ {
IF: Always
DO: Chat {/vt nav load nav_kyujorujen}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateStormEnhancement} ~~ {
IF: Always
DO: Chat {/vt nav load nav_enliyuo}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateFieryEnhancement} ~~ {
IF: Always
DO: Chat {/vt nav load nav_rikshenri}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateIcyEnhancement} ~~ {
IF: Always
DO: Chat {/vt nav load nav_lubao}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateCausticEnhancement} ~~ {
IF: Always
DO: Chat {/vt nav load nav_shujiomilao}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {nav_zaikhal} ~~ {
IF: Always
DO: Chat {/vt nav load nav_zaikhal}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_fiun} ~~ {
IF: Always
DO: Chat {/vt nav load nav_fiun}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {stateCiandrasFortune} ~~ {
IF: Always
DO: Chat {/vt nav load nav_kriscennis}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateCharmedSmith} ~~ {
IF: Always
DO: Chat {/vt nav load nav_lug}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateJibrilsEssence} ~~ {
IF: Always
DO: Chat {/vt nav load nav_joshunfelden}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateYoshisEssence} ~~ {
IF: Always
DO: Chat {/vt nav load nav_briennecarlus}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateCeldisethsEssence} ~~ {
IF: Always
DO: Chat {/vt nav load nav_burrellsammrun}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateKogasEssence} ~~ {
IF: Always
DO: Chat {/vt nav load nav_lenorturk}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateCiandrasEssence} ~~ {
IF: Always
DO: Chat {/vt nav load nav_robertcrow}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateMasterSteelCircle} ~~ {
IF: Always
DO: Chat {/vt nav load nav_carlitogallo}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateMasterFiveFoldPath} ~~ {
IF: Always
DO: Chat {/vt nav load nav_rahinabintzalanis}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateMasterFocusedEye} ~~ {
IF: Always
DO: Chat {/vt nav load nav_kilaf}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {stateJackTrades} ~~ {
IF: Always
DO: Chat {/vt nav load nav_ariannatheadept}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {nav_silyun} ~~ {
IF: Always
DO: Chat {/vt nav load nav_silyun}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_hebianto} ~~ {
IF: Always
DO: Chat {/vt nav load nav_hebianto}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_cragstone} ~~ {
IF: Always
DO: Chat {/vt nav load nav_cragstone}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_oolutangasrefuge} ~~ {
IF: Always
DO: Chat {/vt nav load nav_oolutangasrefuge}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_ab} ~~ {
IF: Always
DO: Chat {/vt nav load nav_ab}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_bandit} ~~ {
IF: Always
DO: Chat {/vt nav load nav_bandit}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_sanamar} ~~ {
IF: Always
DO: Chat {/vt nav load nav_sanamar}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_candeth} ~~ {
IF: Always
DO: Chat {/vt nav load nav_candeth}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {nav_mayoi} ~~ {
IF: Always
DO: Chat {/vt nav load nav_mayoi}
IF: Always
DO: DestroyAllViews
IF: NavEmpty
DO: DoExpr {chatbox[\/vt setmetastate +getvar[Augment]]}
~~ }
STATE: {stateBella} ~~ {
IF: Always
DO: DoAll
DoExpr {echo[`OK RUNNING BELLA`,13]}
DestroyView {main}
IF: ExitPortal
DO: SetState {mp_primary}
IF: Always
DO: Chat {/mp}
~~ }
STATE: {jaw_hunt} ~~ {
IF: ExitPortal
DO: DoAll
Chat {/vt opt set enablenav true}
SetState {turn_in_jaw}
IF: ItemCountGE 1 {Insatiable Eater Jaw}
DO: DoAll
Chat {/vt nav load to_fiun}
~~ }
STATE: {jaw_run} ~~ {
IF: NavEmpty
DO: DoAll
Chat {/vt nav load jaw_1_hunt}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
SetState {jaw_hunt}
~~ }
STATE: {kill_bella} ~~ {
IF: ItemCountGE 1 {Blank Augmentation Gem}
DO: DoAll
Chat {/vt nav load recallls}
Chat {/vt opt set enablelooting false}
SetState {Default}
~~ }
STATE: {mp_primary} ~~ {
IF: Always
DO: DoAll
Chat {/vt nav load mp_primary}
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set NavPriorityBoost false}
Chat {/vt opt set navclosestoprange 0.008}
IF: ExitPortal
DO: DoAll
Chat {/vt nav load jaw_1}
SetState {jaw_run}
~~ }
STATE: {mp_secondary} ~~ {
IF: ExitPortal
DO: DoAll
Chat {/vt nav load to_bella}
SetState {kill_bella}
~~ }
STATE: {mp_trans} ~~ {
IF: ExitPortal
DO: DoAll
Chat {/vt nav load mp_secondary}
SetState {mp_secondary}
~~ }
STATE: {turn_in_jaw} ~~ {
IF: ChatMatch {^Fiun Rehlyun.*}
DO: DoAll
Chat {/mp}
SetState {mp_trans}
~~ }
STATE: {stateStipend} ~~ {
IF: Always
DO: DoAll
DoExpr {echo[`OK RUNNING STIPEND`,13]}
DestroyView {main}
Chat {/vt opt set enablenav true}
IF: Always
DO: EmbedNav nav0__stipend_nav {stipend.nav}
IF: NavEmpty
DO: SetState {Default}
~~ }
~~========================= ONLY NAVS APPEAR BELOW THIS LINE =========================~~
NAV: nav0__stipend_nav once ~~ {
cht -101.597905190786 -96.6216093699137 2.08333134651184E-05 {/ah}
pnt 59.3590666453044 -28.7057823816935 0.0500208298365275
pnt 59.3771680514018 -28.7152192115784 0.0500208298365275
ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 59.3936458587647 -28.7256083488464 0.0508250035345554 14 {Portal to Town Network}
pnt -101.615851815542 -96.6388638178507 2.08333134651184E-05
pnt -101.657751337687 -96.5832635879517 2.08333134651184E-05
pnt -101.658352184296 -96.5325949986776 2.08333134651184E-05
pnt -101.605949529012 -96.519695186615 2.08333134651184E-05
ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 -101.588099161784 -96.5166525046031 -0.000262499845121056 14 {Portal to Arwic}
pnt 56.6498762130737 33.416518386205 0.175020837783813
pnt 56.655900033315 33.5368880271912 0.175020837783813
pnt 56.7470087051392 33.5495386441549 0.175020837783813
pnt 56.7795230229696 33.6337207794189 0.175020837783813
tlk -101.597905190786 -96.6216093699137 2.08333134651184E-05 56.7816291809082 33.6455291748047 0.175020843744278 37 {Monroe}
~~ }

View file

@ -0,0 +1,101 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
STATE: {Default} ~~ {
IF: ExitPortal
DO: SetState {mp_primary}
IF: Always
DO: Chat {/mp}
~~ }
STATE: {jaw_hunt} ~~ {
IF: ExitPortal
DO: DoAll
Chat {/vt opt set enablenav true}
SetState {turn_in_jaw}
IF: ItemCountGE 1 {Insatiable Eater Jaw}
DO: DoAll
Chat {/vt nav load to_fiun}
~~ }
STATE: {jaw_run} ~~ {
IF: NavEmpty
DO: DoAll
Chat {/vt nav load jaw_1_hunt}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
SetState {jaw_hunt}
~~ }
STATE: {kill_bella} ~~ {
IF: ChatCapture {You have solved this quest too recently!} {}
DO: DoAll
Chat {/vt nav load recallls}
Chat {/vt opt set enablelooting false}
~~ }
STATE: {mp_primary} ~~ {
IF: Always
DO: DoAll
Chat {/vt nav load mp_primary}
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set NavPriorityBoost false}
Chat {/vt opt set navclosestoprange 0.008}
IF: ExitPortal
DO: DoAll
Chat {/vt nav load jaw_1}
SetState {jaw_run}
~~ }
STATE: {mp_secondary} ~~ {
IF: ExitPortal
DO: DoAll
Chat {/vt nav load to_bella}
SetState {kill_bella}
~~ }
STATE: {mp_trans} ~~ {
IF: ExitPortal
DO: DoAll
Chat {/vt nav load mp_secondary}
SetState {mp_secondary}
~~ }
STATE: {turn_in_jaw} ~~ {
IF: ChatMatch {^Fiun Rehlyun.*}
DO: DoAll
Chat {/mp}
SetState {mp_trans}
~~ }

View file

@ -0,0 +1,964 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
~~ /ub mexec setvar[navNumber,VILKETNUMMER]
STATE: {combat} ~~ {
IF: Always
DO: Chat {/vt opt set enablecombat true}
IF: SecsInStateGE 60
DO: SetState {ring_fail}
IF: NoMobsInDist 5
DO: SetState {hunt}
~~ }
STATE: {death} ~~ {
IF: Always
DO: Chat {/vt nav load death_pause}
IF: Always
DO: Chat {/vt opt set enablenav true}
IF: NavEmpty
DO: SetState {navbore}
~~ }
STATE: {hunt} ~~ {
IF: Always
DO: Chat {/vt opt set enablecombat false}
IF: Expr {getvar[lootTime] == 0}
DO: DoExpr {setvar[lootTime, 21600]}
IF: Not Expr {testvar[serviceCheckInterval]}
DO: DoExpr {setvar[serviceCheckInterval, 43200]}
IF: Not Expr {testvar[serviceClock]}
DO: DoAll
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
IF: Death
DO: DoAll
Chat {/a I died! But I will comeback!}
SetState {death}
IF: All
ItemCountLE 100 {Prismatic Taper}
NoMobsInDist 5
DO: DoAll
Chat {/a Out of tapers! Recomping.}
SetState {recomp}
IF: MainSlotsLE 4
DO: DoAll
Chat {/a Out of inventory slots! Recomping.}
SetState {recomp}
IF: All
Expr {getvar[serviceCheckInterval] > 0}
Expr {stopwatchelapsedseconds[getvar[serviceClock]]>=getvar[serviceCheckInterval]}
NoMobsInDist 5
DO: SetState {service_quest_refresh}
IF: Always
DO: SetWatchdog 3 120 {navbore}
IF: DistToRteGE 1000
DO: SetState {navbore}
IF: All
Expr {setvar[nearPortal, wobjectfindnearestbyobjectclass[14]]}
Expr {istrue[getvar[nearPortal]]}
Expr {coordinatedistancewithz[getplayercoordinates[], wobjectgetphysicscoordinates[getvar[nearPortal]]]<=4}
Not Expr {wobjectgetname[getvar[nearPortal]]==Singularity Caul}
DO: DoAll
ChatExpr {\/me is to close to +wobjectgetname[getvar[nearPortal]]}
ChatExpr {\/me at +coordinatetostring[getplayercoordinates[]]}
IF: All
Any
Expr {getvar[mobsClose] == 0}
Expr {getvar[mobsClose] == 1}
MobsInDist_Name 1 6 {}
DO: SetState {combat}
IF: All
Expr {getvar[mobsClose] == 2}
MobsInDist_Name 2 6 {}
DO: SetState {combat}
IF: All
Expr {stopwatchelapsedseconds[getvar[lootclock]]>getvar[lootTime]}
DO: DoAll
Chat {/vt opt set enablecombat true}
SetState {loot}
IF: All
Expr {setvar[portal, wobjectfindnearestbyobjectclass[14]]}
Expr {istrue[getvar[portal]]}
Expr {coordinatedistancewithz[getplayercoordinates[], wobjectgetphysicscoordinates[getvar[portal]]] <= 8}
Not Expr {wobjectgetname[getvar[portal]]==Singularity Caul}
Not Expr {wobjectgetname[getvar[portal]]#Destroyed.*}
Expr {wobjectisvalid[getvar[portal]]}
Expr {setvar[portalHeading, getheadingto[getvar[portal]]]}
Expr {setvar[playerHeading, getheading[wobjectgetplayer[]]]}
Expr {setvar[relativeAngle, abs[getvar[portalHeading] - getvar[playerHeading]]]}
Expr {setvar[relativeAngle, iif[getvar[relativeAngle] > 180, 360 - getvar[relativeAngle], getvar[relativeAngle]]]}
Expr {setvar[turnDirection, iif[randint[0,2] == 0, -90, 90]]}
Expr {getvar[relativeAngle] <= 11.25}
Expr {setvar[newHeading, abs[getvar[playerHeading] + getvar[turnDirection]]]}
DO: DoAll
Chat {/vt opt set enablenav false}
Chat {/vt opt set enablecombat false}
SetState {avoid_portal}
~~ }
STATE: {loot_rare} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablelooting true}
Chat {/vt opt set enablenav false}
IF: SecsInStateGE 30
DO: DoAll
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablenav true}
SetState {hunt}
~~ }
STATE: {Default} ~~ {
IF: Not Expr {testvar[portalAvoidAngle]}
DO: DoExpr {setvar[portalAvoidAngle, 11.25]}
IF: Not Expr {testvar[augInnateStrengthTarget]}
DO: DoExpr {setvar[augInnateStrengthTarget, 0]}
IF: Not Expr {testvar[augInnateEnduranceTarget]}
DO: DoExpr {setvar[augInnateEnduranceTarget, 0]}
IF: Not Expr {testvar[augInnateCoordinationTarget]}
DO: DoExpr {setvar[augInnateCoordinationTarget, 0]}
IF: Not Expr {testvar[augInnateQuicknessTarget]}
DO: DoExpr {setvar[augInnateQuicknessTarget, 0]}
IF: Not Expr {testvar[augInnateFocusTarget]}
DO: DoExpr {setvar[augInnateFocusTarget, 0]}
IF: Not Expr {testvar[augInnateSelfTarget]}
DO: DoExpr {setvar[augInnateSelfTarget, 0]}
IF: Not Expr {testvar[augResistBluntTarget]}
DO: DoExpr {setvar[augResistBluntTarget, 0]}
IF: Not Expr {testvar[augResistSlashTarget]}
DO: DoExpr {setvar[augResistSlashTarget, 0]}
IF: Not Expr {testvar[augResistPierceTarget]}
DO: DoExpr {setvar[augResistPierceTarget, 0]}
IF: Not Expr {testvar[augResistLightningTarget]}
DO: DoExpr {setvar[augResistLightningTarget, 0]}
IF: Not Expr {testvar[augResistFireTarget]}
DO: DoExpr {setvar[augResistFireTarget, 0]}
IF: Not Expr {testvar[augResistFrostTarget]}
DO: DoExpr {setvar[augResistFrostTarget, 0]}
IF: Not Expr {testvar[augResistAcidTarget]}
DO: DoExpr {setvar[augResistAcidTarget, 0]}
IF: Not Expr {testvar[disableAugmentServices]}
DO: DoExpr {setvar[disableAugmentServices, 0]}
IF: Not Expr {testvar[serviceCheckInterval]}
DO: DoExpr {setvar[serviceCheckInterval, 43200]}
IF: Not Expr {testvar[serviceClock]}
DO: DoAll
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
IF: Always
DO: DoAll
Chat {/ub opt set VTank.PatchExpressionEngine true}
Chat {/vt opt set autocram true}
ChatExpr {\/vt nav load bore_circuit+getvar[navNumber]}
Chat {/vt opt set enablenav true}
Chat {/vt opt set buffprofile_prots 2}
Chat {/vt opt set buffprofile_banes 5}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set navpriorityboost false}
Chat {/vt opt set lootpriorityboost true}
Chat {/vt opt set lootonlyrarecorpses true}
DoExpr {setvar[lootclock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[lootclock]]}
SetState {service_quest_refresh}
~~ }
STATE: {service_quest_refresh} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/myquests}
DoExpr {touchvar[questsRequested]}
IF: All
Expr {testvar[questsRequested]}
Expr {isrefreshingquests[]==0}
SecsInStateGE 2
DO: SetState {service_pending_eval}
IF: SecsInStateGE 8
DO: SetState {service_pending_eval}
~~ }
STATE: {service_pending_eval} ~~ {
IF: Always
DO: DoAll
DoExpr {setvar[pendingAugments,0]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[230]<5,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[229]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[297]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[296]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[295]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[294]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[328]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[231]<3,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[232]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[234]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[237]<2,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[238]<5,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augInnateStrengthTarget]>0,iif[getcharintprop[217]<10,iif[getcharintprop[218]<getvar[augInnateStrengthTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augInnateEnduranceTarget]>0,iif[getcharintprop[217]<10,iif[getcharintprop[219]<getvar[augInnateEnduranceTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augInnateCoordinationTarget]>0,iif[getcharintprop[217]<10,iif[getcharintprop[220]<getvar[augInnateCoordinationTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augInnateQuicknessTarget]>0,iif[getcharintprop[217]<10,iif[getcharintprop[221]<getvar[augInnateQuicknessTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augInnateFocusTarget]>0,iif[getcharintprop[217]<10,iif[getcharintprop[222]<getvar[augInnateFocusTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augInnateSelfTarget]>0,iif[getcharintprop[217]<10,iif[getcharintprop[223]<getvar[augInnateSelfTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistBluntTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[242]<getvar[augResistBluntTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistSlashTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[240]<getvar[augResistSlashTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistPierceTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[241]<getvar[augResistPierceTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistLightningTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[246]<getvar[augResistLightningTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistFireTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[244]<getvar[augResistFireTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistFrostTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[245]<getvar[augResistFrostTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getvar[augResistAcidTarget]>0,iif[getcharintprop[239]<2,iif[getcharintprop[243]<getvar[augResistAcidTarget],1,getvar[pendingAugments]],getvar[pendingAugments]],getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[233]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[309]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[310]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[298]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[299]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[235]<4,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[236]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[226]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[225]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[227]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[228]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[224]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[300]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[302]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[301]<1,1,getvar[pendingAugments]]]}
DoExpr {setvar[pendingAugments, iif[getcharintprop[326]<1,1,getvar[pendingAugments]]]}
SetState {service_decide}
~~ }
STATE: {service_decide} ~~ {
IF: All
Expr {getqueststatus[`stipendtimer_0812`]==1}
DO: SetState {service_stipend}
IF: All
Expr {getvar[disableAugmentServices]!=1}
ItemCountGE 1 {Blank Augmentation Gem}
Expr {getvar[pendingAugments]==1}
DO: SetState {service_apply_augment}
IF: All
Expr {getvar[disableAugmentServices]!=1}
Expr {getqueststatus[`augmentationblankgemacquired`]==1}
Expr {getvar[pendingAugments]==1}
DO: SetState {service_bella_start}
IF: Always
DO: DoAll
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
SetState {hunt}
~~ }
STATE: {service_stipend} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: SetWatchdog 3 600 {service_reset_main}
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablenav true}
DoExpr {touchvar[stipendNavLoaded]}
EmbedNav nav0__stipend_nav {stipend.nav}
IF: All
Expr {testvar[stipendNavLoaded]}
NavEmpty
SecsInStateGE 2
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_start} ~~ {
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: SetState {service_bella_mp_primary}
IF: Always
DO: Chat {/mp}
IF: SecsInStateGE 120
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_mp_primary} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/vt nav load mp_primary}
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set NavPriorityBoost false}
Chat {/vt opt set navclosestoprange 0.008}
IF: ExitPortal
DO: DoAll
Chat {/vt nav load jaw_1}
SetState {service_bella_jaw_run}
IF: SecsInStateGE 300
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_jaw_run} ~~ {
IF: Death
DO: SetState {death}
IF: NavEmpty
DO: DoAll
Chat {/vt nav load jaw_1_hunt}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
Chat {/vt opt set lootonlyrarecorpses false}
SetState {service_bella_jaw_hunt}
IF: SecsInStateGE 300
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_jaw_hunt} ~~ {
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: DoAll
Chat {/vt opt set enablenav true}
SetState {service_bella_turn_in_jaw}
IF: ItemCountGE 1 {Insatiable Eater Jaw}
DO: DoAll
Chat {/vt nav load to_fiun}
IF: SecsInStateGE 1200
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_turn_in_jaw} ~~ {
IF: Death
DO: SetState {death}
IF: ChatMatch {^Fiun Rehlyun.*}
DO: DoAll
Chat {/mp}
SetState {service_bella_mp_trans}
IF: SecsInStateGE 300
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_mp_trans} ~~ {
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: DoAll
Chat {/vt nav load mp_secondary}
SetState {service_bella_mp_secondary}
IF: SecsInStateGE 120
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_mp_secondary} ~~ {
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: DoAll
Chat {/vt opt set enablecombat true}
Chat {/vt nav load to_bella}
SetState {service_bella_kill_bella}
IF: SecsInStateGE 300
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_kill_bella} ~~ {
IF: Death
DO: SetState {death}
IF: ItemCountGE 1 {Blank Augmentation Gem}
DO: DoAll
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablecombat false}
SetState {service_bella_escape}
IF: SecsInStateGE 300
DO: SetState {service_reset_main}
~~ }
STATE: {service_bella_escape} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablecombat false}
IF: Always
DO: SetWatchdog 3 120 {service_reset_main}
IF: SecsInStateGE 1
DO: ChatExpr {\/ub setmotion forward 1}
IF: SecsInStateGE 4
DO: ChatExpr {\/ub setmotion forward 0}
IF: SecsInStateGE 6
DO: Chat {/vt opt set enablecombat false}
IF: SecsInStateGE 7
DO: Chat {/ah}
IF: SecsInStateGE 17
DO: SetState {service_apply_augment}
~~ }
STATE: {service_apply_augment} ~~ {
IF: Death
DO: SetState {death}
IF: ItemCountLE 0 {Blank Augmentation Gem}
DO: SetState {service_reset_main}
IF: Always
DO: DoAll
DoExpr {clearvar[augmentHub]}
DoExpr {clearvar[augmentHubLoaded]}
DoExpr {clearvar[augmentTargetLoaded]}
DoExpr {setvar[augmentStep,0]}
IF: Expr {getcharintprop[230]<5}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_husoon`]}
DoExpr {setvar[augmentItem,`Might of the Seventh Mule`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[229]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_dumidabintruminre`]}
DoExpr {setvar[augmentItem,`Shadow of the Seventh Mule`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[297]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_rapheldetante`]}
DoExpr {setvar[augmentItem,`Infused War Magic`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[296]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_akemifei`]}
DoExpr {setvar[augmentItem,`Infused Life Magic`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[295]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_ganfo`]}
DoExpr {setvar[augmentItem,`Infused Item Magic`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[294]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_gustuvlansdown`]}
DoExpr {setvar[augmentItem,`Infused Creature Magic`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[328]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_morathe`]}
DoExpr {setvar[augmentItem,`Infused Void Magic`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[231]<3}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_rohulabintludun`]}
DoExpr {setvar[augmentItem,`Clutch of the Miser`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[232]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_erikfestus`]}
DoExpr {setvar[augmentItem,`Enduring Enchantment`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[234]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_rickarddumalia`]}
DoExpr {setvar[augmentItem,`Quick Learner`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[237]<2}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_alisondulane`]}
DoExpr {setvar[augmentItem,`Innate Renewal`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[238]<5}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_nawamaraujio`]}
DoExpr {setvar[augmentItem,`Archmage's Endurance`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augInnateStrengthTarget]>0}
Expr {getcharintprop[217]<10}
Expr {getcharintprop[218]<getvar[augInnateStrengthTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_fiunluunere`]}
DoExpr {setvar[augmentItem,`Reinforcement of the Lugians`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augInnateEnduranceTarget]>0}
Expr {getcharintprop[217]<10}
Expr {getcharintprop[219]<getvar[augInnateEnduranceTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_fiunruun`]}
DoExpr {setvar[augmentItem,`Bleeargh's Fortitude`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augInnateCoordinationTarget]>0}
Expr {getcharintprop[217]<10}
Expr {getcharintprop[220]<getvar[augInnateCoordinationTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_fiunbayaas`]}
DoExpr {setvar[augmentItem,`Oswald's Enhancement`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augInnateQuicknessTarget]>0}
Expr {getcharintprop[217]<10}
Expr {getcharintprop[221]<getvar[augInnateQuicknessTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_fiunriish`]}
DoExpr {setvar[augmentItem,`Siraluun's Blessing`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augInnateFocusTarget]>0}
Expr {getcharintprop[217]<10}
Expr {getcharintprop[222]<getvar[augInnateFocusTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_fiunvasherr`]}
DoExpr {setvar[augmentItem,`Enduring Calm`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augInnateSelfTarget]>0}
Expr {getcharintprop[217]<10}
Expr {getcharintprop[223]<getvar[augInnateSelfTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_fiunnoress`]}
DoExpr {setvar[augmentItem,`Steadfast Will`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistBluntTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[242]<getvar[augResistBluntTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_nawamaradia`]}
DoExpr {setvar[augmentItem,`Enhancement of the Mace Turner`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistSlashTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[240]<getvar[augResistSlashTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_ilinwis`]}
DoExpr {setvar[augmentItem,`Enhancement of the Blade Turner`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistPierceTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[241]<getvar[augResistPierceTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_kyujorujen`]}
DoExpr {setvar[augmentItem,`Enhancement of the Arrow Turner`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistLightningTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[246]<getvar[augResistLightningTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_enliyuo`]}
DoExpr {setvar[augmentItem,`Storm's Enhancement`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistFireTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[244]<getvar[augResistFireTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_rikshenri`]}
DoExpr {setvar[augmentItem,`Fiery Enhancement`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistFrostTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[245]<getvar[augResistFrostTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_lubao`]}
DoExpr {setvar[augmentItem,`Icy Enhancement`]}
SetState {service_apply_augment_nav}
IF: All
Expr {getvar[augResistAcidTarget]>0}
Expr {getcharintprop[239]<2}
Expr {getcharintprop[243]<getvar[augResistAcidTarget]}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_shujiomilao`]}
DoExpr {setvar[augmentItem,`Caustic Enhancement`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[233]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_piersantilinante`]}
DoExpr {setvar[augmentItem,`Critical Protection`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[309]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_neelanashua`]}
DoExpr {setvar[augmentItem,`Frenzy of the Slayer`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[310]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_emilyyarow`]}
DoExpr {setvar[augmentItem,`Iron Skin of the Invincible`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[298]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_anframmellow`]}
DoExpr {setvar[augmentItem,`Eye of the Remorseless`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[299]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_alishiabintaldan`]}
DoExpr {setvar[augmentItem,`Hand of the Remorseless`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[235]<4}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_kriscennis`]}
DoExpr {setvar[augmentItem,`Ciandra's Fortune`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[236]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_lug`]}
DoExpr {setvar[augmentItem,`Charmed Smith`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[226]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_joshunfelden`]}
DoExpr {setvar[augmentItem,`Jibril's Essence`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[225]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_briennecarlus`]}
DoExpr {setvar[augmentItem,`Yoshi's Essence`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[227]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_burrellsammrun`]}
DoExpr {setvar[augmentItem,`Celdiseth's Essence`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[228]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_lenorturk`]}
DoExpr {setvar[augmentItem,`Koga's Essence`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[224]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_robertcrow`]}
DoExpr {setvar[augmentItem,`Ciandra's Essence`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[300]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_carlitogallo`]}
DoExpr {setvar[augmentItem,`Master of the Steel Circle`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[302]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_rahinabintzalanis`]}
DoExpr {setvar[augmentItem,`Master of the Five Fold Path`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[301]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_kilaf`]}
DoExpr {setvar[augmentItem,`Master of the Focused Eye`]}
SetState {service_apply_augment_nav}
IF: Expr {getcharintprop[326]<1}
DO: DoAll
DoExpr {setvar[augmentNav,`nav_ariannatheadept`]}
DoExpr {setvar[augmentItem,`Jack of All Trades`]}
SetState {service_apply_augment_nav}
IF: Always
DO: SetState {service_reset_main}
~~ }
STATE: {service_apply_augment_nav} ~~ {
IF: Death
DO: SetState {death}
IF: Not Expr {testvar[augmentStep]}
DO: DoExpr {setvar[augmentStep,0]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_husoon`}
Expr {getvar[augmentNav]==`nav_dumidabintruminre`}
Expr {getvar[augmentNav]==`nav_rahinabintzalanis`}
Expr {getvar[augmentNav]==`nav_kilaf`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_zaikhal`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_rapheldetante`}
Expr {getvar[augmentNav]==`nav_rickarddumalia`}
Expr {getvar[augmentNav]==`nav_carlitogallo`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_silyun`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_akemifei`}
Expr {getvar[augmentNav]==`nav_ganfo`}
Expr {getvar[augmentNav]==`nav_nawamaradia`}
Expr {getvar[augmentNav]==`nav_ilinwis`}
Expr {getvar[augmentNav]==`nav_kyujorujen`}
Expr {getvar[augmentNav]==`nav_enliyuo`}
Expr {getvar[augmentNav]==`nav_rikshenri`}
Expr {getvar[augmentNav]==`nav_lubao`}
Expr {getvar[augmentNav]==`nav_shujiomilao`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_hebianto`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_gustuvlansdown`}
Expr {getvar[augmentNav]==`nav_emilyyarow`}
Expr {getvar[augmentNav]==`nav_kriscennis`}
Expr {getvar[augmentNav]==`nav_joshunfelden`}
Expr {getvar[augmentNav]==`nav_briennecarlus`}
Expr {getvar[augmentNav]==`nav_burrellsammrun`}
Expr {getvar[augmentNav]==`nav_lenorturk`}
Expr {getvar[augmentNav]==`nav_robertcrow`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_cragstone`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_rohulabintludun`}
Expr {getvar[augmentNav]==`nav_erikfestus`}
Expr {getvar[augmentNav]==`nav_anframmellow`}
Expr {getvar[augmentNav]==`nav_alishiabintaldan`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_ab`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_alisondulane`}
Expr {getvar[augmentNav]==`nav_neelanashua`}
Expr {getvar[augmentNav]==`nav_ariannatheadept`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_bandit`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Any
Expr {getvar[augmentNav]==`nav_fiunluunere`}
Expr {getvar[augmentNav]==`nav_fiunruun`}
Expr {getvar[augmentNav]==`nav_fiunbayaas`}
Expr {getvar[augmentNav]==`nav_fiunriish`}
Expr {getvar[augmentNav]==`nav_fiunvasherr`}
Expr {getvar[augmentNav]==`nav_fiunnoress`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_fiun`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Expr {getvar[augmentNav]==`nav_piersantilinante`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_sanamar`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Expr {getvar[augmentNav]==`nav_lug`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_oolutangasrefuge`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Expr {getvar[augmentNav]==`nav_nawamaraujio`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_mayoi`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==0}
Expr {getvar[augmentNav]==`nav_morathe`}
DO: DoAll
DoExpr {setvar[augmentHub,`nav_candeth`]}
DoExpr {setvar[augmentStep,1]}
IF: All
Expr {getvar[augmentStep]==1}
Expr {testvar[augmentHub]}
Not Expr {testvar[augmentHubLoaded]}
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablebuffing true}
DoExpr {touchvar[augmentHubLoaded]}
ChatExpr {\/vt nav load +getvar[augmentHub]}
Chat {/vt opt set enablenav true}
IF: All
Expr {getvar[augmentStep]==1}
Expr {testvar[augmentHubLoaded]}
NavEmpty
SecsInStateGE 2
DO: DoExpr {setvar[augmentStep,2]}
IF: All
Expr {getvar[augmentStep]==2}
Not Expr {testvar[augmentTargetLoaded]}
DO: DoAll
DoExpr {touchvar[augmentTargetLoaded]}
ChatExpr {\/vt nav load +getvar[augmentNav]}
Chat {/vt opt set enablenav true}
DoExpr {setvar[augmentStep,3]}
IF: All
Expr {getvar[augmentStep]==3}
Expr {testvar[augmentTargetLoaded]}
NavEmpty
SecsInStateGE 2
DO: SetState {service_use_augment}
IF: Always
DO: SetWatchdog 3 600 {service_reset_main}
~~ }
STATE: {service_use_augment} ~~ {
IF: Death
DO: SetState {death}
IF: Not Expr {testvar[augmentItem]}
DO: SetState {service_reset_main}
IF: Always
DO: SetWatchdog 3 120 {service_reset_main}
IF: SecsInStateGE 3
DO: Chat {/ub prepclick yes 10}
IF: SecsInStateGE 7
DO: ChatExpr {\/ub use +getvar[augmentItem]}
IF: SecsInStateGE 15
DO: SetState {service_reset_main}
~~ }
STATE: {service_reset_main} ~~ {
IF: Always
DO: DoAll
DoExpr {clearvar[questsRequested]}
DoExpr {clearvar[stipendNavLoaded]}
DoExpr {clearvar[augmentNav]}
DoExpr {clearvar[augmentHub]}
DoExpr {clearvar[augmentHubLoaded]}
DoExpr {clearvar[augmentTargetLoaded]}
DoExpr {clearvar[augmentStep]}
DoExpr {clearvar[augmentItem]}
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
SetState {Default}
~~ }
STATE: {avoid_portal} ~~ {
IF: SecsInStateGE 1
DO: ChatExpr {\/me portalHeading \= +getvar[portalHeading]}
IF: SecsInStateGE 2
DO: ChatExpr {\/me playerHeading \= +getvar[playerHeading]}
IF: SecsInStateGE 3
DO: ChatExpr {\/me relativeAngle \= +getvar[relativeAngle]}
IF: SecsInStateGE 4
DO: ChatExpr {\/me Portal detected ahead\! Avoiding now\!}
IF: SecsInStateGE 5
DO: ChatExpr {\/me is too close to +wobjectgetname[wobjectfindnearestbyobjectclass[14]]}
IF: SecsInStateGE 6
DO: ChatExpr {\/me at +coordinatetostring[getplayercoordinates[]]}
IF: SecsInStateGE 7
DO: ChatExpr {\/ub face +getvar[newHeading]}
IF: SecsInStateGE 9
DO: ChatExpr {\/ub setmotion forward 1}
IF: SecsInStateGE 11
DO: ChatExpr {\/ub setmotion forward 0}
IF: SecsInStateGE 12
DO: ChatExpr {\/ub face +getvar[playerHeading]}
IF: SecsInStateGE 14
DO: ChatExpr {\/ub setmotion forward 1}
IF: SecsInStateGE 16
DO: ChatExpr {\/ub setmotion forward 0}
IF: SecsInStateGE 17
DO: ChatExpr {\/mm nextwp}
IF: SecsInStateGE 18
DO: ChatExpr {\/vt opt set enablenav true}
IF: SecsInStateGE 19
DO: ChatExpr {\/vt setmetastate hunt}
~~ }
STATE: {navbore} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set lootonlyrarecorpses true}
IF: Always
DO: ChatExpr {\/vt nav load bore_route+getvar[navNumber]}
IF: Always
DO: Chat {/vt opt set enablebuffing true}
IF: Always
DO: SetWatchdog 3 300 {navbore}
IF: NavEmpty
DO: DoAll
ChatExpr {\/vt nav load bore_circuit+getvar[navNumber]}
SetState {hunt}
~~ }
STATE: {recomp} ~~ {
IF: NavEmpty
DO: SetState {navbore}
IF: Always
DO: SetWatchdog 3 300 {navbore}
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablebuffing false}
Chat {/vt nav load ah_rarecomp}
~~ }
STATE: {ring_fail} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
SetWatchdog 100 3 {hunt}
~~ }
STATE: {loot} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablelooting true}
Chat {/vt opt set lootonlyrarecorpses false}
Chat {/vt opt set lootpriorityboost false}
IF: SecsInStateGE 300
DO: DoAll
Chat {/vt opt set lootonlyrarecorpses true}
Chat {/vt opt set lootpriorityboost true}
Chat {/vt opt set enablelooting false}
DoExpr {setvar[lootclock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[lootclock]]}
SetState {hunt}
IF: All
Expr {setvar[portal, wobjectfindnearestbyobjectclass[14]]}
Expr {istrue[getvar[portal]]}
Expr {coordinatedistancewithz[getplayercoordinates[], wobjectgetphysicscoordinates[getvar[portal]]] <= 8}
Not Expr {wobjectgetname[getvar[portal]]==Singularity Caul}
Not Expr {wobjectgetname[getvar[portal]]#Destroyed.*}
Expr {wobjectisvalid[getvar[portal]]}
Expr {setvar[portalHeading, getheadingto[getvar[portal]]]}
Expr {setvar[playerHeading, getheading[wobjectgetplayer[]]]}
Expr {setvar[relativeAngle, abs[getvar[portalHeading] - getvar[playerHeading]]]}
Expr {setvar[relativeAngle, iif[getvar[relativeAngle] > 180, 360 - getvar[relativeAngle], getvar[relativeAngle]]]}
Expr {setvar[turnDirection, iif[randint[0,2] == 0, -90, 90]]}
Expr {getvar[relativeAngle] <= 11.25}
Expr {setvar[newHeading, abs[getvar[playerHeading] + getvar[turnDirection]]]}
DO: DoAll
Chat {/vt opt set enablenav false}
Chat {/vt opt set lootonlyrarecorpses true}
Chat {/vt opt set lootpriorityboost true}
Chat {/vt opt set enablelooting false}
DoExpr {setvar[lootclock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[lootclock]]}
SetState {avoid_portal}
~~ }
~~========================= ONLY NAVS APPEAR BELOW THIS LINE =========================~~
NAV: nav0__stipend_nav once ~~ {
cht -101.597905190786 -96.6216093699137 2.08333134651184E-05 {/ah}
pnt 59.3590666453044 -28.7057823816935 0.0500208298365275
pnt 59.3771680514018 -28.7152192115784 0.0500208298365275
ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 59.3936458587647 -28.7256083488464 0.0508250035345554 14 {Portal to Town Network}
pnt -101.615851815542 -96.6388638178507 2.08333134651184E-05
pnt -101.657751337687 -96.5832635879517 2.08333134651184E-05
pnt -101.658352184296 -96.5325949986776 2.08333134651184E-05
pnt -101.605949529012 -96.519695186615 2.08333134651184E-05
ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 -101.588099161784 -96.5166525046031 -0.000262499845121056 14 {Portal to Arwic}
pnt 56.6498762130737 33.416518386205 0.175020837783813
pnt 56.655900033315 33.5368880271912 0.175020837783813
pnt 56.7470087051392 33.5495386441549 0.175020837783813
pnt 56.7795230229696 33.6337207794189 0.175020837783813
tlk -101.597905190786 -96.6216093699137 2.08333134651184E-05 56.7816291809082 33.6455291748047 0.175020843744278 37 {Monroe}
~~ }

View file

@ -0,0 +1,186 @@
~~
~~ Bore Dungeon Quest Meta
~~ Navigates to the Bore dungeon, fights through to the boss,
~~ loots the quest item, returns to town to turn in.
~~ Includes death recovery.
~~
STATE: {Default}
IF: Death
DO: SetState {Death}
IF: ItemCountGE 1 {Bore Trophy}
DO: SetState {TurnIn}
IF: ItemCountLE 50 {Prismatic Taper}
DO: DoAll
Chat {Running low on tapers, restocking.}
SetState {Restock}
IF: Always
DO: DoAll
EmbedNav navToBore {Navigate to Bore dungeon entrance}
SetWatchdog 10.0 180.0 {Stuck}
STATE: {EnterDungeon}
IF: Death
DO: SetState {Death}
IF: IntoPortal
DO: SetState {InsideDungeon}
IF: SecsInStateGE 30
DO: SetState {Default}
STATE: {InsideDungeon}
IF: Death
DO: SetState {Death}
IF: ExitPortal
DO: DoAll
SetOpt {enablecombat} {true}
SetOpt {enablelooting} {true}
EmbedNav navDungeonRoute {Run through dungeon to boss}
SetWatchdog 10.0 120.0 {Stuck}
STATE: {BossRoom}
IF: Death
DO: SetState {Death}
IF: MobsInDist_Name 1 30.0 {Bore Boss}
DO: DoAll
SetOpt {enablecombat} {true}
SetState {Fighting}
IF: All
NavEmpty
NoMobsInDist 25.0
DO: SetState {LootBoss}
IF: Always
DO: None
STATE: {Fighting}
IF: Death
DO: SetState {Death}
IF: NoMobsInDist 25.0
DO: SetState {LootBoss}
IF: SecsInStateGE 300
DO: SetState {BossRoom}
STATE: {LootBoss}
IF: Death
DO: SetState {Death}
IF: ItemCountGE 1 {Bore Trophy}
DO: SetState {ExitDungeon}
IF: SecsInStateGE 30
DO: DoAll
Chat {No trophy found, exiting anyway.}
SetState {ExitDungeon}
IF: Always
DO: DoAll
SetOpt {enablelooting} {true}
None
STATE: {ExitDungeon}
IF: Death
DO: SetState {Death}
IF: All
NavEmpty
DO: DoAll
SetOpt {enablecombat} {false}
SetOpt {enablelooting} {false}
SetState {TurnIn}
IF: Always
DO: DoAll
EmbedNav navExitDungeon {Exit the dungeon}
SetWatchdog 10.0 120.0 {Stuck}
STATE: {TurnIn}
IF: Death
DO: SetState {Death}
IF: All
NavEmpty
DO: DoAll
Chat {/ub givep Bore Trophy to Bore Quest NPC}
Chat {/ub prepclick yes 3}
SetState {WaitTurnIn}
IF: Always
DO: DoAll
EmbedNav navToNPC {Go to turn-in NPC}
SetWatchdog 10.0 120.0 {Stuck}
STATE: {WaitTurnIn}
IF: ChatMatch {^Bore Quest NPC tells you,}
DO: SetState {TurnInComplete}
IF: ChatCapture {You have solved this quest too recently!} {}
DO: SetState {QuestCooldown}
IF: SecsInStateGE 15
DO: SetState {TurnIn}
STATE: {TurnInComplete}
IF: Always
DO: DoAll
Chat {Quest turn-in complete! Restarting.}
SetState {Default}
STATE: {QuestCooldown}
IF: Always
DO: DoAll
Chat {Quest on cooldown, waiting...}
SetState {Default}
STATE: {Restock}
IF: Death
DO: SetState {Death}
IF: All
NavEmpty
VendorOpen
DO: SetState {Selling}
IF: Always
DO: DoAll
EmbedNav navRestock {Go to vendor to restock}
SetWatchdog 10.0 120.0 {Stuck}
STATE: {Selling}
IF: VendorClosed
DO: SetState {Default}
IF: SecsInStateGE 30
DO: SetState {Default}
STATE: {Stuck}
IF: Always
DO: DoAll
Chat {/ub face}
SetState {Default}
STATE: {Death}
IF: ExitPortal
DO: SetState {Default}
~~ ============================================================
~~ NAV ROUTES
~~ ============================================================
NAV: navToBore once
~~ Route from lifestone to Bore dungeon entrance
rcl 0.0 0.0 0.0 {Lifestone Recall}
pnt 0.0 0.0 0.0
pnt 0.0 0.0 0.0
ptl 0.0 0.0 0.0 0.0 0.0 0.0 14 {Bore Dungeon}
NAV: navDungeonRoute once
~~ Route through dungeon to boss room
pnt 0.0 0.0 0.0
pnt 0.0 0.0 0.0
pnt 0.0 0.0 0.0
pnt 0.0 0.0 0.0
NAV: navExitDungeon once
~~ Route from boss room back to exit portal
pnt 0.0 0.0 0.0
pnt 0.0 0.0 0.0
ptl 0.0 0.0 0.0 0.0 0.0 0.0 14 {Exit Portal}
NAV: navToNPC once
~~ Route to quest turn-in NPC
rcl 0.0 0.0 0.0 {Lifestone Recall}
pnt 0.0 0.0 0.0
tlk 0.0 0.0 0.0 0.0 0.0 0.0 37 {Bore Quest NPC}
NAV: navRestock once
~~ Route to vendor for restocking tapers
rcl 0.0 0.0 0.0 {Lifestone Recall}
pnt 0.0 0.0 0.0
vnd 0.0 0.0 0.0 00000000 {Vendor}

View file

@ -0,0 +1,217 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
STATE: {Default} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/ub opt set VTank.PatchExpressionEngine true}
SetOpt {enablecombat} {false}
SetOpt {enablenav} {true}
SetOpt {enablelooting} {false}
SetOpt {enablebuffing} {true}
SetOpt {attackdistance} {0.0125}
DoExpr {setvar[currentLevel,`upper`]}
DoExpr {setvar[currentRoom,`north`]}
DoExpr {setvar[roomsDone,0]}
Chat {/vt nav load nav_empyrean}
SetState {enter_dungeon}
~~ }
STATE: {enter_dungeon} ~~ {
IF: Death
DO: SetState {death}
IF: All
SecsInStateGE 3
NavEmpty
DO: DoAll
SetOpt {enablecombat} {true}
SetState {goto_room}
IF: Always
DO: DoAll
SetWatchdog 10 300 {stuck}
~~ }
STATE: {goto_room} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
ChatExpr {\/vt nav load emp_+getvar[currentLevel]+`_`+getvar[currentRoom]}
DoExpr {clearvar[navLoaded]}
SetState {arrive_room}
~~ }
STATE: {arrive_room} ~~ {
IF: Death
DO: SetState {death}
IF: All
Expr {testvar[navLoaded]}
NavEmpty
DO: DoAll
ClearWatchdog
SetState {kill_wisps}
IF: Always
DO: DoAll
DoExpr {touchvar[navLoaded]}
SetWatchdog 10 180 {stuck}
~~ }
STATE: {kill_wisps} ~~ {
IF: Death
DO: SetState {death}
IF: All
SecsInStateGE 5
NoMobsInDist 4
DO: SetState {use_stone}
IF: SecsInStateGE 300
DO: SetState {use_stone}
IF: Always
DO: DoAll
SetWatchdog 10 300 {stuck}
~~ }
STATE: {use_stone} ~~ {
IF: Death
DO: SetState {death}
IF: ChatMatch {A shiver moves across your body}
DO: DoAll
DoExpr {echo[`Room complete! (`+getvar[currentLevel]+` `+getvar[currentRoom]+`)`,1]}
SetState {next_room}
IF: ChatMatch {no change}
DO: DoAll
DoExpr {echo[`Room already done (`+getvar[currentLevel]+` `+getvar[currentRoom]+`)`,1]}
SetState {next_room}
IF: SecsInStateGE 5
DO: DoAll
Chat {/ub use Aligned Mana Stone}
SetState {use_stone}
IF: Always
DO: DoAll
Chat {/ub use Aligned Mana Stone}
~~ }
STATE: {next_room} ~~ {
IF: Death
DO: SetState {death}
IF: Expr {getvar[currentRoom]==`north`}
DO: DoAll
DoExpr {setvar[currentRoom,`east`]}
SetState {goto_room}
IF: Expr {getvar[currentRoom]==`east`}
DO: DoAll
DoExpr {setvar[currentRoom,`south`]}
SetState {goto_room}
IF: Expr {getvar[currentRoom]==`south`}
DO: DoAll
DoExpr {setvar[currentRoom,`west`]}
SetState {goto_room}
IF: Expr {getvar[currentRoom]==`west`}
DO: DoAll
DoExpr {setvar[currentRoom,`center`]}
SetState {goto_room}
IF: Expr {getvar[currentRoom]==`center`}
DO: SetState {use_stone_center}
~~ }
STATE: {use_stone_center} ~~ {
IF: Death
DO: SetState {death}
IF: ChatMatch {A shiver moves across your body}
DO: DoAll
DoExpr {echo[`Level complete! (`+getvar[currentLevel]+`)`,1]}
SetState {take_portal}
IF: ChatMatch {no change}
DO: DoAll
DoExpr {echo[`Level already done (`+getvar[currentLevel]+`)`,1]}
SetState {take_portal}
IF: SecsInStateGE 5
DO: DoAll
Chat {/ub use Aligned Mana Stone}
SetState {use_stone_center}
IF: Always
DO: DoAll
Chat {/ub use Aligned Mana Stone}
~~ }
STATE: {take_portal} ~~ {
IF: Death
DO: SetState {death}
IF: Expr {getvar[currentLevel]==`upper`}
DO: DoAll
DoExpr {setvar[currentLevel,`middle`]}
DoExpr {setvar[currentRoom,`north`]}
Chat {/ub use Empyrean Facility Middle Level}
SetState {portal_transition}
IF: Expr {getvar[currentLevel]==`middle`}
DO: DoAll
DoExpr {setvar[currentLevel,`lower`]}
DoExpr {setvar[currentRoom,`north`]}
Chat {/ub use Empyrean Facility Lower Level}
SetState {portal_transition}
IF: Expr {getvar[currentLevel]==`lower`}
DO: DoAll
DoExpr {echo[`All 3 aetheria slots unlocked!`,1]}
SetState {quest_done}
~~ }
STATE: {portal_transition} ~~ {
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: DoAll
DoExpr {echo[`Arrived at `+getvar[currentLevel]+` level`,1]}
SetState {goto_room}
IF: SecsInStateGE 15
DO: SetState {take_portal}
~~ }
STATE: {quest_done} ~~ {
IF: Always
DO: DoAll
DoExpr {echo[`Empyrean Facility quest COMPLETE! All aetheria slots unlocked.`,1]}
Chat {/mp}
~~ }
STATE: {stuck} ~~ {
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/ub face}
DoExpr {echo[`Stuck! Trying to recover...`,1]}
SetState {goto_room}
~~ }
STATE: {death} ~~ {
IF: ExitPortal
DO: DoAll
DoExpr {echo[`Died! Re-entering dungeon...`,1]}
Chat {/vt nav load nav_empyrean}
SetState {enter_dungeon}
~~ }

View file

@ -0,0 +1,221 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
STATE: {Default} ~~ {
IF: Always
DO: DoAll
DoExpr {setvar[mssortstring,`CV,AS,EQ,TR-,OC,IC,AL-,EP,MX-`]}
SetState {SortManaStones}
~~ }
STATE: {SortManaStones} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 7}
Chat {/ms set ocfilter manastone}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortHealingKits}
~~ }
STATE: {SortHealingKits} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 7}
Chat {/ms set ocfilter healingkit}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortMisc}
~~ }
STATE: {SortMisc} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 2}
Chat {/ms set ocfilter misc}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortGems}
~~ }
STATE: {SortGems} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 3}
Chat {/ms set ocfilter gem}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortWeapons}
~~ }
STATE: {SortWeapons} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 1}
Chat {/ms set ocfilter meleeweapon}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortWands}
~~ }
STATE: {SortWands} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 1}
Chat {/ms set ocfilter wand}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortSpellComps}
~~ }
STATE: {SortSpellComps} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest pack 8}
Chat {/ms set ocfilter spellcomp}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortInventory}
~~ }
STATE: {SortInventory} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source player}
Chat {/ms set dest player}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack1}
~~ }
STATE: {SortPack1} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 1}
Chat {/ms set dest pack 1}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack2}
~~ }
STATE: {SortPack2} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 2}
Chat {/ms set dest pack 2}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack3}
~~ }
STATE: {SortPack3} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 3}
Chat {/ms set dest pack 3}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack4}
~~ }
STATE: {SortPack4} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 4}
Chat {/ms set dest pack 4}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack5}
~~ }
STATE: {SortPack5} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 5}
Chat {/ms set dest pack 5}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack6}
~~ }
STATE: {SortPack6} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 6}
Chat {/ms set dest pack 6}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack7}
~~ }
STATE: {SortPack7} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 7}
Chat {/ms set dest pack 7}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {SortPack8}
~~ }
STATE: {SortPack8} ~~ {
IF: Always
DO: DoAll
Chat {/ms set source pack 8}
Chat {/ms set dest pack 8}
Chat {/ms clear ocfilter}
ChatExpr {`/ms set flags `+getvar[mssortstring]}
Chat {/ms start}
IF: ChatMatch {^You think\, \"done sorting\!\"$}
DO: SetState {AllDone}
~~ }

View file

@ -0,0 +1,218 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
~~ Follower Meta: Follows group leader, responds to chat commands, death recovery
STATE: {Default} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablenav false}
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablelooting false}
DoExpr {setvar[leaderName,]}
DoExpr {setvar[deathCount, 0]}
SetState {idle}
~~ }
STATE: {idle} ~~ {
~~ Wait for leader assignment via chat command
IF: ChatMatch {.+tells you, "follow me"}
DO: DoAll
DoExpr {setvar[leaderName, getregexmatch[(.+) tells you, 1]]}
ChatExpr {\/t +getvar[leaderName]+, Following you now\!}
SetState {following}
IF: ChatMatch {.+tells you, "follow (.+)"}
DO: DoAll
DoExpr {setvar[leaderName, getregexmatch[.+tells you, "follow (.+)", 1]]}
ChatExpr {\/t +getvar[leaderName]+, Following you now\!}
SetState {following}
IF: Death
DO: SetState {death_recovery}
IF: NeedToBuff
DO: SetState {buffing}
IF: Always
DO: Chat {/vt opt set enablecombat false}
~~ }
STATE: {following} ~~ {
~~ Main follow state - follow the leader
IF: Always
DO: Chat {/vt opt set enablecombat false}
IF: Always
DO: Chat {/vt opt set enablenav false}
IF: Always
DO: ChatExpr {\/ub follow +getvar[leaderName]}
~~ Death handling
IF: Death
DO: DoAll
DoExpr {setvar[deathCount, getvar[deathCount] + 1]}
ChatExpr {\/t +getvar[leaderName]+, I died\! Death count: +cstr[getvar[deathCount]]+. Recovering...}
SetState {death_recovery}
~~ Chat command: buff
IF: ChatMatch {.+tells you, "buff"}
DO: DoAll
Chat {/vt opt set enablebuffing true}
SetState {buffing}
~~ Chat command: recall
IF: ChatMatch {.+tells you, "recall"}
DO: DoAll
ChatExpr {\/t +getvar[leaderName]+, Recalling now\!}
SetState {recalling}
~~ Chat command: stop
IF: ChatMatch {.+tells you, "stop"}
DO: DoAll
ChatExpr {\/t +getvar[leaderName]+, Stopping follow\.}
DoExpr {setvar[leaderName,]}
Chat {/ub follow off}
SetState {idle}
~~ Chat command: combat on
IF: ChatMatch {.+tells you, "combat on"}
DO: DoAll
Chat {/vt opt set enablecombat true}
ChatExpr {\/t +getvar[leaderName]+, Combat enabled\!}
~~ Chat command: combat off
IF: ChatMatch {.+tells you, "combat off"}
DO: DoAll
Chat {/vt opt set enablecombat false}
ChatExpr {\/t +getvar[leaderName]+, Combat disabled\!}
~~ Need to rebuff
IF: NeedToBuff
DO: DoAll
Chat {/ub follow off}
SetState {buffing}
~~ Watchdog - if stuck for too long, try to recover
IF: Always
DO: SetWatchdog 3 300 {stuck_recovery}
~~ }
STATE: {buffing} ~~ {
~~ Buff up then return to following
IF: Always
DO: DoAll
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablecombat false}
IF: Death
DO: DoAll
DoExpr {setvar[deathCount, getvar[deathCount] + 1]}
SetState {death_recovery}
IF: All
Not NeedToBuff
SecsInStateGE 5
DO: DoAll
Chat {/vt opt set enablebuffing true}
SetState {rejoin_leader}
IF: SecsInStateGE 120
DO: DoAll
Chat {/vt opt set enablebuffing true}
SetState {rejoin_leader}
~~ }
STATE: {death_recovery} ~~ {
~~ Wait at lifestone after death, then rebuff and rejoin
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablenav false}
Chat {/vt opt set enablebuffing false}
~~ Wait a few seconds at lifestone before doing anything
IF: SecsInStateGE 10
DO: DoAll
Chat {/vt opt set enablebuffing true}
SetState {death_rebuff}
~~ }
STATE: {death_rebuff} ~~ {
~~ Rebuff after death before rejoining
IF: Always
DO: Chat {/vt opt set enablebuffing true}
IF: Death
DO: DoAll
DoExpr {setvar[deathCount, getvar[deathCount] + 1]}
SetState {death_recovery}
IF: All
Not NeedToBuff
SecsInStateGE 10
DO: SetState {rejoin_leader}
IF: SecsInStateGE 180
DO: SetState {rejoin_leader}
~~ }
STATE: {rejoin_leader} ~~ {
~~ Rejoin the leader after buffing or death recovery
IF: Expr {getvar[leaderName] == 0}
DO: SetState {idle}
IF: Expr {cstr[getvar[leaderName]] == cstr[]}
DO: SetState {idle}
IF: Always
DO: DoAll
ChatExpr {\/t +getvar[leaderName]+, Rejoining you now\!}
ChatExpr {\/ub follow +getvar[leaderName]}
SetState {following}
~~ }
STATE: {recalling} ~~ {
~~ Recall to lifestone
IF: Always
DO: DoAll
Chat {/ub follow off}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablenav false}
IF: SecsInStateGE 2
DO: Chat {/lifestone}
IF: Death
DO: DoAll
DoExpr {setvar[deathCount, getvar[deathCount] + 1]}
SetState {death_recovery}
IF: SecsInStateGE 15
DO: DoAll
ChatExpr {\/t +getvar[leaderName]+, Recalled to lifestone\. Tell me to follow you when ready\.}
SetState {idle}
~~ }
STATE: {stuck_recovery} ~~ {
~~ Try to recover from being stuck
IF: Always
DO: DoAll
Chat {/ub follow off}
Chat {/vt opt set enablecombat false}
ChatExpr {\/t +getvar[leaderName]+, I appear to be stuck\. Trying to recover\.}
IF: SecsInStateGE 3
DO: Chat {/lifestone}
IF: Death
DO: DoAll
DoExpr {setvar[deathCount, getvar[deathCount] + 1]}
SetState {death_recovery}
IF: SecsInStateGE 15
DO: DoAll
Chat {/vt opt set enablebuffing true}
SetState {death_rebuff}
~~ }

View file

@ -0,0 +1,175 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
STATE: {all_run_back} ~~ {
IF: Always
DO: Chat {/ub bc /vt setmetastate just_run_back}
~~ }
STATE: {strength} ~~ {
IF: Always
DO: DoAll
Chat {/ub bc /ub use Luminous Crystal of Surging Strength}
SetState {Default}
~~ }
STATE: {closestportal} ~~ {
IF: Always
DO: Chat {/ub bc /ub closestportal}
IF: Always
DO: SetState {Default}
~~ }
STATE: {death} ~~ {
IF: Always
DO: Chat {/vt nav load death_pause}
IF: Always
DO: Chat {/vt opt set enablenav true}
IF: NavEmpty
DO: SetState {run_back}
~~ }
STATE: {Default} ~~ {
IF: Always
DO: None
IF: Death
DO: SetState {death}
~~ }
STATE: {defence} ~~ {
IF: Always
DO: DoAll
Chat {/ub bc /ub use Luminous Crystal of Towering Defense}
SetState {Default}
~~ }
STATE: {dispell} ~~ {
IF: Always
DO: DoAll
Chat {/ub bc /ub use Black Market Gem of Dispelling}
SetState {Default}
~~ }
STATE: {follow} ~~ {
IF: Always
DO: DoAll
ChatExpr {\/ub bc \/ub follow +wobjectgetname[wobjectgetplayer[]]}
Chat {/ub bc /vt opt set enablenav true}
IF: Always
DO: SetState {Default}
~~ }
STATE: {jump} ~~ {
IF: Always
DO: Chat {/ub bc /ub jumpsw 400}
IF: Always
DO: SetState {Default}
~~ }
STATE: {just_run_back} ~~ {
IF: Always
DO: DoAll
Chat {/vt nav load gauntlet_just_run}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablemeta true}
Chat {/vt opt set enablecombat true}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {load_follow} ~~ {
IF: Always
DO: Chat {/ub bc /vt meta load gauntlet_follower}
IF: Always
DO: SetState {Default}
~~ }
STATE: {nav_false} ~~ {
IF: Always
DO: Chat {/ub bc /vt opt set enablenav false}
IF: Always
DO: SetState {Default}
~~ }
STATE: {nav_true} ~~ {
IF: Always
DO: Chat {/ub bc /vt opt set enablenav true}
IF: Always
DO: SetState {Default}
~~ }
STATE: {run_back} ~~ {
IF: Always
DO: DoAll
Chat {/vt nav load gauntlet_run}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablemeta true}
Chat {/vt opt set enablecombat true}
IF: NavEmpty
DO: SetState {Default}
~~ }
STATE: {setup} ~~ {
IF: Always
DO: ChatExpr {\/ub bc \/ub follow +wobjectgetname[wobjectgetplayer[]]}
IF: Always
DO: Chat {/ub bc /vt opt set enablelooting false}
IF: Always
DO: Chat {/ub bc /vt opt set enablenav true}
IF: Always
DO: Chat {/ub bc /vt opt set enablebuffing true}
IF: Always
DO: Chat {/ub bc /vt opt set enablecombat true}
IF: Always
DO: Chat {/ub bc /vt opt set attackdistance 0.125}
IF: Always
DO: Chat {/ub bc /vt opt set navpriorityboost true}
IF: Always
DO: Chat {/ub bc /vt opt set buffprofile_prots 2}
IF: Always
DO: Chat {/ub bc /vt opt set buffprofile_banes 2}
IF: Always
DO: Chat {/ub bc /vt forcebuff}
IF: Always
DO: Chat {/ub bc /vt start}
IF: Always
DO: Chat {/ub bc /vt opt set enablemeta true}
IF: Always
DO: SetState {Default}
~~ }
STATE: {summon_false} ~~ {
IF: Always
DO: Chat {/ub bc /vt opt set SummonPets false}
IF: Always
DO: SetState {Default}
~~ }
STATE: {summon_true} ~~ {
IF: Always
DO: Chat {/ub bc /vt opt set SummonPets true}
IF: Always
DO: SetState {Default}
~~ }

View file

@ -0,0 +1,220 @@
~~ {
~~ Hunting Meta - Solo lifestone grinding with death recovery, restock, and stuck detection
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators:
~~ STATE: DO:
~~ IF: NAV:
~~
~~ All recognized CONDITION (IF:) operation keywords:
~~ Never NavEmpty MobsInDist_Priority Not
~~ Always Death NeedToBuff PSecsInStateGE
~~ All VendorOpen NoMobsInDist SecsOnSpellGE
~~ Any VendorClosed BlockE BuPercentGE
~~ ChatMatch ItemCountLE CellE DistToRteGE
~~ MainSlotsLE ItemCountGE IntoPortal Expr
~~ SecsInStateGE MobsInDist_Name ExitPortal ChatCapture
~~
~~ All recognized ACTION (DO:) operation keywords:
~~ None EmbedNav ChatExpr SetOpt
~~ SetState CallState SetWatchdog CreateView
~~ Chat Return ClearWatchdog DestroyView
~~ DoAll DoExpr GetOpt DestroyAllViews
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~ }
STATE: {Default} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
Chat {/vt opt set navpriorityboost false}
Chat {/vt opt set lootpriorityboost true}
Chat {/vt nav load hunt_circuit}
SetState {Hunt}
~~ }
STATE: {Hunt} ~~ {
IF: Death
DO: SetState {Death}
IF: All
ItemCountLE 20 {Prismatic Taper}
NoMobsInDist 5
DO: DoAll
Chat {/a Low on tapers, heading to vendor.}
SetState {Restock}
IF: All
ItemCountLE 20 {Scarab}
NoMobsInDist 5
DO: DoAll
Chat {/a Low on scarabs, heading to vendor.}
SetState {Restock}
IF: MainSlotsLE 3
DO: DoAll
Chat {/a Inventory full, heading to vendor.}
SetState {Restock}
IF: NeedToBuff
DO: DoAll
Chat {/vt opt set enablenav false}
Chat {/vt opt set enablecombat false}
SetState {Buffing}
IF: Always
DO: SetWatchdog 3 120 {Stuck}
IF: DistToRteGE 500
DO: SetState {Stuck}
IF: MobsInDist_Name 1 12 {}
DO: DoAll
Chat {/vt opt set enablecombat true}
IF: NoMobsInDist 12
DO: DoAll
Chat {/vt opt set enablecombat false}
IF: NavEmpty
DO: DoAll
Chat {/vt nav load hunt_circuit}
Chat {/vt opt set enablenav true}
~~ }
STATE: {Death} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablenav false}
Chat {/a I died! Recovering...}
IF: SecsInStateGE 5
DO: DoAll
Chat {/vt opt set enablebuffing true}
SetState {DeathRecovery}
~~ }
STATE: {DeathRecovery} ~~ {
IF: Always
DO: DoAll
Chat {/vt nav load ls_to_hunt}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat false}
IF: NeedToBuff
DO: DoAll
Chat {/vt opt set enablenav false}
SetState {Buffing}
IF: NavEmpty
DO: DoAll
Chat {/vt nav load hunt_circuit}
Chat {/vt opt set enablecombat true}
SetState {Hunt}
IF: Always
DO: SetWatchdog 3 300 {Stuck}
~~ }
STATE: {Buffing} ~~ {
IF: Always
DO: DoAll
Chat {/vt opt set enablebuffing true}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablenav false}
IF: All
Not NeedToBuff
NoMobsInDist 5
DO: DoAll
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat true}
SetState {Hunt}
IF: SecsInStateGE 180
DO: DoAll
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablecombat true}
SetState {Hunt}
~~ }
STATE: {Restock} ~~ {
IF: Death
DO: SetState {Death}
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt nav load hunt_to_vendor}
Chat {/vt opt set enablenav true}
IF: Always
DO: SetWatchdog 3 300 {Stuck}
IF: NavEmpty
DO: SetState {AtVendor}
~~ }
STATE: {AtVendor} ~~ {
IF: Death
DO: SetState {Death}
IF: VendorOpen
DO: DoAll
Chat {/vt opt set enablenav false}
IF: All
VendorClosed
ItemCountGE 200 {Prismatic Taper}
ItemCountGE 200 {Scarab}
DO: DoAll
Chat {/a Restocked, heading back to hunt.}
Chat {/vt nav load vendor_to_hunt}
Chat {/vt opt set enablenav true}
SetState {ReturnToHunt}
IF: SecsInStateGE 120
DO: DoAll
Chat {/a Vendor timeout, returning to hunt.}
Chat {/vt nav load vendor_to_hunt}
Chat {/vt opt set enablenav true}
SetState {ReturnToHunt}
~~ }
STATE: {ReturnToHunt} ~~ {
IF: Death
DO: SetState {Death}
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
IF: Always
DO: SetWatchdog 3 300 {Stuck}
IF: NeedToBuff
DO: DoAll
Chat {/vt opt set enablenav false}
SetState {Buffing}
IF: NavEmpty
DO: DoAll
Chat {/vt nav load hunt_circuit}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
SetState {Hunt}
~~ }
STATE: {Stuck} ~~ {
IF: Always
DO: DoAll
Chat {/a I appear to be stuck, attempting recovery.}
Chat {/vt opt set enablenav false}
Chat {/vt opt set enablecombat false}
IF: SecsInStateGE 3
DO: DoAll
Chat {/vt nav load ls_to_hunt}
Chat {/vt opt set enablenav true}
SetState {StuckRecovery}
~~ }
STATE: {StuckRecovery} ~~ {
IF: Death
DO: SetState {Death}
IF: Always
DO: SetWatchdog 3 300 {Stuck}
IF: NeedToBuff
DO: DoAll
Chat {/vt opt set enablenav false}
SetState {Buffing}
IF: NavEmpty
DO: DoAll
Chat {/vt nav load hunt_circuit}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
SetState {Hunt}
~~ }

View file

@ -0,0 +1,416 @@
~~
~~ lockandkey.af - Portal Bot with Stipend & Partial Bella Services
~~
~~ A portal summoning bot that:
~~ - Listens for tells containing "primary" or "secondary" to summon portals
~~ - Collects stipend on timer (with backoff)
~~ - Runs partial bella (jaw turn-in only, no kill bella)
~~ - Returns home via ah_recall after services
~~
~~ Portal Spells:
~~ Summon Primary Portal I = spell 157
~~ Summon Secondary Portal I = spell 2648
~~
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getplayerlandblock wobjectisvalid getitemcountbytemplatetype ifthen
~~ }
STATE: {Default}
~~ Init variables then transition to service check
IF: Not Expr {testvar[bellaTurnInAttempts]}
DO: DoExpr {setvar[bellaTurnInAttempts, 0]}
IF: Not Expr {testvar[bellaJawRunRecoverAttempts]}
DO: DoExpr {setvar[bellaJawRunRecoverAttempts, 0]}
IF: Not Expr {testvar[bellaBackoffSeconds]}
DO: DoExpr {setvar[bellaBackoffSeconds, 86400]}
IF: Not Expr {testvar[stipendBackoffSeconds]}
DO: DoExpr {setvar[stipendBackoffSeconds, 86400]}
IF: Not Expr {testvar[disableBellaServices]}
DO: DoExpr {setvar[disableBellaServices, 0]}
IF: Not Expr {testvar[serviceCheckInterval]}
DO: DoExpr {setvar[serviceCheckInterval, 900]}
IF: Not Expr {testvar[serviceClock]}
DO: DoAll
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
IF: Always
DO: DoAll
Chat {/ub opt set VTank.PatchExpressionEngine true}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablenav false}
Chat {/vt opt set enablebuffing false}
SetState {service_quest_refresh}
~~ }
STATE: {idle}
~~ Main loop: listen for portal tells, check service timer
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/vt opt set enablebuffing false}
DoExpr {setcombatstate[`peace`]}
DoExpr {clearvar[queuePrimary]}
DoExpr {clearvar[queueSecondary]}
~~ Listen for tells containing "primary"
IF: ChatCapture {(^(\[[A-z]+?\] |)You|.*\<Tell:IIDString:.+:(?<who>[^\<]*)\>.+\<\\Tell\>) tells you, ".*primary.*"$} {}
DO: SetState {summon_primary}
~~ Listen for tells containing "secondary"
IF: ChatCapture {(^(\[[A-z]+?\] |)You|.*\<Tell:IIDString:.+:(?<who>[^\<]*)\>.+\<\\Tell\>) tells you, ".*secondary.*"$} {}
DO: SetState {summon_secondary}
~~ Periodic service check
IF: All
Expr {testvar[serviceClock]}
Expr {stopwatchelapsedseconds[getvar[serviceClock]]>=getvar[serviceCheckInterval]}
DO: SetState {service_quest_refresh}
IF: Always
DO: None
STATE: {summon_primary}
~~ Face 180, equip wand, cast Summon Primary Portal I (157), retry on fizzle
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/a Summoning 125 Eaters portal...}
Chat {/vt opt set enablecombat false}
Chat {/ub face 180}
DoExpr {setcombatstate[`magic`]}
DoExpr {actiontryequipanywand[]}
DoExpr {clearvar[queuePrimary]}
IF: SecsInStateGE 4
DO: DoExpr {actiontrycastbyid[157]}
~~ Fizzle: retry cast
IF: ChatMatch {Your spell fizzled.}
DO: DoAll
Chat {/a Fizzled! Retrying 125 Eaters portal...}
DoExpr {actiontrycastbyid[157]}
SetState {summon_primary}
~~ Queue incoming tells while casting
IF: ChatCapture {(^(\[[A-z]+?\] |)You|.*\<Tell:IIDString:.+:(?<who>[^\<]*)\>.+\<\\Tell\>) tells you, ".*primary.*"$} {}
DO: DoAll
Chat {/a Queued 125 Eaters portal, will summon shortly.}
DoExpr {setvar[queuePrimary, 1]}
IF: ChatCapture {(^(\[[A-z]+?\] |)You|.*\<Tell:IIDString:.+:(?<who>[^\<]*)\>.+\<\\Tell\>) tells you, ".*secondary.*"$} {}
DO: DoAll
Chat {/a Queued Bellas portal, will summon shortly.}
DoExpr {setvar[queueSecondary, 1]}
IF: SecsInStateGE 17
DO: SetState {summon_done}
STATE: {summon_secondary}
~~ Face 90, equip wand, cast Summon Secondary Portal I (2648), retry on fizzle
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/a Summoning Bellas portal...}
Chat {/vt opt set enablecombat false}
Chat {/ub face 90}
DoExpr {setcombatstate[`magic`]}
DoExpr {actiontryequipanywand[]}
DoExpr {clearvar[queueSecondary]}
IF: SecsInStateGE 4
DO: DoExpr {actiontrycastbyid[2648]}
~~ Fizzle: retry cast
IF: ChatMatch {Your spell fizzled.}
DO: DoAll
Chat {/a Fizzled! Retrying Bellas portal...}
DoExpr {actiontrycastbyid[2648]}
SetState {summon_secondary}
~~ Queue incoming tells while casting
IF: ChatCapture {(^(\[[A-z]+?\] |)You|.*\<Tell:IIDString:.+:(?<who>[^\<]*)\>.+\<\\Tell\>) tells you, ".*primary.*"$} {}
DO: DoAll
Chat {/a Queued 125 Eaters portal, will summon shortly.}
DoExpr {setvar[queuePrimary, 1]}
IF: ChatCapture {(^(\[[A-z]+?\] |)You|.*\<Tell:IIDString:.+:(?<who>[^\<]*)\>.+\<\\Tell\>) tells you, ".*secondary.*"$} {}
DO: DoAll
Chat {/a Queued Bellas portal, will summon shortly.}
DoExpr {setvar[queueSecondary, 1]}
IF: SecsInStateGE 17
DO: SetState {summon_done}
STATE: {summon_done}
~~ Check queue: process next summon request or return to idle
IF: Expr {testvar[queuePrimary]}
DO: DoAll
Chat {/a Processing queued 125 Eaters portal...}
SetState {summon_primary}
IF: Expr {testvar[queueSecondary]}
DO: DoAll
Chat {/a Processing queued Bellas portal...}
SetState {summon_secondary}
IF: Always
DO: SetState {idle}
STATE: {service_quest_refresh}
~~ Request quest status refresh, wait for completion
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/myquests}
DoExpr {touchvar[questsRequested]}
IF: All
Expr {testvar[questsRequested]}
Expr {isrefreshingquests[]==0}
SecsInStateGE 2
DO: SetState {service_pending_eval}
IF: SecsInStateGE 8
DO: SetState {service_pending_eval}
STATE: {service_pending_eval}
~~ Check if bella jaw quest is ready (no augment application for portal bot)
IF: Always
DO: SetState {service_decide}
STATE: {service_decide}
~~ Decide which services to run: stipend, partial bella, or return to idle
~~ Reset bella backoff if timer expired
IF: All
Expr {getvar[disableBellaServices]==1}
Expr {testvar[bellaBackoffClock]}
Expr {stopwatchelapsedseconds[getvar[bellaBackoffClock]]>=getvar[bellaBackoffSeconds]}
DO: DoAll
DoExpr {setvar[disableBellaServices, 0]}
DoExpr {setvar[bellaTurnInAttempts, 0]}
DoExpr {setvar[bellaJawRunRecoverAttempts, 0]}
DoExpr {clearvar[bellaBackoffClock]}
~~ Stipend: first run (no backoff clock)
IF: All
Expr {getqueststatus[`stipendtimer_0812`]==1}
Not Expr {testvar[stipendBackoffClock]}
DO: SetState {service_stipend}
~~ Stipend: backoff expired
IF: All
Expr {getqueststatus[`stipendtimer_0812`]==1}
Expr {testvar[stipendBackoffClock]}
Expr {stopwatchelapsedseconds[getvar[stipendBackoffClock]]>=getvar[stipendBackoffSeconds]}
DO: DoAll
DoExpr {clearvar[stipendBackoffClock]}
SetState {service_stipend}
~~ Partial bella: jaw quest ready + services not disabled
IF: All
Expr {getvar[disableBellaServices]!=1}
Expr {getqueststatus[`insatiableeaterjaw`]==1}
DO: SetState {service_bella_jaw_entry}
~~ Nothing to do, return to idle
IF: Always
DO: DoAll
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
SetState {idle}
STATE: {service_stipend}
~~ Run stipend collection nav, start backoff clock
IF: Death
DO: SetState {death}
IF: Always
DO: SetWatchdog 3 600 {service_reset_main}
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablenav true}
DoExpr {setvar[stipendBackoffClock, stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[stipendBackoffClock]]}
DoExpr {touchvar[stipendNavLoaded]}
EmbedNav nav0__stipend_nav {stipend.nav}
IF: All
Expr {testvar[stipendNavLoaded]}
NavEmpty
SecsInStateGE 2
DO: SetState {service_reset_main}
STATE: {service_bella_jaw_entry}
~~ Equip quest gear, buff up, then run nav_lockandkeyjaw (nav handles idle/quest swap + recall)
IF: Death
DO: SetState {death}
IF: Always
DO: DoAll
Chat {/a [bella] equipping quest gear and buffing for jaw dungeon}
Chat {/ub equip load quest.utl}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablebuffing true}
DoExpr {setvar[jawEntryNavLoaded,1]}
Chat {/vt nav load nav_lockandkeyjaw}
SetWatchdog 3 600 {service_bella_backoff}
IF: All
Expr {getvar[jawEntryNavLoaded]==1}
NavEmpty
SecsInStateGE 2
DO: DoAll
DoExpr {setvar[jawEntryNavLoaded,0]}
DoExpr {setvar[bellaJawRunRecoverAttempts, 0]}
Chat {/vt nav load jaw_1_hunt}
Chat {/vt opt set enablecombat true}
Chat {/vt opt set enablelooting true}
Chat {/vt opt set lootonlyrarecorpses false}
SetState {service_bella_jaw_hunt}
STATE: {service_bella_jaw_run_recover}
~~ Recovery wait for jaw run stuck
IF: Death
DO: SetState {death}
IF: SecsInStateGE 20
DO: SetState {service_bella_jaw_entry}
STATE: {service_bella_jaw_hunt}
~~ Hunt for jaw drop
IF: Death
DO: SetState {death}
IF: Always
DO: Chat {/vt opt set enablebuffing true}
IF: ExitPortal
DO: DoAll
Chat {/vt opt set enablenav true}
SetState {service_bella_turn_in_jaw}
IF: ItemCountGE 1 {Insatiable Eater Jaw}
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt nav load to_fiun}
IF: SecsInStateGE 1200
DO: SetState {service_bella_backoff}
STATE: {service_bella_turn_in_jaw}
~~ Turn in jaw to Fiun NPC, then return home (partial bella - no mp_trans/secondary/kill)
IF: Death
DO: SetState {death}
~~ Jaw accepted: return home instead of continuing to mp_secondary
IF: ChatMatch {^.*One who obtains such as this is truly worthy of that which we would teach\. The highest peak of the deadliest isle contains that which you seek.*$}
DO: DoAll
Chat {/a [bella] jaw turned in, returning home (partial bella)}
DoExpr {setvar[bellaTurnInAttempts, 0]}
SetState {equip_idle}
IF: All
SecsInStateGE 300
ItemCountGE 1 {Insatiable Eater Jaw}
Expr {getvar[bellaTurnInAttempts]<3}
DO: DoAll
DoExpr {chatbox[`/a [bella] jaw turn-in timed out, retry `+cstr[getvar[bellaTurnInAttempts]+1]+`/3`]}
DoExpr {setvar[bellaTurnInAttempts,getvar[bellaTurnInAttempts]+1]}
Chat {/vt nav load to_fiun}
SetState {service_bella_turn_in_jaw}
IF: All
SecsInStateGE 300
ItemCountGE 1 {Insatiable Eater Jaw}
Expr {getvar[bellaTurnInAttempts]>=3}
DO: SetState {service_bella_backoff}
~~ Jaw already gone (consumed by NPC), just go home
IF: SecsInStateGE 300
DO: DoAll
DoExpr {setvar[bellaTurnInAttempts, 0]}
SetState {equip_idle}
STATE: {service_bella_backoff}
~~ Disable bella services for 24h, return home
IF: Always
DO: DoAll
Chat {/a [bella] backoff tripped, disabling bella services for 24h}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablenav true}
DoExpr {setvar[disableBellaServices, 1]}
DoExpr {setvar[bellaTurnInAttempts, 0]}
DoExpr {setvar[bellaJawRunRecoverAttempts, 0]}
DoExpr {setvar[bellaBackoffClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[bellaBackoffClock]]}
SetState {equip_idle}
STATE: {equip_idle}
~~ Swap back to idle gear before heading home
IF: Death
DO: SetState {death}
IF: Always
DO: Chat {/ub equip load idle.utl}
IF: SecsInStateGE 12
DO: SetState {service_reset_main}
STATE: {service_reset_main}
~~ Clear service vars, load ah_recall to return home, then go to idle
IF: Death
DO: SetState {death}
IF: Always
DO: SetWatchdog 3 600 {service_reset_main_stuck}
IF: Always
DO: DoAll
DoExpr {clearvar[questsRequested]}
DoExpr {clearvar[stipendNavLoaded]}
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
Chat {/vt opt set enablenav true}
Chat {/vt opt set enablebuffing false}
DoExpr {setvar[serviceClock,stopwatchcreate[]]}
DoExpr {stopwatchstart[getvar[serviceClock]]}
DoExpr {touchvar[resetNavLoaded]}
Chat {/vt nav load ah_recall}
IF: All
Expr {testvar[resetNavLoaded]}
NavEmpty
SecsInStateGE 2
DO: DoAll
DoExpr {clearvar[resetNavLoaded]}
ClearWatchdog
Chat {/vt opt set enablenav false}
SetState {idle}
STATE: {service_reset_main_stuck}
~~ Fallback if ah_recall nav gets stuck
IF: Always
DO: DoAll
Chat {/ah}
SetState {service_reset_main_wait}
STATE: {service_reset_main_wait}
~~ Wait for /ah recall, then retry reset
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: SetState {service_reset_main}
IF: SecsInStateGE 30
DO: SetState {service_reset_main}
STATE: {death}
~~ Death recovery: wait for portal exit, recall home
IF: Always
DO: DoAll
Chat {/vt opt set enablecombat false}
Chat {/vt opt set enablelooting false}
IF: ExitPortal
DO: DoAll
Chat {/ah}
SetState {death_wait_recall}
IF: SecsInStateGE 30
DO: DoAll
Chat {/ah}
SetState {death_wait_recall}
STATE: {death_wait_recall}
~~ Wait for /ah recall portal exit, then return to idle
IF: Death
DO: SetState {death}
IF: ExitPortal
DO: SetState {idle}
IF: SecsInStateGE 60
DO: SetState {idle}
~~========================= ONLY NAVS APPEAR BELOW THIS LINE =========================~~
NAV: nav0__stipend_nav once
pau 47.1262349446615 26.1864453474681 0.225020837783813 2000
cht -101.597905190786 -96.6216093699137 2.08333134651184E-05 {/ah}
pau 47.1262349446615 26.1864453474681 0.225020837783813 15000
pnt 59.3590666453044 -28.7057823816935 0.0500208298365275
ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 59.3936458587647 -28.7256083488464 0.0508250035345554 14 {Portal to Town Network}
pnt -101.615851815542 -96.6388638178507 2.08333134651184E-05
pnt -101.657751337687 -96.5832635879517 2.08333134651184E-05
pnt -101.658352184296 -96.5325949986776 2.08333134651184E-05
ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 -101.588099161784 -96.5166525046031 -0.000262499845121056 14 {Portal to Arwic}
pnt 56.6498762130737 33.416518386205 0.175020837783813
pnt 56.655900033315 33.5368880271912 0.175020837783813
pnt 56.7470087051392 33.5495386441549 0.175020837783813
pnt 56.7795230229696 33.6337207794189 0.175020837783813
tlk -101.597905190786 -96.6216093699137 2.08333134651184E-05 56.7816291809082 33.6455291748047 0.175020843744278 37 {Monroe}

View file

@ -0,0 +1,42 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators in a NAV-ONLY file:
~~ NAV:
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~
~~
~~ REMEMBER THAT NAV-ONLY FILES MUST CONTAIN EXACTLY ONE NAV!
~~ }
NAV: nav0 once ~~ {
cht 59.1563772201538 -28.5892723083496 0.0500208298365275 {/ah}
pau 59.1563772201538 -28.5892723083496 0.0500208298365275 22000
cht 59.1563772201538 -28.5892723083496 0.0500208298365275 {/tell Time according to alex, ab}
pau 59.1563772201538 -28.5892723083496 0.0500208298365275 30000
cht 59.1563772201538 -28.5892723083496 0.0500208298365275 {/ub use Gateway}
pnt -87.9797062555949 -60.4362569173177 0.265733496348063
pnt -87.9643449147542 -60.3346110810836 0.230621592203776
pnt -87.9498847007751 -60.1938330491384 0.185985358556112
pnt -87.9534404754639 -60.1445933977763 0.175027068456014
pnt -87.9548230806986 -60.0900212287903 0.175027068456014
pnt -87.9550152778625 -60.0620187123617 0.175027068456014
pnt -87.9489116032918 -60.0324022928874 0.175027068456014
pnt -87.9241135279338 -59.9791566848755 0.175027068456014
~~ }

View file

@ -0,0 +1,37 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators in a NAV-ONLY file:
~~ NAV:
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~
~~
~~ REMEMBER THAT NAV-ONLY FILES MUST CONTAIN EXACTLY ONE NAV!
~~ }
NAV: nav0 once ~~ {
pnt 48.3606951395671 25.9085505803426 0.231929063796997
pnt 48.3884344100952 25.5719860394796 0.239678192138672
pnt 48.3782159169515 25.5435764312744 0.225020837783813
pnt 48.3625609079997 25.4953356901805 0.225020837783813
pnt 48.3672090530396 25.4595303853353 0.225020837783813
pnt 48.3880900065104 25.4410440921783 0.225020837783813
chk 48.4019992192586 25.4926815191905 0.225020837783813
cht 48.3987060546875 25.4926479816437 0.225020837783813 {/ub give Blank Augmentation Gem to Brienne Carlus}
~~ }

View file

@ -0,0 +1,58 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators in a NAV-ONLY file:
~~ NAV:
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~
~~
~~ REMEMBER THAT NAV-ONLY FILES MUST CONTAIN EXACTLY ONE NAV!
~~ }
NAV: nav0 once ~~ {
rcl -71.8128362019857 -46.2284189224243 2.08333212261399E-05 {Recall to the Singularity Caul}
pnt -94.7016312003136 -97.981881014506 0.367698383331299
ptl -71.8128362019857 -46.2284189224243 2.08333212261399E-05 -94.6987583319346 -98.0068958282471 0.371145516633987 14 {Obsidian Rim Portal}
pnt -65.5345490773519 -48.0928927580516 0.0821696281433106
pnt -66.2971390088399 -48.0919455369314 0.0824445406595866
pnt -68.2659324010213 -47.9679103851318 0.0901949644088745
pnt -68.9330024560293 -47.9617745717367 0.0741503397623698
pnt -68.9996176083883 -47.9868227322896 0.0811579465866089
pnt -69.0741902987162 -47.9962577819824 0.0902631839116414
pnt -69.1185588876406 -48.0084948221842 0.0896445353825887
pnt -69.1932870864868 -48.0247398058573 0.0864912668863932
pnt -69.2399808883667 -48.0406180699666 0.0826785326004028
pnt -69.3729923248291 -48.0507068951925 0.0753742853800456
pnt -69.502007261912 -48.0152909914653 0.0562341809272766
pnt -69.6805796305339 -47.9529766718547 0.00605392009019852
pnt -69.7465653419495 -47.9553860028585 0.000755888223648071
pnt -69.917982506752 -47.9998155911764 0.00417213241259257
pnt -70.4033851623535 -48.1885171890259 0.0103249351183573
pnt -71.328177467982 -48.5185417175293 2.08333212261399E-05
pnt -72.7273258845011 -47.8933919270833 0.0094555139541626
pnt -72.7809668223063 -47.776501083374 0.0289373199144999
pnt -72.7527559280395 -47.166915734609 0.135223134358724
pnt -72.7196399370829 -47.071635723114 0.0760513146718343
pnt -72.7042126337687 -47.0472258249919 0.0588250835736593
pnt -72.6919200261434 -47.0329411188761 0.0555859247843424
pnt -72.5977428436279 -46.9303064982096 0.0106916089852651
pnt -71.9506707509359 -46.338755830129 2.08333212261399E-05
pnt -71.8265404701233 -46.2414363861084 2.08333212261399E-05
ptl -71.8128362019857 -46.2284189224243 2.08333212261399E-05 -71.8083083470662 -46.2096791585286 -0.000262499845121056 14 {Empyrean Facility Upper Level}
~~ }

View file

@ -0,0 +1,51 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators in a NAV-ONLY file:
~~ NAV:
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~
~~
~~ REMEMBER THAT NAV-ONLY FILES MUST CONTAIN EXACTLY ONE NAV!
~~ }
NAV: nav0 once ~~ {
cht 0.675179147720337 13.5718362808228 0.521687507629395 {/ah}
pau 0.675179147720337 13.5718362808228 0.521687507629395 15000
pnt 59.3342878341675 -28.5975779215495 0.0500208298365275
pnt 59.35896803538 -28.6749758720398 0.0500208298365275
pnt 59.37918065389 -28.7118357340495 0.0500208298365275
ptl 0.675179147720337 13.5718362808228 0.521687507629395 59.3936458587647 -28.7256083488464 0.0508250035345554 14 {Portal to Town Network}
pnt -101.617960484823 -96.6229596773783 2.08333134651184E-05
pnt -101.64374332428 -96.6113506952922 2.08333134651184E-05
pnt -101.677829106649 -96.6077419598897 2.08333134651184E-05
pnt -101.708999125163 -96.6393287976583 2.08333134651184E-05
pnt -101.720591862996 -96.644443766276 2.08333134651184E-05
pnt -101.724977588654 -96.6444894790649 2.08333134651184E-05
pnt -101.766201734543 -96.642304166158 2.08333134651184E-05
pnt -101.826050774256 -96.6380467732747 2.08333134651184E-05
pnt -101.873989931742 -96.6189005851746 2.08333134651184E-05
pnt -101.907649771372 -96.5925824801127 2.08333134651184E-05
pnt -101.907772219181 -96.5846062819163 2.08333134651184E-05
ptl 0.675179147720337 13.5718362808228 0.521687507629395 -101.90858749946 -96.5742208321889 -0.000262499845121056 14 {Portal to Zaikhal}
pnt 0.684857130050659 13.498526652654 0.516687520345052
pnt 0.648467270533244 13.6382585525513 0.516687520345052
pnt 0.663638496398926 13.6434263865153 0.516687520345052
tlk 0.675179147720337 13.5718362808228 0.521687507629395 0.675 13.6436941782633 0.516687512397766 37 {Jondor Torgren}
~~ }

View file

@ -0,0 +1,88 @@
~~ {
~~ FOR AUTO-COMPLETION ASSISTANCE: testvar getvar setvar touchvar clearallvars clearvar getcharintprop getchardoubleprop getcharquadprop getcharboolprop getcharstringprop getisspellknown getcancastspell_hunt getcancastspell_buff getcharvital_base getcharvital_current getcharvital_buffedmax getcharskill_traininglevel getcharskill_base getcharskill_buffed getplayerlandcell getplayercoordinates coordinategetns coordinategetwe coordinategetz coordinatetostring coordinateparse coordinatedistancewithz coordinatedistanceflat wobjectgetphysicscoordinates wobjectgetname wobjectgetobjectclass wobjectgettemplatetype wobjectgetisdooropen wobjectfindnearestmonster wobjectfindnearestdoor wobjectfindnearestbyobjectclass wobjectfindininventorybytemplatetype wobjectfindininventorybyname wobjectfindininventorybynamerx wobjectgetselection wobjectgetplayer wobjectfindnearestbynameandobjectclass actiontryselect actiontryuseitem actiontryapplyitem actiontrygiveitem actiontryequipanywand actiontrycastbyid actiontrycastbyidontarget chatbox chatboxpaste statushud statushudcolored uigetcontrol uisetlabel isfalse istrue iif randint cstr strlen getobjectinternaltype cstrf stopwatchcreate stopwatchstart stopwatchstop stopwatchelapsedseconds cnumber floor ceiling round abs getworldname getitemcountininventorybyname getheading getitemcountininventorybynamerx getheadingto actiontrygiveprofile vitae getfellowshipstatus getfellowshipname getfellowshipisopen getfellowshipisleader getfellowshipleaderid getfellowshipcanrecruit getfellowid getfellowshipcount getfellowshiplocked getfellowname getfellowshipisfull sin cos tan sqrt asin acos atan atan2 sinh cosh tanh vtsetmetastate getregexmatch echo chr ord wobjectgetid wobjectgethealth wobjectfindbyid wobjectgetintprop wobjectfindnearestbytemplatetype wobjectgetopencontainer testquestflag getquestktprogress isrefreshingquests getquestktrequired getqueststatus getisday getgamehour getgamehourname getisnight getgameday getgameticks getminutesuntilday getgamemonth getgamemonthname getminutesuntilnight getgameyear uisetvisible uiviewvisible uiviewexists getgvar touchgvar getpvar touchpvar setgvar cleargvar setpvar clearpvar testgvar clearallgvars testpvar clearallpvars dictgetitem dictcreate dicthaskey dictadditem dictkeys dictremovekey dictvalues dictclear dictsize dictcopy listgetitem listpop listcreate listcontains listremove listadd listindexof listremoveat listinsert listlastindexof listclear listcopy listcount listreverse
~~
~~ File auto-generated by metaf, a program created by Eskarina of Morningthaw/Coldeve.
~~ Get metaf here: https://github.com/JJEII/metaf/
~~
~~ All recognized structural designators in a NAV-ONLY file:
~~ NAV:
~~
~~ All recognized NAV types:
~~ circular follow
~~ linear once
~~
~~ All recognized NAV NODE types:
~~ flw vnd
~~ pnt ptl
~~ rcl tlk
~~ pau chk
~~ cht jmp
~~ prt (deprecated in VTank)
~~
~~
~~ REMEMBER THAT NAV-ONLY FILES MUST CONTAIN EXACTLY ONE NAV!
~~ }
NAV: nav0 once ~~ {
cht 59.3306452433268 -28.5473848342896 0.0500208298365275 {/ub equip load idle.utl}
pau 59.3306452433268 -28.5473848342896 0.0500208298365275 10000
cht 59.3306452433268 -28.5473848342896 0.0500208298365275 {/ub equip load quest.utl}
pau 59.3306452433268 -28.5473848342896 0.0500208298365275 10000
rcl 59.3306452433268 -28.5473848342896 0.0500208298365275 {Primary Portal Recall}
pau 59.3306452433268 -28.5473848342896 0.0500208298365275 2000
pnt -101.643393707275 17.8825289090474 2.08333134651184E-05
cht 59.3306452433268 -28.5473848342896 0.0500208298365275 {/ub jumpsw 300}
pnt -101.592045052846 17.8805678685506 -0.0999791701634725
pnt -101.554651578267 17.8813594023387 -0.0999791701634725
pnt -101.533706283569 17.8833907604218 -0.0999791701634725
pnt -101.534181054433 17.8413492997487 -0.0999791701634725
pnt -101.48626130422 17.841304953893 -0.0999791701634725
pnt -101.490094470978 17.8813687324524 -0.0999791701634725
pnt -101.407286643982 17.884686867396 -0.124979170163473
pnt -101.441320260366 17.801603158315 -0.124979170163473
pnt -101.466675154368 17.7969429810842 -0.124979170163473
pnt -101.516472848256 17.8459821859996 -0.124979170163473
pnt -101.531043752035 17.8853654225667 -0.124979170163473
pnt -101.575432650248 17.8846518675486 -0.124979170163473
pnt -101.575820668538 17.7591938654582 -0.149979162216187
pnt -101.529988797506 17.7609985669454 -0.149979162216187
pnt -101.53424466451 17.8423960208893 -0.174979162216187
pnt -101.583576202393 17.8416724999746 -0.174979162216187
pnt -101.57072356542 17.8728516896566 -0.174979162216187
pnt -101.595848846436 17.8807149728139 -0.174979162216187
cht 59.3306452433268 -28.5473848342896 0.0500208298365275 {/ub jumpsw 300}
pnt -101.622127024333 17.8804373105367 -0.274979146321615
pnt -101.616229724884 17.9339917182922 -0.274979146321615
pnt -101.612587674459 17.9819168567657 -0.296296882629395
pnt -101.615892442067 18.0119011123975 -0.299979146321615
pnt -101.590025297801 18.0101968288422 -0.303131580352783
pnt -101.529243914286 18.0071381568909 -0.324979146321615
pnt -101.53581682841 17.9724334478378 -0.324979146321615
pnt -101.572706063588 17.9642690896988 -0.324979146321615
pnt -101.578296979268 17.9251905759176 -0.324979146321615
pnt -101.61803188324 17.9257889827093 -0.324979146321615
pnt -101.620800844828 17.9667853593826 -0.324979146321615
pnt -101.617472012838 18.0454295317332 -0.349979146321615
pnt -101.660903453827 18.0499082385407 -0.349979146321615
pnt -101.658096440633 17.9865849812826 -0.374979146321615
pnt -101.658587837219 17.9719900369644 -0.374979146321615
pnt -101.69858379364 17.9644167820613 -0.374979146321615
pnt -101.706780131658 17.9288707415263 -0.374979146321615
pnt -101.743702141444 17.921234814326 -0.374979146321615
pnt -101.775855763753 17.9226232131322 -0.374979146321615
pnt -101.784734662374 17.9344542185465 -0.374979146321615
pnt -101.78315264384 18.0072518110275 -0.349979146321615
pnt -101.86580046018 18.008648399512 -0.374979146321615
pnt -101.888455645243 18.0071597099304 -0.374979146321615
cht 59.3306452433268 -28.5473848342896 0.0500208298365275 {/ub jumpsw 300}
pnt -101.913387115796 18.0075169881185 -0.474979146321615
pnt -101.889330867926 18.0084470907847 -0.474979146321615
pnt -101.844996301333 17.9643644889196 -0.474979146321615
pnt -101.825047651927 17.9635365645091 -0.474979146321615
pnt -101.821242467562 17.9419899145762 -0.474979146321615
pnt -101.786994679769 17.9041363716125 -0.474979146321615
pnt -101.782102791468 17.8818953514099 -0.474979146321615
pnt -101.747181431452 17.8816454569499 -0.474979146321615
~~ }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,71 @@
UTL
1
1
peas
0;3;9999;15
6
true
30
255
255
255
10
0.1
None
SalvageCombine
277
1
1-6, 7-8, 9, 10
25
10
1-10
14
1-10
16
1-10
17
1-10
18
1-10
19
1-10
22
1-10
25
1-10
29
1-10
30
1-10
36
1-10
37
1-10
41
1-10
47
1-10
35
1-10
27
1-10
26
1-10
21
1-10
15
1-10
13
1-10
50
1-10
49
1-10
34
1-10
52
1-10
51
1-10
0

View file

@ -0,0 +1,59 @@
UTL
1
0
SalvageCombine
277
1
1-6, 7-8, 9, 10
25
10
1-10
14
1-10
16
1-10
17
1-10
18
1-10
19
1-10
22
1-10
25
1-10
29
1-10
30
1-10
36
1-10
37
1-10
41
1-10
47
1-10
35
1-10
27
1-10
26
1-10
21
1-10
15
1-10
13
1-10
50
1-10
49
1-10
34
1-10
52
1-10
51
1-10
0

View file

@ -0,0 +1,931 @@
UTL
1
42
(H) 430 Insane
0;1;12;7;2008;2;13
9
44
159
3
1
17
109
1.17
1.17
8
0
171
18
23308
218103849
(H) Axe (OD +10)
0;1;12;12;2003;7;1;2;13
8
3
353
9
44
159
15
84
218103842
3
1
33
Silifi|Lugian|War Axe|Battle
1
8
0
171
18
23308
218103849
(L) Axe (OD +10)
0;1;12;12;2003;7;1;2;13
8
3
353
9
45
159
15
71
218103842
3
1
32
Dolabra|Ono|Hand|War Hammer
1
8
0
171
18
23308
218103849
(F) Axe (OD +10)
0;1;12;12;2003;7;1;2;13
8
3
353
9
46
159
15
71
218103842
3
1
34
Hatchet|Shou-ono|Tungi|Hammer
1
8
0
171
18
23308
218103849
(H) Dagger (OD +10)
0;1;12;12;2003;7;1;2;13
8
6
353
9
44
159
15
81
218103842
3
1
9
Dirk
1
8
0
171
18
23308
218103849
(H) Dagger MS (OD +10)
0;1;12;12;2003;7;1;2;13
8
6
353
9
44
159
15
48
218103842
3
1
21
Stiletto|Jambiya
1
8
0
171
18
23308
218103849
(L) Dagger (OD +10)
0;1;12;12;2003;7;1;2;13
8
6
353
9
45
159
15
68
218103842
3
1
12
Khanjar
1
8
0
171
18
23308
218103849
(L) Dagger MS (OD +10)
0;1;12;12;2003;7;1;2;13
8
6
353
9
45
159
15
38
218103842
3
1
11
Dagger
1
8
0
171
18
23308
218103849
(F) Dagger (OD +10)
0;1;12;12;2003;7;1;2;13
8
6
353
9
46
159
15
68
218103842
3
1
12
Poniard
1
8
0
171
18
23308
218103849
(F) Dagger MS (OD +10)
0;1;12;12;2003;7;1;2;13
8
6
353
9
46
159
15
38
218103842
3
1
17
Knife|Lancet
1
8
0
171
18
23308
218103849
(H) Mace (OD +10)
0;1;12;12;2003;7;1;2;13
8
4
353
9
44
159
15
79
218103842
3
1
24
Mazule|Mace|Morning
1
8
0
171
18
23308
218103849
(L) Mace (OD +10)
0;1;12;12;2003;7;1;2;13
8
4
353
9
45
159
15
66
218103842
3
1
19
Club|Kasrullah
1
8
0
171
18
23308
218103849
(F) Mace (OD +10)
0;1;12;12;2003;7;1;2;13
8
4
353
9
46
159
15
66
218103842
3
1
22
Board|Tofun|Dabus
1
8
0
171
18
23308
218103849
(H) Spear (OD +10)
0;1;12;12;2003;7;1;2;13
8
5
353
9
44
159
15
82
218103842
3
1
28
Glaive|Trident|Partizan
1
8
0
171
18
23308
218103849
(L) Spear (OD +10)
0;1;12;12;2003;7;1;2;13
8
5
353
9
45
159
15
70
218103842
3
1
15
Spear|Yari
1
8
0
171
18
23308
218103849
(F) Spear (OD +10 )
0;1;12;12;2003;7;1;2;13
8
5
353
9
46
159
15
70
218103842
3
1
20
Naginata|Budiaq
1
8
0
171
18
23308
218103849
(H) Staff (OD +10)
0;1;12;12;2003;7;1;2;13
8
7
353
9
44
159
15
79
218103842
3
1
16
Nabut|Stick
1
8
0
171
18
23308
218103849
(L) Staff (OD +10)
0;1;12;12;2003;7;1;2;13
8
7
353
9
45
159
15
67
218103842
3
1
12
Quarter
1
8
0
171
18
23308
218103849
(F) Staff (OD +10)
0;1;12;12;2003;7;1;2;13
8
7
353
9
46
159
15
67
218103842
3
1
15
Jo|Bastone
1
8
0
171
18
23308
218103849
(H) Sword (OD +10)
0;1;12;12;2003;7;1;2;13
8
2
353
9
44
159
15
81
218103842
3
1
36
Takuba|Flamberge|Ken|Long|Tachi
1
8
0
171
18
23308
218103849
(H) Sword MS (OD +10)
0;1;12;12;2003;7;1;2;13
8
2
353
9
44
159
15
48
218103842
3
1
13
Schlager
1
8
0
171
18
23308
218103849
(L) Sword (OD +10)
0;1;12;12;2003;7;1;2;13
8
2
353
9
45
159
15
68
218103842
3
1
42
Dericost|Broad|Shamshir|Kaskara|Spada
1
8
0
171
18
23308
218103849
(L) Sword MS (OD +10)
0;1;12;12;2003;7;1;2;13
8
2
353
9
45
159
15
37
218103842
3
1
9
Epee
1
8
0
171
18
23308
218103849
(F) Sword (OD +10)
0;1;12;12;2003;7;1;2;13
8
2
353
9
46
159
15
68
218103842
3
1
36
Scimitar|Yaoji|Short|Sabra|Simi
1
8
0
171
18
23308
218103849
(F) Sword MS (OD +10)
0;1;12;12;2003;7;1;2;13
8
2
353
9
46
159
15
38
218103842
3
1
11
Rapier
1
8
0
171
18
23308
218103849
(H) UA (OD +10)
0;1;12;12;2003;7;1;2;13
8
1
353
9
44
159
15
69
218103842
3
1
18
Nekode|Cestus
1
8
0
171
18
23308
218103849
(L) UA (OD +10)
0;1;12;12;2003;7;1;2;13
8
1
353
9
45
159
15
58
218103842
3
1
19
Katar|Knuckles
1
8
0
171
18
23308
218103849
(F) UA (OD +10)
0;1;12;12;2003;7;1;2;13
8
1
353
9
46
159
15
58
218103842
3
1
15
Claw|Wraps
1
8
0
171
18
23308
218103849
(2H) Cleaving (OD +10)
0;1;7;2003;12;1;2;13
3
1
15
55
218103842
9
41
159
75
Nodachi|Shashqa|Spadone|Greataxe|Quadrelle|Khanda-handled|Tetsubo|Star
1
8
0
171
18
23308
218103849
(2H) Cleaving (OD +9)
0;1;7;2003;12;1;2;13
3
1
15
54
218103842
9
41
159
75
Nodachi|Shashqa|Spadone|Greataxe|Quadrelle|Khanda-handled|Tetsubo|Star
1
8
0
171
18
23308
218103849
(2H) Cleaving (OD +8)
0;1;7;2003;12;1;2;13
3
1
15
53
218103842
9
41
159
75
Nodachi|Shashqa|Spadone|Greataxe|Quadrelle|Khanda-handled|Tetsubo|Star
1
8
0
171
18
23308
218103849
(2H) Spear (OD +10)
0;1;7;2003;12;1;2;13
3
1
15
58
218103842
9
41
159
33
Assagai|Pike|Magari|Corsesca
1
8
0
171
18
23308
218103849
(M) Bow (OD +10)
0;1;12;7;2001;12;2;13
8
8
353
3
9
6
78.6
9
47
159
8
0
171
18
23308
218103849
(M) Xbow (OD +10)
0;1;12;7;2001;12;2;13
8
9
353
3
9
4
87
9
47
159
8
0
171
18
23308
218103849
(M) TW (OD +10)
0;1;12;7;2001;12;2;13
9
10
353
3
9
6
85.3
9
47
159
8
0
171
18
23308
218103849
(W) No Wield (29MD)
0;1;7;2005;13;13;13;1;13;2;13
4
31
10
1.29
29
10
375
160
10
355
160
10
330
160
27
Orb|Wand|Sceptre|Staff
1
10
385
160
8
0
171
18
23308
218103849
(W) No Wield (28MD)
0;1;7;2005;13;13;13;1;13;2;13
4
31
10
1.28
29
10
375
160
10
355
160
10
330
160
27
Orb|Wand|Sceptre|Staff
1
10
385
160
8
0
171
18
23308
218103849
(W) Void (OD +7)
0;1;7;2005;1;2;13
4
31
11
1.25
152
11
Nether
1
8
0
171
18
23308
218103849
(W) Void (OD +6)
0;1;7;2005;1;2;13
4
31
11
1.24
152
11
Nether
1
8
0
171
18
23308
218103849
(W) Void (20md + 4Mgcd)
0;1;7;5;1;2005;2;13
4
31
11
1.04
150
11
Nether
1
9
1.2
29
8
0
171
18
23308
218103849
(W) Void (29MD)
0;1;7;1;2005;2;13
4
31
11
Nether
1
10
1.29
29
8
0
171
18
23308
218103849
(W) War (OD +7)
0;1;7;2005;1;2;13
4
31
11
1.25
152
24
Baton|Sceptre|Staff
1
8
0
171
18
23308
218103849
SalvageCombine
11
1
0
0

View file

@ -0,0 +1,121 @@
UTL
1
90
***** Trophies *****
0;1;9999;1
6
true
24
leave this disabled
1
0;1;9999
6
true
** Aetheria **
0;1;1
16
do not edit
1
(T) Aetheria (5)
0;1;1;12
13
Aetheria
1
18
27704
218103849
(T) Aetheria (4+)
0;1;1;3
13
Aetheria
1
18
27703
218103849
(T) Aetheria (3+)
0;1;1;3;9999
13
Aetheria
1
18
27702
218103849
6
true
(T) Aetheria (All)
0;1;1;9999
23
Coalesced Aetheria
1
6
true
(T) Aetheria (Red only)
0;1;1;12;9999
23
Coalesced Aetheria
1
18
42636
218103808
6
true
0;1;9999
6
true
** Armor **
0;1;1
16
do not edit
1
(T) Rynthid Masks
0;1;1;7
12
Rynthid
1
3
2
0;1;9999
6
true
** Consumables **
0;1;1
16
do not edit
1
(T) Stamina Philtre
0;1;1
20
Stamina Philtre
1
(T) Health Philtre
0;1;1
19
Health Philtre
1
(T) Mana Philtre
0;1;1;9999
17
Mana Philtre
1
6
true
(T) Stamina Elixir

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,291 @@
1
CondAct
5
CType
AType
CData
AData
State
n
n
n
n
n
11
i
20
i
1
i
0
s
mp_primary
s
Default
i
1
i
2
i
0
s
/mp
s
Default
i
20
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/vt opt set enablenav true
i
1
s
turn_in_jaw
s
jaw_hunt
i
12
i
3
TABLE
2
k
v
n
n
2
s
n
s
Insatiable Eater Jaw
s
c
i
1
TABLE
2
K
V
n
n
1
i
2
s
/vt nav load to_fiun
s
jaw_hunt
i
7
i
3
i
0
TABLE
2
K
V
n
n
4
i
2
s
/vt nav load jaw_1_hunt
i
2
s
/vt opt set enablecombat true
i
2
s
/vt opt set enablelooting true
i
1
s
jaw_hunt
s
jaw_run
i
28
i
3
TABLE
2
k
v
n
n
2
s
p
s
You have solved this quest too recently!
s
c
s
TABLE
2
K
V
n
n
2
i
2
s
/vt nav load recallls
i
2
s
/vt opt set enablelooting false
s
kill_bella
i
1
i
3
i
0
TABLE
2
K
V
n
n
7
i
2
s
/vt nav load mp_primary
i
2
s
/vt opt set enablebuffing true
i
2
s
/vt opt set enablenav true
i
2
s
/vt opt set enablecombat true
i
2
s
/vt opt set enablelooting false
i
2
s
/vt opt set NavPriorityBoost false
i
2
s
/vt opt set navclosestoprange 0.008
s
mp_primary
i
20
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/vt nav load jaw_1
i
1
s
jaw_run
s
mp_primary
i
20
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/vt nav load to_bella
i
1
s
kill_bella
s
mp_secondary
i
20
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/vt nav load mp_secondary
i
1
s
mp_secondary
s
mp_trans
i
4
i
3
s
^Fiun Rehlyun.*
TABLE
2
K
V
n
n
2
i
2
s
/mp
i
1
s
mp_trans
s
turn_in_jaw

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,910 @@
1
CondAct
5
CType
AType
CData
AData
State
n
n
n
n
n
33
i
1
i
3
i
0
TABLE
2
K
V
n
n
2
i
7
TABLE
2
k
v
n
n
1
s
e
s
setvar[mssortstring,`CV,AS,EQ,TR-,OC,IC,AL-,EP,MX-`]
i
1
s
SortManaStones
s
Default
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 7
i
2
s
/ms set ocfilter manastone
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortManaStones
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortHealingKits
s
SortManaStones
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 7
i
2
s
/ms set ocfilter healingkit
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortHealingKits
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortMisc
s
SortHealingKits
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 2
i
2
s
/ms set ocfilter misc
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortMisc
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortGems
s
SortMisc
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 3
i
2
s
/ms set ocfilter gem
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortGems
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortWeapons
s
SortGems
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 1
i
2
s
/ms set ocfilter meleeweapon
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortWeapons
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortWands
s
SortWeapons
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 1
i
2
s
/ms set ocfilter wand
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortWands
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortSpellComps
s
SortWands
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest pack 8
i
2
s
/ms set ocfilter spellcomp
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortSpellComps
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortInventory
s
SortSpellComps
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source player
i
2
s
/ms set dest player
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortInventory
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack1
s
SortInventory
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 1
i
2
s
/ms set dest pack 1
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack1
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack2
s
SortPack1
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 2
i
2
s
/ms set dest pack 2
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack2
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack3
s
SortPack2
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 3
i
2
s
/ms set dest pack 3
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack3
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack4
s
SortPack3
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 4
i
2
s
/ms set dest pack 4
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack4
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack5
s
SortPack4
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 5
i
2
s
/ms set dest pack 5
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack5
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack6
s
SortPack5
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 6
i
2
s
/ms set dest pack 6
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack6
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack7
s
SortPack6
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 7
i
2
s
/ms set dest pack 7
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack7
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
SortPack8
s
SortPack7
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/ms set source pack 8
i
2
s
/ms set dest pack 8
i
2
s
/ms clear ocfilter
i
8
TABLE
2
k
v
n
n
1
s
e
s
`/ms set flags `+getvar[mssortstring]
i
2
s
/ms start
s
SortPack8
i
4
i
1
s
^You think\, \"done sorting\!\"$
s
AllDone
s
SortPack8

View file

@ -0,0 +1,554 @@
1
CondAct
5
CType
AType
CData
AData
State
n
n
n
n
n
42
i
1
i
2
i
0
s
/ub bc /vt setmetastate just_run_back
s
all_run_back
i
1
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/ub bc /ub use Luminous Crystal of Surging Strength
i
1
s
Default
s
strength
i
1
i
2
i
0
s
/ub bc /ub closestportal
s
closestportal
i
1
i
1
i
0
s
Default
s
closestportal
i
1
i
2
i
0
s
/vt nav load death_pause
s
death
i
1
i
2
i
0
s
/vt opt set enablenav true
s
death
i
7
i
1
i
0
s
run_back
s
death
i
1
i
0
i
0
i
0
s
Default
i
8
i
1
i
0
s
death
s
Default
i
1
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/ub bc /ub use Luminous Crystal of Towering Defense
i
1
s
Default
s
defence
i
1
i
3
i
0
TABLE
2
K
V
n
n
2
i
2
s
/ub bc /ub use Black Market Gem of Dispelling
i
1
s
Default
s
dispell
i
1
i
3
i
0
TABLE
2
K
V
n
n
2
i
8
TABLE
2
k
v
n
n
1
s
e
s
\/ub bc \/ub follow +wobjectgetname[wobjectgetplayer[]]
i
2
s
/ub bc /vt opt set enablenav true
s
follow
i
1
i
1
i
0
s
Default
s
follow
i
1
i
2
i
0
s
/ub bc /ub jumpsw 400
s
jump
i
1
i
1
i
0
s
Default
s
jump
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/vt nav load gauntlet_just_run
i
2
s
/vt opt set enablenav true
i
2
s
/vt opt set enablebuffing true
i
2
s
/vt opt set enablemeta true
i
2
s
/vt opt set enablecombat true
s
just_run_back
i
7
i
1
i
0
s
Default
s
just_run_back
i
1
i
2
i
0
s
/ub bc /vt meta load gauntlet_follower
s
load_follow
i
1
i
1
i
0
s
Default
s
load_follow
i
1
i
2
i
0
s
/ub bc /vt opt set enablenav false
s
nav_false
i
1
i
1
i
0
s
Default
s
nav_false
i
1
i
2
i
0
s
/ub bc /vt opt set enablenav true
s
nav_true
i
1
i
1
i
0
s
Default
s
nav_true
i
1
i
3
i
0
TABLE
2
K
V
n
n
5
i
2
s
/vt nav load gauntlet_run
i
2
s
/vt opt set enablenav true
i
2
s
/vt opt set enablebuffing true
i
2
s
/vt opt set enablemeta true
i
2
s
/vt opt set enablecombat true
s
run_back
i
7
i
1
i
0
s
Default
s
run_back
i
1
i
8
i
0
TABLE
2
k
v
n
n
1
s
e
s
\/ub bc \/ub follow +wobjectgetname[wobjectgetplayer[]]
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set enablelooting false
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set enablenav true
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set enablebuffing true
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set enablecombat true
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set attackdistance 0.125
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set navpriorityboost true
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set buffprofile_prots 2
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set buffprofile_banes 2
s
setup
i
1
i
2
i
0
s
/ub bc /vt forcebuff
s
setup
i
1
i
2
i
0
s
/ub bc /vt start
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set enablemeta true
s
setup
i
1
i
1
i
0
s
Default
s
setup
i
1
i
2
i
0
s
/ub bc /vt opt set SummonPets false
s
summon_false
i
1
i
1
i
0
s
Default
s
summon_false
i
1
i
2
i
0
s
/ub bc /vt opt set SummonPets true
s
summon_true
i
1
i
1
i
0
s
Default
s
summon_true

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,73 @@
uTank2 NAV 1.2
4
13
4
59.1563772201538
-28.5892723083496
0.0500208298365275
0
/ah
3
59.1563772201538
-28.5892723083496
0.0500208298365275
0
22000
4
59.1563772201538
-28.5892723083496
0.0500208298365275
0
/tell Time according to alex, ab
3
59.1563772201538
-28.5892723083496
0.0500208298365275
0
30000
4
59.1563772201538
-28.5892723083496
0.0500208298365275
0
/ub use Gateway
0
-87.9797062555949
-60.4362569173177
0.265733496348063
0
0
-87.9643449147542
-60.3346110810836
0.230621592203776
0
0
-87.9498847007751
-60.1938330491384
0.185985358556112
0
0
-87.9534404754639
-60.1445933977763
0.175027068456014
0
0
-87.9548230806986
-60.0900212287903
0.175027068456014
0
0
-87.9550152778625
-60.0620187123617
0.175027068456014
0
0
-87.9489116032918
-60.0324022928874
0.175027068456014
0
0
-87.9241135279338
-59.9791566848755
0.175027068456014
0

View file

@ -0,0 +1,44 @@
uTank2 NAV 1.2
4
8
0
48.3606951395671
25.9085505803426
0.231929063796997
0
0
48.3884344100952
25.5719860394796
0.239678192138672
0
0
48.3782159169515
25.5435764312744
0.225020837783813
0
0
48.3625609079997
25.4953356901805
0.225020837783813
0
0
48.3672090530396
25.4595303853353
0.225020837783813
0
0
48.3880900065104
25.4410440921783
0.225020837783813
0
8
48.4019992192586
25.4926815191905
0.225020837783813
0
4
48.3987060546875
25.4926479816437
0.225020837783813
0
/ub give Blank Augmentation Gem to Brienne Carlus

View file

@ -0,0 +1,161 @@
uTank2 NAV 1.2
4
29
2
-71.8128362019857
-46.2284189224243
2.08333212261399E-05
0
2943
0
-94.7016312003136
-97.981881014506
0.367698383331299
0
6
-71.8128362019857
-46.2284189224243
2.08333212261399E-05
0
Obsidian Rim Portal
14
True
-94.6987583319346
-98.0068958282471
0.371145516633987
0
-65.5345490773519
-48.0928927580516
0.0821696281433106
0
0
-66.2971390088399
-48.0919455369314
0.0824445406595866
0
0
-68.2659324010213
-47.9679103851318
0.0901949644088745
0
0
-68.9330024560293
-47.9617745717367
0.0741503397623698
0
0
-68.9996176083883
-47.9868227322896
0.0811579465866089
0
0
-69.0741902987162
-47.9962577819824
0.0902631839116414
0
0
-69.1185588876406
-48.0084948221842
0.0896445353825887
0
0
-69.1932870864868
-48.0247398058573
0.0864912668863932
0
0
-69.2399808883667
-48.0406180699666
0.0826785326004028
0
0
-69.3729923248291
-48.0507068951925
0.0753742853800456
0
0
-69.502007261912
-48.0152909914653
0.0562341809272766
0
0
-69.6805796305339
-47.9529766718547
0.00605392009019852
0
0
-69.7465653419495
-47.9553860028585
0.000755888223648071
0
0
-69.917982506752
-47.9998155911764
0.00417213241259257
0
0
-70.4033851623535
-48.1885171890259
0.0103249351183573
0
0
-71.328177467982
-48.5185417175293
2.08333212261399E-05
0
0
-72.7273258845011
-47.8933919270833
0.0094555139541626
0
0
-72.7809668223063
-47.776501083374
0.0289373199144999
0
0
-72.7527559280395
-47.166915734609
0.135223134358724
0
0
-72.7196399370829
-47.071635723114
0.0760513146718343
0
0
-72.7042126337687
-47.0472258249919
0.0588250835736593
0
0
-72.6919200261434
-47.0329411188761
0.0555859247843424
0
0
-72.5977428436279
-46.9303064982096
0.0106916089852651
0
0
-71.9506707509359
-46.338755830129
2.08333212261399E-05
0
0
-71.8265404701233
-46.2414363861084
2.08333212261399E-05
0
6
-71.8128362019857
-46.2284189224243
2.08333212261399E-05
0
Empyrean Facility Upper Level
14
True
-71.8083083470662
-46.2096791585286
-0.000262499845121056

View file

@ -0,0 +1,133 @@
uTank2 NAV 1.2
4
22
4
0.675179147720337
13.5718362808228
0.521687507629395
0
/ah
3
0.675179147720337
13.5718362808228
0.521687507629395
0
15000
0
59.3342878341675
-28.5975779215495
0.0500208298365275
0
0
59.35896803538
-28.6749758720398
0.0500208298365275
0
0
59.37918065389
-28.7118357340495
0.0500208298365275
0
6
0.675179147720337
13.5718362808228
0.521687507629395
0
Portal to Town Network
14
True
59.3936458587647
-28.7256083488464
0.0508250035345554
0
-101.617960484823
-96.6229596773783
2.08333134651184E-05
0
0
-101.64374332428
-96.6113506952922
2.08333134651184E-05
0
0
-101.677829106649
-96.6077419598897
2.08333134651184E-05
0
0
-101.708999125163
-96.6393287976583
2.08333134651184E-05
0
0
-101.720591862996
-96.644443766276
2.08333134651184E-05
0
0
-101.724977588654
-96.6444894790649
2.08333134651184E-05
0
0
-101.766201734543
-96.642304166158
2.08333134651184E-05
0
0
-101.826050774256
-96.6380467732747
2.08333134651184E-05
0
0
-101.873989931742
-96.6189005851746
2.08333134651184E-05
0
0
-101.907649771372
-96.5925824801127
2.08333134651184E-05
0
0
-101.907772219181
-96.5846062819163
2.08333134651184E-05
0
6
0.675179147720337
13.5718362808228
0.521687507629395
0
Portal to Zaikhal
14
True
-101.90858749946
-96.5742208321889
-0.000262499845121056
0
0.684857130050659
13.498526652654
0.516687520345052
0
0
0.648467270533244
13.6382585525513
0.516687520345052
0
0
0.663638496398926
13.6434263865153
0.516687520345052
0
7
0.675179147720337
13.5718362808228
0.521687507629395
0
Jondor Torgren
37
True
0.675
13.6436941782633
0.516687512397766

View file

@ -0,0 +1,307 @@
uTank2 NAV 1.2
4
59
4
59.3306452433268
-28.5473848342896
0.0500208298365275
0
/ub equip load idle.utl
3
59.3306452433268
-28.5473848342896
0.0500208298365275
0
10000
4
59.3306452433268
-28.5473848342896
0.0500208298365275
0
/ub equip load quest.utl
3
59.3306452433268
-28.5473848342896
0.0500208298365275
0
10000
2
59.3306452433268
-28.5473848342896
0.0500208298365275
0
48
3
59.3306452433268
-28.5473848342896
0.0500208298365275
0
2000
0
-101.643393707275
17.8825289090474
2.08333134651184E-05
0
4
59.3306452433268
-28.5473848342896
0.0500208298365275
0
/ub jumpsw 300
0
-101.592045052846
17.8805678685506
-0.0999791701634725
0
0
-101.554651578267
17.8813594023387
-0.0999791701634725
0
0
-101.533706283569
17.8833907604218
-0.0999791701634725
0
0
-101.534181054433
17.8413492997487
-0.0999791701634725
0
0
-101.48626130422
17.841304953893
-0.0999791701634725
0
0
-101.490094470978
17.8813687324524
-0.0999791701634725
0
0
-101.407286643982
17.884686867396
-0.124979170163473
0
0
-101.441320260366
17.801603158315
-0.124979170163473
0
0
-101.466675154368
17.7969429810842
-0.124979170163473
0
0
-101.516472848256
17.8459821859996
-0.124979170163473
0
0
-101.531043752035
17.8853654225667
-0.124979170163473
0
0
-101.575432650248
17.8846518675486
-0.124979170163473
0
0
-101.575820668538
17.7591938654582
-0.149979162216187
0
0
-101.529988797506
17.7609985669454
-0.149979162216187
0
0
-101.53424466451
17.8423960208893
-0.174979162216187
0
0
-101.583576202393
17.8416724999746
-0.174979162216187
0
0
-101.57072356542
17.8728516896566
-0.174979162216187
0
0
-101.595848846436
17.8807149728139
-0.174979162216187
0
4
59.3306452433268
-28.5473848342896
0.0500208298365275
0
/ub jumpsw 300
0
-101.622127024333
17.8804373105367
-0.274979146321615
0
0
-101.616229724884
17.9339917182922
-0.274979146321615
0
0
-101.612587674459
17.9819168567657
-0.296296882629395
0
0
-101.615892442067
18.0119011123975
-0.299979146321615
0
0
-101.590025297801
18.0101968288422
-0.303131580352783
0
0
-101.529243914286
18.0071381568909
-0.324979146321615
0
0
-101.53581682841
17.9724334478378
-0.324979146321615
0
0
-101.572706063588
17.9642690896988
-0.324979146321615
0
0
-101.578296979268
17.9251905759176
-0.324979146321615
0
0
-101.61803188324
17.9257889827093
-0.324979146321615
0
0
-101.620800844828
17.9667853593826
-0.324979146321615
0
0
-101.617472012838
18.0454295317332
-0.349979146321615
0
0
-101.660903453827
18.0499082385407
-0.349979146321615
0
0
-101.658096440633
17.9865849812826
-0.374979146321615
0
0
-101.658587837219
17.9719900369644
-0.374979146321615
0
0
-101.69858379364
17.9644167820613
-0.374979146321615
0
0
-101.706780131658
17.9288707415263
-0.374979146321615
0
0
-101.743702141444
17.921234814326
-0.374979146321615
0
0
-101.775855763753
17.9226232131322
-0.374979146321615
0
0
-101.784734662374
17.9344542185465
-0.374979146321615
0
0
-101.78315264384
18.0072518110275
-0.349979146321615
0
0
-101.86580046018
18.008648399512
-0.374979146321615
0
0
-101.888455645243
18.0071597099304
-0.374979146321615
0
4
59.3306452433268
-28.5473848342896
0.0500208298365275
0
/ub jumpsw 300
0
-101.913387115796
18.0075169881185
-0.474979146321615
0
0
-101.889330867926
18.0084470907847
-0.474979146321615
0
0
-101.844996301333
17.9643644889196
-0.474979146321615
0
0
-101.825047651927
17.9635365645091
-0.474979146321615
0
0
-101.821242467562
17.9419899145762
-0.474979146321615
0
0
-101.786994679769
17.9041363716125
-0.474979146321615
0
0
-101.782102791468
17.8818953514099
-0.474979146321615
0
0
-101.747181431452
17.8816454569499
-0.474979146321615
0

View file

@ -0,0 +1,12 @@
1
Spells
4
SpellID
EndTime
Target
CastTime
n
n
n
n
0

View file

@ -0,0 +1,14 @@
1
CondAct
5
CType
AType
CData
AData
State
n
n
n
n
n
0

View file

@ -0,0 +1,3 @@
uTank2 NAV 1.2
1
0

View file

@ -0,0 +1,468 @@
8
AntiExtraBuffSpells
1
ExemplarId
n
0
AssistItems
2
Object
Type
n
n
2
s
Bread
i
3
s
Handy Healing Kit
i
0
BuffedItems
2
Object
Spell
n
n
11
i
-2147482735
i
1599
i
-2147482735
i
35
i
-2147482735
i
49
i
-2147482735
i
1587
i
-2147482739
i
1599
i
-2147482739
i
1475
i
-2147482739
i
3253
i
-2147482734
i
1599
i
-2147482734
i
35
i
-2147482734
i
49
i
-2147482734
i
1587
ExtraBuffSpells
1
ExemplarId
n
0
GemFoodItems
2
Name
Spell
n
n
2
s
Asheron's Benediction
i
3810
s
Blackmoor's Favor
i
3811
ItemUseSpecifiers
2
Object
Uses
y
n
0
MyMonsters
21
MonsterName
AttackPriority
DamageType
WeaponToUse
Imperil
Vuln
Yield
GravityW
Attack
Ring
Broadside
Fester
WeakeningCurse
FesteringCurse
Corruption
DestructiveCurse
Corrosion
Streak
SecondaryVuln
SecondaryEquip
PetDamageType
y
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
1
s
<DEFAULT>
i
1
i
8
i
-1
b
False
b
False
b
False
b
False
b
True
b
False
b
False
b
False
b
False
b
False
b
False
b
False
b
False
b
True
i
98
i
0
i
101
Settings
2
Setting
Value
y
n
1
s
RechargeHandlerSet
TABLE
5
Vital
HandlerString
MinPercent
MaxPercent
Stance
n
n
n
n
n
26
i
1
s
Stamina to Health
i
0
i
15
i
1
i
1
s
Mana to Health
i
0
i
15
i
1
i
1
s
Regular Spell
i
0
i
15
i
1
i
1
s
Recharge With Food
i
0
i
15
i
1
i
1
s
Kit Recharge
i
0
i
100
i
1
i
1
s
Stamina to Health
i
0
i
100
i
1
i
1
s
Mana to Health
i
0
i
100
i
1
i
1
s
Regular Spell
i
0
i
100
i
1
i
1
s
Recharge With Food
i
0
i
100
i
1
i
1
s
Recharge With Food
i
0
i
15
i
2
i
1
s
Kit Recharge
i
0
i
100
i
2
i
1
s
Recharge With Food
i
0
i
100
i
2
i
1
s
Stamina to Health
i
0
i
10
i
2
i
1
s
Regular Spell
i
0
i
100
i
2
i
2
s
Kit Recharge
i
0
i
100
i
1
i
2
s
Regular Spell
i
0
i
100
i
1
i
2
s
Recharge With Food
i
0
i
100
i
1
i
2
s
Kit Recharge
i
0
i
100
i
2
i
2
s
Recharge With Food
i
0
i
100
i
2
i
2
s
Regular Spell
i
0
i
100
i
2
i
3
s
Kit Recharge
i
0
i
100
i
1
i
3
s
Recharge With Food
i
0
i
100
i
1
i
3
s
Regular Spell
i
0
i
100
i
1
i
3
s
Kit Recharge
i
0
i
100
i
2
i
3
s
Recharge With Food
i
0
i
100
i
2
i
3
s
Regular Spell
i
0
i
100
i
2

View file

@ -0,0 +1,12 @@
1
Spells
4
SpellID
EndTime
Target
CastTime
n
n
n
n
0

View file

@ -0,0 +1,424 @@
8
AntiExtraBuffSpells
1
ExemplarId
n
0
AssistItems
2
Object
Type
n
n
2
s
Adept Healing Kit
i
0
s
Bread
i
3
BuffedItems
2
Object
Spell
n
n
0
ExtraBuffSpells
1
ExemplarId
n
0
GemFoodItems
2
Name
Spell
n
n
2
s
Asheron's Benediction
i
3810
s
Blackmoor's Favor
i
3811
ItemUseSpecifiers
2
Object
Uses
y
n
0
MyMonsters
21
MonsterName
AttackPriority
DamageType
WeaponToUse
Imperil
Vuln
Yield
GravityW
Attack
Ring
Broadside
Fester
WeakeningCurse
FesteringCurse
Corruption
DestructiveCurse
Corrosion
Streak
SecondaryVuln
SecondaryEquip
PetDamageType
y
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
1
s
<DEFAULT>
i
1
i
8
i
-1
b
False
b
False
b
False
b
False
b
True
b
False
b
False
b
False
b
False
b
False
b
False
b
False
b
False
b
True
i
98
i
0
i
101
Settings
2
Setting
Value
y
n
1
s
RechargeHandlerSet
TABLE
5
Vital
HandlerString
MinPercent
MaxPercent
Stance
n
n
n
n
n
26
i
1
s
Stamina to Health
i
0
i
15
i
1
i
1
s
Mana to Health
i
0
i
15
i
1
i
1
s
Regular Spell
i
0
i
15
i
1
i
1
s
Recharge With Food
i
0
i
15
i
1
i
1
s
Kit Recharge
i
0
i
100
i
1
i
1
s
Stamina to Health
i
0
i
100
i
1
i
1
s
Mana to Health
i
0
i
100
i
1
i
1
s
Regular Spell
i
0
i
100
i
1
i
1
s
Recharge With Food
i
0
i
100
i
1
i
1
s
Recharge With Food
i
0
i
15
i
2
i
1
s
Kit Recharge
i
0
i
100
i
2
i
1
s
Recharge With Food
i
0
i
100
i
2
i
1
s
Stamina to Health
i
0
i
10
i
2
i
1
s
Regular Spell
i
0
i
100
i
2
i
2
s
Kit Recharge
i
0
i
100
i
1
i
2
s
Regular Spell
i
0
i
100
i
1
i
2
s
Recharge With Food
i
0
i
100
i
1
i
2
s
Kit Recharge
i
0
i
100
i
2
i
2
s
Recharge With Food
i
0
i
100
i
2
i
2
s
Regular Spell
i
0
i
100
i
2
i
3
s
Kit Recharge
i
0
i
100
i
1
i
3
s
Recharge With Food
i
0
i
100
i
1
i
3
s
Regular Spell
i
0
i
100
i
1
i
3
s
Kit Recharge
i
0
i
100
i
2
i
3
s
Recharge With Food
i
0
i
100
i
2
i
3
s
Regular Spell
i
0
i
100
i
2

View file

@ -0,0 +1,12 @@
1
Spells
4
SpellID
EndTime
Target
CastTime
n
n
n
n
0

View file

@ -0,0 +1,468 @@
8
AntiExtraBuffSpells
1
ExemplarId
n
0
AssistItems
2
Object
Type
n
n
1
s
Mana Draught
i
5
BuffedItems
2
Object
Spell
n
n
10
i
-2147465511
i
1599
i
-2147465511
i
35
i
-2147465511
i
49
i
-2147465511
i
1587
i
-2147465453
i
1599
i
-2147465453
i
1475
i
-2147465453
i
3253
i
-2147482575
i
1599
i
-2147482575
i
35
i
-2147482575
i
49
ExtraBuffSpells
1
ExemplarId
n
0
GemFoodItems
2
Name
Spell
n
n
4
s
Asheron's Benediction
i
3810
s
Blackmoor's Favor
i
3811
s
White Sapphire
i
2053
s
Tourmaline
i
169
ItemUseSpecifiers
2
Object
Uses
y
n
0
MyMonsters
21
MonsterName
AttackPriority
DamageType
WeaponToUse
Imperil
Vuln
Yield
GravityW
Attack
Ring
Broadside
Fester
WeakeningCurse
FesteringCurse
Corruption
DestructiveCurse
Corrosion
Streak
SecondaryVuln
SecondaryEquip
PetDamageType
y
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
n
1
s
<DEFAULT>
i
1
i
8
i
-1
b
False
b
False
b
False
b
False
b
True
b
False
b
False
b
False
b
False
b
False
b
False
b
False
b
False
b
True
i
98
i
0
i
101
Settings
2
Setting
Value
y
n
1
s
RechargeHandlerSet
TABLE
5
Vital
HandlerString
MinPercent
MaxPercent
Stance
n
n
n
n
n
26
i
1
s
Stamina to Health
i
0
i
15
i
1
i
1
s
Mana to Health
i
0
i
15
i
1
i
1
s
Regular Spell
i
0
i
15
i
1
i
1
s
Recharge With Food
i
0
i
15
i
1
i
1
s
Kit Recharge
i
0
i
100
i
1
i
1
s
Stamina to Health
i
0
i
100
i
1
i
1
s
Mana to Health
i
0
i
100
i
1
i
1
s
Regular Spell
i
0
i
100
i
1
i
1
s
Recharge With Food
i
0
i
100
i
1
i
1
s
Recharge With Food
i
0
i
15
i
2
i
1
s
Kit Recharge
i
0
i
100
i
2
i
1
s
Recharge With Food
i
0
i
100
i
2
i
1
s
Stamina to Health
i
0
i
10
i
2
i
1
s
Regular Spell
i
0
i
100
i
2
i
2
s
Kit Recharge
i
0
i
100
i
1
i
2
s
Regular Spell
i
0
i
100
i
1
i
2
s
Recharge With Food
i
0
i
100
i
1
i
2
s
Kit Recharge
i
0
i
100
i
2
i
2
s
Recharge With Food
i
0
i
100
i
2
i
2
s
Regular Spell
i
0
i
100
i
2
i
3
s
Kit Recharge
i
0
i
100
i
1
i
3
s
Recharge With Food
i
0
i
100
i
1
i
3
s
Regular Spell
i
0
i
100
i
1
i
3
s
Kit Recharge
i
0
i
100
i
2
i
3
s
Recharge With Food
i
0
i
100
i
2
i
3
s
Regular Spell
i
0
i
100
i
2

View file

@ -0,0 +1,691 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MetafSerializerTests
{
private static readonly string FixturesRoot = Path.Combine(
AppContext.BaseDirectory, "Fixtures", "vtank");
private static string[] AllAfFixtures() => Directory.GetFiles(
Path.Combine(FixturesRoot, "af"), "*.af");
private static string[] NavOnlyAfFixtures() => Directory.GetFiles(
Path.Combine(FixturesRoot, "af"), "nav_*.af");
private static string[] MetaAfFixtures() => Directory.GetFiles(
Path.Combine(FixturesRoot, "af"), "*.af")
.Where(static path => !Path.GetFileName(path).StartsWith(
"nav_", StringComparison.Ordinal))
.ToArray();
public static TheoryData<string> AllAfFixtureData()
{
var data = new TheoryData<string>();
foreach (string path in AllAfFixtures())
data.Add(path);
return data;
}
public static TheoryData<string> MetaAfFixtureData()
{
var data = new TheoryData<string>();
foreach (string path in MetaAfFixtures())
data.Add(path);
return data;
}
public static TheoryData<string> NavOnlyAfFixtureData()
{
var data = new TheoryData<string>();
foreach (string path in NavOnlyAfFixtures())
data.Add(path);
return data;
}
// Proof (1): every .af under the fixture set parses without error.
[Theory]
[MemberData(nameof(MetaAfFixtureData))]
public void EveryMetaAfFixtureParses(string path)
{
string text = File.ReadAllText(path);
Assert.True(
MetafSerializer.TryLoadMeta(text, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
$"{Path.GetFileName(path)}: {error}");
Assert.NotEmpty(profile.Rules);
}
[Theory]
[MemberData(nameof(NavOnlyAfFixtureData))]
public void EveryNavOnlyAfFixtureParses(string path)
{
string text = File.ReadAllText(path);
var target = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(text, target, NoOpSpellCatalog.Instance, out string error),
$"{Path.GetFileName(path)}: {error}");
}
// Proof (2): parse -> write -> parse is identical (model equality; the
// writer's own byte-for-byte shape is proof (4), below).
[Theory]
[MemberData(nameof(MetaAfFixtureData))]
public void MetaParseWriteParseIsIdentical(string path)
{
string text = File.ReadAllText(path);
Assert.True(MetafSerializer.TryLoadMeta(
text, NoOpSpellCatalog.Instance, out MetaProfile first, out string error1), error1);
string rewritten = MetafSerializer.SaveMeta(first);
Assert.True(MetafSerializer.TryLoadMeta(
rewritten, NoOpSpellCatalog.Instance, out MetaProfile second, out string error2), error2);
AssertProfilesEqual(first, second);
}
[Theory]
[MemberData(nameof(NavOnlyAfFixtureData))]
public void NavParseWriteParseIsIdentical(string path)
{
string text = File.ReadAllText(path);
var first = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(
text, first, NoOpSpellCatalog.Instance, out string error1), error1);
string rewritten = MetafSerializer.SaveNav(first);
var second = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(
rewritten, second, NoOpSpellCatalog.Instance, out string error2), error2);
AssertNavigationEqual(first, second);
}
// Proof (3): for the met/ and nav/ samples that have a matching af/
// conversion, our model loaded from the binary import path equals our
// model loaded from metaf's own .af conversion.
public static TheoryData<string, string> MetMatchingAfPairs()
{
var data = new TheoryData<string, string>();
string metDir = Path.Combine(FixturesRoot, "met");
string afDir = Path.Combine(FixturesRoot, "af");
foreach (string metPath in Directory.GetFiles(metDir, "*.met"))
{
string name = Path.GetFileNameWithoutExtension(metPath);
string afPath = Path.Combine(afDir, name + ".af");
if (File.Exists(afPath))
data.Add(metPath, afPath);
}
return data;
}
[Theory]
[MemberData(nameof(MetMatchingAfPairs))]
public void BinaryImportMatchesMetafAfConversion(string metPath, string afPath)
{
string metText = File.ReadAllText(metPath);
Assert.True(
VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError),
$"{Path.GetFileName(metPath)}: {metError}");
string afText = File.ReadAllText(afPath);
Assert.True(
MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError),
$"{Path.GetFileName(afPath)}: {afError}");
AssertProfilesEqual(fromMet, fromAf);
}
public static TheoryData<string, string> NavMatchingAfPairs()
{
var data = new TheoryData<string, string>();
string navDir = Path.Combine(FixturesRoot, "nav");
string afDir = Path.Combine(FixturesRoot, "af");
foreach (string navPath in Directory.GetFiles(navDir, "*.nav"))
{
string name = Path.GetFileNameWithoutExtension(navPath);
string afPath = Path.Combine(afDir, name + ".af");
if (File.Exists(afPath))
data.Add(navPath, afPath);
}
return data;
}
[Theory]
[MemberData(nameof(NavMatchingAfPairs))]
public void BinaryNavImportMatchesMetafAfConversion(string navPath, string afPath)
{
string navText = File.ReadAllText(navPath);
var fromNav = new NavigationSettings();
Assert.True(
VtankNavRouteSerializer.TryLoad(navText, fromNav, NoOpSpellCatalog.Instance, out string navError),
$"{Path.GetFileName(navPath)}: {navError}");
string afText = File.ReadAllText(afPath);
var fromAf = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(afText, fromAf, NoOpSpellCatalog.Instance, out string afError),
$"{Path.GetFileName(afPath)}: {afError}");
AssertNavigationEqual(fromNav, fromAf);
}
// Proof (4): the writer's output is REAL byte-identical to metaf's own
// canonical emission (item E, slice-1 fix round: this used to strip
// "~~" comments before comparing, which hid two facts — metaf's own
// ExportToMetAF DOES mechanically emit the auto-completion banner
// (OutputText.metaHeader, metaf_monolithic.py:365-406) plus a "~~ {"/
// "~~ }" editor-fold pair around every STATE:/NAV: block
// (State.ExportToMetAF py:12463-12467, Nav.ExportToMetAF py:12050-12054),
// which this writer now reproduces; and several committed fixtures do
// NOT carry metaf's fresh canonical header at all, for reasons that
// have nothing to do with this writer's correctness — see below).
public static TheoryData<string> ByteIdenticalFixtureData()
{
var data = new TheoryData<string>();
// Excluded, all for reasons independent of this writer (confirmed
// by inspecting each file's own first bytes, not asserted):
//
// bore_enhanced.af / bore_quest.af: hand-edited after generation —
// some IF:/DO: lines use a space instead of metaf's own tab
// separator (bore_quest.af line 9: "\tIF: Death", not
// "\tIF:\tDeath"; metaf's Rule.ExportToMetAF always joins with a
// tab, py:12371/12373).
//
// aphus.af, neftet.af, hunting.af, follower.af: the header's
// internal line breaks are metaf's own bare "\n" for a freshly
// generated file (confirmed against augments.af/bella.af/
// gauntlet_leader.af/empyrean_facility.af/example_sort_meta.af,
// whose first 8 bytes are "~~ {\n~~ "), but these four instead open
// with "~~ {\r\n~~ " (aphus/neftet/follower) or, for hunting.af, a
// wholly custom banner ("~~ Hunting Meta - Solo lifestone
// grinding..." instead of "~~ FOR AUTO-COMPLETION ASSISTANCE...").
// Both are evidence the file was re-saved by something other than
// a fresh metaf conversion (a text editor normalizing every line
// ending to CRLF loses metaf's internal bare-LF header formatting;
// a custom banner is an intentional hand edit) — not something
// this writer could or should reproduce.
//
// lockandkey.af: opens with a fully custom multi-line description
// ("~~\n~~\tlockandkey.af - Portal Bot with Stipend..."), never
// metaf's own banner at all.
//
// All five still exercise every other proof (parse,
// parse-write-parse, and — for aphus/augments/lockandkey/neftet —
// the ptl/tlk two-triple fidelity direct assertions in
// PtlNodeKeepsBothCoordinateTriplesDistinct) normally.
foreach (string name in new[]
{
"bella", "gauntlet_leader", "empyrean_facility",
"augments", "example_sort_meta",
})
{
data.Add(Path.Combine(FixturesRoot, "af", name + ".af"));
}
return data;
}
[Theory]
[MemberData(nameof(ByteIdenticalFixtureData))]
public void WriterOutputMatchesMetafCanonicalEmission(string path)
{
string original = File.ReadAllText(path);
Assert.True(
MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
string rewritten = MetafSerializer.SaveMeta(profile);
Assert.Equal(original, rewritten);
}
// Proof (4), nav-only case: SaveNav's own header (navHeader) and
// single-Nav fold-marker wrap are byte-identical to a real nav-only
// .af fixture. Every listed fixture carries metaf's own fresh banner
// (first bytes "~~ {\n~~ "); round 3 item 8 extended this from a
// single fixture (nav_ab.af) to the three checkpoint/recall/portal2
// fixtures copied from the metas repo (nav_briennecarlus.af carries
// chk+jmp, nav_empyrean.af carries rcl+ptl, nav_lockandkeyjaw.af
// carries rcl+chk) so the theory actually exercises every node kind
// metaf's own grammar supports, not just plain point waypoints.
public static TheoryData<string> NavByteIdenticalFixtureData()
{
var data = new TheoryData<string>();
foreach (string name in new[]
{
"nav_ab", "nav_briennecarlus", "nav_empyrean", "nav_lockandkeyjaw",
})
{
data.Add(Path.Combine(FixturesRoot, "af", name + ".af"));
}
return data;
}
[Theory]
[MemberData(nameof(NavByteIdenticalFixtureData))]
public void WriterOutputMatchesMetafCanonicalEmissionNavOnly(string path)
{
string original = File.ReadAllText(path);
var settings = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(original, settings, NoOpSpellCatalog.Instance, out string error),
error);
string rewritten = MetafSerializer.SaveNav(settings);
Assert.Equal(original, rewritten);
}
[Fact]
public void ExampleSortMetaBinaryImportMatchesAf()
{
string metText = File.ReadAllText(Path.Combine(FixturesRoot, "met", "example_sort_meta.met"));
Assert.True(VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError), metError);
string afText = File.ReadAllText(Path.Combine(FixturesRoot, "af", "example_sort_meta.af"));
Assert.True(
MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError),
afError);
AssertProfilesEqual(fromMet, fromAf);
}
// aphus.af's embedded "nav0__stipend_nav" route carries a real "ptl"
// node whose two coordinate triples genuinely differ (line 1026:
// "-101.597905190786 -96.6216093699137 2.08333134651184E-05" myxyz,
// "59.3936458587647 -28.7256083488464 0.0508250035345554" tgtxyz). A
// prior port collapsed both onto RouteWaypoint.Position, which this
// test would have caught immediately: before the fix, Position held
// 59.39/-28.72/0.0508 (the second triple) and there was nowhere to
// read -101.59/-96.62/2.08e-05 back from at all.
[Fact]
public void PtlNodeKeepsBothCoordinateTriplesDistinct()
{
string original = File.ReadAllText(Path.Combine(FixturesRoot, "af", "aphus.af"));
Assert.True(
MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
RouteWaypoint waypoint = FindNavWaypoint(profile, "Portal to Town Network");
Assert.Equal(-101.597905190786d, waypoint.Position.EastWest, 6);
Assert.Equal(-96.6216093699137d, waypoint.Position.NorthSouth, 6);
Assert.Equal(2.08333134651184E-05d, waypoint.Position.Elevation, 10);
Assert.Equal(59.3936458587647d, waypoint.ReferencePosition.EastWest, 6);
Assert.Equal(-28.7256083488464d, waypoint.ReferencePosition.NorthSouth, 6);
Assert.Equal(0.0508250035345554d, waypoint.ReferencePosition.Elevation, 6);
string rewritten = MetafSerializer.SaveMeta(profile);
Assert.Contains(
"ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 "
+ "59.3936458587647 -28.7256083488464 0.0508250035345554 14 "
+ "{Portal to Town Network}",
rewritten,
StringComparison.Ordinal);
}
/// <summary>
/// Walks every <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
/// action's typed <see cref="MetaAction.EmbeddedRoute"/> looking for a
/// waypoint by object name.
/// </summary>
private static RouteWaypoint FindNavWaypoint(MetaProfile profile, string objectName)
{
foreach (MetaRule rule in profile.Rules)
{
RouteWaypoint? found = FindNavWaypoint(rule.Action, objectName);
if (found is not null)
return found;
}
throw new InvalidOperationException($"no nav waypoint named '{objectName}' found.");
}
private static RouteWaypoint? FindNavWaypoint(MetaAction action, string objectName)
{
if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute
&& action.EmbeddedRoute is { } nav)
{
foreach (RouteWaypoint waypoint in nav.Waypoints)
{
if (waypoint.ObjectName == objectName)
return waypoint;
}
}
foreach (MetaAction child in action.Children)
{
RouteWaypoint? found = FindNavWaypoint(child, objectName);
if (found is not null)
return found;
}
return null;
}
// Item H (slice-1 fix round): real VTank/metaf has no marker for a
// disabled rule at all, so SaveMeta refuses by default rather than
// silently dropping it — see docs/research/vtank-kb/
// 07-meta-and-expressions.md section 5, gap 6.
[Fact]
public void SaveMetaRefusesToDropADisabledRuleByDefault()
{
var profile = new MetaProfile
{
Rules =
[
new MetaRule
{
Enabled = false,
Condition = MetaCondition.Always(),
Action = new MetaAction
{
Kind = MetaActionKind.ChatCommand,
Text = "/say must not silently vanish",
},
},
],
};
InvalidOperationException thrown = Assert.Throws<InvalidOperationException>(
() => MetafSerializer.SaveMeta(profile));
Assert.Contains("1 disabled rule", thrown.Message, StringComparison.Ordinal);
}
[Fact]
public void SaveMetaDropsDisabledRulesOnlyWhenExplicitlyToldTo()
{
var profile = new MetaProfile
{
Rules =
[
new MetaRule
{
Enabled = false,
Condition = MetaCondition.Always(),
Action = new MetaAction
{
Kind = MetaActionKind.ChatCommand,
Text = "/say must not run",
},
},
],
};
string source = MetafSerializer.SaveMeta(profile, dropDisabledRules: true);
Assert.DoesNotContain("must not run", source, StringComparison.Ordinal);
Assert.True(MetafSerializer.TryLoadMeta(
source, NoOpSpellCatalog.Instance, out MetaProfile loaded, out string error), error);
Assert.Empty(loaded.Rules);
}
[Fact]
public void NestedAllAnyNotParsesCorrectly()
{
string af = string.Join("\r\n",
[
"STATE: {Default}",
"\tIF:\tAll",
"\t\t\tAlways",
"\t\t\tNot Death",
"\t\t\tAny",
"\t\t\t\tVendorOpen",
"\t\t\t\tVendorClosed",
"\t\tDO:\tDoAll",
"\t\t\t\tChat {hello}",
"\t\t\t\tSetState {Next}",
]) + "\r\n";
Assert.True(
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
MetaRule rule = Assert.Single(profile.Rules);
Assert.Equal(MetaConditionKind.All, rule.Condition.Kind);
Assert.Equal(3, rule.Condition.Children.Count);
Assert.Equal(MetaConditionKind.Always, rule.Condition.Children[0].Kind);
Assert.Equal(MetaConditionKind.Not, rule.Condition.Children[1].Kind);
Assert.Equal(MetaConditionKind.CharacterDeath, rule.Condition.Children[1].Children[0].Kind);
Assert.Equal(MetaConditionKind.Any, rule.Condition.Children[2].Kind);
Assert.Equal(2, rule.Condition.Children[2].Children.Count);
Assert.Equal(MetaActionKind.All, rule.Action.Kind);
Assert.Equal(2, rule.Action.Children.Count);
Assert.Equal("hello", rule.Action.Children[0].Text);
Assert.Equal("Next", rule.Action.Children[1].Text);
}
// Item J (slice-1 fix round): MobsInDist_Priority is never exercised by
// any committed real fixture (only its name appears, in the
// auto-completion header banner text) — this synthesized round trip
// pins the argument order (count, distance, priority — see the
// regex-table comment in MetafSerializer.cs) with three DISTINCT
// numeric values so an accidental swap of any two fields fails loudly
// instead of silently agreeing with itself.
[Fact]
public void MobsInDistPriorityRoundTripsAllThreeNumbersDistinctly()
{
string af = string.Join("\r\n",
[
"STATE: {Default}",
"\tIF:\tMobsInDist_Priority 7 12.5 3",
"\t\tDO:\tChat {seen}",
]) + "\r\n";
Assert.True(
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
MetaRule rule = Assert.Single(profile.Rules);
Assert.Equal(MetaConditionKind.MonsterPriorityCountWithinDistance, rule.Condition.Kind);
Assert.Equal(7, rule.Condition.Number);
Assert.Equal(12.5, rule.Condition.SecondaryNumber);
Assert.Equal(3, rule.Condition.TertiaryNumber);
string rewritten = MetafSerializer.SaveMeta(profile);
Assert.True(
MetafSerializer.TryLoadMeta(rewritten, NoOpSpellCatalog.Instance, out MetaProfile reloaded, out string error2),
error2);
MetaRule reloadedRule = Assert.Single(reloaded.Rules);
Assert.Equal(7, reloadedRule.Condition.Number);
Assert.Equal(12.5, reloadedRule.Condition.SecondaryNumber);
Assert.Equal(3, reloadedRule.Condition.TertiaryNumber);
}
[Fact]
public void SynthesizedGetOptFollowAndJumpFixtureRoundTrips()
{
// GetOpt, the "flw" nav node, and "jmp" never appear in any real
// fixture in metas/af (grep confirmed at slice-1 authoring time), so
// this small hand-authored fixture exercises them — validated as
// real metaf grammar via `py metaf_monolithic.py` during authoring
// (not re-run automatically here, since python is not guaranteed on
// the Ubuntu portable CI lane).
string af = string.Join("\r\n",
[
"STATE: {Default}",
"\tIF:\tAlways",
"\t\tDO:\tDoAll",
"\t\t\t\tGetOpt {AttackDistance} {myvar}",
"\t\t\t\tSetOpt {AttackDistance} {myvar}",
"NAV: myfollow follow",
"\tflw 00001234 {Some Monster}",
]) + "\r\n";
Assert.True(
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
MetaRule rule = Assert.Single(profile.Rules);
MetaAction getOpt = rule.Action.Children[0];
MetaAction setOpt = rule.Action.Children[1];
Assert.Equal(MetaActionKind.GetVtankOption, getOpt.Kind);
Assert.Equal("AttackDistance", getOpt.Text);
Assert.Equal("myvar", getOpt.SecondaryText);
Assert.Equal(MetaActionKind.SetVtankOption, setOpt.Kind);
var nav = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(af, nav, NoOpSpellCatalog.Instance, out string navError),
navError);
Assert.Equal(RouteMode.Target, nav.Mode);
Assert.Equal(0x00001234u, nav.FollowTargetObjectId);
Assert.Equal("Some Monster", nav.FollowTargetName);
}
/// <summary>
/// Round 3 item 5: the retail 2000 ms jump-charge ceiling
/// (refs/vtank/decompiled/bi.cs:502-505) is EXECUTION-time behavior
/// (NavigationController.TickJump), not a storage-format limit — metaf
/// and VTank both round-trip the authored value verbatim
/// (metaf_monolithic.py:11708-11820, NJump — no clamp at all). An
/// authored 5000 ms waypoint must therefore survive an .af load
/// unchanged; renamed from JumpNodeClampsChargeMillisecondsTo2000,
/// which pinned the pre-fix (wrong) load-time clamp.
/// </summary>
[Fact]
public void JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling()
{
const string af = """
NAV: j once
jmp 1 2 3 90 {True} 5000
""";
var target = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
Assert.Equal(5000, waypoint.JumpChargeMilliseconds);
Assert.True(waypoint.JumpRun);
Assert.Equal(90f, waypoint.JumpHeadingDegrees);
}
[Fact]
public void JumpNodeBelowClampPassesThroughUnchanged()
{
const string af = """
NAV: j once
jmp 1 2 3 90 {False} 500
""";
var target = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(500, waypoint.JumpChargeMilliseconds);
Assert.False(waypoint.JumpRun);
}
/// <summary>
/// Round 3 item 5 companion: SaveNav must round-trip the same
/// above-ceiling value back out unchanged (the save side never had a
/// clamp; this pins that save+load together preserve it).
/// </summary>
[Fact]
public void JumpNodeSaveThenLoadRoundTripsChargeMillisecondsAboveRetailCeiling()
{
var source = new NavigationSettings { Mode = RouteMode.Once };
source.Waypoints.Add(new RouteWaypoint
{
Type = RouteWaypointType.Jump,
JumpHeadingDegrees = 90f,
JumpRun = true,
JumpChargeMilliseconds = 5000,
});
string af = MetafSerializer.SaveNav(source);
var target = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(5000, waypoint.JumpChargeMilliseconds);
}
private static void AssertProfilesEqual(MetaProfile expected, MetaProfile actual)
{
Assert.Equal(expected.Rules.Count, actual.Rules.Count);
for (int i = 0; i < expected.Rules.Count; i++)
{
MetaRule a = expected.Rules[i];
MetaRule b = actual.Rules[i];
Assert.Equal(a.State, b.State);
AssertConditionsEqual(a.Condition, b.Condition);
AssertActionsEqual(a.Action, b.Action);
}
}
private static void AssertConditionsEqual(MetaCondition expected, MetaCondition actual)
{
Assert.Equal(expected.Kind, actual.Kind);
Assert.Equal(expected.Text, actual.Text);
Assert.Equal(expected.SecondaryText, actual.SecondaryText);
Assert.Equal(expected.Number, actual.Number, 6);
Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6);
Assert.Equal(expected.TertiaryNumber, actual.TertiaryNumber, 6);
Assert.Equal(expected.Children.Count, actual.Children.Count);
for (int i = 0; i < expected.Children.Count; i++)
AssertConditionsEqual(expected.Children[i], actual.Children[i]);
}
private static void AssertActionsEqual(MetaAction expected, MetaAction actual)
{
Assert.Equal(expected.Kind, actual.Kind);
if (expected.Kind == MetaActionKind.LoadEmbeddedNavigationRoute)
{
// Item B: MetaAction carries a typed EmbeddedRoute, not a
// re-parse-me blob in Text (which is now always empty for this
// action kind) — compare the routes waypoint-by-waypoint
// instead of skipping the comparison entirely.
Assert.Equal(expected.Text, actual.Text);
Assert.NotNull(expected.EmbeddedRoute);
Assert.NotNull(actual.EmbeddedRoute);
AssertNavigationEqual(expected.EmbeddedRoute!, actual.EmbeddedRoute!);
}
else
{
Assert.Equal(expected.Text, actual.Text);
}
Assert.Equal(expected.SecondaryText, actual.SecondaryText);
Assert.Equal(expected.Number, actual.Number, 6);
Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6);
Assert.Equal(expected.Children.Count, actual.Children.Count);
for (int i = 0; i < expected.Children.Count; i++)
AssertActionsEqual(expected.Children[i], actual.Children[i]);
}
private static void AssertNavigationEqual(NavigationSettings expected, NavigationSettings actual)
{
Assert.Equal(expected.Mode, actual.Mode);
if (expected.Mode == RouteMode.Target)
{
Assert.Equal(expected.FollowTargetObjectId, actual.FollowTargetObjectId);
Assert.Equal(expected.FollowTargetName, actual.FollowTargetName);
return;
}
Assert.Equal(expected.Waypoints.Count, actual.Waypoints.Count);
for (int i = 0; i < expected.Waypoints.Count; i++)
{
RouteWaypoint a = expected.Waypoints[i];
RouteWaypoint b = actual.Waypoints[i];
Assert.Equal(a.Type, b.Type);
Assert.Equal(a.Position.EastWest, b.Position.EastWest, 3);
Assert.Equal(a.Position.NorthSouth, b.Position.NorthSouth, 3);
Assert.Equal(a.Position.Elevation, b.Position.Elevation, 3);
Assert.Equal(a.ReferencePosition.EastWest, b.ReferencePosition.EastWest, 3);
Assert.Equal(a.ReferencePosition.NorthSouth, b.ReferencePosition.NorthSouth, 3);
Assert.Equal(a.ReferencePosition.Elevation, b.ReferencePosition.Elevation, 3);
Assert.Equal(a.ObjectId, b.ObjectId);
Assert.Equal(a.ObjectName, b.ObjectName);
Assert.Equal(a.Text, b.Text);
Assert.Equal(a.DurationMilliseconds, b.DurationMilliseconds);
// Recall waypoints are the one field-set that is genuinely
// asymmetric between the two formats with a no-op spell
// catalog: the binary .nav stores a spell ID and derives the
// name only via a successful catalog lookup (which NoOpSpellCatalog
// never provides), while .af stores the name literally and
// derives the ID the same (catalog-dependent) way in reverse.
// Comparing RecallSpellName/Id here would fail for reasons that
// have nothing to do with the .af port's correctness.
Assert.Equal(a.JumpHeadingDegrees, b.JumpHeadingDegrees, 3);
Assert.Equal(a.JumpRun, b.JumpRun);
Assert.Equal(a.JumpChargeMilliseconds, b.JumpChargeMilliseconds);
// JumpDirection is likewise NOT compared here: metaf's .af
// format has no field for it at all (docs/research/vtank-kb/
// 06-navigation-and-nav.md section 6, gap 9), so a binary .nav
// fixture using StrafeLeft/StrafeRight always comes back
// Forward from the matching .af conversion — a real, expected
// asymmetry, not a bug in either reader.
}
}
private sealed class NoOpSpellCatalog : ISpellCatalog
{
public static NoOpSpellCatalog Instance { get; } = new();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
info = default;
return false;
}
}
}

View file

@ -164,7 +164,9 @@ public sealed class MossTankMarkupContractTests
XElement[] controls = root.Descendants()
.Where(element => interactive.Contains(element.Name.LocalName))
.ToArray();
Assert.Equal(190, controls.Length);
// Round 3 item 10: +3 for the Route/Loot/Meta Delete buttons
// (Settings already had one from round 2 step 5).
Assert.Equal(194, controls.Length);
foreach (XElement control in controls)
{

View file

@ -26,6 +26,401 @@ public sealed class MossTankPanelTests
Assert.Contains("{ this is not json", backup.Value, StringComparison.Ordinal);
}
/// <summary>
/// Round 3 item 12: a side-car monster rule whose Expression fails to
/// compile used to be silently dropped (a bare
/// <c>catch (FormatException) { }</c>); it must now log a warning
/// instead, while still falling back gracefully (the malformed rule is
/// skipped, everything else loads).
/// </summary>
[Fact]
public void CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed()
{
var storage = new MemoryStorage();
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
storage.Text[usdKey] = VtankDefaultSettingsDatabase.Parse().Render();
storage.Text["profiles/macro/sidecar/--Barris_.usd.json"] = """
{
"CombatRules": [
{ "Expression": "(((" },
{ "Expression": "DEFAULT" }
]
}
""";
var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage);
_ = new MossTankPanel(host);
Assert.Contains(host.Logger.Warnings, message =>
message.Contains("monster rule", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Reproduces the pre-cutover hashed JSON key a "By char" macro profile
/// used to be stored under (<c>MossTankProfileStore.LegacyProfileKey</c>,
/// not directly callable from a test — the hash and identity string are
/// reproduced verbatim here since the format is the migration contract
/// itself, byte for byte).
/// </summary>
private static string LegacyByCharacterProfileKey(string characterName)
{
string identity = "char:" + characterName.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(identity)));
return $"profiles/macro/{hash}.json";
}
[Fact]
public void FirstLoadMigratesLegacyJsonMacroProfileToUsdAndDeletesTheJsonKey()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string legacyKey = LegacyByCharacterProfileKey("Barris");
storage.Text[legacyKey] = """
{
"Combat": { "MaximumRange": 48.0 },
"ItemNames": ["Wand of Testing"]
}
""";
var panel = new MossTankPanel(new FakeHost(automation, storage));
// The legacy key is gone; the real .usd file (this character's
// auto "By char" name) exists with the migrated value.
Assert.False(storage.Text.ContainsKey(legacyKey));
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
Assert.True(storage.Text.ContainsKey(usdKey));
Assert.Equal(0.2d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
Assert.Contains("Wand of Testing", panel.ItemProfileText, StringComparison.Ordinal);
// Idempotent second run: a fresh panel against the same storage
// loads straight from the now-real .usd file, migrates nothing
// (there is no legacy key left to consult), and keeps the value.
var reloaded = new MossTankPanel(new FakeHost(
new FakeAutomation { Name = "Barris" }, storage));
Assert.Equal(0.2d, reloaded.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
Assert.Contains("Wand of Testing", reloaded.ItemProfileText, StringComparison.Ordinal);
}
[Fact]
public void ExistingUsdCounterpartLeavesLegacyJsonUntouchedAndUnread()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string legacyKey = LegacyByCharacterProfileKey("Barris");
storage.Text[legacyKey] = """{ "Combat": { "MaximumRange": 240.0 } }""";
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
var seedCombat = new CombatSettings { MaximumRange = 120d };
VtankDatabase seedDatabase = VtankSettingsProfileSerializer.CreateNew(
new VtankSettingsProfileSerializer.AllSettings
{
Combat = seedCombat,
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
});
storage.Text[usdKey] = seedDatabase.Render();
var panel = new MossTankPanel(new FakeHost(automation, storage));
// The real .usd file wins; the stale legacy JSON is left completely
// alone (not read, not deleted) rather than silently discarded.
Assert.True(storage.Text.ContainsKey(legacyKey));
Assert.Equal(0.5d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
}
/// <summary>
/// Round 3 item 1 (BLOCKER): a drop-in <c>.usd</c> with
/// <c>EnableLooting=True</c> and NO side-car at all must load with
/// looting enabled. Before the fix, <c>SideCarDocument.Apply</c> ran
/// AFTER the <c>.usd</c> and unconditionally wrote its own
/// <c>InventoryEnableLooting</c> field (defaulting to <see langword="false"/>
/// for a profile that has never had a side-car written) over
/// <c>settings.Inventory.Loot.Enabled</c>, silently turning looting back
/// off.
/// </summary>
[Fact]
public void DropInUsdWithLootingEnabledAndNoSideCarLoadsLootingEnabled()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
VtankDatabase database = VtankDefaultSettingsDatabase.Parse();
VtankTable settingsTable = database.Find("Settings")!;
int nameColumn = settingsTable.ColumnIndex("Setting");
int valueColumn = settingsTable.ColumnIndex("Value");
VtankRow row = settingsTable.Rows.First(candidate =>
candidate.Cells[nameColumn].AsString().Equals(
"EnableLooting", StringComparison.OrdinalIgnoreCase));
row.Cells[valueColumn] = VtankCell.Bool(true);
storage.Text[usdKey] = database.Render();
// No side-car key at all — a real drop-in, not something MossTank
// itself ever saved.
var panel = new MossTankPanel(new FakeHost(automation, storage));
Assert.True(panel.LootEnabled);
}
/// <summary>
/// Round 3 item 2: the pre-cutover "profiles/index.json" roster carried
/// EVERY named profile a character had (<c>Profiles: [{Name, Owner}]</c>),
/// not just whichever one happened to be selected. Before the fix, the
/// settings cutover only ever converted the currently-selected profile
/// (via <c>MigrateLegacyIfNeeded</c>) — every OTHER named profile in the
/// roster was silently orphaned: never converted, never listed, and (once
/// <c>SetMineOnly</c> saved) permanently unreachable once the shared key
/// got flattened to a bare <c>{MineOnly}</c> document.
/// </summary>
private static string LegacyNamedProfileKey(string name)
{
string identity = "named:" + name.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(identity)));
return $"profiles/macro/{hash}.json";
}
[Fact]
public void SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
storage.Text["profiles/index.json"] = """
{
"Version": 1,
"MineOnly": false,
"Profiles": [
{ "Name": "Farming", "Owner": "Barris" },
{ "Name": "Buffing", "Owner": "Barris" }
],
"SelectedByCharacter": {}
}
""";
storage.Text[LegacyNamedProfileKey("Farming")] =
"""{ "Combat": { "MaximumRange": 42.0 } }""";
storage.Text[LegacyNamedProfileKey("Buffing")] =
"""{ "Combat": { "MaximumRange": 24.0 } }""";
var panel = new MossTankPanel(new FakeHost(automation, storage));
// MineOnly recovered from the old roster's own shape...
Assert.False(panel.MineOnlyEnabled);
// ...and both named profiles converted to real per-character
// sub-profile .usd files, each keeping its own distinct value.
string farmingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
+ "Farming.usd";
string buffingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
+ "Buffing.usd";
Assert.Equal(42d, RangeOf(storage, farmingUsd));
Assert.Equal(24d, RangeOf(storage, buffingUsd));
// Both legacy JSON keys and the whole (now fully-swept) roster key
// are gone; MineOnly now lives at its own dedicated key.
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Farming")));
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Buffing")));
Assert.False(storage.Text.ContainsKey("profiles/index.json"));
Assert.True(storage.Text.ContainsKey("profiles/macro/preferences.json"));
// Idempotent: a fresh panel against the same storage sweeps nothing
// more (there is no roster key left to read) and keeps both values.
var reloaded = new MossTankPanel(new FakeHost(
new FakeAutomation { Name = "Barris" }, storage));
Assert.False(reloaded.MineOnlyEnabled);
Assert.Equal(42d, RangeOf(storage, farmingUsd));
Assert.Equal(24d, RangeOf(storage, buffingUsd));
}
private static double RangeOf(MemoryStorage storage, string usdKey)
{
string text = Assert.Contains(usdKey, (IDictionary<string, string>)storage.Text);
var settings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = new CombatSettings(),
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
};
VtankSettingsProfileSerializer.Load(text, settings);
return settings.Combat.MaximumRange;
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON
/// hash key (its own <c>Hash(string)</c> — 12-byte truncated SHA256,
/// lowercase hex — is private; the format is the migration contract
/// itself, reproduced verbatim here).
/// </summary>
private static string LegacyMetaByCharacterKey(string characterName)
{
byte[] hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(characterName.ToLowerInvariant()));
return $"profiles/meta/by-character/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
}
[Fact]
public void MetaStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey()
{
var storage = new MemoryStorage();
string legacyKey = LegacyMetaByCharacterKey("Barris");
var legacyProfile = new MetaProfile
{
Rules =
[
new MetaRule
{
State = "Default",
Condition = MetaCondition.Always(),
Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say hi" },
Enabled = true,
},
],
};
storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(
legacyProfile,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
MetaProfile loaded = store.LoadCurrent();
Assert.False(storage.Text.ContainsKey(legacyKey));
MetaRule rule = Assert.Single(loaded.Rules);
Assert.Equal("/say hi", rule.Action.Text);
// Idempotent second run: nothing left to migrate, loads straight
// from the now-real .af file.
var reopened = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
reopened.BindCharacter("Barris");
MetaProfile reloaded = reopened.LoadCurrent();
Assert.Single(reloaded.Rules);
}
[Fact]
public void MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists()
{
var storage = new MemoryStorage();
string legacyKey = LegacyMetaByCharacterKey("Barris");
storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale" } }],
});
string realKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
storage.Text[realKey] = MetafSerializer.SaveMeta(new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say real" } }],
});
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
MetaProfile loaded = store.LoadCurrent();
Assert.True(storage.Text.ContainsKey(legacyKey));
Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON
/// hash key (its own <c>LegacyNamedKey</c>/<c>Hash(string)</c> are
/// private; the format is the migration contract itself, reproduced
/// verbatim here).
/// </summary>
private static string LegacyMetaNamedKey(string name)
{
byte[] hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(name.ToLowerInvariant()));
return $"profiles/meta/named/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
}
/// <summary>
/// Round 3 item 2: like the settings roster, MossTankMetaProfileStore's
/// pre-cutover "profiles/meta/index.json" carried EVERY named Meta
/// profile's name (never owner-scoped — one shared, globally-hashed key
/// per name), and the cutover stopped reading it entirely — orphaning
/// every named profile except whichever one happened to be selected.
/// </summary>
[Fact]
public void MetaRosterSweepConvertsEveryNamedLegacyProfileOnce()
{
var storage = new MemoryStorage();
storage.Text["profiles/meta/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
storage.Text[LegacyMetaNamedKey("Farming")] = System.Text.Json.JsonSerializer.Serialize(
new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say farming" } }],
});
storage.Text[LegacyMetaNamedKey("Buffing")] = System.Text.Json.JsonSerializer.Serialize(
new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say buffing" } }],
});
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
store.LoadCurrent();
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Farming")));
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Buffing")));
Assert.False(storage.Text.ContainsKey("profiles/meta/index.json"));
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["Farming.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile farming, out _));
Assert.Equal("/say farming", Assert.Single(farming.Rules).Action.Text);
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["Buffing.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile buffing, out _));
Assert.Equal("/say buffing", Assert.Single(buffing.Rules).Action.Text);
// Idempotent: a fresh store against the same storage sweeps nothing
// more (there is no roster key left to read).
var reopened = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
reopened.BindCharacter("Barris");
reopened.LoadCurrent();
Assert.True(storage.Text.ContainsKey("Farming.af"));
Assert.True(storage.Text.ContainsKey("Buffing.af"));
}
[Fact]
public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent()
{
var storage = new MemoryStorage();
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
var enabledOnly = new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } }],
};
Assert.True(store.SaveCurrent(enabledOnly));
string key = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
string goodContent = storage.Text[key];
var withDisabledRule = new MetaProfile
{
Rules =
[
new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } },
new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say off" }, Enabled = false },
],
};
bool saved = store.SaveCurrent(withDisabledRule);
Assert.False(saved);
Assert.NotNull(store.SaveNotice);
Assert.Contains("disabled", store.SaveNotice, StringComparison.OrdinalIgnoreCase);
// The refusal must not silently drop the disabled rule: the file on
// disk keeps its last good, fully-representable content.
Assert.Equal(goodContent, storage.Text[key]);
}
[Fact]
public void FirstRunGuidanceExplainsProfilesImportsAndPersistentShelfOnce()
{
@ -477,7 +872,89 @@ public sealed class MossTankPanelTests
Assert.Contains("name ~= coin", second.LootRuleRows[0], StringComparison.Ordinal);
Assert.Equal("Keep up to 2", second.LootKeepCountText);
Assert.Equal("Priority 1", second.LootPriorityText);
Assert.Equal("Corpse range 38m", second.LootRangeText);
// A brand-new profile now seeds from VTank's own shipped
// defaultsettings.usd (CorpseApproachRange-Max = 0), not a
// MossTank-invented 40m CLR default, so one decrement from 0 clamps
// at the 2m floor rather than landing on 38.
Assert.Equal("Corpse range 2m", second.LootRangeText);
}
/// <summary>
/// Reproduces MossTankLootProfileStore's pre-cutover hashed JSON key
/// (its own private <c>ProfileKey</c> is not directly callable from a
/// test — the hash and identity string are reproduced verbatim here
/// since the format is the migration contract itself, byte for byte).
/// </summary>
private static string LegacyLootProfileKey(string value, bool byCharacter)
{
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(identity)));
return $"profiles/loot/{hash}.json";
}
/// <summary>
/// Round 3 item 9: unlike Meta/Route (whose pre-cutover roster was
/// already abandoned before their own cutovers), loot's
/// "profiles/loot/index.json" was still the LIVE mechanism right up to
/// this exact commit, so this single one-time sweep must convert BOTH
/// this character's own "By char" JSON document AND every other named
/// profile the roster still lists — not just whichever one happened to
/// be selected.
/// </summary>
[Fact]
public void LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
storage.Text[LegacyLootProfileKey("Barris", byCharacter: true)] = """
{
"Rules": [
{ "Name": "Coins", "Expression": "name ~= coin", "Action": 0, "Priority": 3 }
]
}
""";
storage.Text["profiles/loot/index.json"] = """
{
"Version": 1,
"Names": ["Farming"],
"SelectedByCharacter": {}
}
""";
storage.Text[LegacyLootProfileKey("Farming", byCharacter: false)] = """
{
"Rules": [
{ "Name": "Salvage", "Expression": "name ~= salvage", "Action": 0, "Priority": 1 }
]
}
""";
var panel = new MossTankPanel(new FakeHost(automation, storage));
string byCharacterFile = VtankProfileDirectory.AutoCharacterFileName(
"Barris", string.Empty, "utl");
Assert.True(storage.Text.ContainsKey(byCharacterFile));
Assert.True(storage.Text.ContainsKey("Farming.utl"));
Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Barris", byCharacter: true)));
Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Farming", byCharacter: false)));
Assert.False(storage.Text.ContainsKey("profiles/loot/index.json"));
// The "By char" profile is already loaded (panel construction binds
// and loads it); the raw .utl round-trips VTClassic-side, but the
// human expression text is only visible through the store's own
// load path (MossTankLootProfileStore.ApplyMossTankExpressions),
// which the panel's rule rows expose.
Assert.Contains("name ~= coin", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal);
panel.SelectLootProfile("Farming");
Assert.Contains("name ~= salvage", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal);
// Idempotent: a fresh panel against the same storage sweeps nothing
// more (there is no roster key left to read) and keeps both values.
var reloaded = new MossTankPanel(new FakeHost(
new FakeAutomation { Name = "Barris" }, storage));
Assert.Contains("name ~= coin", Assert.Single(reloaded.LootRuleRows), StringComparison.Ordinal);
}
[Fact]
@ -500,9 +977,9 @@ public sealed class MossTankPanelTests
Assert.Single(panel.LootRuleRows);
panel.SelectLootProfile("Currency");
Assert.Equal(2, panel.LootRuleRows.Count);
Assert.Contains(
storage.Text.Keys,
key => key.StartsWith("profiles/loot/", StringComparison.Ordinal));
// Round 3 item 9: a named loot profile is now a real .utl file, not
// a hashed JSON document under profiles/loot/.
Assert.True(storage.Text.ContainsKey("Currency.utl"));
}
[Fact]
@ -618,7 +1095,10 @@ public sealed class MossTankPanelTests
Assert.Equal("Legacy", panel.LootProfileName);
Assert.Single(panel.LootRuleRows);
Assert.Contains("KeepUpTo", panel.LootRuleRows[0], StringComparison.Ordinal);
string exported = storage.Text["exports/Legacy.utl"];
// Round 3 item 9: the imported profile is now written directly as
// the real "Legacy.utl" file — there is no separate exports/
// mirror any more (WriteLegacyExport is deleted).
string exported = storage.Text["Legacy.utl"];
Assert.True(VtankLootProfileSerializer.TryRead(
exported,
out VtankLootProfile roundTrip,
@ -638,18 +1118,158 @@ public sealed class MossTankPanelTests
panel.SetProfileNameDraft("Fellowship");
panel.CopyProfile();
Assert.Equal("Fellowship", panel.SelectedMacroProfile);
Assert.Contains("Fellowship", panel.MacroProfileNames);
// Selection now surfaces the real VTank file name (the
// "--Name_Server_" sub-profile convention), not the bare name the
// user typed — this is the whole point of the .usd cutover.
const string fellowshipFile = "--Moss Wart__Fellowship.usd";
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Contains(fellowshipFile, panel.MacroProfileNames);
panel.SetNormalHealth(0.88f);
panel.SelectMacroProfile(MossTankProfileStore.ByCharacter);
Assert.Equal(0.42f, panel.NormalHealthValue, precision: 2);
// A bare name typed back (matching VTank's own "/vt settings load"
// convention) still resolves to this character's own sub-profile.
panel.SelectMacroProfile("Fellowship");
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Equal(0.88f, panel.NormalHealthValue, precision: 2);
}
// Campaign VT slice 1 Part A round 2 step 5: the Profiles tab's new
// Delete button (mosstank.xml), bound through DeleteProfile exactly
// like every other markup action.
[Fact]
public void NavCommandsImportAndExportExactVtankNavFiles()
public void DeleteProfileRemovesTheRealFileAndFallsBackToByCharacter()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Moss Wart" };
var panel = new MossTankPanel(new FakeHost(automation, storage));
panel.SetNormalHealth(0.42f);
panel.SetProfileNameDraft("Fellowship");
panel.CopyProfile();
const string fellowshipFile = "--Moss Wart__Fellowship.usd";
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Contains(fellowshipFile, panel.MacroProfileNames);
panel.DeleteProfile();
Assert.Equal(MossTankProfileStore.ByCharacter, panel.SelectedMacroProfile);
Assert.DoesNotContain(fellowshipFile, panel.MacroProfileNames);
Assert.False(storage.Text.ContainsKey(fellowshipFile));
Assert.Contains("Deleted", panel.ProfileLifecycleNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteProfileRefusesToRemoveByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.DeleteProfile();
Assert.Equal(MossTankProfileStore.ByCharacter, panel.SelectedMacroProfile);
Assert.Contains("cannot be deleted", panel.ProfileLifecycleNotice, StringComparison.Ordinal);
}
// Round 3 item 10: Meta/Route/Loot get the same Delete action Settings
// already had from round 2 step 5.
[Fact]
public void DeleteRouteProfileRemovesTheRealFileAndFallsBackToByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.SetRouteProfileNameDraft("Fellowship");
panel.CopyRouteProfile();
Assert.Equal("Fellowship", panel.SelectedRouteProfile);
Assert.Contains("Fellowship", panel.RouteProfileNames);
panel.DeleteRouteProfile();
Assert.Equal(MossTankRouteProfileStore.ByCharacter, panel.SelectedRouteProfile);
Assert.DoesNotContain("Fellowship", panel.RouteProfileNames);
Assert.False(storage.Text.ContainsKey("nav_Fellowship.af"));
Assert.Contains("Deleted", panel.RouteNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteRouteProfileRefusesToRemoveByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.DeleteRouteProfile();
Assert.Equal(MossTankRouteProfileStore.ByCharacter, panel.SelectedRouteProfile);
Assert.Contains("cannot be deleted", panel.RouteNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteMetaProfileRemovesTheRealFileAndFallsBackToByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.SetMetaProfileNameDraft("Fellowship");
panel.CopyMetaProfile();
Assert.Equal("Fellowship", panel.SelectedMetaProfile);
Assert.Contains("Fellowship", panel.MetaProfileNames);
panel.DeleteMetaProfile();
Assert.Equal(MossTankMetaProfileStore.ByCharacter, panel.SelectedMetaProfile);
Assert.DoesNotContain("Fellowship", panel.MetaProfileNames);
Assert.False(storage.Text.ContainsKey("Fellowship.af"));
Assert.Contains("Deleted", panel.MetaNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteMetaProfileRefusesToRemoveByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.DeleteMetaProfile();
Assert.Equal(MossTankMetaProfileStore.ByCharacter, panel.SelectedMetaProfile);
Assert.Contains("cannot be deleted", panel.MetaNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteLootProfileRemovesTheRealFileAndFallsBackToByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.SetLootProfileNameDraft("Fellowship");
panel.CopyLootProfile();
Assert.Equal("Fellowship", panel.LootProfileName);
Assert.Contains("Fellowship", panel.LootProfileNames);
panel.DeleteLootProfile();
Assert.Equal(MossTankLootProfileStore.ByCharacter, panel.LootProfileName);
Assert.DoesNotContain("Fellowship", panel.LootProfileNames);
Assert.False(storage.Text.ContainsKey("Fellowship.utl"));
Assert.Contains("Deleted", panel.LootEditorNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteLootProfileRefusesToRemoveByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.DeleteLootProfile();
Assert.Equal(MossTankLootProfileStore.ByCharacter, panel.LootProfileName);
Assert.Contains("cannot be deleted", panel.LootEditorNotice, StringComparison.Ordinal);
}
// Item J (slice-1 fix round): renamed from
// NavCommandsImportAndExportExactVtankNavFiles — .af is a real writer
// output now (item E's header/fold-marker emission), not a byte-exact
// pass-through of the imported .nav, so the old name overstated what
// this proves.
[Fact]
public void NavCommandsImportLegacyAndExportAf()
{
var storage = new MemoryStorage();
storage.Text["imports/Legacy.nav"] = """
@ -672,34 +1292,89 @@ public sealed class MossTankPanelTests
Assert.Single(panel.RouteRows);
Assert.Contains("12.5", panel.RouteRows[0], StringComparison.Ordinal);
// .af is the ONLY storage/authoring format now (Campaign VT slice 1
// Part A round 2 — the old "exports/nav/" side-car mirror is gone;
// this is the real route file, named with the "nav_" prefix so it
// never collides with a Meta profile of the same user-typed name in
// the shared VtankProfiles directory).
Command(panel, "nav save Exported.nav");
Assert.StartsWith(
"uTank2 NAV 1.2\r\n",
storage.Text["exports/Exported.nav"],
// Item E (slice-1 fix round): the writer now prepends metaf's own
// navHeader block, so the file no longer STARTS with "NAV: " —
// Contains proves the body is still there.
Assert.Contains(
"NAV: ",
storage.Text["nav_Exported.af"],
StringComparison.Ordinal);
}
// Item J (slice-1 fix round): "/vt nav save Foo.af" used to keep the
// ".af" suffix (only ".nav" was stripped), producing a doubled
// "nav_Foo.af.af" export instead of "nav_Foo.af".
[Fact]
public void MetaCommandsImportAndExportExactVtankMetFiles()
public void NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
{
var storage = new MemoryStorage();
storage.Text["imports/Legacy.met"] = VtankMetaProfileSerializer.Save(
new MetaProfile
{
Rules =
[
new MetaRule
{
State = "Default",
Condition = MetaCondition.Always(),
Action = new MetaAction
{
Kind = MetaActionKind.ChatCommand,
Text = "/say imported",
},
},
],
});
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
Command(panel, "nav save Foo.af");
Assert.True(storage.Text.ContainsKey("nav_Foo.af"));
Assert.False(storage.Text.ContainsKey("nav_Foo.af.af"));
}
// Item C (Campaign VT slice-1 fix round): a Meta profile and a route
// (Navigation) profile named identically used to write into the SAME
// flat "exports/" directory — "Same.af" from one silently clobbered
// "Same.af" from the other. Campaign VT slice-1 round 2 replaced the
// separate exports/meta/ + exports/nav/ side-car mirrors with the real
// storage cutover: both stores now write directly into the shared
// VtankProfiles directory, so the same collision risk exists there
// instead — resolved by the route store's "nav_" file-name prefix
// (metaf's own observed convention for a stand-alone nav .af).
[Fact]
public void MetaAndRouteProfilesWithTheSameNameDoNotCollide()
{
var storage = new MemoryStorage();
storage.Text["imports/Same.met"] =
"1\r\nCondAct\r\n5\r\nCType\r\nAType\r\nCData\r\nAData\r\nState\r\n"
+ "n\r\nn\r\nn\r\nn\r\nn\r\n1\r\n"
+ "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say imported\r\n"
+ "s\r\nDefault\r\n";
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
Command(panel, "meta load Same.met");
Command(panel, "meta save Same.met");
Command(panel, "nav save Same.nav");
Assert.True(storage.Text.ContainsKey("Same.af"));
Assert.True(storage.Text.ContainsKey("nav_Same.af"));
Assert.Contains(
"STATE: ",
storage.Text["Same.af"],
StringComparison.Ordinal);
Assert.Contains(
"NAV: ",
storage.Text["nav_Same.af"],
StringComparison.Ordinal);
}
// Item J (slice-1 fix round): renamed from
// MetaCommandsImportAndExportExactVtankMetFiles for the same reason as
// the nav test above.
[Fact]
public void MetaCommandsImportLegacyAndExportAf()
{
var storage = new MemoryStorage();
// Hand-authored CondAct binary payload (one rule: Always -> Chat
// "/say imported", state "Default") — VtankMetaProfileSerializer's
// writer was deleted (one-shot import only now), so this is built
// directly from the exact format its TryLoad still parses, matching
// condition type 1 (Always) / action type 2 (ChatCommand).
storage.Text["imports/Legacy.met"] =
"1\r\nCondAct\r\n5\r\nCType\r\nAType\r\nCData\r\nAData\r\nState\r\n"
+ "n\r\nn\r\nn\r\nn\r\nn\r\n1\r\n"
+ "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say imported\r\n"
+ "s\r\nDefault\r\n";
var panel = new MossTankPanel(new FakeHost(
new FakeAutomation(),
storage));
@ -710,18 +1385,48 @@ public sealed class MossTankPanelTests
Assert.Single(panel.MetaRows);
Assert.Contains("/say imported", panel.MetaRows[0], StringComparison.Ordinal);
// .af is the ONLY storage/authoring format now (Campaign VT slice 1
// Part A round 2 — the .met writer was deleted, one-shot import
// only, and the old "exports/meta/" side-car mirror is gone: this
// IS the real profile file).
Command(panel, "meta save Exported.met");
Assert.StartsWith(
"1\r\nCondAct\r\n5\r\n",
storage.Text["exports/Exported.met"],
Assert.Contains(
"STATE: ",
storage.Text["Exported.af"],
StringComparison.Ordinal);
Assert.True(VtankMetaProfileSerializer.TryLoad(
storage.Text["exports/Exported.met"],
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["Exported.af"],
NoOpSpellCatalogForExport.Instance,
out MetaProfile exported,
out string error), error);
Assert.Single(exported.Rules);
}
// Item J (slice-1 fix round): same doubled-extension bug as the nav
// side, "/vt meta save Foo.af".
[Fact]
public void MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
Command(panel, "meta save Foo.af");
Assert.True(storage.Text.ContainsKey("Foo.af"));
Assert.False(storage.Text.ContainsKey("Foo.af.af"));
}
private sealed class NoOpSpellCatalogForExport : ISpellCatalog
{
public static NoOpSpellCatalogForExport Instance { get; } = new();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
info = default;
return false;
}
}
[Fact]
public void ByCharacterProfilesAreIsolatedByCharacterName()
{
@ -1014,6 +1719,97 @@ public sealed class MossTankPanelTests
"uboptget['AttackDistance']").AsNumber(), precision: 7);
}
/// <summary>
/// Round 3 item 6: retail's bk.a text (refs/vtank/decompiled/bk.cs:32)
/// is "Done saving setting X to all profiles. (Changed N profiles)" —
/// no "= value" suffix, unlike the plain "set" success message.
/// </summary>
[Fact]
public void VtankSetInAllReportsRetailsExactMessageText()
{
var automation = new FakeAutomation();
var panel = new MossTankPanel(new FakeHost(automation));
Command(panel, "opt setinall AttackDistance 0.03");
string message = Assert.Single(automation.Messages, static text =>
text.StartsWith("Done saving setting", StringComparison.Ordinal));
Assert.StartsWith(
"Done saving setting AttackDistance to all profiles. (Changed ",
message,
StringComparison.Ordinal);
Assert.EndsWith(" profiles)", message, StringComparison.Ordinal);
Assert.DoesNotContain("=", message, StringComparison.Ordinal);
}
/// <summary>
/// Round 3 item 6: a value that does not parse as the catalog's own
/// declared type (EnableLooting is Bool) fails with retail's exact
/// text (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612).
/// </summary>
[Theory]
[InlineData("set")]
[InlineData("setinall")]
public void VtankOptSetRejectsAWrongTypedValueWithRetailsExactText(string operation)
{
var automation = new FakeAutomation();
var panel = new MossTankPanel(new FakeHost(automation));
Command(panel, $"opt {operation} EnableLooting notaboolean");
Assert.Equal(
"Option set: Invalid value specified. Proper type of EnableLooting is System.Boolean.",
Assert.Single(automation.Messages));
}
/// <summary>
/// Round 3 item 6: <c>bk.a</c> (refs/vtank/decompiled/bk.cs:22-27)
/// APPENDS a new Settings row — [0]=name, [1]=value, every other column
/// left as a void <c>gy()</c> cell — when a <c>.usd</c> file's Settings
/// table has no row for the option at all, rather than skipping that
/// file outright.
/// </summary>
[Fact]
public void SetOptionInAllAppendsARowWhenAFileHasNoneForThatSetting()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
// A hand-trimmed Settings table (4 real columns: Setting, Value,
// Description, SettingType) with exactly one row, for a DIFFERENT
// setting — "EnableLooting" is deliberately absent.
const string minimalUsd =
"1\r\nSettings\r\n4\r\nSetting\r\nValue\r\nDescription\r\nSettingType\r\n"
+ "y\r\nn\r\nn\r\nn\r\n1\r\ns\r\nEnableNav\r\nb\r\nFalse\r\ns\r\n\r\ni\r\n1\r\n";
storage.Text["Other.usd"] = minimalUsd;
var store = new MossTankProfileStore(new FakeHost(automation, storage));
var settings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = new CombatSettings(),
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
};
settings.Inventory.Loot.Enabled = true;
int count = store.SetOptionInAll("EnableLooting", settings);
VtankDatabase rewritten = VtankDatabase.Parse(storage.Text["Other.usd"]);
VtankTable table = rewritten.Find("Settings")!;
int nameColumn = table.ColumnIndex("Setting");
int valueColumn = table.ColumnIndex("Value");
VtankRow appended = Assert.Single(table.Rows, row =>
row.Cells[nameColumn].AsString().Equals(
"EnableLooting", StringComparison.OrdinalIgnoreCase));
Assert.True(appended.Cells[valueColumn].AsBool());
// The remaining columns (Description/SettingType) stay void, never
// copied from the existing EnableNav row.
Assert.Equal("0", appended.Cells[2].Tag);
Assert.Equal("0", appended.Cells[3].Tag);
Assert.True(count >= 1);
}
[Fact]
public void VtankHelpAndExpressionsUseTheLocalCommandSurface()
{
@ -1387,10 +2183,14 @@ public sealed class MossTankPanelTests
private sealed class FakeHost(
IAutomationSurface automation,
IPluginStorage? storage = null,
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new FakeLogger();
// Round 3 item 12: exposed as the concrete FakeLogger (not just the
// IPluginHost.Log interface view) so a test can inspect Warnings.
public FakeLogger Logger { get; } = new();
public IPluginLogger Log => Logger;
public IGameState State { get; } = new FakeState();
public IEvents Events { get; } = new FakeEvents();
public ISelectionService Selection { get; } = new FakeSelection();
@ -1400,6 +2200,14 @@ public sealed class MossTankPanelTests
public IAutomationSurface Automation { get; } = automation;
public IPluginLootClassifierRegistry LootClassifiers { get; } =
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
// Real VTank .usd/.af/.cdf storage. Defaults to the SAME backing
// store as the plugin's own JSON storage when a test supplies one
// (the key namespaces never collide: JSON keys are always "/"-
// prefixed, VTank file names never are) so existing tests keep
// exercising real persistence without every call site needing a
// second storage instance.
public IPluginStorage VtankProfiles { get; } =
vtankProfiles ?? storage ?? NoOpPluginStorage.Instance;
}
private sealed class FakeLootClassifierRegistry(
@ -1706,8 +2514,13 @@ public sealed class MossTankPanelTests
private sealed class FakeLogger : IPluginLogger
{
// Round 3 item 12: capture Warn() calls so a test can prove a
// corrupt monster-rule expression is logged rather than silently
// swallowed by the two former bare "catch (FormatException) { }"
// blocks in MossTankProfileStore.
public List<string> Warnings { get; } = [];
public void Info(string message) { }
public void Warn(string message) { }
public void Warn(string message) => Warnings.Add(message);
public void Error(string message, Exception? exception = null) { }
}
@ -1719,7 +2532,8 @@ public sealed class MossTankPanelTests
public string? ReadText(string key) =>
Text.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList<string> List(string prefix) => Text.Keys
.Where(key => key.StartsWith(prefix + "/", StringComparison.Ordinal))
.Where(key => prefix.Length == 0
|| key.StartsWith(prefix + "/", StringComparison.Ordinal))
.OrderBy(static key => key, StringComparer.Ordinal)
.ToArray();
public void WriteText(string key, string content) => Text[key] = content;

View file

@ -0,0 +1,57 @@
using System.Reflection;
namespace AcDream.Plugins.MossTank.Tests;
/// <summary>
/// Round 3 item 1: proves <c>MossTankProfileStore.SideCarDocument</c> (the
/// private JSON side-car for state that has no VTank Settings-table name at
/// all) carries no field that ALSO has a real <c>.usd</c> catalog name —
/// any such overlap is a live duplication risk exactly like the
/// <c>InventoryEnableLooting</c>/<c>EnableLooting</c> bug this round fixed
/// (the side-car's own copy is always applied AFTER the <c>.usd</c> row, so
/// a stale or default side-car value silently clobbers whatever the real
/// profile file says).
/// </summary>
public sealed class MossTankProfileStoreSideCarTests
{
// The side-car's own property names are grouped by settings section
// (Combat*/Buff*/Vitals*/Inventory*) purely for readability — VTank's
// catalog names carry no such prefix, so each candidate is checked both
// as typed and with its known group prefix stripped.
private static readonly string[] GroupPrefixes = ["Combat", "Buff", "Vitals", "Inventory"];
[Fact]
public void SideCarDocumentHasNoFieldNamedForARealVtankSetting()
{
Type sideCar = typeof(MossTankProfileStore).GetNestedType(
"SideCarDocument", BindingFlags.NonPublic)
?? throw new InvalidOperationException(
"MossTankProfileStore.SideCarDocument was not found by reflection.");
var catalogNames = new HashSet<string>(
VtankOptionCatalog.Names, StringComparer.OrdinalIgnoreCase);
var overlaps = new List<string>();
foreach (PropertyInfo property in sideCar.GetProperties(
BindingFlags.Public | BindingFlags.Instance))
{
string name = property.Name;
if (catalogNames.Contains(name))
{
overlaps.Add(name);
continue;
}
foreach (string prefix in GroupPrefixes)
{
if (name.Length > prefix.Length
&& name.StartsWith(prefix, StringComparison.Ordinal)
&& catalogNames.Contains(name[prefix.Length..]))
{
overlaps.Add(name);
break;
}
}
}
Assert.Empty(overlaps);
}
}

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