- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
16 KiB
C#
265 lines
16 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using GameCult.Caching;
|
|
using UnityEditor;
|
|
using UnityEditor.SceneManagement;
|
|
using UnityEngine;
|
|
using float2 = CultMath.float2;
|
|
using float4 = CultMath.float4;
|
|
|
|
// End-to-end proof of the belt draw (rulings asteroid-tops-at-layer, rock-meshes-by-size), measured where it fails: two belts are loaded
|
|
// into a ZoneRenderer carrying ARPG.unity's real AsteroidMeshes (meshes, stored radii) and Settings.asset, over a zone whose wells make the
|
|
// field differ rock to rock, and ZoneRenderer.DrawBelts runs as Update runs it. Every instanced matrix it hands to the GPU is read back with the
|
|
// mesh it is drawn with (ZoneRenderer.DrawInstanced), and for each drawn rock the verify measures, from the mesh's real vertices:
|
|
// - the farthest vertex from the rock's centre after the matrix (the reach, a bound on the rock's top under any rotation) is the rock's
|
|
// gameplay radius, and centre y + reach is the floater layer over the field at the rock's xz and the drawn time;
|
|
// - the rock is drawn exactly once, on exactly one matrix of a real pose (no spare instance in a batch);
|
|
// - within a belt a bigger rock never takes a smaller mesh (by the mesh's own farthest vertex), and the belt keeps min(AsteroidMeshCount, meshes) meshes.
|
|
// The arguments the renderer hands the draw list (radius field, belt, batch size, kept count) are not inspected: their consequence is.
|
|
//
|
|
// Reached: ZoneRenderer.LoadPlanet for each belt, DrawBelts. Not reached: LoadZone's other planets (their prefabs), the GPU draw itself.
|
|
// Needs a GPU device for MapPresenter's belt buffer: run without -nographics.
|
|
//
|
|
// Unity -batchmode -projectPath <project> -executeMethod Aetheria.EditorTools.BeltFloaterLayerVerify.Run -logFile <log>
|
|
//
|
|
// Do not pass -quit: Run enters play mode (a domain reload), the check runs there, and the Editor exits itself, 0 on success and 1 on any failure.
|
|
namespace Aetheria.EditorTools
|
|
{
|
|
[InitializeOnLoad]
|
|
public static class BeltFloaterLayerVerify
|
|
{
|
|
private const string Tag = "[BeltFloaterLayerVerify]";
|
|
private const string Flag = "BeltFloaterLayerVerify.running";
|
|
private const string Wiring = "BeltFloaterLayerVerify.wiring";
|
|
private const double Budget = 300; // real seconds
|
|
private const float Lead = .008f, Layer = -1.5f, Tolerance = .02f;
|
|
private static IEnumerator _check;
|
|
private static double _started;
|
|
private static string _work;
|
|
|
|
static BeltFloaterLayerVerify()
|
|
{
|
|
if (SessionState.GetBool(Flag, false)) EditorApplication.playModeStateChanged += OnPlayMode;
|
|
}
|
|
|
|
public static void Run()
|
|
{
|
|
try
|
|
{
|
|
SessionState.SetString(Wiring, ReadWiring());
|
|
SessionState.SetBool(Flag, true);
|
|
EditorApplication.EnterPlaymode();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogException(exception);
|
|
EditorApplication.Exit(1);
|
|
}
|
|
}
|
|
|
|
private static void OnPlayMode(PlayModeStateChange change)
|
|
{
|
|
if (change != PlayModeStateChange.EnteredPlayMode) return;
|
|
_work = Path.Combine(Path.GetTempPath(), $"belt-floater-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_work);
|
|
_started = Time.realtimeSinceStartupAsDouble;
|
|
_check = Check();
|
|
EditorApplication.update += Step;
|
|
}
|
|
|
|
private static void Step()
|
|
{
|
|
try
|
|
{
|
|
if (Time.realtimeSinceStartupAsDouble - _started > Budget) throw new InvalidOperationException($"The check did not finish in {Budget} seconds.");
|
|
if (_check.MoveNext()) return;
|
|
Finish(0);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogException(exception);
|
|
Finish(1);
|
|
}
|
|
}
|
|
|
|
private static void Finish(int code)
|
|
{
|
|
EditorApplication.update -= Step;
|
|
SessionState.SetBool(Flag, false);
|
|
try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch (Exception) { /* a temp directory */ }
|
|
EditorApplication.Exit(code);
|
|
}
|
|
|
|
private static void Require(bool condition, string what)
|
|
{
|
|
if (!condition) throw new InvalidOperationException(what);
|
|
}
|
|
|
|
// A belt of rocks of every size, its sizes in the given order, round an orbit about the planet.
|
|
private static Asteroid[] Rocks(int count, Func<int, float> size, float start) => Enumerable.Range(0, count).Select(i => new Asteroid
|
|
{
|
|
Distance = start + i * 9.7f, Phase = Mathf.Repeat(i * .6180339887f, 1f), Size = size(i), RotationSpeed = .1f + i * .013f
|
|
}).ToArray();
|
|
|
|
private static IEnumerator Check()
|
|
{
|
|
var game = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset") ?? throw new InvalidOperationException("No Settings.asset.");
|
|
var settings = game.GameplaySettings;
|
|
settings.FloaterLayerHeight = Layer; // in memory, never saved back to the asset: a draw that drops the layer fails
|
|
|
|
var meshes = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('~')).Select(p => new InstancedMesh
|
|
{
|
|
Mesh = AssetDatabase.LoadAllAssetsAtPath(p[0]).OfType<Mesh>().First(m => m.name == p[1]),
|
|
Material = p[2] == "" ? null : AssetDatabase.LoadAssetAtPath<Material>(p[2]),
|
|
Radius = float.Parse(p[3], System.Globalization.CultureInfo.InvariantCulture)
|
|
}).ToArray();
|
|
Require(meshes.Length > game.AsteroidMeshCount, $"ARPG.unity has {meshes.Length} rock meshes for a kept count of {game.AsteroidMeshCount}: nothing to prune.");
|
|
|
|
var cache = AetheriaStores.Open(Path.Combine(_work, "belts.cc"), Path.Combine(_work, "belts-run.cc"), catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
cache.FlushAsync().Wait();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
|
|
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
|
|
{
|
|
var data = new OrbitData { Distance = distance, Phase = phase, FixedPosition = fixedPosition };
|
|
if (parent.IsSet()) data.Parent = new CultRecordRef<OrbitData>(parent);
|
|
return cache.Upsert(data).Key;
|
|
}
|
|
|
|
// A heavy sun and a planet on a moving rail, the belts round the planet: the field under a rock differs from rock to rock.
|
|
var sunOrbit = Orbit(default, 0f, 0f, new float2(700f, -300f));
|
|
var planetOrbit = Orbit(sunOrbit, 600f, .3f);
|
|
var beltOrbitA = Orbit(planetOrbit, 150f, .1f);
|
|
var beltOrbitB = Orbit(planetOrbit, 300f, .6f);
|
|
var beltA = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitA), Asteroids = Rocks(24, i => (i * 7 % 24) / 23f, 40f) }).Key;
|
|
var beltB = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitB), Asteroids = Rocks(24, i => (i * 5 % 24) / 23f, 60f) }).Key;
|
|
var pack = new ZonePack { Radius = 5000f, Mass = 10000f };
|
|
foreach (var orbit in new[] { sunOrbit, planetOrbit, beltOrbitA, beltOrbitB }) pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
|
|
pack.Planets.Add(new CultRecordRef<BodyData>(cache.Upsert(new PlanetData { Name = "Belt Sun", Orbit = new CultRecordRef<OrbitData>(sunOrbit), Mass = 400f }).Key));
|
|
pack.Planets.Add(new CultRecordRef<BodyData>(cache.Upsert(new PlanetData { Name = "Belt Planet", Orbit = new CultRecordRef<OrbitData>(planetOrbit), Mass = 400f }).Key));
|
|
pack.Planets.Add(new CultRecordRef<BodyData>(beltA));
|
|
pack.Planets.Add(new CultRecordRef<BodyData>(beltB));
|
|
var curves = new PlanetSettings
|
|
{
|
|
BodyRadius = new ExponentialCurve(),
|
|
WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },
|
|
RocheLimit = new ExponentialCurve { Constant = 10f },
|
|
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
|
|
AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f },
|
|
AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f }
|
|
};
|
|
var zone = new Zone(items, curves, pack, null, null);
|
|
zone.Update(.5f);
|
|
|
|
// The renderer, configured as the scene configures it for belts: Awake builds the map, LoadPlanet reads Zone, Settings and AsteroidMeshes.
|
|
var rendererObject = new GameObject("Belt Zone Renderer");
|
|
rendererObject.SetActive(false);
|
|
var renderer = rendererObject.AddComponent<ZoneRenderer>();
|
|
renderer.Settings = game;
|
|
renderer.AsteroidMeshes = meshes;
|
|
renderer.ItemManager = items;
|
|
renderer.LODHandler = new GameObject("Belt LOD").AddComponent<LODHandler>();
|
|
const BindingFlags Any = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
|
|
typeof(ZoneRenderer).GetProperty("Zone").GetSetMethod(true).Invoke(renderer, new object[] { zone });
|
|
typeof(ZoneRenderer).GetMethod("Awake", Any).Invoke(renderer, null);
|
|
var load = typeof(ZoneRenderer).GetMethod("LoadPlanet", Any);
|
|
foreach (var belt in new[] { beltA, beltB }) load.Invoke(renderer, new object[] { belt, zone.Planets[belt] });
|
|
|
|
var planetAt = DrawAhead.Orbit(zone, planetOrbit, Lead);
|
|
var camera = new GameObject("Belt Camera").AddComponent<Camera>();
|
|
camera.transform.position = new Vector3(planetAt.x, 4000f, planetAt.y);
|
|
camera.transform.rotation = Quaternion.Euler(90, 0, 0);
|
|
camera.farClipPlane = 20000f;
|
|
renderer.MainCamera = camera;
|
|
yield return null;
|
|
|
|
ActionGameManager.Clock.Reset();
|
|
ActionGameManager.Clock.Advance(Lead, _ => throw new InvalidOperationException("a step ran"));
|
|
var lead = ActionGameManager.Clock.Lead;
|
|
Require(Mathf.Abs(lead - Lead) < 1e-6f, $"The clock's lead is {lead}, not {Lead}.");
|
|
|
|
var drawn = new List<(Mesh mesh, Matrix4x4[] matrices)>();
|
|
renderer.DrawInstanced = (mesh, _, matrices) => drawn.Add((mesh, (Matrix4x4[]) matrices.Clone()));
|
|
renderer.DrawBelts(lead);
|
|
|
|
// The poses the sim defines at the drawn time: every rock of both belts.
|
|
var rocks = new List<(CultRecordKey belt, int index, float4 pose)>();
|
|
foreach (var belt in new[] { beltA, beltB })
|
|
{
|
|
var poses = new float4[((AsteroidBeltData) zone.Planets[belt]).Asteroids.Length];
|
|
zone.EvaluateBelt(belt, poses, DrawAhead.Time(zone, lead));
|
|
for (var i = 0; i < poses.Length; i++) rocks.Add((belt, i, poses[i]));
|
|
}
|
|
Require(drawn.Count > 0, "DrawBelts drew nothing: the belts are out of the camera or have no rocks.");
|
|
|
|
// The field differs between rocks, so a draw that drops it or samples it at the wrong place fails.
|
|
var layers = rocks.Select(r => DrawAhead.FloaterHeight(zone, settings, r.pose.xy, lead)).ToArray();
|
|
Require(layers.Max() - layers.Min() > 1f, $"The fixture's field is flat under the rocks: layer heights span {layers.Max() - layers.Min()}.");
|
|
|
|
var seen = new Dictionary<(CultRecordKey, int), float>(); // rock -> its mesh's own farthest vertex
|
|
var vertices = new Dictionary<Mesh, Vector3[]>();
|
|
foreach (var (mesh, matrices) in drawn)
|
|
{
|
|
if (!vertices.TryGetValue(mesh, out var v)) vertices[mesh] = v = mesh.vertices;
|
|
var own = Mathf.Sqrt(v.Max(x => x.sqrMagnitude));
|
|
foreach (var matrix in matrices)
|
|
{
|
|
var centre = (Vector3) matrix.GetColumn(3);
|
|
var at = rocks.Where(r => Mathf.Abs(r.pose.x - centre.x) < .01f && Mathf.Abs(r.pose.y - centre.z) < .01f).ToArray();
|
|
Require(at.Length == 1, $"An instance of {mesh.name} sits at {centre.x},{centre.z}, where {at.Length} rocks are (a spare or misplaced instance).");
|
|
var rock = at[0];
|
|
Require(seen.TryAdd((rock.belt, rock.index), own), $"Rock {rock.index} of {rock.belt} is drawn twice.");
|
|
var reach = v.Max(x => (matrix.MultiplyPoint3x4(x) - centre).magnitude);
|
|
Require(Mathf.Abs(reach - rock.pose.w) < Tolerance * Mathf.Max(1f, rock.pose.w),
|
|
$"Rock {rock.index} of {rock.belt} on {mesh.name}: its farthest vertex is {reach} from its centre, its gameplay radius {rock.pose.w}.");
|
|
var top = centre.y + reach;
|
|
var want = DrawAhead.FloaterHeight(zone, settings, rock.pose.xy, lead);
|
|
Require(Mathf.Abs(top - want) < Tolerance,
|
|
$"Rock {rock.index} of {rock.belt} on {mesh.name}: its top is {top}, the floater layer over the field there {want}.");
|
|
}
|
|
}
|
|
Require(seen.Count == rocks.Count, $"{seen.Count} of {rocks.Count} rocks were drawn.");
|
|
|
|
foreach (var belt in new[] { beltA, beltB })
|
|
{
|
|
var mine = rocks.Where(r => r.belt.Equals(belt)).ToArray();
|
|
var radii = mine.Select(r => seen[(belt, r.index)]).Distinct().OrderBy(x => x).ToArray();
|
|
Require(radii.Length >= 2, $"Belt {belt} is drawn on {radii.Length} distinct mesh radii: the size match is not exercised.");
|
|
var kept = drawn.Where(d => d.matrices.Any(m => mine.Any(r => Mathf.Abs(r.pose.x - m.m03) < .01f && Mathf.Abs(r.pose.y - m.m23) < .01f))).Select(d => d.mesh).Distinct().Count();
|
|
Require(kept == Mathf.Min(game.AsteroidMeshCount, meshes.Length), $"Belt {belt} is drawn on {kept} meshes, not the kept count {game.AsteroidMeshCount}.");
|
|
var size = mine.ToDictionary(r => r.index, r => zone.AsteroidBelts[belt].UndamagedSize(r.index, zone.Settings));
|
|
foreach (var a in mine)
|
|
foreach (var b in mine)
|
|
if (size[a.index] < size[b.index])
|
|
Require(seen[(belt, a.index)] <= seen[(belt, b.index)],
|
|
$"Belt {belt}: rock {a.index} (size {size[a.index]}) is smaller than rock {b.index} ({size[b.index]}) and takes a bigger mesh ({seen[(belt, a.index)]} against {seen[(belt, b.index)]}).");
|
|
var biggest = mine.OrderByDescending(r => size[r.index]).First();
|
|
Require(Mathf.Approximately(seen[(belt, biggest.index)], radii[^1]), $"Belt {belt}: its biggest rock is not on its biggest mesh.");
|
|
}
|
|
ActionGameManager.Clock.Reset();
|
|
Debug.Log($"{Tag} ok: {rocks.Count} rocks of 2 belts drawn at the floater layer, big rocks on big meshes ({drawn.Count} batches)");
|
|
}
|
|
|
|
// The AsteroidMeshes ARPG.unity's ZoneRenderer names, read before the play session (a scene cannot be opened inside one) and carried across the
|
|
// domain reload as asset paths: mesh path, mesh name, material path, stored radius.
|
|
private static string ReadWiring()
|
|
{
|
|
var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Additive);
|
|
try
|
|
{
|
|
var found = UnityEngine.Object.FindObjectsByType<ZoneRenderer>(FindObjectsInactive.Include, FindObjectsSortMode.None)
|
|
.Where(r => r.gameObject.scene == scene).ToArray();
|
|
Require(found.Length == 1, $"ARPG.unity has {found.Length} ZoneRenderers.");
|
|
var meshes = found[0].AsteroidMeshes ?? throw new InvalidOperationException("ARPG.unity's ZoneRenderer has no AsteroidMeshes.");
|
|
return string.Join("|", meshes.Select(m => string.Join("~", AssetDatabase.GetAssetPath(m.Mesh), m.Mesh.name,
|
|
m.Material == null ? "" : AssetDatabase.GetAssetPath(m.Material), m.Radius.ToString("R", System.Globalization.CultureInfo.InvariantCulture))));
|
|
}
|
|
finally { EditorSceneManager.CloseScene(scene, true); }
|
|
}
|
|
}
|
|
}
|