- 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
509 lines
16 KiB
C#
509 lines
16 KiB
C#
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);
|
|
}
|