diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs
index c3cec1418..5fde615c7 100644
--- a/src/AcDream.Plugins.MossTank/Navigation.cs
+++ b/src/AcDream.Plugins.MossTank/Navigation.cs
@@ -1009,6 +1009,31 @@ internal sealed class NavigationController
RouteWaypoint waypoint,
in PluginNavigationSnapshot navigation)
{
+ // D-4 (round E architecture re-check): an .af "rcl" node whose
+ // spell name did not resolve against the character's known spells
+ // (MetafSerializer.ReadNavNode's "rcl" case, MetafSerializer.cs:
+ // 883-885) leaves RecallSpellId at 0 and Recall at its default
+ // ordinal (RouteRecallKind.PrimaryPortalRecall) — it never touches
+ // the enum. Every path that DOES intend a real cast
+ // (AddRouteRecallCore, the binary .nav loader, the D-1 legacy-
+ // JSON-route migration) always writes RecallSpellId directly, so
+ // no waypoint can legitimately reach this point with
+ // RecallSpellId == 0 unless it is Marketplace (which has no spell
+ // by design) or an unresolved import. Refuse the cast outright —
+ // SubmitRecall's old fallback (RouteWaypoint.SpellIdForRecall
+ // (waypoint.Recall)) would silently cast Primary Portal Recall
+ // (spell 48) for a waypoint that was never authored as one — and
+ // skip it the same way TickUse already refuses an unavailable
+ // object (waypoint.ObjectId == 0u, above).
+ if (waypoint.RecallSpellId == 0u && waypoint.Recall != RouteRecallKind.Marketplace)
+ {
+ _status = string.IsNullOrWhiteSpace(waypoint.RecallSpellName)
+ ? "Recall waypoint has no spell; skipping."
+ : $"Recall spell '{waypoint.RecallSpellName}' not found; skipping waypoint.";
+ CompleteAction();
+ return true;
+ }
+
if (navigation.IsPortalSpace)
_sawPortalSpace = true;
if (_sawPortalSpace && !navigation.IsPortalSpace)
@@ -1052,23 +1077,22 @@ internal sealed class NavigationController
/// tab's own combo, and the metaf/binary-.nav loaders populate it from
/// the file itself) so the common path is just "cast that spell,"
/// exactly like every other retail spell-cast action in this plugin.
- /// The two fallbacks only matter for a waypoint saved before this round
- /// with no recorded id: Marketplace still has none to record (VTank's
- /// own combo issues it as a slash command too), and any other kind
- /// resolves its id from the SAME table AddRouteRecallCore uses, rather
- /// than the old runtime KnownSelfBuffs name lookup (which depended on
- /// the character already knowing the spell under that exact name).
+ /// Round E item D-4 removed the Recall-enum fallback that used to run
+ /// here for a zero RecallSpellId (it silently cast Primary Portal
+ /// Recall for any waypoint whose recorded name failed to resolve,
+ /// since 's default ordinal IS
+ /// ) — TickRecall now
+ /// refuses and skips that case before this method is ever called, so
+ /// the only way to reach here with RecallSpellId == 0 is Marketplace,
+ /// which has no spell to cast (VTank's own combo issues it as a slash
+ /// command too).
///
private bool SubmitRecall(RouteWaypoint waypoint)
{
if (waypoint.RecallSpellId != 0u)
return _host.Automation.Magic.Cast(waypoint.RecallSpellId);
- if (waypoint.Recall == RouteRecallKind.Marketplace)
- return _host.Automation.Chat.Submit("/marketplace");
-
- uint spellId = RouteWaypoint.SpellIdForRecall(waypoint.Recall);
- return spellId != 0u && _host.Automation.Magic.Cast(spellId);
+ return _host.Automation.Chat.Submit("/marketplace");
}
private bool TickJump(
diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
index 13873d457..1f359e8ee 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
@@ -945,6 +945,52 @@ public sealed class NavigationTests
Assert.Contains("/marketplace", automation.SubmittedChat);
}
+ ///
+ /// D-4 (round E architecture re-check): an .af "rcl" node whose spell
+ /// name did not resolve against the character's known spells leaves
+ /// RecallSpellId at 0 and Recall at its default ordinal
+ /// (RouteRecallKind.PrimaryPortalRecall, MetafSerializer.cs:883-885
+ /// only ever assigns RecallSpellName/RecallSpellId, never the enum).
+ /// Before this fix SubmitRecall's Recall-based fallback silently cast
+ /// Primary Portal Recall (spell 48) for a waypoint that was never
+ /// authored as one. It must instead refuse the cast, name the
+ /// unresolved spell in the status notice, and skip the waypoint (the
+ /// same "refuse and continue" shape TickUse already uses for an
+ /// unavailable object).
+ ///
+ [Fact]
+ public void RecallWaypointWithUnresolvedSpellNameRefusesAndSkipsWithoutCasting()
+ {
+ var magic = new FakeMagic();
+ var automation = new FakeAutomation
+ {
+ NavigationSnapshot = Snapshot(Position(0d, 0d)),
+ Magic = magic,
+ };
+ var waypoint = new RouteWaypoint
+ {
+ Type = RouteWaypointType.Recall,
+ // Recall left at its default (PrimaryPortalRecall) — exactly
+ // what MetafSerializer.ReadNavNode's "rcl" case leaves it at
+ // when the imported name fails to resolve.
+ RecallSpellId = 0u,
+ RecallSpellName = "NotARealSpell",
+ Position = Position(0d, 0d),
+ };
+ NavigationController controller = Controller(automation, RouteMode.Once, waypoint);
+
+ Assert.True(controller.Tick(0.1d, canAct: true));
+
+ Assert.Empty(magic.CastSpellIds);
+ Assert.Empty(automation.SubmittedChat);
+ Assert.Contains("NotARealSpell", controller.Status);
+
+ // The waypoint was skipped (not retried): the Once route's only
+ // waypoint is gone, so the very next tick reports completion.
+ Assert.False(controller.Tick(0.1d, canAct: true));
+ Assert.Equal("Once route complete.", controller.Status);
+ }
+
private sealed class FakeMagic : IMagicCommands
{
public List CastSpellIds { get; } = [];