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:
parent
96db725383
commit
529d18dfa4
7 changed files with 1033 additions and 50 deletions
|
|
@ -4184,6 +4184,48 @@ public static class AgentHarness
|
|||
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":
|
||||
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
|
||||
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
|
||||
|
|
@ -4275,13 +4317,6 @@ public static class AgentHarness
|
|||
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);
|
||||
if (slots.Count > 0 && slots[0] != null)
|
||||
{
|
||||
ok = slots[0].Prefer || preferBefore;
|
||||
}
|
||||
|
||||
if (ok)
|
||||
{
|
||||
|
|
@ -10088,6 +10123,22 @@ public static class AgentHarness
|
|||
detail = $"cachedFaces={have} want={want} mode={(atLeast ? "min" : "exact")} shown={LifeSagaRail.LastShownCount}";
|
||||
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":
|
||||
{
|
||||
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
||||
|
|
@ -15609,6 +15660,7 @@ public static class AgentHarness
|
|||
_rememberedFocusKey = "";
|
||||
_rememberedTip = "";
|
||||
_rememberedRelatedId = 0;
|
||||
RailFaceCatalogAudit.Cancel();
|
||||
InterestDirector.SetHarnessFastTiming(false);
|
||||
SpeciesDiscovery.SetHarnessFastTiming(false);
|
||||
try
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ internal static class HarnessScenarios
|
|||
return SagaReplaceWeaker();
|
||||
case "saga_rail_active_highlight":
|
||||
return SagaRailActiveHighlight();
|
||||
case "saga_rail_live_animal_frame":
|
||||
return SagaRailLiveAnimalFrame();
|
||||
case "saga_rail_prefer_click":
|
||||
return SagaRailPreferClick();
|
||||
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("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
|
||||
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("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>
|
||||
private static List<HarnessCommand> SagaRailPreferClick()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -142,13 +142,16 @@ public static class HudIcons
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readable saga-rail face: live inspect sprite when usable, else species icon.
|
||||
/// Skips egg forms and near-empty tiny crops that read as a single pixel.
|
||||
/// Actor-specific saga-rail frame only. Returns null for eggs, dead actors, or
|
||||
/// unusable render frames; callers decide whether to display a temporary fallback.
|
||||
/// </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())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool egg = false;
|
||||
try
|
||||
{
|
||||
|
|
@ -159,22 +162,105 @@ public static class HudIcons
|
|||
egg = false;
|
||||
}
|
||||
|
||||
if (!egg)
|
||||
if (egg)
|
||||
{
|
||||
Sprite live = FromActorLive(actor);
|
||||
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
|
||||
{
|
||||
// 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
|
||||
{
|
||||
colored = null;
|
||||
}
|
||||
|
||||
Sprite ui = null;
|
||||
if (civilized)
|
||||
{
|
||||
try
|
||||
{
|
||||
ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main);
|
||||
}
|
||||
catch
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
Sprite asset = FromActor(actor);
|
||||
if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset))
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string species = speciesFallbackId;
|
||||
if (string.IsNullOrEmpty(species) && actor?.asset != null)
|
||||
{
|
||||
|
|
@ -193,10 +279,12 @@ public static class HudIcons
|
|||
|
||||
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 h = sprite.rect.height;
|
||||
if (w < 8f || h < 8f)
|
||||
if (w < 2f || h < 2f || (w * h) < 4f)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (sprite == null)
|
||||
|
|
|
|||
509
IdleSpectator/RailFaceCatalogAudit.cs
Normal file
509
IdleSpectator/RailFaceCatalogAudit.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ public static class LifeSagaRail
|
|||
private static RectTransform _rowRt;
|
||||
private static string _fingerprint = "";
|
||||
private static float _nextFaceRefreshAt;
|
||||
private static int _retryCursor;
|
||||
|
||||
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
|
||||
public static string LastTipSample { get; private set; } = "";
|
||||
|
|
@ -110,6 +111,10 @@ public static class LifeSagaRail
|
|||
public static int LastShownCount { get; private set; }
|
||||
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
|
||||
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)
|
||||
{
|
||||
|
|
@ -143,6 +148,10 @@ public static class LifeSagaRail
|
|||
LastPreferPipVisible = false;
|
||||
LastHoverRowCount = 0;
|
||||
LastHoverHeight = 0f;
|
||||
_retryCursor = 0;
|
||||
LiveCaptureRetryCount = 0;
|
||||
LastLiveCaptureMs = 0f;
|
||||
MaxLiveCaptureMs = 0f;
|
||||
LifeSagaViewController.CancelPreviewAndPause();
|
||||
if (_row != null)
|
||||
{
|
||||
|
|
@ -269,6 +278,7 @@ public static class LifeSagaRail
|
|||
|
||||
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
|
||||
_row.SetActive(LastShownCount > 0);
|
||||
RetryOneMissingLiveFace(watchId);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -287,6 +297,12 @@ public static class LifeSagaRail
|
|||
|
||||
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)
|
||||
|
|
@ -482,30 +498,7 @@ public static class LifeSagaRail
|
|||
return;
|
||||
}
|
||||
|
||||
Actor actor = saga.Resolve();
|
||||
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);
|
||||
}
|
||||
Sprite sprite = ResolveFace(saga, refreshLive);
|
||||
|
||||
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()
|
||||
{
|
||||
var keep = new HashSet<long>();
|
||||
|
|
|
|||
|
|
@ -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 each unit's latest unique portrait cached for the duration of roster membership and through a
|
||||
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:
|
||||
`Name again · <new development>`.
|
||||
- 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
|
||||
start an extra world scan.
|
||||
- `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
|
||||
thresholds during a directed developed-world sample.
|
||||
|
||||
|
|
|
|||
|
|
@ -102,9 +102,23 @@ portrait keeps the same body anchor used by Saga hover.
|
|||
|
||||
## 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.
|
||||
- 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`).
|
||||
- Prefer uses a star pip / distinct border.
|
||||
- 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;
|
||||
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.
|
||||
- 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.
|
||||
- RelationEvidence caches invalidate on memory revision + live lover/friend change.
|
||||
- Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the
|
||||
|
|
|
|||
Loading…
Reference in a new issue