diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
index 55a78fb0f..6d9a64ffb 100644
--- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
@@ -40,6 +40,7 @@ internal sealed class MossTankMetaProfileStore
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
+ private bool _flatFolderMigrationSwept;
public MossTankMetaProfileStore(IPluginHost host)
{
@@ -109,6 +110,7 @@ internal sealed class MossTankMetaProfileStore
public MetaProfile LoadCurrent()
{
+ MigrateFlatFilesToMetasFolderIfNeeded();
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded();
string fileName = CurrentFileName();
@@ -308,6 +310,59 @@ internal sealed class MossTankMetaProfileStore
return empty;
}
+ // ------------------------------------------------------------------
+ // Slice 1c step 2: one-time flat-file -> metas/ folder migration.
+ // ------------------------------------------------------------------
+
+ ///
+ /// Owner decision 2026-09-07: a ONE-TIME sweep (guarded by
+ /// ) that moves every flat
+ /// root-level .af file that is NOT a legacy route file (see
+ /// — those
+ /// belong to 's own sweep) into
+ /// , unmarked, since Meta
+ /// files never carried a marker. Runs BEFORE the older
+ /// /
+ /// JSON migrations so those see the real .af content already at
+ /// its new folder-qualified path rather than treating it as missing.
+ /// When the real destination already exists, the flat file is left in
+ /// place untouched (never overwritten) and the collision is logged —
+ /// this can only happen from an external/manual file drop, since this
+ /// store itself never wrote a flat file.
+ ///
+ private void MigrateFlatFilesToMetasFolderIfNeeded()
+ {
+ if (_flatFolderMigrationSwept)
+ return;
+ _flatFolderMigrationSwept = true;
+ if (!VtankStorage.IsAvailable)
+ return;
+ int migrated = 0;
+ foreach (string bareName in VtankProfileDirectory.ListFlatAfFileNames(VtankStorage))
+ {
+ if (VtankProfileDirectory.IsLegacyFlatRouteFileName(bareName))
+ continue; // MossTankRouteProfileStore's own sweep owns this one.
+ string destination = $"{VtankProfileDirectory.MetaFolder}/{bareName}";
+ if (VtankStorage.ReadText(destination) is not null)
+ {
+ _host.Log.Warn(
+ $"MossTank left flat Meta profile '{bareName}' in place: '{destination}' already exists.");
+ continue;
+ }
+ string? content = VtankStorage.ReadText(bareName);
+ if (content is null)
+ continue; // listed but unreadable; skip defensively.
+ VtankStorage.WriteText(destination, content);
+ VtankStorage.Delete(bareName);
+ migrated++;
+ }
+ if (migrated > 0)
+ {
+ _host.Log.Warn(
+ $"Migrated {migrated} flat MossTank Meta profile(s) into {VtankProfileDirectory.MetaFolder}/.");
+ }
+ }
+
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
index 938e2a5b4..d07eda500 100644
--- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
@@ -49,6 +49,7 @@ internal sealed class MossTankRouteProfileStore
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
+ private bool _flatFolderMigrationSwept;
public MossTankRouteProfileStore(IPluginHost host)
{
@@ -173,6 +174,7 @@ internal sealed class MossTankRouteProfileStore
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(spells);
+ MigrateFlatFilesToNavsFolderIfNeeded();
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded(target, spells);
string fileName = CurrentFileName();
@@ -269,6 +271,60 @@ internal sealed class MossTankRouteProfileStore
SaveCurrent(target);
}
+ // ------------------------------------------------------------------
+ // Slice 1c step 2: one-time flat-file -> navs/ folder migration.
+ // ------------------------------------------------------------------
+
+ ///
+ /// Owner decision 2026-09-07: a ONE-TIME sweep (guarded by
+ /// ) that moves every flat
+ /// root-level route file recognized by
+ ///
+ /// (nav_Name.af, hidden --nav_Name_Server.af) into
+ /// with the marker
+ /// stripped ().
+ /// Every other flat .af file is left alone — it belongs to
+ /// 's own sweep. Runs BEFORE the
+ /// older /
+ /// JSON migrations so those see the real .af content already at
+ /// its new folder-qualified path rather than treating it as missing.
+ /// When the real destination already exists, the flat file is left in
+ /// place untouched (never overwritten) and the collision is logged.
+ ///
+ private void MigrateFlatFilesToNavsFolderIfNeeded()
+ {
+ if (_flatFolderMigrationSwept)
+ return;
+ _flatFolderMigrationSwept = true;
+ if (!VtankStorage.IsAvailable)
+ return;
+ int migrated = 0;
+ foreach (string bareName in VtankProfileDirectory.ListFlatAfFileNames(VtankStorage))
+ {
+ if (!VtankProfileDirectory.IsLegacyFlatRouteFileName(bareName))
+ continue; // MossTankMetaProfileStore's own sweep owns this one.
+ string strippedName = VtankProfileDirectory.StripLegacyNavMarker(bareName);
+ string destination = $"{VtankProfileDirectory.NavFolder}/{strippedName}";
+ if (VtankStorage.ReadText(destination) is not null)
+ {
+ _host.Log.Warn(
+ $"MossTank left flat route profile '{bareName}' in place: '{destination}' already exists.");
+ continue;
+ }
+ string? content = VtankStorage.ReadText(bareName);
+ if (content is null)
+ continue; // listed but unreadable; skip defensively.
+ VtankStorage.WriteText(destination, content);
+ VtankStorage.Delete(bareName);
+ migrated++;
+ }
+ if (migrated > 0)
+ {
+ _host.Log.Warn(
+ $"Migrated {migrated} flat MossTank route profile(s) into {VtankProfileDirectory.NavFolder}/.");
+ }
+ }
+
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
index 368f92d66..24b01825d 100644
--- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
+++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
@@ -51,6 +51,58 @@ internal static class VtankProfileDirectory
/// See .
internal const string NavFolder = "navs";
+ ///
+ /// The pre-slice-1c flat-directory route marker (round 3 items 3/4):
+ /// used ONLY by the one-time flat-file migration sweep
+ /// (/)
+ /// to recognize an old nav_Name.af/--nav_Name_Server.af
+ /// file left over from before the two-folder cutover. No code writes
+ /// this marker anymore.
+ ///
+ private const string LegacyNavMarker = "nav_";
+
+ ///
+ /// True when (a flat, un-prefixed root
+ /// key with no folder) is a pre-slice-1c route file: nav_*.af,
+ /// or the hidden per-character auto shape --nav_*.af (hidden
+ /// prefix first, marker second — the old
+ /// AutoCharacterFileName(..., NavMarker) overload's output).
+ /// Every OTHER flat .af file is a Meta profile. Used ONLY to
+ /// partition old root-level files between and
+ /// during the one-time migration sweep.
+ ///
+ internal static bool IsLegacyFlatRouteFileName(string bareFileName) =>
+ bareFileName.StartsWith(LegacyNavMarker, StringComparison.Ordinal)
+ || (bareFileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
+ && bareFileName[HiddenPrefix.Length..].StartsWith(
+ LegacyNavMarker, StringComparison.Ordinal));
+
+ ///
+ /// Strips the pre-slice-1c nav_ marker from a flat route file
+ /// name recognized by , keeping
+ /// a leading hidden in place ahead of the
+ /// stripped remainder: nav_Hunt.af → Hunt.af,
+ /// --nav_Name_Server.af → --Name_Server.af. Not meaningful
+ /// (and not called) for a file
+ /// returns for.
+ ///
+ internal static string StripLegacyNavMarker(string bareFileName)
+ {
+ bool isHidden = bareFileName.StartsWith(HiddenPrefix, StringComparison.Ordinal);
+ string rest = isHidden ? bareFileName[HiddenPrefix.Length..] : bareFileName;
+ rest = rest[LegacyNavMarker.Length..];
+ return isHidden ? HiddenPrefix + rest : rest;
+ }
+
+ ///
+ /// Lists every flat (root-level, un-prefixed) .af file name —
+ /// the one-time flat-file migration sweep's own input, materialized
+ /// eagerly since both stores mutate (move
+ /// files out of the root) while consuming this list.
+ ///
+ internal static IReadOnlyList ListFlatAfFileNames(IPluginStorage storage) =>
+ EnumerateFileNames(storage, ".af").ToList();
+
///
/// VTank's single per-character default filename
/// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext.
diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
index 4b51e8d70..1da9d6d95 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
@@ -326,6 +326,97 @@ public sealed class MossTankPanelTests
Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
}
+ // ------------------------------------------------------------------
+ // Slice 1c step 2: one-time flat-file -> metas/ folder migration.
+ // ------------------------------------------------------------------
+
+ ///
+ /// Owner decision 2026-09-07: an existing flat root-level
+ /// Name.af (the pre-slice-1c naming) moves to
+ /// metas/Name.af on first load — no marker to strip, since Meta
+ /// files never carried one.
+ ///
+ [Fact]
+ public void MetaStoreMigratesFlatAfFileIntoMetasFolder()
+ {
+ var storage = new MemoryStorage();
+ storage.Text["Shared.af"] = MetafSerializer.SaveMeta(new MetaProfile
+ {
+ Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say shared" } }],
+ });
+
+ var store = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ store.BindCharacter("Barris");
+ store.LoadCurrent();
+
+ Assert.True(storage.Text.ContainsKey("metas/Shared.af"));
+ Assert.False(storage.Text.ContainsKey("Shared.af"));
+
+ // Idempotent second run: nothing left at the root to migrate.
+ var reopened = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ reopened.BindCharacter("Barris");
+ reopened.LoadCurrent();
+ Assert.True(storage.Text.ContainsKey("metas/Shared.af"));
+ Assert.False(storage.Text.ContainsKey("Shared.af"));
+ }
+
+ ///
+ /// A flat nav_*.af/--nav_*.af file belongs to
+ /// 's own sweep, not this one —
+ /// it must be left alone by the Meta store.
+ ///
+ [Fact]
+ public void MetaStoreLeavesFlatNavMarkedFilesForTheRouteStore()
+ {
+ var storage = new MemoryStorage();
+ storage.Text["nav_Hunt.af"] = "1\r\n";
+ storage.Text["--nav_Barris_Coldeve.af"] = "1\r\n";
+
+ var store = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ store.BindCharacter("Barris");
+ store.LoadCurrent();
+
+ Assert.True(storage.Text.ContainsKey("nav_Hunt.af"));
+ Assert.True(storage.Text.ContainsKey("--nav_Barris_Coldeve.af"));
+ Assert.False(storage.Text.ContainsKey("metas/nav_Hunt.af"));
+ Assert.False(storage.Text.ContainsKey("metas/--nav_Barris_Coldeve.af"));
+ }
+
+ ///
+ /// Owner decision 2026-09-07, collision rule: when the real destination
+ /// already exists, the leftover flat file is left in place untouched
+ /// (never overwritten) and the collision is logged.
+ ///
+ [Fact]
+ public void MetaStoreLeavesFlatFileInPlaceWhenMetasDestinationAlreadyExists()
+ {
+ var storage = new MemoryStorage();
+ string canonicalContent = MetafSerializer.SaveMeta(new MetaProfile
+ {
+ Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say canonical" } }],
+ });
+ storage.Text["metas/Shared.af"] = canonicalContent;
+ storage.Text["Shared.af"] = MetafSerializer.SaveMeta(new MetaProfile
+ {
+ Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale-flat" } }],
+ });
+
+ var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage);
+ var store = new MossTankMetaProfileStore(host);
+ store.BindCharacter("Barris");
+ store.LoadCurrent();
+
+ Assert.Equal(canonicalContent, storage.Text["metas/Shared.af"]);
+ Assert.True(storage.Text.ContainsKey("Shared.af"));
+ Assert.Contains(
+ host.Logger.Warnings,
+ message => message.Contains("Shared.af", StringComparison.Ordinal)
+ && message.Contains("metas/Shared.af", StringComparison.Ordinal));
+ }
+
///
/// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON
/// hash key (its own LegacyNamedKey/Hash(string) are
diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
index ed4b17b0a..467738593 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
@@ -628,6 +628,128 @@ public sealed class NavigationTests
Assert.True(storage.Text.ContainsKey("navs/Buffing.af"));
}
+ // ------------------------------------------------------------------
+ // Slice 1c step 2: one-time flat-file -> navs/ folder migration.
+ // ------------------------------------------------------------------
+
+ ///
+ /// Owner decision 2026-09-07: an existing flat root-level
+ /// nav_Name.af (the pre-slice-1c naming) moves to
+ /// navs/Name.af with the marker stripped, on first load.
+ ///
+ [Fact]
+ public void RouteStoreMigratesFlatNavMarkedFileIntoNavsFolderWithMarkerStripped()
+ {
+ var storage = new MemoryStorage();
+ var route = new NavigationSettings();
+ route.Waypoints.Add(new RouteWaypoint
+ {
+ Type = RouteWaypointType.Point,
+ Position = new PluginNavigationPosition(0x00010001u, 7d, 8d, 0d, 0f, true),
+ });
+ storage.Text["nav_Hunt.af"] = MetafSerializer.SaveNav(route);
+
+ var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
+ Assert.True(store.BindCharacter("Barris"));
+ var target = new NavigationSettings();
+ store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
+
+ Assert.True(storage.Text.ContainsKey("navs/Hunt.af"));
+ Assert.False(storage.Text.ContainsKey("nav_Hunt.af"));
+
+ // Idempotent second run: nothing left at the root to migrate.
+ var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
+ Assert.True(reopened.BindCharacter("Barris"));
+ reopened.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance);
+ Assert.True(storage.Text.ContainsKey("navs/Hunt.af"));
+ Assert.False(storage.Text.ContainsKey("nav_Hunt.af"));
+ }
+
+ ///
+ /// Owner decision 2026-09-07: the hidden per-character auto route file
+ /// used the combined --nav_Name_Server.af shape before slice 1c
+ /// (hidden prefix first, marker second) — the sweep must recognize this
+ /// shape too and move it to navs/--Name_Server.af (hidden prefix
+ /// kept, marker dropped).
+ ///
+ [Fact]
+ public void RouteStoreMigratesFlatHiddenAutoRouteFileWithMarkerStripped()
+ {
+ var storage = new MemoryStorage();
+ storage.Text["--Barris_Coldeve.af"] = MetafSerializer.SaveNav(new NavigationSettings());
+ // meta's own (unmarked) flat auto file — must be left for the Meta
+ // store's own sweep, not touched here.
+ var route = new NavigationSettings();
+ route.Waypoints.Add(new RouteWaypoint
+ {
+ Type = RouteWaypointType.Point,
+ Position = new PluginNavigationPosition(0x00010001u, 1d, 2d, 0d, 0f, true),
+ });
+ storage.Text["--nav_Barris_Coldeve.af"] = MetafSerializer.SaveNav(route);
+
+ var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
+ Assert.True(store.BindCharacter("Barris"));
+ var target = new NavigationSettings();
+ store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
+
+ Assert.True(storage.Text.ContainsKey("navs/--Barris_Coldeve.af"));
+ Assert.False(storage.Text.ContainsKey("--nav_Barris_Coldeve.af"));
+ // Meta's own flat auto file (no nav_ marker) is NOT this store's
+ // concern — left exactly as found.
+ Assert.True(storage.Text.ContainsKey("--Barris_Coldeve.af"));
+ }
+
+ ///
+ /// Owner decision 2026-09-07, collision rule: when the real destination
+ /// already exists, the leftover flat file is left in place untouched
+ /// (never overwritten) and the collision is logged.
+ ///
+ [Fact]
+ public void RouteStoreLeavesFlatFileInPlaceWhenNavsDestinationAlreadyExists()
+ {
+ var storage = new MemoryStorage();
+ var canonical = new NavigationSettings();
+ canonical.Waypoints.Add(new RouteWaypoint
+ {
+ Type = RouteWaypointType.Point,
+ Position = new PluginNavigationPosition(0x00010001u, 9d, 9d, 0d, 0f, true),
+ });
+ string canonicalContent = MetafSerializer.SaveNav(canonical);
+ storage.Text["navs/Hunt.af"] = canonicalContent;
+ storage.Text["nav_Hunt.af"] = MetafSerializer.SaveNav(new NavigationSettings());
+
+ var host = new FakeHost(new FakeAutomation(), storage);
+ var store = new MossTankRouteProfileStore(host);
+ Assert.True(store.BindCharacter("Barris"));
+ store.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance);
+
+ Assert.Equal(canonicalContent, storage.Text["navs/Hunt.af"]);
+ Assert.True(storage.Text.ContainsKey("nav_Hunt.af"));
+ Assert.Contains(
+ host.Logger.Warnings,
+ message => message.Contains("nav_Hunt.af", StringComparison.Ordinal)
+ && message.Contains("navs/Hunt.af", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Every OTHER root-level .af file (no nav_/--nav_
+ /// marker) belongs to 's own
+ /// sweep, not this store's — it must be left alone.
+ ///
+ [Fact]
+ public void RouteStoreLeavesNonMarkedFlatAfFilesForTheMetaStore()
+ {
+ var storage = new MemoryStorage();
+ storage.Text["SharedMeta.af"] = "1\r\n";
+
+ var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
+ Assert.True(store.BindCharacter("Barris"));
+ store.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance);
+
+ Assert.True(storage.Text.ContainsKey("SharedMeta.af"));
+ Assert.False(storage.Text.ContainsKey("navs/SharedMeta.af"));
+ }
+
[Fact]
public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
{
@@ -702,7 +824,11 @@ public sealed class NavigationTests
IPluginStorage? storage = null) : IPluginHost
{
public bool HasUi => false;
- public IPluginLogger Log { get; } = new FakeLogger();
+ // Exposed as the concrete FakeLogger (not just the IPluginLogger
+ // interface view) so a test can inspect Warnings, matching
+ // MossTankPanelTests.FakeHost's own pattern.
+ public FakeLogger Logger { get; } = new();
+ public IPluginLogger Log => Logger;
public IGameState State { get; } = new FakeState();
public IEvents Events { get; } = new FakeEvents();
public ISelectionService Selection { get; } = new FakeSelection();
@@ -804,8 +930,9 @@ public sealed class NavigationTests
private sealed class FakeLogger : IPluginLogger
{
+ public List Warnings { get; } = [];
public void Info(string message) { }
- public void Warn(string message) { }
+ public void Warn(string message) => Warnings.Add(message);
public void Error(string message, Exception? exception = null) { }
}