feat(caption): enhance saga hover functionality and visual stability

- Introduce new anchors for caption avatar and narrative positioning
- Implement stable typography capture during saga hover sessions
- Add diagnostics for avatar visibility and anchored position
- Refactor saga hover refresh logic to maintain visual consistency
- Update harness scenarios to validate hover visual stability
This commit is contained in:
DazedAnon 2026-07-24 14:29:10 -05:00
parent 12e2ba0d01
commit 96db725383
8 changed files with 236 additions and 54 deletions

View file

@ -42,6 +42,9 @@ public static class AgentHarness
private static string LastScreenshotPath = "";
private static Vector2 _sagaPanelSizeAnchor;
private static string _captionBeatAnchor = "";
private static Vector2 _captionAvatarAnchor = Vector2.zero;
private static float _captionBodyAnchorY;
private static float _captionNarrativeAnchorY;
private static readonly string[] PreferredSpawnAssets =
{
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
@ -4171,6 +4174,7 @@ public static class AgentHarness
{
LifeSagaRoster.Tick(Time.unscaledTime);
LifeSagaRail.Refresh();
WatchCaption.RequestRelayout();
_cmdOk++;
Emit(
cmd,
@ -4182,8 +4186,16 @@ public static class AgentHarness
case "caption_capture_beat":
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
_captionBodyAnchorY = WatchCaption.LastBodyAnchorY;
_captionNarrativeAnchorY = WatchCaption.LastNarrativeAnchorY;
_cmdOk++;
Emit(cmd, ok: true, detail: $"beat='{_captionBeatAnchor}'");
Emit(
cmd,
ok: true,
detail:
$"beat='{_captionBeatAnchor}' avatar={_captionAvatarAnchor} "
+ $"anchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0}");
break;
case "saga_show_hover_first":
@ -4252,12 +4264,17 @@ public static class AgentHarness
LifeSagaRail.Refresh();
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
LifeSagaRail.HarnessClick(0);
int clicks = preferBefore ? 2 : 1;
bool clicked = true;
for (int i = 0; i < clicks; i++)
{
clicked = LifeSagaRail.HarnessClickUnit(focusId) && clicked;
}
WatchCaption.RequestRelayout();
// Prefer toggle only - does not open hover / pin saga subject.
bool preferAfter = LifeSagaRail.LastPreferPipVisible
|| (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter;
bool ok = clicked && focusPreferAfter;
// After click, Prefer state on slot 0 should be readable.
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(slots);
@ -4275,7 +4292,12 @@ public static class AgentHarness
_cmdFail++;
}
Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
Emit(
cmd,
ok,
detail:
$"clicked={clicked} clicks={clicks} focus={focusId} preferBefore={preferBefore} "
+ $"preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
break;
}
@ -10279,6 +10301,57 @@ public static class AgentHarness
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
break;
}
case "saga_hover_visual_stable":
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
string hoverName = (model?.Name ?? "").Replace("★ ", "").Trim();
// Organic idle refresh: no layout invalidation. This catches visibility
// ownership bugs hidden by the initial pointer-enter relayout.
WatchCaption.HarnessRefreshSagaSteadyState();
bool steadyPanel = LifeSagaPanel.Visible;
bool steadyAvatar = DossierAvatar.HostVisible;
string beforeName = WatchCaption.VisibleNametagText;
int beforeFont = WatchCaption.VisibleNametagFontSize;
Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition;
// A second same-hover refresh used to overwrite the hovered name with the
// camera headline, enlarge the font, and reveal the portrait before placement.
WatchCaption.RequestRelayout();
string afterName = WatchCaption.VisibleNametagText;
int afterFont = WatchCaption.VisibleNametagFontSize;
Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition;
bool correctName = !string.IsNullOrEmpty(hoverName)
&& beforeName.IndexOf(hoverName, StringComparison.Ordinal) >= 0
&& afterName.IndexOf(hoverName, StringComparison.Ordinal) >= 0;
bool fontStable = beforeFont == afterFont
&& afterFont > 0
&& afterFont <= WatchCaption.SagaHoverFontCeiling;
bool avatarStable = DossierAvatar.HostVisible
&& Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f
&& Vector2.Distance(_captionAvatarAnchor, afterAvatar) < 0.1f;
bool narrativeBelowBody = _captionNarrativeAnchorY
>= _captionBodyAnchorY + 39.9f;
pass = LifeSagaViewController.IsHoverPreview
&& LifeSagaPanel.Visible
&& steadyPanel
&& steadyAvatar
&& correctName
&& fontStable
&& avatarStable
&& narrativeBelowBody;
detail =
$"name='{beforeName}'/'{afterName}' hoverName='{hoverName}' "
+ $"font={beforeFont}/{afterFont} ceiling={WatchCaption.SagaHoverFontCeiling} "
+ $"avatar={_captionAvatarAnchor}/{beforeAvatar}/{afterAvatar} "
+ $"dossierAnchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0} "
+ $"hoverAnchor={WatchCaption.LastBodyAnchorY:0.0} below={narrativeBelowBody} "
+ $"steady={steadyPanel}/{steadyAvatar} "
+ $"visible={DossierAvatar.HostVisible} pass={pass}";
break;
}
case "caption_mc_identity":
{
string idLine = WatchCaption.LastHeadline ?? "";

View file

@ -29,6 +29,14 @@ public static class DossierAvatar
/// <summary>Fallen archive species-icon portrait is showing.</summary>
public static bool ShowingSpeciesFallback => _shownActorId == -2;
/// <summary>Harness/UI diagnostic: portrait host is currently paintable.</summary>
public static bool HostVisible =>
_host != null && _host.gameObject.activeSelf;
/// <summary>Harness/UI diagnostic: final local placement within the dossier card.</summary>
public static Vector2 HostAnchoredPosition =>
_host != null ? _host.anchoredPosition : Vector2.zero;
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
public static bool ContainsScreenPoint(Vector2 screenPoint)
{

View file

@ -3979,6 +3979,7 @@ internal static class HarnessScenarios
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
expect: "harness_cast_relation"),
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
Step("sad11a", "saga_rail_refresh"),
// saga_select_tab saga → hover preview of watched MC (compat).
Step("sad11b", "caption_capture_beat"),
Step("sad12", "saga_select_tab", value: "saga"),
@ -3986,6 +3987,7 @@ internal static class HarnessScenarios
Step("sad14", "assert", expect: "saga_panel_bound"),
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"),
Step("sad14c2", "assert", expect: "saga_hover_visual_stable"),
Step("sad14d", "assert", expect: "saga_panel_cast_first"),
Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"),
Step("sad15", "screenshot", value: "saga-hover-depth"),

View file

@ -629,6 +629,26 @@ public static class LifeSagaRail
OnClick(index);
}
/// <summary>Harness: click the visible slot for an exact unit instead of assuming slot 0.</summary>
internal static bool HarnessClickUnit(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i] != null && Slots[i].UnitId == unitId)
{
OnClick(i);
return true;
}
}
return false;
}
private static void OnClick(int index)
{
if (index < 0 || index >= Slots.Length)

View file

@ -88,6 +88,13 @@ public static class LifeSagaViewController
return;
}
if (_hoverPreviewId == 0)
{
// Capture the already-painted dossier typography before hover changes ownership
// of the header. Every glyph in this hover session uses that same ceiling.
WatchCaption.BeginSagaHoverSession();
}
_hoverExitAt = -1f;
_hoverPreviewId = unitId;
WatchCaption.PrimeSagaHoverHeader(unitId);

