diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 0e434fe..8864c69 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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(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 diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 5fd1675..2a21350 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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"), }; } + /// + /// Tall and short creature glyphs retain animation frames, then the full living + /// species catalog is checked under the same extraction policy. + /// + private static List SagaRailLiveAnimalFrame() + { + return new List + { + 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"), + }; + } + /// Prefer toggle flips on then off with immediate pip state. private static List SagaRailPreferClick() { diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs index d71bf73..e8f6457 100644 --- a/IdleSpectator/HudIcons.cs +++ b/IdleSpectator/HudIcons.cs @@ -142,37 +142,123 @@ public static class HudIcons } /// - /// 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. /// - 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 { - 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 { - egg = false; + colored = null; } - if (!egg) + Sprite ui = null; + if (civilized) { - Sprite live = FromActorLive(actor); - if (IsUsableRailFace(live)) + try { - return live; + ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main); } - - Sprite asset = FromActor(actor); - if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset)) + catch { - 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; + } + } + + /// + /// 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. + /// + public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null) + { + Sprite live = FromActorLiveRailFace(actor); + if (IsUsableRailFace(live)) + { + return live; } string species = speciesFallbackId; @@ -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 } } + /// True when two sprites point at the same atlas region, even as different objects. + 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; + } + } + + /// Harness diagnostic for WorldBox's competing actor-frame sources. + 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; + } + } + + /// Harness: retained frame matches the actor's palette/UI-generated live frame. + 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) diff --git a/IdleSpectator/RailFaceCatalogAudit.cs b/IdleSpectator/RailFaceCatalogAudit.cs new file mode 100644 index 0000000..01273f1 --- /dev/null +++ b/IdleSpectator/RailFaceCatalogAudit.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// 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. +/// +public static class RailFaceCatalogAudit +{ + private static readonly List Assets = new List(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; } + + /// + /// Starts an exhaustive, one-asset-per-frame sweep. Missing representatives are + /// temporarily spawned and immediately destroyed with AttackType.None. + /// + 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; + } + + /// Processes one registered asset so test-only creation is amortized by frame. + 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); +} diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs index 1a00b25..4d83a3d 100644 --- a/IdleSpectator/Story/LifeSagaRail.cs +++ b/IdleSpectator/Story/LifeSagaRail.cs @@ -27,6 +27,7 @@ public static class LifeSagaRail private static RectTransform _rowRt; private static string _fingerprint = ""; private static float _nextFaceRefreshAt; + private static int _retryCursor; /// Harness: tip text of the first visible rail slot after last Refresh. public static string LastTipSample { get; private set; } = ""; @@ -110,6 +111,10 @@ public static class LifeSagaRail public static int LastShownCount { get; private set; } /// Harness: unit-keyed last-face snapshots retained for the current roster. public static int CachedFaceCount => LastFaces.Count; + /// Harness/telemetry: bounded inactive live-capture attempts. + 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(); diff --git a/docs/five-mc-ensemble-plan.md b/docs/five-mc-ensemble-plan.md index 1b15730..6aac167 100644 --- a/docs/five-mc-ensemble-plan.md +++ b/docs/five-mc-ensemble-plan.md @@ -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 · `. - 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. diff --git a/docs/life-saga.md b/docs/life-saga.md index 2aa1485..63136da 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -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