2026-10-18 00:29:04 +02:00
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 ( ) ;
2026-10-21 19:01:41 +02:00
var items = new ItemManager ( 1 u , cache , new ProvenanceLedger ( ) , settings , _ = > { } ) ;
2026-10-18 00:29:04 +02:00
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 ;
2026-10-18 00:34:50 +02:00
var beltB = cache . Upsert ( new AsteroidBeltData { Orbit = new CultRecordRef < OrbitData > ( beltOrbitB ) , Asteroids = Rocks ( 24 , i = > ( i * 5 % 24 ) / 23f , 60f ) } ) . Key ;
2026-10-18 00:29:04 +02:00
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 ) ; }
}
}
}