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