From 812a533b83b2f7362a4a525facfb7489c65245ae Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 06:32:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(vtank):=20slice=201c=20step=201=20?= =?UTF-8?q?=E2=80=94=20two-folder=20metas/navs=20layout,=20no=20nav=5F=20m?= =?UTF-8?q?arker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 2 +- docs/launch-options.md | 2 +- .../MossTankMetaProfileStore.cs | 49 ++++--- .../MossTankRouteProfileStore.cs | 42 +++--- .../VtankProfileDirectory.cs | 120 +++++++++++------- .../MossTankPanelTests.cs | 65 +++++----- .../NavigationTests.cs | 15 ++- .../VtankProfileDirectoryTests.cs | 76 +++++------ 8 files changed, 204 insertions(+), 167 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 18f8c858b..5a0de69dc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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. | diff --git a/docs/launch-options.md b/docs/launch-options.md index 39484a95a..405af5763 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -238,7 +238,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` | | `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | | `ACDREAM_VULKAN_PROBE_FRAMES` | `=` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` | -| `ACDREAM_VTANK_PROFILE_DIR` | `=` | Overrides the directory `IPluginHost.VtankProfiles` (a VTank-compatible plugin's real `.usd`/`.ast`/`.af` profile storage — see `AcDream.Plugins.MossTank.VtankProfileDirectory`) is rooted at, composed as a `FilePluginStorage`. Set it to a real installed VTank's own profile folder (e.g. `C:\Games\VirindiPlugins\VirindiTank`) for direct interop. | Redirects only that one plugin-storage root; no other startup behavior changes. An unset/empty-string value is treated as "no override" (`NullIfEmpty`); whitespace-only is NOT special-cased (matches every other `NullIfEmpty`-read flag, e.g. `ACDREAM_AC_DIR`/`ACDREAM_UI_PROBE_SCRIPT`) and would be used as a literal (almost certainly invalid) root. **Warning:** MossTank's own `.cdf` writes name its Nav/Meta profiles with the `.af` (metaf text) extension, not real VTank's native binary `.nav`/`.met` — pointed at a real installed VTank folder, that same character's Nav/Meta profiles become unloadable by the real VTank client sharing the directory (Settings `.usd` and Loot `.utl` stay real/binary-compatible; see register row AD-122). | unset → `/vtank` | `RuntimeOptions.VtankProfileDirectoryOverride` → `Program.cs` (composes `AppPluginHost`'s `vtankProfiles` argument). The Headless host has no equivalent override (its path overrides are `HeadlessPathOverrides`, not env vars) and always uses `HeadlessPathSet.VtankProfilesDirectory` (`/vtank`). | +| `ACDREAM_VTANK_PROFILE_DIR` | `=` | Overrides the directory `IPluginHost.VtankProfiles` (a VTank-compatible plugin's real `.usd`/`.ast`/`.af` profile storage — see `AcDream.Plugins.MossTank.VtankProfileDirectory`) is rooted at, composed as a `FilePluginStorage`. Set it to a real installed VTank's own profile folder (e.g. `C:\Games\VirindiPlugins\VirindiTank`) for direct interop. | Redirects only that one plugin-storage root; no other startup behavior changes. An unset/empty-string value is treated as "no override" (`NullIfEmpty`); whitespace-only is NOT special-cased (matches every other `NullIfEmpty`-read flag, e.g. `ACDREAM_AC_DIR`/`ACDREAM_UI_PROBE_SCRIPT`) and would be used as a literal (almost certainly invalid) root. **Warning:** MossTank 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 → `/vtank` | `RuntimeOptions.VtankProfileDirectoryOverride` → `Program.cs` (composes `AppPluginHost`'s `vtankProfiles` argument). The Headless host has no equivalent override (its path overrides are `HeadlessPathOverrides`, not env vars) and always uses `HeadlessPathSet.VtankProfilesDirectory` (`/vtank`). | | `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs` → `MovementTruthDiagnosticController` | ## Permanent diagnostics diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs index 719e6c0f1..55a78fb0f 100644 --- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -14,7 +14,13 @@ namespace AcDream.Plugins.MossTank; /// Meta profiles are plain shared files (unlike the Settings picker, there /// is no per-character [Char] suffix sub-profile carve-out here), /// while the per-character auto file uses VTank's --Name_Server.af -/// naming and is therefore hidden from that listing by construction. +/// naming and is therefore hidden from that listing by construction. Owner +/// decision 2026-09-07: every real file this store touches lives under +/// metas/Name.af, +/// auto metas/--Name_Server.af — a dedicated subfolder of +/// , distinct from +/// 's navs/ folder. No +/// file-name marker distinguishes the two anymore. /// 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 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() diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs index 5035eb043..938e2a5b4 100644 --- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -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 nav_ prefix (metaf's own observed -/// convention for a stand-alone nav-only .af, see the committed -/// nav_*.af fixtures) so a route and a Meta profile sharing the same -/// user-typed name never collide in the shared -/// directory. +/// Owner decision 2026-09-07: every real file this store touches lives +/// under navs/Name.af, +/// auto navs/--Name_Server.af — a dedicated subfolder of +/// , distinct from +/// 's metas/ 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 nav_ prefix) is needed anymore. /// 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 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() { diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs index ec6af7482..368f92d66 100644 --- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -36,24 +36,27 @@ internal static class VtankProfileDirectory internal const string NoneLabel = "[None]"; /// - /// The nav_ marker (round 3 items 3/4) that distinguishes a - /// stand-alone route .af from a Meta profile sharing the same - /// flat directory and extension - /// — metaf's own observed convention for a nav-only .af (see the - /// committed nav_*.af fixtures). MUST be checked AFTER stripping - /// when the marker is combined with it (see - /// 's - /// marker parameter): a hidden per-character route file is named - /// --nav_Name_Server.af — hidden prefix FIRST, marker SECOND — - /// never nav_--Name_Server.af, which does not start with - /// at all and so defeats every - /// StartsWith("--") hidden-file check in this class. + /// Owner decision 2026-09-07 (Campaign VT slice 1c): Meta and Navigation + /// profiles both use metaf's .af grammar and are told apart by + /// living in two separate subfolders of , + /// with NO file-name marker — for + /// , for + /// . This replaces the earlier + /// flat-directory nav_ 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. /// - internal const string NavMarker = "nav_"; + internal const string MetaFolder = "metas"; + + /// See . + internal const string NavFolder = "navs"; /// /// VTank's single per-character default filename /// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext. + /// Callers that store this file inside a subfolder (Meta/Nav, see + /// /) prepend that folder + /// themselves — this method only ever produces the bare file name. /// public static string AutoCharacterFileName( string characterName, @@ -61,21 +64,6 @@ internal static class VtankProfileDirectory string extension) => $"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}"; - /// - /// Overload for a per-character auto file that ALSO carries a kind - /// marker (currently only , for - /// 's route .af): the - /// hidden prefix always comes first so the file stays hidden from every - /// other character's picker exactly like the unmarked overload above — - /// --{marker}Name_Server.ext, e.g. --nav_Name_Server.af. - /// - public static string AutoCharacterFileName( - string characterName, - string server, - string extension, - string marker) => - $"{HiddenPrefix}{marker}{characterName}_{server}.{extension.TrimStart('.')}"; - /// /// The longer, trailing-underscore prefix /// (uTank2/PluginCore.cs:933,3866, field dw) that marks a @@ -186,10 +174,12 @@ internal static class VtankProfileDirectory /// VTank's navigation-profile list (l(), /// uTank2/PluginCore.cs:7156-7185): seeds /// /, then every - /// -marked .af file that starts with - /// neither -- nor ~~. Round 3 item 4: a Meta profile and a - /// route share this same flat, single-extension directory — without - /// the marker check this returned every Meta .af too. + /// .af file directly under that starts + /// with neither -- nor ~~. Owner decision 2026-09-07: the + /// folder itself is what separates route profiles from Meta profiles + /// now — see . + /// carries the folder-relative real storage key (navs/Name.af); + /// is the bare name. /// public static IReadOnlyList 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 (ac(), /// uTank2/PluginCore.cs:7187+): seeds /// /, then every - /// non---, non--marked file (a meta and a - /// nav profile share the same directory and extension here — - /// .af — so the marker is what VTank's own separate - /// .met/.nav extensions used to provide; round 3 item 4: - /// without excluding files this returned every - /// route .af too). + /// non--- .af file directly under . + /// Owner decision 2026-09-07: the folder itself is what separates Meta + /// profiles from route profiles now — see . + /// carries the folder-relative real + /// storage key (metas/Name.af); + /// is the bare name. /// public static IReadOnlyList 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 /// when the file is short (line 5 is present /// only when the stream isn't already at EOF, da.cs:125-128 — /// an older .cdf predating meta support has no line 5 at all). + /// This type is a generic pass-through: / + /// store and return whatever strings + /// the two callers hand them. Since owner decision 2026-09-07, + /// NavFileName/MetaFileName are the folder-relative real + /// storage keys / + /// write (navs/Name.af, + /// metas/Name.af), not a bare file name. /// public readonly record struct VtankCharacterBinding( string SettingsFileName, @@ -401,4 +394,33 @@ internal static class VtankProfileDirectory yield return key; } } + + /// + /// Lists BARE file names (folder prefix stripped) directly under + /// matching — + /// the / counterpart of + /// above. A key nested one level + /// deeper than (a sub-directory of it) is + /// skipped for the same reason skips + /// any key containing '/' — neither Meta nor Nav storage is ever + /// nested past its one folder. + /// + private static IEnumerable 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; + } + } } diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 68f30fb91..4b51e8d70 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -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 diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs index d1c4267b6..ed4b17b0a 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs @@ -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] diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs index c2cac139d..37cc05d9b 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs @@ -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 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)); } /// - /// Round 3 items 3/4: a Meta profile and a route share the exact same - /// flat directory and .af 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 (metas//navs/) of the + /// same 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. /// [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 navEntries = VtankProfileDirectory.ListNavigationProfiles(storage); IReadOnlyList 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)); } /// - /// Round 3 item 3 (BLOCKER-adjacent leak): before the fix, a hidden - /// per-character route file was named nav_--Name_Server.af - /// (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 (--Name_Server.af, no marker anymore) + /// must stay invisible to its own folder's picker. /// [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 navEntries =