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); }