View file

@ -127,6 +127,10 @@ public static class WatchCaption
private static string _statusBanner = "";
private static float _statusBannerUntil;
private static string _lastLoggedCaption = "";
// Saga hover owns the painted header without changing the camera dossier's semantic
// LastHeadline. Keep its display text and entry typography separate from that cache.
private static string _sagaHoverHeadline = "";
private static int _sagaHoverFontCeiling;
// 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.
@ -175,6 +179,8 @@ public static class WatchCaption
public static string LastHeadline { get; private set; } = "";
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0;
public static int SagaHoverFontCeiling => _sagaHoverFontCeiling;
public static string LastDetail { get; private set; } = "";
@ -246,6 +252,10 @@ public static class WatchCaption
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
public static bool LastLayoutOk { get; private set; }
/// <summary>Harness/UI: top anchor shared by the dossier and Saga portrait.</summary>
public static float LastBodyAnchorY { get; private set; }
/// <summary>Harness/UI: first bottom narrative row after body/status/trait details.</summary>
public static float LastNarrativeAnchorY { get; private set; }
/// <summary>True when the dossier card GameObject is active.</summary>
public static bool Visible => _visible && _root != null && _root.activeSelf;
@ -537,6 +547,8 @@ public static class WatchCaption
_nextRoutineLiveRefreshAt = 0f;
_nextRoutineSpineAt = 0f;
_headlineLocked = false;
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = 0;
_historyColW = HistoryColMinW;
_statusBanner = "";
_statusBannerUntil = 0f;
@ -940,6 +952,18 @@ public static class WatchCaption
RefreshStorySpine(force: true);
}
/// <summary>
/// Capture stable typography before Saga hover takes temporary ownership of the
/// painted header. Switching between glyphs must not make the name pulse larger.
/// </summary>
public static void BeginSagaHoverSession()
{
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = _nameText != null
? Mathf.Clamp(_nameText.fontSize, 7, 10)
: 10;
}
/// <summary>
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
/// immediate even when the deeper saga model has several relationships to resolve.
@ -968,7 +992,7 @@ public static class WatchCaption
SpeciesId = slot?.SpeciesId
?? (actor != null && actor.asset != null ? actor.asset.id : "")
};
ApplySagaHoverHeader(model);
ApplySagaHoverHeader(model, deferPortraitReveal: true);
}
/// <summary>
@ -986,6 +1010,15 @@ public static class WatchCaption
RefreshOwnedReason();
}
/// <summary>
/// Harness: exercise the periodic same-hover refresh path without marking geometry dirty.
/// Cast/Legacy and portrait must remain visible when Relayout correctly has no work.
/// </summary>
public static void HarnessRefreshSagaSteadyState()
{
RefreshStorySpine(force: true);
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
@ -1632,7 +1665,9 @@ public static class WatchCaption
LifeSagaPanel.Bind(model);
}
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
private static void ApplySagaHoverHeader(
LifeSagaViewModel model,
bool deferPortraitReveal = false)
{
if (model == null || model.UnitId == 0)
{
@ -1656,11 +1691,12 @@ public static class WatchCaption
string headline = !string.IsNullOrEmpty(title)
? name + " · " + title
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
_sagaHoverHeadline = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48)
: headline;
if (_nameText != null)
{
_nameText.text = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48)
: headline;
_nameText.text = _sagaHoverHeadline;
}
if (_speciesIcon != null)
@ -1692,6 +1728,13 @@ public static class WatchCaption
_sexIcon.gameObject.SetActive(sexSprite != null);
}
// The pointer-enter prime stages the new portrait while hidden. Periodic same-hover
// refreshes must preserve its current visibility because unchanged geometry correctly
// skips Relayout.
if (deferPortraitReveal)
{
DossierAvatar.SetHostVisible(false);
}
if (actor != null && actor.isAlive())
{
DossierAvatar.Show(actor);
@ -1700,7 +1743,6 @@ public static class WatchCaption
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
DossierAvatar.SetHostVisible(true);
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
}
@ -3130,6 +3172,8 @@ public static class WatchCaption
bool hasReason,
int historyCount)
{
LastBodyAnchorY = 0f;
LastNarrativeAnchorY = 0f;
float headerW = MeasureHeaderWidth(hasTask);
// Traits beside avatar only when history and statuses leave the body-right slot free.
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
@ -3218,37 +3262,6 @@ public static class WatchCaption
if (!panelMode)
{
bool hasConnection = _connectionText != null
&& _connectionText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_connectionText.text);
if (hasConnection)
{
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
y += ConnectionH + Gap;
}
// Narrative header order: Identity → Connection → Beat → Context. The event that
// explains the shot must be readable before portrait, history, status, and traits.
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(
_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null,
y,
reasonH,
PadX);
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
// Row visibility is owned by Place*/fill - never leave empty rows active at stale
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
if (historyCount <= 0 && _historyCol != null)
@ -3268,6 +3281,7 @@ public static class WatchCaption
if (hasBody)
{
LastBodyAnchorY = y;
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
PlaceBody(y, historyCount, bodyH);
if (historyCount > 0)
@ -3298,14 +3312,50 @@ public static class WatchCaption
WidenPanelForChipRow(traitsUsed);
y += TraitsH + Gap;
}
// Keep the portrait/body anchor invariant between Dossier and Saga hover.
// Connection, orange Beat, and Context explain the shot after the character
// details instead of inserting a variable-height gap above the portrait.
LastNarrativeAnchorY = y;
bool hasConnection = _connectionText != null
&& _connectionText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_connectionText.text);
if (hasConnection)
{
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
y += ConnectionH + Gap;
}
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(
_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null,
y,
reasonH,
PadX);
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
}
if (panelMode)
{
LifeSagaPanel.EnsureBuilt(_root.transform);
DossierAvatar.SetHostVisible(false);
LastBodyAnchorY = y;
LastNarrativeAnchorY = 0f;
DossierAvatar.Place(PadX, y, LiveMax);
DossierAvatar.SetHostVisible(true);
y = LifeSagaPanel.Place(y, PadX);
DossierAvatar.SetHostVisible(true);
if (railChrome)
{
_panelWidth = PanelWidthMax;
@ -3605,8 +3655,14 @@ public static class WatchCaption
maxW = Mathf.Max(NameMinW, maxW);
_nameText.resizeTextForBestFit = false;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
_nameText.fontSize = 10;
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "");
bool hover = LifeSagaViewController.IsHoverPreview;
int baseFontSize = hover && _sagaHoverFontCeiling > 0
? Mathf.Clamp(_sagaHoverFontCeiling, 7, 10)
: 10;
_nameText.fontSize = baseFontSize;
string full = hover && !string.IsNullOrEmpty(_sagaHoverHeadline)
? _sagaHoverHeadline
: (!string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""));
if (string.IsNullOrEmpty(full))
{
_nameText.text = "";
@ -3620,7 +3676,10 @@ public static class WatchCaption
return Mathf.Clamp(natural, NameMinW, maxW);
}
int fittedSize = Mathf.Clamp(Mathf.FloorToInt(10f * maxW / natural), 7, 10);
int fittedSize = Mathf.Clamp(
Mathf.FloorToInt(baseFontSize * maxW / natural),
7,
baseFontSize);
_nameText.fontSize = fittedSize;
_nameText.text = full;
_nameText.verticalOverflow = VerticalWrapMode.Truncate;

