feat(vtank): slice 1c step 1 — two-folder metas/navs layout, no nav_ marker
Owner decision 2026-09-07: Meta and Nav profiles both use metaf .af and are
told apart by living in two dedicated VtankProfiles subfolders (metas/,
navs/) instead of the flat-directory nav_/--nav_ marker scheme from slice 1
Part A, which was only ever how the owner happened to name files in their
own metas repo. VtankProfileDirectory.ListMetaProfiles/ListNavigationProfiles
now enumerate metas/ and navs/ respectively via a new folder-scoped
EnumerateFolderFileNames helper; the NavMarker constant and the marker
overload of AutoCharacterFileName are deleted. MossTankMetaProfileStore and
MossTankRouteProfileStore build every real storage key with their folder
prefix (CurrentFileName, Select, Create, TryImportLegacy, the legacy-roster
sweep) and strip it back off for display (StripAf/Strip). The .cdf's Nav/Meta
lines (4-5) now carry the folder-relative key ("metas/Name.af",
"navs/Name.af"); AD-122 and the ACDREAM_VTANK_PROFILE_DIR launch-option row
are updated to describe this.
Mutation demonstrated: reverting VtankProfileDirectory.cs,
MossTankMetaProfileStore.cs, and MossTankRouteProfileStore.cs to HEAD~ (the
flat nav_-marker layout) while keeping the updated tests reproduces 8 test
failures (KeyNotFoundException / Assert.True(false) against the new
"metas/…"/"navs/…" keys the tests now expect, e.g.
MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt,
MetaAndRouteProfilesWithTheSameNameDoNotCollide,
NavCommandsImportLegacyAndExportAf, MetaCommandsImportLegacyAndExportAf,
MetaRosterSweepConvertsEveryNamedLegacyProfileOnce,
MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists,
MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent,
NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt) — confirmed by running the
suite immediately after the production-code edit, before the test-file
updates landed. All 625 tests pass after both sides of the change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
8080a99eef
commit
812a533b83
8 changed files with 204 additions and 167 deletions
|
|
@ -121,7 +121,7 @@ readiness/requeue adaptation. See
|
|||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| AD-123 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 9.** Retail's real loot-profile picker (`aa()`, `uTank2/PluginCore.cs:7127-7154`) seeds ONLY `[None]` — there is no per-character auto loot file and no "mine only" filter for loot at all (`docs/research/vtank-kb/01-settings-and-profiles.md` section 3: "the loot default has no equivalent auto-name; loot profiles default to none"). MossTank keeps its own `ByCharacter`/"By char" auto-profile convention for loot anyway, for internal consistency with the Settings/Nav/Meta stores (all three of which DO have a real retail auto-file). | `src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs` (`ByCharacter`, `CurrentFileName`); `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`ListLootProfiles`) | The auto file is a normal `.utl`, named the same `--Name_Server.utl` shape the other three auto-files use; a real VTank install never creates or reads this convention itself, so it is additive, not a collision with anything retail writes. | A user comparing acdream's loot picker to real VTank's own `cmbLootSet` sees an extra "By char" entry retail never shows, and (harmlessly) an extra `--Name_Server.utl` file in a shared real-VTank profile directory. | `aa()` (`uTank2/PluginCore.cs:7127-7154`). |
|
||||
| AD-122 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7.** `VtankProfileDirectory.WriteCharacterBinding` writes a real `.cdf`'s Nav/Meta lines (4-5) as MossTank's own `.af` names (metaf's human-readable grammar), not VTank's native binary `.nav`/`.met`. When `ACDREAM_VTANK_PROFILE_DIR` points at a REAL installed VirindiTank profile folder for direct interop, the `.cdf` this store writes there names files a real VTank instance cannot load (it only reads `.nav`/`.met`). | `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`WriteCharacterBinding`); `src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs`/`MossTankRouteProfileStore.cs` (`.af` naming) | `.usd` settings and `.utl` loot stay real/binary-compatible; only Nav/Meta went `.af`-only for slice 1 (see `docs/research/vtank-kb/06-navigation-and-nav.md`/`07-meta-and-expressions.md`). acdream itself only ever reads its own `.cdf` writes back, so this is self-consistent as long as the two clients never share one profile directory. | A user pointing `ACDREAM_VTANK_PROFILE_DIR` at their real VTank install and then opening that character in real VTank gets a Nav/Meta load failure (wrong format for the recorded filename) even though Settings/Loot still work. | `da.q()`/`da.e()` (`refs/vtank/decompiled/da.cs:105-164`) — real VTank's own `.cdf` read/write. |
|
||||
| AD-122 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7; naming updated 2026-09-07 at Campaign VT slice 1c.** `VtankProfileDirectory.WriteCharacterBinding` writes a real `.cdf`'s Nav/Meta lines (4-5) as MossTank's own `.af` names (metaf's human-readable grammar), not VTank's native binary `.nav`/`.met` — and, since slice 1c's two-folder layout (owner decision 2026-09-07: Meta and Nav profiles both use `.af` and are told apart by folder, not a file-name marker), those two lines are now the folder-relative real storage keys `metas/Name.af`/`navs/Name.af`, not a bare file name. When `ACDREAM_VTANK_PROFILE_DIR` points at a REAL installed VirindiTank profile folder for direct interop, the `.cdf` this store writes there names files a real VTank instance cannot load (wrong format, and — now — a subfolder path a real VTank's own flat-directory `.cdf` reader was never built to resolve). | `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`WriteCharacterBinding`, `MetaFolder`/`NavFolder`); `src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs`/`MossTankRouteProfileStore.cs` (`.af` naming, folder-qualified `CurrentFileName`) | `.usd` settings and `.utl` loot stay real/binary-compatible; only Nav/Meta went `.af`-only for slice 1 (see `docs/research/vtank-kb/06-navigation-and-nav.md`/`07-meta-and-expressions.md`). acdream itself only ever reads its own `.cdf` writes back, so this is self-consistent as long as the two clients never share one profile directory. | A user pointing `ACDREAM_VTANK_PROFILE_DIR` at their real VTank install and then opening that character in real VTank gets a Nav/Meta load failure (wrong format AND wrong path for the recorded filename) even though Settings/Loot still work. | `da.q()`/`da.e()` (`refs/vtank/decompiled/da.cs:105-164`) — real VTank's own `.cdf` read/write. |
|
||||
| AD-119 | **Filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2; `docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md` §6 R3).** Retail's portal-depth draws (`D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90, the `BLEND_SRCALPHA`/`BLEND_INVSRCALPHA` `SetBlendFunction` call) keep color writes ENABLED with a zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend — every OTHER piece of R3's state (`DEPTHTEST_ALWAYS`, depth write on, `CULLMODE_NONE`, no stencil) is ported exactly. acdream instead disables the color-write mask outright on the SAME pipeline (`ColorWrite = false` alongside `Blend = GpuBlendMode.None`) and `portal_depth.frag`'s `main()` writes no color output at all — a write-mask substituting for a zero-alpha blend. | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs:92,100` (`CreatePortalPipeline`'s `Blend`/`ColorWrite` fields); `src/AcDream.App/Rendering/Shaders/portal_depth.frag` (empty `main()`, no color output) | Retail's blend equation is `dst' = src*srcAlpha + dst*(1-srcAlpha)`; with `srcAlpha` fixed at 0 this collapses to `dst' = dst` for every fragment regardless of its RGB — the destination color buffer is left byte-identical either way. A write mask reaches the SAME outcome (the destination is never touched) through a structurally simpler path — no blend-unit work per fragment, no fragment color output to author or keep in sync with a "must stay zero" alpha invariant — so the two are pixel-identical, not merely usually-equivalent. | None expected: the equivalence is provable from the blend algebra above, not measured, so no capture, transcript, or visual gate can distinguish the two. The write mask is in fact the SAFER of the two going forward — a future edit that gives `portal_depth.frag` a real color output (e.g. an authored debug tint) still writes nothing under today's mask, where a ported zero-alpha blend would depend on that same edit remembering to keep alpha at exactly 0. | `D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90 (`SetBlendFunction(BLEND_SRCALPHA, BLEND_INVSRCALPHA, BLENDOP_ADD)`, `SetDepthBufferMode(DEPTHTEST_ALWAYS, ...)`, `SetCullMode(CULLMODE_NONE)`); `PortalDepthMaskRenderer.Rhi.cs` |
|
||||
| AD-120 | **Filed 2026-09-04 at the S4-c2 fix round 1 (M3).** Retail's `DrawMesh` row 1 immediate draw happens IN PLACE, inside the mesh draw call itself, the instant `curr_detail_surface != null` is observed for a translucent building-shell subset — there is no separate "later" point at which it could draw. acdream's walk stream-batches ORDINARY opaque instances into one deferred submission, so a translucent building-shell instance under building detail cannot draw "in place" in that same sense; `WbDrawDispatcher.SubmitToAlphaQueue`'s `RetailAlphaMeshAction.Immediate` branch instead draws it via `DrawImmediateAlphaInstance`/`DrawImmediateAlphaInstanceRhi` at the entity's own `AlphaSubmitMark` — the point `WalkFrameDriver.Replay` reaches that instance's alpha submission within the walk's existing turn order (after the entity's building/land-cell turn, same relative position an ordinary deferred instance would occupy). | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`SubmitToAlphaQueue`, `DrawImmediateAlphaInstance`); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`DrawImmediateAlphaInstanceRhi`) | The walk's turn order already places a building's alpha-eligible instances at the correct relative position among that turn's other content (retail draws building shells within `DrawBuilding`, itself ordered exactly where the walk visits that building) — moving the draw from "mid-mesh-call" to "at the turn's alpha submission point" changes WHEN within one turn it executes, never WHICH turn, so it still lands between the same neighboring opaque/translucent content retail's own turn order would bracket it with. | A translucent building-shell instance under detail could theoretically composite differently than retail if some OTHER alpha content is submitted between the entity's own opaque stream mark and its alpha submission mark within the SAME turn — no such intervening content exists in the current walk (an entity's alpha submission immediately follows its own classification), so this is a structural note, not an observed symptom. | `D3DPolyRender::DrawMesh` @0x0059d4a0 (row 1); `RenderDeviceD3D::DrawBuilding` @0x0059f2a0 (`curr_detail_surface` install/clear) |
|
||||
| AD-121 | **Filed 2026-09-05 at #476 F1.** The opt-in retained Vulkan screenshot path requires the actual capture allocation to be host-coherent. CaptureBackbuffer rejects noncoherent memory with NotSupportedException before waiting or reading; it does not implement atom-safe isolated cache invalidation. | src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs (RecordBackbufferCapture, CaptureBackbuffer); VulkanBackbufferCaptureValidityTests | After the copy, COPY/TRANSFER_WRITE to HOST/HOST_READ publishes the exact capture range; the existing DeviceWaitIdle supplies completion. A capture-local coherence check avoids stale mapped bytes without changing shared heap preferences, generic buffer reads or neighboring suballocations. Default capture-off gameplay is unchanged. | A device whose capture allocation is only host-visible cannot take these opt-in screenshots until a separately bounded isolated invalidation path exists. Failure is explicit; no successful PNG is fabricated. This is a diagnostic capability restriction, not a retail equivalence claim. | Device::GetScreenshotSurface00439160 and RenderDeviceD3D::GenerateSurfaceFromFrontBuffer005A0210 capture completed retail front-buffer contents; #476 paired proof is in476-capture-lifetime-diagnosis.md. Vulkan host visibility/completion requirements are in476-fix-round1-host-visibility.md. |
|
||||
|
|
|
|||
|
|
@ -238,7 +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_VTANK_PROFILE_DIR` | `=<path>` | Overrides the directory `IPluginHost.VtankProfiles` (a VTank-compatible plugin's real `.usd`/`.ast`/`.af` profile storage — see `AcDream.Plugins.MossTank.VtankProfileDirectory`) is rooted at, composed as a `FilePluginStorage`. Set it to a real installed VTank's own profile folder (e.g. `C:\Games\VirindiPlugins\VirindiTank`) for direct interop. | Redirects only that one plugin-storage root; no other startup behavior changes. An unset/empty-string value is treated as "no override" (`NullIfEmpty`); whitespace-only is NOT special-cased (matches every other `NullIfEmpty`-read flag, e.g. `ACDREAM_AC_DIR`/`ACDREAM_UI_PROBE_SCRIPT`) and would be used as a literal (almost certainly invalid) root. **Warning:** MossTank writes Nav/Meta profiles under two dedicated subfolders of this directory — `metas/` for `MossTankMetaProfileStore`, `navs/` for `MossTankRouteProfileStore`, owner decision 2026-09-07 (Campaign VT slice 1c) — and its `.cdf` names them with the `.af` (metaf text) extension, not real VTank's native binary `.nav`/`.met`, at those folder-relative paths. Pointed at a real installed VTank folder, that same character's Nav/Meta profiles become unloadable by the real VTank client sharing the directory (wrong format, wrong subfolder — Settings `.usd` and Loot `.utl` stay real/binary-compatible, flat at the directory root; see register row AD-122). | 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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ namespace AcDream.Plugins.MossTank;
|
|||
/// 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.
|
||||
/// naming and is therefore hidden from that listing by construction. Owner
|
||||
/// decision 2026-09-07: every real file this store touches lives under
|
||||
/// <see cref="VtankProfileDirectory.MetaFolder"/> — <c>metas/Name.af</c>,
|
||||
/// auto <c>metas/--Name_Server.af</c> — a dedicated subfolder of
|
||||
/// <see cref="IPluginHost.VtankProfiles"/>, distinct from
|
||||
/// <see cref="MossTankRouteProfileStore"/>'s <c>navs/</c> folder. No
|
||||
/// file-name marker distinguishes the two anymore.
|
||||
/// </summary>
|
||||
internal sealed class MossTankMetaProfileStore
|
||||
{
|
||||
|
|
@ -53,12 +59,19 @@ internal sealed class MossTankMetaProfileStore
|
|||
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;
|
||||
private const string FolderPrefix = VtankProfileDirectory.MetaFolder + "/";
|
||||
|
||||
private static string StripAf(string name)
|
||||
{
|
||||
if (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
return name;
|
||||
string value = name.StartsWith(FolderPrefix, StringComparison.Ordinal)
|
||||
? name[FolderPrefix.Length..]
|
||||
: name;
|
||||
return value.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? value[..^3]
|
||||
: value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> AvailableNames
|
||||
{
|
||||
|
|
@ -164,9 +177,10 @@ internal sealed class MossTankMetaProfileStore
|
|||
return true;
|
||||
}
|
||||
|
||||
string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
string bare = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: normalized + ".af";
|
||||
string plain = $"{VtankProfileDirectory.MetaFolder}/{bare}";
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
|
||||
{
|
||||
_selected = plain;
|
||||
|
|
@ -199,9 +213,10 @@ internal sealed class MossTankMetaProfileStore
|
|||
notice = "Enter a unique Meta profile name (1-64 characters).";
|
||||
return false;
|
||||
}
|
||||
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
string bare = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: normalized + ".af";
|
||||
string fileName = $"{VtankProfileDirectory.MetaFolder}/{bare}";
|
||||
MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
|
||||
if (!SaveTo(fileName, document, out notice))
|
||||
return false;
|
||||
|
|
@ -209,8 +224,8 @@ internal sealed class MossTankMetaProfileStore
|
|||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = copyCurrent
|
||||
? $"Copied Meta profile to {fileName}."
|
||||
: $"Created Meta profile {fileName}.";
|
||||
? $"Copied Meta profile to {bare}."
|
||||
: $"Created Meta profile {bare}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -246,9 +261,10 @@ internal sealed class MossTankMetaProfileStore
|
|||
notice = $"Could not import {Path.GetFileName(key)}: {error}";
|
||||
return false;
|
||||
}
|
||||
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
string bare = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: normalized + ".af";
|
||||
string fileName = $"{VtankProfileDirectory.MetaFolder}/{bare}";
|
||||
if (!SaveTo(fileName, profile, out string saveNotice))
|
||||
{
|
||||
notice = saveNotice;
|
||||
|
|
@ -257,7 +273,7 @@ internal sealed class MossTankMetaProfileStore
|
|||
_selected = fileName;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = $"Imported VTank Meta profile {fileName}.";
|
||||
notice = $"Imported VTank Meta profile {bare}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -281,7 +297,7 @@ internal sealed class MossTankMetaProfileStore
|
|||
_selected = ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = $"Deleted Meta profile {fileName}.";
|
||||
notice = $"Deleted Meta profile {StripAf(fileName)}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -330,9 +346,10 @@ internal sealed class MossTankMetaProfileStore
|
|||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
string bare = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? name
|
||||
: name + ".af";
|
||||
string fileName = $"{VtankProfileDirectory.MetaFolder}/{bare}";
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
|
||||
{
|
||||
if (!SaveTo(fileName, legacy, out string notice))
|
||||
|
|
@ -476,7 +493,7 @@ internal sealed class MossTankMetaProfileStore
|
|||
// ------------------------------------------------------------------
|
||||
|
||||
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
|
||||
? $"{VtankProfileDirectory.MetaFolder}/{VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")}"
|
||||
: _selected;
|
||||
|
||||
private void WriteBinding()
|
||||
|
|
|
|||
|
|
@ -22,16 +22,19 @@ namespace AcDream.Plugins.MossTank;
|
|||
/// 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.
|
||||
/// Owner decision 2026-09-07: every real file this store touches lives
|
||||
/// under <see cref="VtankProfileDirectory.NavFolder"/> — <c>navs/Name.af</c>,
|
||||
/// auto <c>navs/--Name_Server.af</c> — a dedicated subfolder of
|
||||
/// <see cref="IPluginHost.VtankProfiles"/>, distinct from
|
||||
/// <see cref="MossTankMetaProfileStore"/>'s <c>metas/</c> folder. A route
|
||||
/// and a Meta profile sharing the same user-typed name never collide
|
||||
/// because they live in different folders; no file-name marker (the
|
||||
/// earlier flat-directory <c>nav_</c> prefix) is needed anymore.
|
||||
/// </summary>
|
||||
internal sealed class MossTankRouteProfileStore
|
||||
{
|
||||
public const string ByCharacter = "By char";
|
||||
private const string NavPrefix = "nav_";
|
||||
private const string FolderPrefix = VtankProfileDirectory.NavFolder + "/";
|
||||
// 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
|
||||
|
|
@ -63,12 +66,12 @@ internal sealed class MossTankRouteProfileStore
|
|||
{
|
||||
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;
|
||||
string value = fileName.StartsWith(FolderPrefix, StringComparison.Ordinal)
|
||||
? fileName[FolderPrefix.Length..]
|
||||
: fileName;
|
||||
return value.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? value[..^3]
|
||||
: value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> AvailableNames
|
||||
|
|
@ -399,19 +402,16 @@ internal sealed class MossTankRouteProfileStore
|
|||
// 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.
|
||||
// Owner decision 2026-09-07: the auto per-character route file is the
|
||||
// bare "--Name_Server.af" hidden-prefix name (same as Meta's), living
|
||||
// under this store's own navs/ folder — the folder is what keeps it out
|
||||
// of ListMetaProfiles now, not a "--" + "nav_" combined marker.
|
||||
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix)
|
||||
? $"{VtankProfileDirectory.NavFolder}/{VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af")}"
|
||||
: _selected;
|
||||
|
||||
private static string ToFileName(string bareName) =>
|
||||
NavPrefix + bareName + ".af";
|
||||
$"{VtankProfileDirectory.NavFolder}/{bareName}.af";
|
||||
|
||||
private void WriteBinding()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,24 +36,27 @@ internal static class VtankProfileDirectory
|
|||
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.
|
||||
/// Owner decision 2026-09-07 (Campaign VT slice 1c): Meta and Navigation
|
||||
/// profiles both use metaf's <c>.af</c> grammar and are told apart by
|
||||
/// living in two separate subfolders of <see cref="IPluginHost.VtankProfiles"/>,
|
||||
/// with NO file-name marker — <see cref="MetaFolder"/> for
|
||||
/// <see cref="MossTankMetaProfileStore"/>, <see cref="NavFolder"/> for
|
||||
/// <see cref="MossTankRouteProfileStore"/>. This replaces the earlier
|
||||
/// flat-directory <c>nav_</c> marker scheme (round 3 items 3/4), which
|
||||
/// was only ever how the owner happened to name files in their own metas
|
||||
/// repo, not a real VTank convention.
|
||||
/// </summary>
|
||||
internal const string NavMarker = "nav_";
|
||||
internal const string MetaFolder = "metas";
|
||||
|
||||
/// <summary>See <see cref="MetaFolder"/>.</summary>
|
||||
internal const string NavFolder = "navs";
|
||||
|
||||
/// <summary>
|
||||
/// VTank's single per-character default filename
|
||||
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
|
||||
/// Callers that store this file inside a subfolder (Meta/Nav, see
|
||||
/// <see cref="MetaFolder"/>/<see cref="NavFolder"/>) prepend that folder
|
||||
/// themselves — this method only ever produces the bare file name.
|
||||
/// </summary>
|
||||
public static string AutoCharacterFileName(
|
||||
string characterName,
|
||||
|
|
@ -61,21 +64,6 @@ internal static class VtankProfileDirectory
|
|||
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
|
||||
|
|
@ -186,10 +174,12 @@ internal static class VtankProfileDirectory
|
|||
/// 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.
|
||||
/// <c>.af</c> file directly under <see cref="NavFolder"/> that starts
|
||||
/// with neither <c>--</c> nor <c>~~</c>. Owner decision 2026-09-07: the
|
||||
/// folder itself is what separates route profiles from Meta profiles
|
||||
/// now — see <see cref="MetaFolder"/>. <see cref="ProfileEntry.FileName"/>
|
||||
/// carries the folder-relative real storage key (<c>navs/Name.af</c>);
|
||||
/// <see cref="ProfileEntry.DisplayName"/> is the bare name.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
|
||||
{
|
||||
|
|
@ -198,16 +188,14 @@ internal static class VtankProfileDirectory
|
|||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".af"))
|
||||
foreach (string bareName in EnumerateFolderFileNames(storage, NavFolder, ".af"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
|
||||
if (bareName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||
|| bareName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!fileName.StartsWith(NavMarker, StringComparison.Ordinal))
|
||||
continue;
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
entries.Add(new ProfileEntry($"{NavFolder}/{bareName}", bareName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
|
@ -216,12 +204,12 @@ internal static class VtankProfileDirectory
|
|||
/// 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).
|
||||
/// non-<c>--</c> <c>.af</c> file directly under <see cref="MetaFolder"/>.
|
||||
/// Owner decision 2026-09-07: the folder itself is what separates Meta
|
||||
/// profiles from route profiles now — see <see cref="NavFolder"/>.
|
||||
/// <see cref="ProfileEntry.FileName"/> carries the folder-relative real
|
||||
/// storage key (<c>metas/Name.af</c>); <see cref="ProfileEntry.DisplayName"/>
|
||||
/// is the bare name.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
|
||||
{
|
||||
|
|
@ -230,13 +218,11 @@ internal static class VtankProfileDirectory
|
|||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".af"))
|
||||
foreach (string bareName in EnumerateFolderFileNames(storage, MetaFolder, ".af"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
if (bareName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
if (fileName.StartsWith(NavMarker, StringComparison.Ordinal))
|
||||
continue;
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
entries.Add(new ProfileEntry($"{MetaFolder}/{bareName}", bareName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
|
@ -302,6 +288,13 @@ internal static class VtankProfileDirectory
|
|||
/// <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).
|
||||
/// This type is a generic pass-through: <see cref="TryReadCharacterBinding"/>/
|
||||
/// <see cref="WriteCharacterBinding"/> store and return whatever strings
|
||||
/// the two callers hand them. Since owner decision 2026-09-07,
|
||||
/// <c>NavFileName</c>/<c>MetaFileName</c> are the folder-relative real
|
||||
/// storage keys <see cref="MossTankRouteProfileStore"/>/
|
||||
/// <see cref="MossTankMetaProfileStore"/> write (<c>navs/Name.af</c>,
|
||||
/// <c>metas/Name.af</c>), not a bare file name.
|
||||
/// </summary>
|
||||
public readonly record struct VtankCharacterBinding(
|
||||
string SettingsFileName,
|
||||
|
|
@ -401,4 +394,33 @@ internal static class VtankProfileDirectory
|
|||
yield return key;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists BARE file names (folder prefix stripped) directly under
|
||||
/// <paramref name="folder"/> matching <paramref name="extension"/> —
|
||||
/// the <see cref="MetaFolder"/>/<see cref="NavFolder"/> counterpart of
|
||||
/// <see cref="EnumerateFileNames"/> above. A key nested one level
|
||||
/// deeper than <paramref name="folder"/> (a sub-directory of it) is
|
||||
/// skipped for the same reason <see cref="EnumerateFileNames"/> skips
|
||||
/// any key containing '/' — neither Meta nor Nav storage is ever
|
||||
/// nested past its one folder.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> EnumerateFolderFileNames(
|
||||
IPluginStorage storage,
|
||||
string folder,
|
||||
string extension)
|
||||
{
|
||||
if (!storage.IsAvailable)
|
||||
yield break;
|
||||
string folderPrefix = folder + "/";
|
||||
foreach (string bareName in storage.List(folder)
|
||||
.Where(key => key.StartsWith(folderPrefix, StringComparison.Ordinal))
|
||||
.Select(key => key[folderPrefix.Length..])
|
||||
.Where(bareName => !bareName.Contains('/', StringComparison.Ordinal))
|
||||
.Where(bareName => bareName.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static bareName => bareName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return bareName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ public sealed class MossTankPanelTests
|
|||
{
|
||||
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale" } }],
|
||||
});
|
||||
string realKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
|
||||
string realKey = "metas/" + 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" } }],
|
||||
|
|
@ -371,10 +371,10 @@ public sealed class MossTankPanelTests
|
|||
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 _));
|
||||
storage.Text["metas/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 _));
|
||||
storage.Text["metas/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
|
||||
|
|
@ -383,8 +383,8 @@ public sealed class MossTankPanelTests
|
|||
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"));
|
||||
Assert.True(storage.Text.ContainsKey("metas/Farming.af"));
|
||||
Assert.True(storage.Text.ContainsKey("metas/Buffing.af"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -399,7 +399,7 @@ public sealed class MossTankPanelTests
|
|||
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 key = "metas/" + VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
|
||||
string goodContent = storage.Text[key];
|
||||
|
||||
var withDisabledRule = new MetaProfile
|
||||
|
|
@ -1182,12 +1182,13 @@ public sealed class MossTankPanelTests
|
|||
panel.CopyRouteProfile();
|
||||
Assert.Equal("Fellowship", panel.SelectedRouteProfile);
|
||||
Assert.Contains("Fellowship", panel.RouteProfileNames);
|
||||
Assert.True(storage.Text.ContainsKey("navs/Fellowship.af"));
|
||||
|
||||
panel.DeleteRouteProfile();
|
||||
|
||||
Assert.Equal(MossTankRouteProfileStore.ByCharacter, panel.SelectedRouteProfile);
|
||||
Assert.DoesNotContain("Fellowship", panel.RouteProfileNames);
|
||||
Assert.False(storage.Text.ContainsKey("nav_Fellowship.af"));
|
||||
Assert.False(storage.Text.ContainsKey("navs/Fellowship.af"));
|
||||
Assert.Contains("Deleted", panel.RouteNotice, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
@ -1212,12 +1213,13 @@ public sealed class MossTankPanelTests
|
|||
panel.CopyMetaProfile();
|
||||
Assert.Equal("Fellowship", panel.SelectedMetaProfile);
|
||||
Assert.Contains("Fellowship", panel.MetaProfileNames);
|
||||
Assert.True(storage.Text.ContainsKey("metas/Fellowship.af"));
|
||||
|
||||
panel.DeleteMetaProfile();
|
||||
|
||||
Assert.Equal(MossTankMetaProfileStore.ByCharacter, panel.SelectedMetaProfile);
|
||||
Assert.DoesNotContain("Fellowship", panel.MetaProfileNames);
|
||||
Assert.False(storage.Text.ContainsKey("Fellowship.af"));
|
||||
Assert.False(storage.Text.ContainsKey("metas/Fellowship.af"));
|
||||
Assert.Contains("Deleted", panel.MetaNotice, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
@ -1294,22 +1296,23 @@ public sealed class MossTankPanelTests
|
|||
|
||||
// .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).
|
||||
// owner decision 2026-09-07 (slice 1c): this is the real route file,
|
||||
// living under navs/ 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");
|
||||
// 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"],
|
||||
storage.Text["navs/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".
|
||||
// "nav_Foo.af.af" export instead of "nav_Foo.af" (now "navs/Foo.af.af"
|
||||
// vs "navs/Foo.af" under the slice 1c folder layout).
|
||||
[Fact]
|
||||
public void NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
|
||||
{
|
||||
|
|
@ -1318,19 +1321,17 @@ public sealed class MossTankPanelTests
|
|||
|
||||
Command(panel, "nav save Foo.af");
|
||||
|
||||
Assert.True(storage.Text.ContainsKey("nav_Foo.af"));
|
||||
Assert.False(storage.Text.ContainsKey("nav_Foo.af.af"));
|
||||
Assert.True(storage.Text.ContainsKey("navs/Foo.af"));
|
||||
Assert.False(storage.Text.ContainsKey("navs/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).
|
||||
// "Same.af" from the other. Owner decision 2026-09-07 (slice 1c)
|
||||
// resolved this permanently: Meta lives under metas/, routes under
|
||||
// navs/ — two different subfolders of the same VtankProfiles
|
||||
// directory, so the same bare name in each never collides.
|
||||
[Fact]
|
||||
public void MetaAndRouteProfilesWithTheSameNameDoNotCollide()
|
||||
{
|
||||
|
|
@ -1346,15 +1347,15 @@ public sealed class MossTankPanelTests
|
|||
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.True(storage.Text.ContainsKey("metas/Same.af"));
|
||||
Assert.True(storage.Text.ContainsKey("navs/Same.af"));
|
||||
Assert.Contains(
|
||||
"STATE: ",
|
||||
storage.Text["Same.af"],
|
||||
storage.Text["metas/Same.af"],
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"NAV: ",
|
||||
storage.Text["nav_Same.af"],
|
||||
storage.Text["navs/Same.af"],
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
@ -1388,14 +1389,15 @@ public sealed class MossTankPanelTests
|
|||
// .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).
|
||||
// IS the real profile file, living under metas/ per the slice 1c
|
||||
// owner decision).
|
||||
Command(panel, "meta save Exported.met");
|
||||
Assert.Contains(
|
||||
"STATE: ",
|
||||
storage.Text["Exported.af"],
|
||||
storage.Text["metas/Exported.af"],
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(MetafSerializer.TryLoadMeta(
|
||||
storage.Text["Exported.af"],
|
||||
storage.Text["metas/Exported.af"],
|
||||
NoOpSpellCatalogForExport.Instance,
|
||||
out MetaProfile exported,
|
||||
out string error), error);
|
||||
|
|
@ -1403,7 +1405,8 @@ public sealed class MossTankPanelTests
|
|||
}
|
||||
|
||||
// Item J (slice-1 fix round): same doubled-extension bug as the nav
|
||||
// side, "/vt meta save Foo.af".
|
||||
// side, "/vt meta save Foo.af" (now "metas/Foo.af.af" vs "metas/Foo.af"
|
||||
// under the slice 1c folder layout).
|
||||
[Fact]
|
||||
public void MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
|
||||
{
|
||||
|
|
@ -1412,8 +1415,8 @@ public sealed class MossTankPanelTests
|
|||
|
||||
Command(panel, "meta save Foo.af");
|
||||
|
||||
Assert.True(storage.Text.ContainsKey("Foo.af"));
|
||||
Assert.False(storage.Text.ContainsKey("Foo.af.af"));
|
||||
Assert.True(storage.Text.ContainsKey("metas/Foo.af"));
|
||||
Assert.False(storage.Text.ContainsKey("metas/Foo.af.af"));
|
||||
}
|
||||
|
||||
private sealed class NoOpSpellCatalogForExport : ISpellCatalog
|
||||
|
|
|
|||
|
|
@ -545,9 +545,10 @@ public sealed class NavigationTests
|
|||
var storage = new MemoryStorage();
|
||||
string legacyKey = LegacyRouteByCharacterKey("Barris");
|
||||
storage.Text[legacyKey] = """{ "Mode": 1, "Waypoints": [] }""";
|
||||
// Round 3 item 3: hidden prefix first, nav_ marker second.
|
||||
string realKey = VtankProfileDirectory.AutoCharacterFileName(
|
||||
"Barris", string.Empty, "af", VtankProfileDirectory.NavMarker);
|
||||
// Owner decision 2026-09-07 (slice 1c): the auto route file lives
|
||||
// under navs/, no marker needed anymore.
|
||||
string realKey = "navs/" + VtankProfileDirectory.AutoCharacterFileName(
|
||||
"Barris", string.Empty, "af");
|
||||
var real = new NavigationSettings { Mode = RouteMode.Circular };
|
||||
real.Waypoints.Add(new RouteWaypoint
|
||||
{
|
||||
|
|
@ -610,11 +611,11 @@ public sealed class NavigationTests
|
|||
Assert.False(storage.Text.ContainsKey("profiles/route/index.json"));
|
||||
var farming = new NavigationSettings();
|
||||
Assert.True(MetafSerializer.TryLoadNav(
|
||||
storage.Text["nav_Farming.af"], farming, MetafSerializer.NoOpSpells.Instance, out _));
|
||||
storage.Text["navs/Farming.af"], farming, MetafSerializer.NoOpSpells.Instance, out _));
|
||||
Assert.Equal(1.0d, Assert.Single(farming.Waypoints).Position.EastWest, precision: 3);
|
||||
var buffing = new NavigationSettings();
|
||||
Assert.True(MetafSerializer.TryLoadNav(
|
||||
storage.Text["nav_Buffing.af"], buffing, MetafSerializer.NoOpSpells.Instance, out _));
|
||||
storage.Text["navs/Buffing.af"], buffing, MetafSerializer.NoOpSpells.Instance, out _));
|
||||
Assert.Equal(3.0d, Assert.Single(buffing.Waypoints).Position.EastWest, precision: 3);
|
||||
|
||||
// Idempotent: a fresh store against the same storage sweeps nothing
|
||||
|
|
@ -623,8 +624,8 @@ public sealed class NavigationTests
|
|||
Assert.True(reopened.BindCharacter("Barris"));
|
||||
var reloadTarget = new NavigationSettings();
|
||||
reopened.LoadCurrent(reloadTarget, MetafSerializer.NoOpSpells.Instance);
|
||||
Assert.True(storage.Text.ContainsKey("nav_Farming.af"));
|
||||
Assert.True(storage.Text.ContainsKey("nav_Buffing.af"));
|
||||
Assert.True(storage.Text.ContainsKey("navs/Farming.af"));
|
||||
Assert.True(storage.Text.ContainsKey("navs/Buffing.af"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -71,77 +71,71 @@ public sealed class VtankProfileDirectoryTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ListNavigationProfilesFiltersBothReservedPrefixes()
|
||||
public void ListNavigationProfilesFiltersBothReservedPrefixesWithinNavsFolder()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
// Round 3 item 4: a route .af must carry the nav_ marker to be
|
||||
// listed at all — an un-marked file is a Meta profile, not a route.
|
||||
storage.WriteText("nav_Hunt.af", "1\r\n");
|
||||
storage.WriteText("--nav_Barris_Coldeve.af", "1\r\n");
|
||||
storage.WriteText("~~backup.af", "1\r\n");
|
||||
// Owner decision 2026-09-07 (slice 1c): route profiles live under
|
||||
// navs/, told apart from Meta by the folder, not a nav_ marker.
|
||||
storage.WriteText("navs/Hunt.af", "1\r\n");
|
||||
storage.WriteText("navs/--Barris_Coldeve.af", "1\r\n");
|
||||
storage.WriteText("navs/~~backup.af", "1\r\n");
|
||||
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListNavigationProfiles(storage);
|
||||
|
||||
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
|
||||
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||
Assert.Contains(entries, static e => e.DisplayName == "nav_Hunt.af");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
|
||||
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
|
||||
Assert.Contains(entries, static e => e.FileName == "navs/Hunt.af");
|
||||
Assert.DoesNotContain(entries, static e => e.DisplayName.StartsWith("--", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(entries, static e => e.DisplayName.StartsWith("~~", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 items 3/4: a Meta profile and a route share the exact same
|
||||
/// flat directory and <c>.af</c> extension, so the nav_ marker (and the
|
||||
/// hidden "--" prefix coming BEFORE it on a per-character auto file) is
|
||||
/// the only thing that keeps the two pickers from showing each other's
|
||||
/// files. This is the disambiguation test both pickers must pass
|
||||
/// together against one mixed directory.
|
||||
/// Owner decision 2026-09-07 (slice 1c): a Meta profile and a route now
|
||||
/// live in two SEPARATE subfolders (<c>metas/</c>/<c>navs/</c>) of the
|
||||
/// same <see cref="IPluginHost.VtankProfiles"/> directory, so the same
|
||||
/// user-typed bare name never collides between the two pickers, and
|
||||
/// each picker only ever sees its own folder's files.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NavigationAndMetaPickersPartitionTheSameMixedDirectory()
|
||||
public void NavigationAndMetaPickersEachSeeOnlyTheirOwnFolder()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
storage.WriteText("nav_Hunt.af", "1\r\n");
|
||||
storage.WriteText("MyMeta.af", "1\r\n");
|
||||
// Barris's own hidden per-character route: hidden prefix FIRST,
|
||||
// marker SECOND (item 3's fix) — must be invisible to BOTH pickers,
|
||||
// not just the nav one.
|
||||
storage.WriteText("--nav_Barris_Coldeve.af", "1\r\n");
|
||||
// Barris's own hidden per-character Meta file (unmarked, as
|
||||
// MossTankMetaProfileStore has always named it) — must likewise be
|
||||
// invisible to both.
|
||||
storage.WriteText("--Barris_Coldeve.af", "1\r\n");
|
||||
storage.WriteText("navs/Hunt.af", "1\r\n");
|
||||
storage.WriteText("metas/Hunt.af", "1\r\n"); // same bare name, other folder.
|
||||
// Barris's own hidden per-character route: must be invisible to
|
||||
// the nav picker (and never even considered by the meta picker,
|
||||
// since it lives in a different folder).
|
||||
storage.WriteText("navs/--Barris_Coldeve.af", "1\r\n");
|
||||
// Barris's own hidden per-character Meta file: same, for metas/.
|
||||
storage.WriteText("metas/--Barris_Coldeve.af", "1\r\n");
|
||||
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> navEntries =
|
||||
VtankProfileDirectory.ListNavigationProfiles(storage);
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> metaEntries =
|
||||
VtankProfileDirectory.ListMetaProfiles(storage);
|
||||
|
||||
Assert.Contains(navEntries, static e => e.FileName == "nav_Hunt.af");
|
||||
Assert.DoesNotContain(navEntries, static e => e.FileName == "MyMeta.af");
|
||||
Assert.DoesNotContain(navEntries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
|
||||
Assert.Contains(navEntries, static e => e.FileName == "navs/Hunt.af");
|
||||
Assert.DoesNotContain(navEntries, static e => e.FileName == "metas/Hunt.af");
|
||||
Assert.DoesNotContain(navEntries, static e => e.DisplayName.StartsWith("--", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(metaEntries, static e => e.FileName == "MyMeta.af");
|
||||
Assert.DoesNotContain(metaEntries, static e => e.FileName == "nav_Hunt.af");
|
||||
Assert.DoesNotContain(metaEntries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
|
||||
Assert.Contains(metaEntries, static e => e.FileName == "metas/Hunt.af");
|
||||
Assert.DoesNotContain(metaEntries, static e => e.FileName == "navs/Hunt.af");
|
||||
Assert.DoesNotContain(metaEntries, static e => e.DisplayName.StartsWith("--", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 3 (BLOCKER-adjacent leak): before the fix, a hidden
|
||||
/// per-character route file was named <c>nav_--Name_Server.af</c>
|
||||
/// (marker first) — which does NOT start with "--" and so defeated the
|
||||
/// hidden-file check in BOTH pickers, leaking another character's
|
||||
/// private route/Meta binding.
|
||||
/// Owner decision 2026-09-07 (slice 1c): each store's own hidden
|
||||
/// per-character auto file (<c>--Name_Server.af</c>, no marker anymore)
|
||||
/// must stay invisible to its own folder's picker.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AnotherCharactersAutoRouteIsHiddenFromBothPickers()
|
||||
public void AnotherCharactersAutoRouteIsHiddenFromTheNavPicker()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
// The CORRECT (post-fix) shape: hidden prefix first, marker second.
|
||||
storage.WriteText(
|
||||
VtankProfileDirectory.AutoCharacterFileName(
|
||||
"Someone", "Coldeve", "af", VtankProfileDirectory.NavMarker),
|
||||
"navs/" + VtankProfileDirectory.AutoCharacterFileName("Someone", "Coldeve", "af"),
|
||||
"1\r\n");
|
||||
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> navEntries =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue