feat(saga): enhance rail species frame handling and live capture retries

- Introduce new saga rail commands for species frame auditing and sweeping
- Implement retry logic for capturing inactive live frames in the rail
- Refactor face resolution logic to prioritize live frames over species icons
- Update harness scenarios to validate new rail functionality and performance
- Enhance documentation to clarify live capture behavior and fallback handling
This commit is contained in:
DazedAnon 2026-07-24 15:17:03 -05:00
parent 96db725383
commit 529d18dfa4
7 changed files with 1033 additions and 50 deletions

View file

@ -4184,6 +4184,48 @@ public static class AgentHarness
break; break;
} }
case "saga_rail_species_frame_sweep":
{
if (!RailFaceCatalogAudit.Running)
{
WorldTile tile = _lastSpawned != null && _lastSpawned.isAlive()
? _lastSpawned.current_tile
: TileNearCamera();
if (!RailFaceCatalogAudit.BeginExhaustive(HarnessDir(), tile, out string startDetail))
{
_cmdFail++;
Emit(cmd, ok: false, detail: startDetail);
break;
}
}
bool finished = RailFaceCatalogAudit.StepExhaustive(
out bool sweepPass,
out string sweepDetail);
if (!finished)
{
RequeueFront(cmd);
break;
}
if (sweepPass)
{
_cmdOk++;
_assertPass++;
}
else
{
_cmdFail++;
_assertFail++;
}
Emit(cmd, sweepPass, sweepDetail);
LogService.LogInfo(
$"[IdleSpectator][ASSERT] {(sweepPass ? "PASS" : "FAIL")} "
+ $"id={cmd.id} expect=saga_rail_species_frame_sweep {sweepDetail}");
break;
}
case "caption_capture_beat": case "caption_capture_beat":
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? ""; _captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition; _captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
@ -4275,13 +4317,6 @@ public static class AgentHarness
bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId); bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter; bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter;
bool ok = clicked && 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);
if (slots.Count > 0 && slots[0] != null)
{
ok = slots[0].Prefer || preferBefore;
}
if (ok) if (ok)
{ {
@ -10088,6 +10123,22 @@ public static class AgentHarness
detail = $"cachedFaces={have} want={want} mode={(atLeast ? "min" : "exact")} shown={LifeSagaRail.LastShownCount}"; detail = $"cachedFaces={have} want={want} mode={(atLeast ? "min" : "exact")} shown={LifeSagaRail.LastShownCount}";
break; break;
} }
case "saga_rail_fallback_cache_isolation":
{
pass = LifeSagaRail.HarnessProbeFallbackCacheIsolation(out detail);
break;
}
case "saga_rail_focus_live_frame_distinct":
{
LifeSagaRail.Refresh();
pass = LifeSagaRail.HarnessProbeFocusUsesDistinctLiveFrame(out detail);
break;
}
case "saga_rail_species_frame_audit":
{
pass = RailFaceCatalogAudit.Run(HarnessDir(), out detail);
break;
}
case "saga_soft_bias_rank": case "saga_soft_bias_rank":
{ {
InterestCandidate cur = InterestDirector.CurrentCandidate; InterestCandidate cur = InterestDirector.CurrentCandidate;
@ -15609,6 +15660,7 @@ public static class AgentHarness
_rememberedFocusKey = ""; _rememberedFocusKey = "";
_rememberedTip = ""; _rememberedTip = "";
_rememberedRelatedId = 0; _rememberedRelatedId = 0;
RailFaceCatalogAudit.Cancel();
InterestDirector.SetHarnessFastTiming(false); InterestDirector.SetHarnessFastTiming(false);
SpeciesDiscovery.SetHarnessFastTiming(false); SpeciesDiscovery.SetHarnessFastTiming(false);
try try

View file

@ -276,6 +276,8 @@ internal static class HarnessScenarios
return SagaReplaceWeaker(); return SagaReplaceWeaker();
case "saga_rail_active_highlight": case "saga_rail_active_highlight":
return SagaRailActiveHighlight(); return SagaRailActiveHighlight();
case "saga_rail_live_animal_frame":
return SagaRailLiveAnimalFrame();
case "saga_rail_prefer_click": case "saga_rail_prefer_click":
return SagaRailPreferClick(); return SagaRailPreferClick();
case "saga_soft_fill_no_pack": case "saga_soft_fill_no_pack":
@ -4645,11 +4647,47 @@ internal static class HarnessScenarios
Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"), Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"),
Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"), Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
Step("sra25b", "assert", expect: "saga_rail_cached_faces", value: "2", label: "min"), Step("sra25b", "assert", expect: "saga_rail_cached_faces", value: "2", label: "min"),
Step("sra25c", "assert", expect: "saga_rail_fallback_cache_isolation"),
Step("sra90", "fast_timing", value: "false"), Step("sra90", "fast_timing", value: "false"),
Step("sra99", "snapshot"), Step("sra99", "snapshot"),
}; };
} }
/// <summary>
/// Tall and short creature glyphs retain animation frames, then the full living
/// species catalog is checked under the same extraction policy.
/// </summary>
private static List<HarnessCommand> SagaRailLiveAnimalFrame()
{
return new List<HarnessCommand>
{
Step("srl0", "dismiss_windows"),
Step("srl1", "wait_world"),
Step("srl2", "set_setting", expect: "enabled", value: "true"),
Step("srl3", "fast_timing", value: "true"),
Step("srl4", "spawn", asset: "cat", count: 1),
Step("srl5", "spectator", value: "off"),
Step("srl6", "spectator", value: "on"),
Step("srl7", "interest_saga_clear"),
Step("srl8", "pick_unit", asset: "cat"),
Step("srl9", "focus", asset: "cat"),
Step("srl10", "saga_force_admit_focus"),
Step("srl11", "saga_rail_refresh"),
Step("srl12", "assert", expect: "saga_rail_focus_live_frame_distinct"),
Step("srl13", "screenshot", value: "saga-rail-cat-live"),
Step("srl14", "spawn", asset: "fox", count: 1),
Step("srl15", "pick_unit", asset: "fox"),
Step("srl16", "focus", asset: "fox"),
Step("srl17", "saga_force_admit_focus"),
Step("srl18", "saga_rail_refresh"),
Step("srl19", "assert", expect: "saga_rail_focus_live_frame_distinct"),
Step("srl20", "screenshot", value: "saga-rail-fox-live"),
Step("srl21", "saga_rail_species_frame_sweep"),
Step("srl90", "fast_timing", value: "false"),
Step("srl99", "snapshot"),
};
}
/// <summary>Prefer toggle flips on then off with immediate pip state.</summary> /// <summary>Prefer toggle flips on then off with immediate pip state.</summary>
private static List<HarnessCommand> SagaRailPreferClick() private static List<HarnessCommand> SagaRailPreferClick()
{ {

View file

@ -142,37 +142,123 @@ public static class HudIcons
} }
/// <summary> /// <summary>
/// Readable saga-rail face: live inspect sprite when usable, else species icon. /// Actor-specific saga-rail frame only. Returns null for eggs, dead actors, or
/// Skips egg forms and near-empty tiny crops that read as a single pixel. /// unusable render frames; callers decide whether to display a temporary fallback.
/// </summary> /// </summary>
public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null) public static Sprite FromActorLiveRailFace(Actor actor)
{ {
if (actor != null && actor.isAlive()) if (actor == null || !actor.isAlive())
{ {
bool egg = false; return null;
}
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (egg)
{
return null;
}
try
{
Sprite main = actor.calculateMainSprite();
if (main == null || LooksLikeEggSprite(main))
{
return null;
}
Sprite speciesIcon = FromActor(actor);
bool civilized = actor.asset != null && actor.asset.civ;
Sprite colored = null;
try try
{ {
egg = actor.isEgg(); // Some creatures report hasColoredSprite=false even though this returns the
// palette-resolved live frame (cat: walk_0 -> gen_walk_0).
colored = actor.calculateColoredSprite(main);
} }
catch catch
{ {
egg = false; colored = null;
} }
if (!egg) Sprite ui = null;
if (civilized)
{ {
Sprite live = FromActorLive(actor); try
if (IsUsableRailFace(live))
{ {
return live; ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main);
} }
catch
Sprite asset = FromActor(actor);
if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset))
{ {
return asset; ui = null;
} }
} }
// Civ units benefit from the combined inspect frame (body/head/equipment).
if (civilized
&& IsUsableRailFace(ui)
&& !SameSpriteSource(ui, speciesIcon))
{
return ui;
}
// Creature palette generation is the closest single-frame equivalent to the
// live renderer. It must precede the raw atlas frame or palette-key colors leak.
if (IsUsableRailFace(colored)
&& !LooksLikeEggSprite(colored)
&& !SameSpriteSource(colored, speciesIcon))
{
return colored;
}
if (!civilized
&& IsUsableRailFace(main)
&& !SameSpriteSource(main, speciesIcon))
{
return main;
}
// The UI helper is allowed to synthesize a copy of the asset portrait for
// creatures. Source-region comparison cannot detect that copy, so never use
// it as a non-civ "live" frame. Their main/colored animation is authoritative.
if (civilized
&& IsUsableRailFace(ui)
&& !SameSpriteSource(ui, speciesIcon))
{
return ui;
}
return civilized
&& IsUsableRailFace(main)
&& !SameSpriteSource(main, speciesIcon)
? main
: null;
}
catch
{
return null;
}
}
/// <summary>
/// Readable saga-rail face: actor-specific live frame when available, else species icon.
/// The combined helper is for non-caching presentation such as Cast cells.
/// </summary>
public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null)
{
Sprite live = FromActorLiveRailFace(actor);
if (IsUsableRailFace(live))
{
return live;
} }
string species = speciesFallbackId; string species = speciesFallbackId;
@ -193,10 +279,12 @@ public static class HudIcons
try try
{ {
// Reject 1-4px crops and blank atlas slices that look like dust on the rail. // Actor animation frames are not square. Foxes and other long-bodied creatures
// legitimately use 5-7px-tall frames, so a per-axis 8px floor rejects the live
// animation and makes callers fall back to the standing species portrait.
float w = sprite.rect.width; float w = sprite.rect.width;
float h = sprite.rect.height; float h = sprite.rect.height;
if (w < 8f || h < 8f) if (w < 2f || h < 2f || (w * h) < 4f)
{ {
return false; return false;
} }
@ -214,6 +302,146 @@ public static class HudIcons
} }
} }
/// <summary>True when two sprites point at the same atlas region, even as different objects.</summary>
public static bool SameSpriteSource(Sprite a, Sprite b)
{
if (a == null || b == null)
{
return false;
}
if (ReferenceEquals(a, b))
{
return true;
}
try
{
Rect ar = a.rect;
Rect br = b.rect;
return a.texture == b.texture
&& Mathf.Abs(ar.x - br.x) < 0.1f
&& Mathf.Abs(ar.y - br.y) < 0.1f
&& Mathf.Abs(ar.width - br.width) < 0.1f
&& Mathf.Abs(ar.height - br.height) < 0.1f;
}
catch
{
return false;
}
}
/// <summary>Harness diagnostic for WorldBox's competing actor-frame sources.</summary>
public static string DescribeActorRailSources(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return "actor=none";
}
try
{
Sprite species = FromActor(actor);
Sprite main = actor.calculateMainSprite();
Sprite colored = null;
try
{
colored = main != null ? actor.calculateColoredSprite(main) : null;
}
catch
{
colored = null;
}
Sprite ui = null;
try
{
ui = main != null
? DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main)
: null;
}
catch
{
ui = null;
}
return "main=" + DescribeSprite(main, species)
+ " colored=" + DescribeSprite(colored, species)
+ " ui=" + DescribeSprite(ui, species)
+ " species=" + DescribeSprite(species, species);
}
catch (Exception ex)
{
return "sources_error=" + ex.GetType().Name;
}
}
/// <summary>Harness: retained frame matches the actor's palette/UI-generated live frame.</summary>
public static bool MatchesGeneratedActorFrame(Actor actor, Sprite retained)
{
if (actor == null || retained == null || !actor.isAlive())
{
return false;
}
try
{
Sprite main = actor.calculateMainSprite();
if (main == null)
{
return false;
}
Sprite colored = null;
Sprite ui = null;
try
{
colored = actor.calculateColoredSprite(main);
}
catch
{
colored = null;
}
try
{
ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main);
}
catch
{
ui = null;
}
return (IsUsableRailFace(colored) && SameSpriteSource(retained, colored))
|| (IsUsableRailFace(main) && SameSpriteSource(retained, main))
|| (IsUsableRailFace(ui) && SameSpriteSource(retained, ui));
}
catch
{
return false;
}
}
private static string DescribeSprite(Sprite sprite, Sprite species)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0")
+ (SameSpriteSource(sprite, species) ? ":species" : ":distinct");
}
catch
{
return "invalid";
}
}
private static bool LooksLikeEggSprite(Sprite sprite) private static bool LooksLikeEggSprite(Sprite sprite)
{ {
if (sprite == null) if (sprite == null)

View file

@ -0,0 +1,509 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Non-mutating audit of every registered actor asset and every species represented
/// by a living unit in the loaded world. Keeps rail-frame regressions catalog-visible
/// without spawning test creatures into a player's world.
/// </summary>
public static class RailFaceCatalogAudit
{
private static readonly List<string> Assets = new List<string>(384);
private static readonly StringBuilder Rows = new StringBuilder(32768);
private static string _outputDirectory = "";
private static WorldTile _spawnTile;
private static int _cursor;
private static int _catalog;
private static int _represented;
private static int _spawned;
private static int _passed;
private static int _failed;
private static int _unspawnable;
private static int _notApplicable;
private static float _captureTotalMs;
private static float _captureMaxMs;
private static float _stepMaxMs;
private static string _failSample = "";
private static Actor _pendingTemporary;
private static string _pendingId = "";
private static ActorAsset _pendingAsset;
public static bool Running { get; private set; }
/// <summary>
/// Starts an exhaustive, one-asset-per-frame sweep. Missing representatives are
/// temporarily spawned and immediately destroyed with AttackType.None.
/// </summary>
public static bool BeginExhaustive(string outputDirectory, WorldTile spawnTile, out string detail)
{
Cancel();
if (spawnTile == null || World.world?.units == null)
{
detail = "world/spawn tile unavailable";
return false;
}
ActorAssetLibrary library = AssetManager.actor_library;
if (library?.list == null)
{
detail = "actor library unavailable";
return false;
}
for (int i = 0; i < library.list.Count; i++)
{
ActorAsset asset = library.list[i];
if (asset != null && !string.IsNullOrEmpty(asset.id))
{
Assets.Add(asset.id);
}
}
_outputDirectory = outputDirectory;
_spawnTile = spawnTile;
_catalog = Assets.Count;
Rows.AppendLine(
"asset\tciv\torigin\tliving\tstatus\tselected\tsources\tspecies\tcapture_ms\tstep_ms");
Running = Assets.Count > 0;
detail = "catalog sweep started assets=" + Assets.Count;
return Running;
}
/// <summary>Processes one registered asset so test-only creation is amortized by frame.</summary>
public static bool StepExhaustive(out bool success, out string detail)
{
success = false;
detail = "catalog sweep not running";
if (!Running)
{
return true;
}
long stepStarted = Stopwatch.GetTimestamp();
bool warmedTemporary = _pendingTemporary != null;
string id = warmedTemporary ? _pendingId : Assets[_cursor++];
ActorAsset asset = warmedTemporary
? _pendingAsset
: ActivityAssetCatalog.TryGetActorAsset(id);
Actor actor = warmedTemporary ? _pendingTemporary : null;
Actor temporary = actor;
_pendingTemporary = null;
_pendingId = "";
_pendingAsset = null;
int living = 0;
string origin = warmedTemporary ? "temporary+warmed" : "world";
string status = "";
float captureMs = 0f;
Sprite selected = null;
Sprite species = null;
string sources = "actor=none";
try
{
if (!warmedTemporary)
{
actor = FindLivingNonEgg(asset, out living);
if (actor == null)
{
origin = "temporary";
try
{
temporary = World.world.units.spawnNewUnit(
id,
_spawnTile,
pSpawnSound: false,
pMiracleSpawn: true);
}
catch (Exception ex)
{
status = "spawn_exception:" + ex.GetType().Name;
}
if (temporary == null || !temporary.isAlive())
{
_unspawnable++;
if (string.IsNullOrEmpty(status))
{
status = "not_spawnable";
}
}
else
{
actor = temporary;
_spawned++;
}
}
else
{
temporary = null;
_represented++;
}
}
if (actor != null && actor.isAlive())
{
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (egg)
{
_notApplicable++;
status = "egg";
}
else
{
long captureStarted = Stopwatch.GetTimestamp();
selected = HudIcons.FromActorLiveRailFace(actor);
captureMs = ElapsedMs(captureStarted);
_captureTotalMs += captureMs;
_captureMaxMs = Mathf.Max(_captureMaxMs, captureMs);
species = HudIcons.FromActor(actor);
sources = HudIcons.DescribeActorRailSources(actor);
bool usable = HudIcons.IsUsableRailFace(selected);
// Some special actors (notably Crabzilla) need one Update after
// spawn before calculateMainSprite has initialized its renderer.
if (!usable && temporary != null && !warmedTemporary)
{
_pendingTemporary = temporary;
_pendingId = id;
_pendingAsset = asset;
temporary = null;
detail = $"catalog sweep {_cursor}/{Assets.Count} warming={id}";
return false;
}
// Player-controlled/special-renderer assets may be registered as Actors
// without exposing an actor sprite at all. They cannot supply a live rail
// frame; keeping the species fallback is a capability constraint, not a
// bad selection of the default icon.
if (!usable
&& warmedTemporary
&& sources.StartsWith("sources_error=", StringComparison.Ordinal))
{
_notApplicable++;
status = "special_renderer";
}
else
{
bool distinct = usable && !HudIcons.SameSpriteSource(selected, species);
bool liveSource = usable
&& HudIcons.MatchesGeneratedActorFrame(actor, selected);
if (distinct && liveSource)
{
_passed++;
status = "pass";
}
else
{
_failed++;
status = usable ? "non_live_source" : "no_live_frame";
AddSample(ref _failSample, id + ":" + status);
}
}
}
}
else if (warmedTemporary)
{
_failed++;
status = "died_before_warm_capture";
AddSample(ref _failSample, id + ":" + status);
}
}
catch (Exception ex)
{
_failed++;
status = "audit_exception:" + ex.GetType().Name;
AddSample(ref _failSample, id + ":" + status);
}
finally
{
if (temporary != null)
{
try
{
temporary.dieAndDestroy(AttackType.None);
}
catch
{
// Test actor cleanup is best-effort; the row remains diagnostic.
}
}
}
float stepMs = ElapsedMs(stepStarted);
_stepMaxMs = Mathf.Max(_stepMaxMs, stepMs);
Rows.Append(id).Append('\t')
.Append(asset?.civ ?? false).Append('\t')
.Append(origin).Append('\t')
.Append(living).Append('\t')
.Append(status).Append('\t')
.Append(Describe(selected)).Append('\t')
.Append(sources).Append('\t')
.Append(Describe(species)).Append('\t')
.Append(captureMs.ToString("0.000")).Append('\t')
.Append(stepMs.ToString("0.000"))
.AppendLine();
if (_cursor < Assets.Count)
{
detail = $"catalog sweep {_cursor}/{Assets.Count}";
return false;
}
Running = false;
string path = Path.Combine(_outputDirectory, "saga-rail-species-frames.tsv");
try
{
Directory.CreateDirectory(_outputDirectory);
File.WriteAllText(path, Rows.ToString());
}
catch (Exception ex)
{
detail = "audit write exception=" + ex.GetType().Name + ":" + ex.Message;
return true;
}
int audited = _passed + _failed;
success = audited > 0 && _failed == 0;
detail =
$"catalog={_catalog} audited={audited} represented={_represented} spawned={_spawned} "
+ $"pass={_passed} fail={_failed} unspawnable={_unspawnable} n/a={_notApplicable} "
+ $"captureMs={_captureTotalMs:0.00}/{_captureMaxMs:0.00} stepMaxMs={_stepMaxMs:0.00} "
+ $"sample='{_failSample}' path={path}";
return true;
}
public static void Cancel()
{
Running = false;
Assets.Clear();
Rows.Length = 0;
_outputDirectory = "";
_spawnTile = null;
_cursor = 0;
_catalog = 0;
_represented = 0;
_spawned = 0;
_passed = 0;
_failed = 0;
_unspawnable = 0;
_notApplicable = 0;
_captureTotalMs = 0f;
_captureMaxMs = 0f;
_stepMaxMs = 0f;
_failSample = "";
if (_pendingTemporary != null)
{
try
{
_pendingTemporary.dieAndDestroy(AttackType.None);
}
catch
{
// best effort
}
}
_pendingTemporary = null;
_pendingId = "";
_pendingAsset = null;
}
public static bool Run(string outputDirectory, out string detail)
{
var rows = new StringBuilder(32768);
rows.AppendLine("asset\tciv\tliving\tstatus\tselected\tsources\tspecies\tcapture_ms");
int catalog = 0;
int represented = 0;
int passed = 0;
int failed = 0;
int unavailable = 0;
float totalMs = 0f;
float maxMs = 0f;
string failSample = "";
try
{
ActorAssetLibrary library = AssetManager.actor_library;
if (library?.list == null)
{
detail = "actor library unavailable";
return false;
}
for (int i = 0; i < library.list.Count; i++)
{
ActorAsset asset = library.list[i];
if (asset == null || string.IsNullOrEmpty(asset.id))
{
continue;
}
catalog++;
Actor actor = FindLivingNonEgg(asset, out int living);
if (actor == null)
{
unavailable++;
rows.Append(asset.id).Append('\t')
.Append(asset.civ).Append('\t')
.Append(living).Append('\t')
.AppendLine(living > 0 ? "egg_only" : "no_living_actor");
continue;
}
represented++;
long started = Stopwatch.GetTimestamp();
Sprite selected = HudIcons.FromActorLiveRailFace(actor);
float captureMs = (float)(
(Stopwatch.GetTimestamp() - started) * 1000.0 / Stopwatch.Frequency);
totalMs += captureMs;
maxMs = Mathf.Max(maxMs, captureMs);
Sprite species = HudIcons.FromActor(actor);
bool usable = HudIcons.IsUsableRailFace(selected);
bool distinct = usable && !HudIcons.SameSpriteSource(selected, species);
bool liveSource = usable && HudIcons.MatchesGeneratedActorFrame(actor, selected);
bool ok = distinct && liveSource;
if (ok)
{
passed++;
}
else
{
failed++;
AddSample(
ref failSample,
asset.id + ":" + (usable ? "non-live-source" : "no-frame"));
}
rows.Append(asset.id).Append('\t')
.Append(asset.civ).Append('\t')
.Append(living).Append('\t')
.Append(ok ? "pass" : "fail").Append('\t')
.Append(Describe(selected)).Append('\t')
.Append(HudIcons.DescribeActorRailSources(actor)).Append('\t')
.Append(Describe(species)).Append('\t')
.Append(captureMs.ToString("0.000"))
.AppendLine();
}
}
catch (Exception ex)
{
detail = "audit exception=" + ex.GetType().Name + ":" + ex.Message;
return false;
}
string path = Path.Combine(outputDirectory, "saga-rail-species-frames.tsv");
try
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(path, rows.ToString());
}
catch (Exception ex)
{
detail = "audit write exception=" + ex.GetType().Name + ":" + ex.Message;
return false;
}
detail =
$"catalog={catalog} represented={represented} pass={passed} fail={failed} "
+ $"unavailable={unavailable} captureMs={totalMs:0.00}/{maxMs:0.00} "
+ $"sample='{failSample}' path={path}";
return represented > 0 && failed == 0;
}
private static Actor FindLivingNonEgg(ActorAsset asset, out int living)
{
living = 0;
Actor first = null;
try
{
if (asset?.units == null)
{
return null;
}
foreach (Actor actor in asset.units)
{
if (actor == null || !actor.isAlive())
{
continue;
}
living++;
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (!egg && first == null)
{
first = actor;
}
}
}
catch
{
return first;
}
return first;
}
private static string Describe(Sprite sprite)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0");
}
catch
{
return "invalid";
}
}
private static void AddSample(ref string sample, string value)
{
if (sample.Length >= 240)
{
return;
}
if (sample.Length > 0)
{
sample += "; ";
}
sample += value;
}
private static float ElapsedMs(long started) =>
(float)((Stopwatch.GetTimestamp() - started) * 1000.0 / Stopwatch.Frequency);
}