View file

@ -21,7 +21,9 @@ validation window is pending.
- Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for
non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack
terminology.
- Saga hover primes the new identity immediately and reads sex directly from the hovered actor.
- Saga hover primes the new identity immediately, reads sex directly from the hovered actor, keeps
the entry font ceiling, and reveals the staged portrait only after its fixed layout is placed;
steady-state refresh never hides already-placed hover chrome.
It no longer invokes the full dossier/species-population scan before the visible name swap.
## Product outcome
@ -198,7 +200,8 @@ Recognition should come from repeated, stable signals rather than extra prose on
When the camera follows a non-MC because of an MC connection, the viewer must see that connection
immediately.
- Add one muted, single-line **Connection** row directly below Identity and above the orange Beat:
- Add one muted, single-line **Connection** row at the start of the bottom narrative group,
directly above the orange Beat:
```text
Identity Omya (Human)
@ -214,7 +217,8 @@ immediately.
- When an event's exact named participant has a typed relation to a current MC and that relation is
not already stated, Beat may add one compact qualifier: `Kills Omya (child of Waf)`. This explains
significance without turning the focused character's Connection into duplicate prose.
- Render Identity → Connection → Beat → Context before portrait/history and detail chips.
- Render Identity → portrait/history → status/trait details → Connection → Beat → Context.
Bottom-grouping the narrative rows keeps the portrait anchored when Saga hover hides them.
- Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`,
`Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
`Caught in Waf's city`.
@ -327,7 +331,7 @@ refill budget.
6. Bind and relayout the rail only when its ordered unit-id sequence or visible state actually
changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
every frame.
7. Add the one-line Connection slot to the camera dossier between Identity and Beat.
7. Add the one-line Connection slot above Beat in the bottom narrative group.
8. Measure maximum authored Connection strings at supported UI scales and keep them to one
ellipsized line.
9. Include Connection identity in the dossier fingerprint so focus changes update atomically

