diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 8864c69..c61a0f5 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -4280,6 +4280,99 @@ public static class AgentHarness
break;
}
+ case "saga_scrub_hover_second":
+ {
+ LifeSagaRail.Refresh();
+ if (!LifeSagaViewController.IsHoverPreview)
+ {
+ LifeSagaRail.ShowHover(0);
+ WatchCaption.RequestRelayout();
+ }
+
+ long firstId = LifeSagaViewController.HoverPreviewId;
+ string firstName = WatchCaption.VisibleNametagText;
+ int secondIndex = -1;
+ for (int i = 0; i < LifeSagaRoster.Cap; i++)
+ {
+ long id = LifeSagaRail.UnitIdAt(i);
+ if (id != 0 && id != firstId)
+ {
+ secondIndex = i;
+ break;
+ }
+ }
+
+ LifeSagaRail.HideHover();
+ bool deferredHeld = LifeSagaViewController.IsHoverPreview
+ && LifeSagaViewController.HoverPreviewId == firstId
+ && InterestDirector.ReadPauseActive
+ && string.Equals(
+ WatchCaption.VisibleNametagText,
+ firstName,
+ StringComparison.Ordinal);
+
+ if (secondIndex >= 0)
+ {
+ LifeSagaRail.ShowHover(secondIndex);
+ WatchCaption.RequestRelayout();
+ }
+
+ LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
+ string expected = (model?.Name ?? "").Replace("★ ", "").Trim();
+ string beforeWriter = WatchCaption.VisibleNametagText;
+ int beforeFont = WatchCaption.VisibleNametagFontSize;
+ Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition;
+ int blockedBefore = WatchCaption.SagaHoverBlockedDossierWrites;
+
+ // Reproduce the real contention: the camera dossier receives a normal bind
+ // while a different Saga glyph owns the shared header.
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ if (focus != null && focus.isAlive())
+ {
+ WatchCaption.SetFromActor(focus);
+ }
+ WatchCaption.Update();
+ WatchCaption.RequestRelayout();
+
+ string afterWriter = WatchCaption.VisibleNametagText;
+ int afterFont = WatchCaption.VisibleNametagFontSize;
+ Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition;
+ long secondId = LifeSagaViewController.HoverPreviewId;
+ bool correctSecond = secondIndex >= 0
+ && secondId != 0
+ && secondId != firstId
+ && !string.IsNullOrEmpty(expected)
+ && afterWriter.IndexOf(expected, StringComparison.Ordinal) >= 0;
+ bool stable = string.Equals(beforeWriter, afterWriter, StringComparison.Ordinal)
+ && beforeFont == afterFont
+ && Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f;
+ bool blocked = WatchCaption.SagaHoverBlockedDossierWrites > blockedBefore;
+ bool ok = deferredHeld
+ && correctSecond
+ && stable
+ && blocked
+ && InterestDirector.ReadPauseActive;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail:
+ $"first={firstId} second={secondId} index={secondIndex} held={deferredHeld} "
+ + $"name='{firstName}'/'{beforeWriter}'/'{afterWriter}' expected='{expected}' "
+ + $"font={beforeFont}/{afterFont} avatar={beforeAvatar}/{afterAvatar} "
+ + $"blocked={blockedBefore}/{WatchCaption.SagaHoverBlockedDossierWrites} "
+ + $"pause={InterestDirector.ReadPauseActive}");
+ break;
+ }
+
case "saga_hide_hover":
{
LifeSagaRail.HideHover();
@@ -10403,6 +10496,11 @@ public static class AgentHarness
+ $"visible={DossierAvatar.HostVisible} pass={pass}";
break;
}
+ case "saga_hover_nametag_ownership":
+ {
+ pass = WatchCaption.HarnessProbeSagaHoverNametagOwnership(out detail);
+ break;
+ }
case "caption_mc_identity":
{
string idLine = WatchCaption.LastHeadline ?? "";
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 2a21350..9a2fbb7 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -3978,6 +3978,7 @@ internal static class HarnessScenarios
Step("sad8b", "pick_unit", asset: "human", value: "other"),
Step("sad9", "focus", asset: "human"),
Step("sad10", "saga_force_admit_focus"),
+ Step("sad10p", "saga_force_admit_partner"),
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
expect: "harness_cast_relation"),
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
@@ -4010,6 +4011,8 @@ internal static class HarnessScenarios
Step("sad19", "assert", expect: "saga_tab_effective", value: "saga"),
Step("sad20", "assert", expect: "saga_hover_preview", value: "true"),
Step("sad21", "assert", expect: "saga_read_pause", value: "true"),
+ Step("sad21a", "saga_scrub_hover_second"),
+ Step("sad21a2", "assert", expect: "saga_hover_nametag_ownership"),
Step("sad21b", "saga_hide_hover"),
Step("sad21d", "saga_hover_tick_away"),
Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"),
diff --git a/IdleSpectator/Story/LifeSagaViewController.cs b/IdleSpectator/Story/LifeSagaViewController.cs
index 522deb0..d84fe81 100644
--- a/IdleSpectator/Story/LifeSagaViewController.cs
+++ b/IdleSpectator/Story/LifeSagaViewController.cs
@@ -122,7 +122,10 @@ public static class LifeSagaViewController
return;
}
- FinishHoverExit();
+ // PointerExit is delivered before PointerEnter when scrubbing directly from one
+ // glyph to another. Keep Saga ownership alive across that event handoff so the
+ // dossier cannot paint one intermediate header frame.
+ _hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
}
public static void Tick()
@@ -155,8 +158,14 @@ public static class LifeSagaViewController
public static void CancelPreviewAndPause()
{
+ bool hadPreview = _hoverPreviewId != 0;
_hoverExitAt = -1f;
_hoverPreviewId = 0;
+ if (hadPreview)
+ {
+ WatchCaption.EndSagaHoverSession();
+ }
+
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
@@ -192,6 +201,7 @@ public static class LifeSagaViewController
{
_hoverExitAt = -1f;
_hoverPreviewId = 0;
+ WatchCaption.EndSagaHoverSession();
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 7243eaa..0879792 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -131,6 +131,7 @@ public static class WatchCaption
// LastHeadline. Keep its display text and entry typography separate from that cache.
private static string _sagaHoverHeadline = "";
private static int _sagaHoverFontCeiling;
+ private static int _sagaHoverBlockedDossierWrites;
// OwnedEventReason performs stranger-name presentability checks against the living
// population. A developed world makes that expensive, while the active director
// candidate is normally unchanged for many 0.2-second caption refreshes.
@@ -181,6 +182,7 @@ public static class WatchCaption
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0;
public static int SagaHoverFontCeiling => _sagaHoverFontCeiling;
+ public static int SagaHoverBlockedDossierWrites => _sagaHoverBlockedDossierWrites;
public static string LastDetail { get; private set; } = "";
@@ -572,7 +574,7 @@ public static class WatchCaption
if (_nameText != null && (string.IsNullOrEmpty(_nameText.text) || _current == null))
{
- _nameText.text = "Idle Spectator";
+ PaintDossierHeadline("Idle Spectator");
LastHeadline = "Idle Spectator";
}
@@ -661,10 +663,7 @@ public static class WatchCaption
_boundActor = null;
EnsureBuilt();
ApplyVisual(null, null);
- if (_nameText != null)
- {
- _nameText.text = LastHeadline;
- }
+ PaintDossierHeadline(LastHeadline);
Relayout(false, 0, 0, false, false, 0);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
@@ -804,9 +803,9 @@ public static class WatchCaption
{
ApplyVisual(focus ?? _boundActor, _current);
}
- else if (_nameText != null)
+ else
{
- _nameText.text = LastHeadline;
+ PaintDossierHeadline(LastHeadline);
Relayout(false, 0, 0, false, false, 0);
}
@@ -815,10 +814,13 @@ public static class WatchCaption
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
// must not leave the nametag on the previous person for a frame.
- // Caption Identity/Beat/Context always track camera focus; hover only adds panel depth.
+ // Camera dossier state tracks focus, but Saga hover exclusively owns painted chrome.
long perfStarted = StartPerfSample();
LifeSagaViewController.Tick();
- ReconcileDossierToFocus();
+ if (!LifeSagaViewController.IsHoverPreview)
+ {
+ ReconcileDossierToFocus();
+ }
LastReconcileMs = EndPerfSample(perfStarted);
float now = Time.unscaledTime;
if (now < _nextRoutineLiveRefreshAt)
@@ -959,11 +961,26 @@ public static class WatchCaption
public static void BeginSagaHoverSession()
{
_sagaHoverHeadline = "";
+ _sagaHoverBlockedDossierWrites = 0;
_sagaHoverFontCeiling = _nameText != null
? Mathf.Clamp(_nameText.fontSize, 7, 10)
: 10;
}
+ /// Return nametag ownership to the camera dossier after a true rail exit.
+ public static void EndSagaHoverSession()
+ {
+ _sagaHoverHeadline = "";
+ _sagaHoverFontCeiling = 0;
+ string shown = LastHeadline ?? "";
+ if (shown.Length > 48)
+ {
+ shown = CaptionComposer.TruncateIdentity(shown, 48);
+ }
+
+ PaintDossierHeadline(shown);
+ }
+
///
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
/// immediate even when the deeper saga model has several relationships to resolve.
@@ -995,6 +1012,47 @@ public static class WatchCaption
ApplySagaHoverHeader(model, deferPortraitReveal: true);
}
+ /// Harness: prove a camera-dossier write cannot replace a hovered identity.
+ public static bool HarnessProbeSagaHoverNametagOwnership(out string detail)
+ {
+ string before = VisibleNametagText;
+ int blockedBefore = _sagaHoverBlockedDossierWrites;
+ PaintDossierHeadline("__dossier_writer_probe__");
+ string after = VisibleNametagText;
+ bool pass = LifeSagaViewController.IsHoverPreview
+ && !string.IsNullOrEmpty(before)
+ && string.Equals(before, after, StringComparison.Ordinal)
+ && _sagaHoverBlockedDossierWrites == blockedBefore + 1;
+ detail =
+ $"hover={LifeSagaViewController.HoverPreviewId} before='{before}' after='{after}' "
+ + $"blocked={blockedBefore}/{_sagaHoverBlockedDossierWrites} pass={pass}";
+ return pass;
+ }
+
+ ///
+ /// Single camera-dossier nametag write gate. Saga hover is the exclusive visual
+ /// owner even while camera callbacks continue updating the underlying dossier model.
+ ///
+ private static void PaintDossierHeadline(string text)
+ {
+ if (_nameText == null)
+ {
+ return;
+ }
+
+ if (LifeSagaViewController.IsHoverPreview)
+ {
+ _sagaHoverBlockedDossierWrites++;
+ return;
+ }
+
+ string next = text ?? "";
+ if (!string.Equals(_nameText.text, next, StringComparison.Ordinal))
+ {
+ _nameText.text = next;
+ }
+ }
+
///
/// A same-subject camera event can change its orange reason without rebuilding the
/// dossier. Refresh only the owned reason/spine so UI and camera telemetry commit together.
@@ -1401,20 +1459,14 @@ public static class WatchCaption
shown = CaptionComposer.TruncateIdentity(shown, 48);
}
- if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal))
- {
- _nameText.text = shown;
- }
+ PaintDossierHeadline(shown);
}
}
else if (_current != null && !string.IsNullOrEmpty(_current.Headline))
{
// Fallen/archive: Compose has no live focus - keep dossier headline.
LastHeadline = _current.Headline;
- if (_nameText != null)
- {
- _nameText.text = LastHeadline;
- }
+ PaintDossierHeadline(LastHeadline);
}
}
@@ -1674,6 +1726,13 @@ public static class WatchCaption
return;
}
+ // A delayed model from the previous glyph must never repaint a newer hover.
+ if (!LifeSagaViewController.IsHoverPreview
+ || model.UnitId != LifeSagaViewController.HoverPreviewId)
+ {
+ return;
+ }
+
Actor actor = EventFeedUtil.FindAliveById(model.UnitId);
string name = (model.Name ?? "").Trim();
if (name.StartsWith("★ ", StringComparison.Ordinal))
@@ -1905,7 +1964,10 @@ public static class WatchCaption
/// Keep nametag Identity in sync via CaptionComposer (MC title or species/job).
private static void RefreshLiveIdentity()
{
- if (!_visible || _current == null || _headlineLocked)
+ if (!_visible
+ || _current == null
+ || _headlineLocked
+ || LifeSagaViewController.IsHoverPreview)
{
return;
}
@@ -2282,6 +2344,15 @@ public static class WatchCaption
&& actor.getID() == dossier.UnitId;
_boundActor = hasLive ? actor : null;
+ // Saga hover owns the complete header/body presentation. SetFromActor and other
+ // camera callbacks may still update the dossier snapshot, but they cannot repaint
+ // shared UI until the hover session ends.
+ if (LifeSagaViewController.IsHoverPreview)
+ {
+ _sagaHoverBlockedDossierWrites++;
+ return;
+ }
+
if (_speciesIcon != null)
{
Sprite species = null;
@@ -2318,12 +2389,10 @@ public static class WatchCaption
_sexIcon.gameObject.SetActive(hasSex);
}
- if (_nameText != null)
- {
- _nameText.text = dossier != null && !string.IsNullOrEmpty(dossier.Headline)
+ PaintDossierHeadline(
+ dossier != null && !string.IsNullOrEmpty(dossier.Headline)
? dossier.Headline
- : "";
- }
+ : "");
bool hasBody = dossier != null && dossier.UnitId != 0;
bool hoverDepth = LifeSagaViewController.IsHoverPreview;
diff --git a/docs/five-mc-ensemble-plan.md b/docs/five-mc-ensemble-plan.md
index 6aac167..dc0cca8 100644
--- a/docs/five-mc-ensemble-plan.md
+++ b/docs/five-mc-ensemble-plan.md
@@ -201,6 +201,8 @@ Recognition should come from repeated, stable signals rather than extra prose on
- Identity remains `Name · real title`, otherwise `Name (species/job)`.
- Hover remains the place for Cast and Legacy depth; the live caption stays Identity + Beat +
Context.
+- Treat a glyph-to-glyph scrub as one hover ownership session: defer exit briefly, block all
+ camera-dossier writes to shared header chrome, and restore the camera name only after a true exit.
### MC connection in the camera dossier
diff --git a/docs/life-saga.md b/docs/life-saga.md
index 63136da..0b6a679 100644
--- a/docs/life-saga.md
+++ b/docs/life-saga.md
@@ -219,6 +219,10 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
not call the population-counting dossier builder.
- Hover fitting reads its own display headline rather than the camera dossier's cached identity;
repeated relayouts therefore cannot briefly restore the wrong name or enlarge the font.
+- Saga owns the shared nametag/species/sex/portrait chrome exclusively for the whole hover session.
+ Camera dossier callbacks may update their semantic snapshot but cannot repaint that chrome.
+- Glyph exit is deferred for 50 ms; entering another glyph cancels the exit, producing an atomic
+ cross-glyph handoff with no intermediate dossier name or typography frame.
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
- Missing live rail frames retry at most one inactive slot per ~1s rail tick; successful captures
replace the temporary species icon and unsuccessful attempts never discard an older live frame.