View file

@ -27,6 +27,7 @@ public static class LifeSagaRail
private static RectTransform _rowRt; private static RectTransform _rowRt;
private static string _fingerprint = ""; private static string _fingerprint = "";
private static float _nextFaceRefreshAt; private static float _nextFaceRefreshAt;
private static int _retryCursor;
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary> /// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
public static string LastTipSample { get; private set; } = ""; public static string LastTipSample { get; private set; } = "";
@ -110,6 +111,10 @@ public static class LifeSagaRail
public static int LastShownCount { get; private set; } public static int LastShownCount { get; private set; }
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary> /// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
public static int CachedFaceCount => LastFaces.Count; public static int CachedFaceCount => LastFaces.Count;
/// <summary>Harness/telemetry: bounded inactive live-capture attempts.</summary>
public static int LiveCaptureRetryCount { get; private set; }
public static float LastLiveCaptureMs { get; private set; }
public static float MaxLiveCaptureMs { get; private set; }
public static void EnsureBuilt(Transform parent) public static void EnsureBuilt(Transform parent)
{ {
@ -143,6 +148,10 @@ public static class LifeSagaRail
LastPreferPipVisible = false; LastPreferPipVisible = false;
LastHoverRowCount = 0; LastHoverRowCount = 0;
LastHoverHeight = 0f; LastHoverHeight = 0f;
_retryCursor = 0;
LiveCaptureRetryCount = 0;
LastLiveCaptureMs = 0f;
MaxLiveCaptureMs = 0f;
LifeSagaViewController.CancelPreviewAndPause(); LifeSagaViewController.CancelPreviewAndPause();
if (_row != null) if (_row != null)
{ {
@ -269,6 +278,7 @@ public static class LifeSagaRail
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize); _rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0); _row.SetActive(LastShownCount > 0);
RetryOneMissingLiveFace(watchId);
} }
else else
{ {
@ -287,6 +297,12 @@ public static class LifeSagaRail
ApplyFace(Slots[i], SlotScratch[i], refreshLive: true); ApplyFace(Slots[i], SlotScratch[i], refreshLive: true);
} }
// A unit may enter the roster while its render frame is unavailable (egg,
// atlas not ready, transient tiny crop). Retry at most one inactive missing
// capture per one-second rail cadence; round-robin prevents an egg from
// starving later slots. Species fallbacks are display-only and never cached.
RetryOneMissingLiveFace(watchId);
} }
if (LifeSagaViewController.IsHoverPreview) if (LifeSagaViewController.IsHoverPreview)
@ -482,30 +498,7 @@ public static class LifeSagaRail
return; return;
} }
Actor actor = saga.Resolve(); Sprite sprite = ResolveFace(saga, refreshLive);
Sprite sprite = null;
if (!refreshLive)
{
LastFaces.TryGetValue(saga.UnitId, out sprite);
}
// Capture every character once, then refresh only the watched glyph. When focus
// leaves, its last phenotype/kingdom-tinted frame remains instead of reverting
// to a shared species icon.
if (refreshLive || !HudIcons.IsUsableRailFace(sprite))
{
Sprite live = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
if (HudIcons.IsUsableRailFace(live))
{
sprite = live;
LastFaces[saga.UnitId] = live;
}
}
if (!HudIcons.IsUsableRailFace(sprite))
{
sprite = HudIcons.FromSpeciesId(saga.SpeciesId);
}
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null) if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
{ {
@ -545,6 +538,144 @@ public static class LifeSagaRail
} }
} }
private static Sprite ResolveFace(LifeSagaSlot saga, bool refreshLive)
{
if (saga == null || saga.UnitId == 0)
{
return null;
}
LastFaces.TryGetValue(saga.UnitId, out Sprite sprite);
// Capture every character once, then refresh only the watched glyph. A failed
// refresh never discards the last good phenotype/kingdom-tinted frame.
if (refreshLive)
{
long started = System.Diagnostics.Stopwatch.GetTimestamp();
Sprite live = HudIcons.FromActorLiveRailFace(saga.Resolve());
long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started;
LastLiveCaptureMs = (float)(
elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency);
MaxLiveCaptureMs = Mathf.Max(MaxLiveCaptureMs, LastLiveCaptureMs);
if (HudIcons.IsUsableRailFace(live))
{
sprite = live;
LastFaces[saga.UnitId] = live;
}
}
// Display fallback only. Do not let it masquerade as a retained live frame.
return HudIcons.IsUsableRailFace(sprite)
? sprite
: HudIcons.FromSpeciesId(saga.SpeciesId);
}
private static void RetryOneMissingLiveFace(long watchId)
{
int count = Mathf.Min(Slots.Length, SlotScratch.Count);
if (count <= 0)
{
return;
}
_retryCursor = Mathf.Clamp(_retryCursor, 0, count - 1);
for (int offset = 0; offset < count; offset++)
{
int index = (_retryCursor + offset) % count;
LifeSagaSlot saga = SlotScratch[index];
Slot slot = Slots[index];
if (saga == null
|| slot == null
|| !slot.Root.activeSelf
|| saga.UnitId == 0
|| saga.UnitId == watchId
|| LastFaces.ContainsKey(saga.UnitId))
{
continue;
}
Actor actor = saga.Resolve();
if (actor == null || !actor.isAlive())
{
continue;
}
ApplyFace(slot, saga, refreshLive: true);
LiveCaptureRetryCount++;
_retryCursor = (index + 1) % count;
return;
}
}
public static bool HarnessProbeFallbackCacheIsolation(out string detail)
{
const long syntheticId = -9100001;
int before = LastFaces.Count;
LastFaces.Remove(syntheticId);
var synthetic = new LifeSagaSlot
{
UnitId = syntheticId,
SpeciesId = "human"
};
Sprite fallback = ResolveFace(synthetic, refreshLive: true);
bool fallbackShown = HudIcons.IsUsableRailFace(fallback);
bool cachedFallback = LastFaces.ContainsKey(syntheticId);
// A retained frame must survive a later failed/dead refresh.
Sprite retainedSeed = HudIcons.FromSpeciesId("human");
bool retained = false;
if (HudIcons.IsUsableRailFace(retainedSeed))
{
LastFaces[syntheticId] = retainedSeed;
retained = ResolveFace(synthetic, refreshLive: true) == retainedSeed;
}
LastFaces.Remove(syntheticId);
bool countRestored = LastFaces.Count == before;
bool pass = fallbackShown && !cachedFallback && retained && countRestored;
detail =
$"fallbackShown={fallbackShown} cachedFallback={cachedFallback} "
+ $"retained={retained} count={before}/{LastFaces.Count} pass={pass}";
return pass;
}
public static bool HarnessProbeFocusUsesDistinctLiveFrame(out string detail)
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
LastFaces.TryGetValue(id, out Sprite cached);
Sprite species = focus != null ? HudIcons.FromActor(focus) : null;
bool cachedUsable = HudIcons.IsUsableRailFace(cached);
bool distinct = cachedUsable
&& species != null
&& !HudIcons.SameSpriteSource(cached, species);
bool generated = HudIcons.MatchesGeneratedActorFrame(focus, cached);
detail =
$"id={id} asset={focus?.asset?.id} cached={DescribeSprite(cached)} "
+ $"species={DescribeSprite(species)} distinct={distinct} generated={generated} "
+ $"captureMs={LastLiveCaptureMs:0.00}/{MaxLiveCaptureMs:0.00} "
+ HudIcons.DescribeActorRailSources(focus);
return distinct && generated;
}
private static string DescribeSprite(Sprite sprite)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0");
}
catch
{
return "invalid";
}
}
private static void PruneFaceCache() private static void PruneFaceCache()
{ {
var keep = new HashSet<long>(); var keep = new HashSet<long>();

View file

@ -188,6 +188,13 @@ Recognition should come from repeated, stable signals rather than extra prose on
- Keep rail position stable for an incumbent whenever possible. - Keep rail position stable for an incumbent whenever possible.
- Keep each unit's latest unique portrait cached for the duration of roster membership and through a - Keep each unit's latest unique portrait cached for the duration of roster membership and through a
death/Legacy presentation. death/Legacy presentation.
- Treat species icons as temporary display fallbacks, never cached portraits; retry one missing
inactive live frame per second so eggs/render-not-ready slots eventually acquire their own look.
- For creature glyphs, capture the palette-resolved generated current animation frame, falling
back to the raw frame only when generation fails, and reject frames that resolve to the shared
species-icon atlas region. Never accept the creature inspect composite as a live frame because
WorldBox can copy the standing species portrait into a new texture. Preserve genuine tiny
animation frames (down to the 3x2 fly/beetle family).
- On a meaningful return after the existing reentry threshold, allow one concise reintroduction: - On a meaningful return after the existing reentry threshold, allow one concise reintroduction:
`Name again · <new development>`. `Name again · <new development>`.
- Do not use reintroduction wording for rapid A → B → A cuts, unchanged activity, or texture. - Do not use reintroduction wording for rapid A → B → A cuts, unchanged activity, or texture.
@ -533,6 +540,8 @@ Add or update these scenarios:
- `saga_five_refill_bounded` — no-op, challenger, and pin-overflow refills remain bounded and do not - `saga_five_refill_bounded` — no-op, challenger, and pin-overflow refills remain bounded and do not
start an extra world scan. start an extra world scan.
- `saga_five_ui_noop_perf` — stable rail frames do not relayout or recapture unchanged portraits. - `saga_five_ui_noop_perf` — stable rail frames do not relayout or recapture unchanged portraits.
- `saga_rail_species_frame_sweep` — amortized exhaustive actor-library sweep proves every standard
renderer resolves to a live frame; separate special-renderer assets are reported explicitly.
- `saga_five_perf_health` — hitch probe plus scheduler/roster metrics stay inside the release - `saga_five_perf_health` — hitch probe plus scheduler/roster metrics stay inside the release
thresholds during a directed developed-world sample. thresholds during a directed developed-world sample.

View file

@ -102,9 +102,23 @@ portrait keeps the same body anchor used by Saga hover.
## Rail UX ## Rail UX
- Up to 10 slots with unit-keyed inspect-UI portraits. Each character is captured on first - Up to 5 slots with unit-keyed inspect-UI portraits. Each character is captured on first
display, refreshed while watched, and retains that latest unique frame after focus leaves. display, refreshed while watched, and retains that latest unique frame after focus leaves.
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last). - Egg forms and tiny/blank atlas crops temporarily fall back to the species icon (letter last).
Fallbacks are never stored as live snapshots; one uncaptured inactive slot retries per second,
round-robin, until a valid live frame can be retained through focus changes and death.
- Non-civilized creatures prefer the palette-resolved generated frame from their current
animation (`calculateColoredSprite(calculateMainSprite())`) over the raw atlas frame. This avoids
palette-key colors while still bypassing shared species icons. Their inspect-UI composite is
never accepted because WorldBox may synthesize a new texture containing the standing species
portrait, which cannot be caught by atlas-region identity. Any candidate pointing at the species
icon's atlas region is also rejected as a live capture.
- Live-frame usability is based on non-degenerate area, not an 8x8 square floor: short horizontal
animations such as fox movement frames and genuine 3x2 fly/beetle frames remain valid.
`saga_rail_species_frame_sweep` checks every registered asset one per frame, temporarily spawning
and immediately cleaning up missing representatives, and records the exhaustive results in
`saga-rail-species-frames.tsv`. Assets that only expose a separate special renderer rather than
an actor sprite (currently Crabzilla) are recorded as not applicable and keep the species icon.
- Active chrome = camera follow MC; Involved chrome = other tip-touched MCs (subject/related/follow/pair/short-arc cast only - not mass `ParticipantIds`). - Active chrome = camera follow MC; Involved chrome = other tip-touched MCs (subject/related/follow/pair/short-arc cast only - not mass `ParticipantIds`).
- Prefer uses a star pip / distinct border. - Prefer uses a star pip / distinct border.
- Click toggles saga Prefer only (does not mutate WorldBox unit favorite; does not pin a Saga subject). - Click toggles saga Prefer only (does not mutate WorldBox unit favorite; does not pin a Saga subject).
@ -206,6 +220,8 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- Hover fitting reads its own display headline rather than the camera dossier's cached identity; - 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. 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. - 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.
- Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel. - 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. - RelationEvidence caches invalidate on memory revision + live lover/friend change.
- Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the - Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the