View file

@ -96,8 +96,9 @@ Outside hover, caption Identity/Beat/Context track camera focus. During Saga hov
primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite),
while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
running underneath and restores immediately when hover ends.
Outside hover, the visible order is Identity, Connection, Beat, Context, then portrait/history and
status/trait details, so the reason for the current shot is never stranded below biography chrome.
Outside hover, the visible order is Identity, portrait/history, status/trait details, then
Connection, Beat, and Context. Narrative explanation remains grouped at the bottom while the
portrait keeps the same body anchor used by Saga hover.
## Rail UX
@ -111,6 +112,10 @@ status/trait details, so the reason for the current shot is never stranded below
- Hovering a glyph opens Cast/Legacy depth (`LifeSagaPanel`) and pauses camera switching.
- Leaving the glyph hides the panel, restores dossier body rows, and releases the pause lease.
- Hover temporarily swaps the primary nametag to the hovered life; it does not change camera focus.
- The hovered name, font, portrait, and Cast/Legacy body commit as one visual transition. The name
keeps the dossier's entry font ceiling for the full hover session, and pointer-enter stages the
portrait hidden until its fixed position is assigned. Steady-state refresh preserves visibility
when unchanged geometry correctly skips relayout.
- The orange camera Beat and Context are hidden during hover rather than attributed to the hovered life.
- Hover restores the hovered character's live stone-frame dossier portrait beside the panel
(species fallback only when no living actor is available).
@ -198,10 +203,14 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons).
- Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does
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.
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
- Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel.
- RelationEvidence caches invalidate on memory revision + live lover/friend change.
- Hitch probe: harness `hitch_probe` / load save via `load_save_slot` (slot 2 = Box of Magic). Optional `.harness/autoload_slot`.
- Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the
`hitch_probe` action) / load save via `load_save_slot` (slot 2 = Box of Magic).
Optional `.harness/autoload_slot`.
## Session