2026-10-16 00:50:44 +02:00
using System ;
using System.Collections ;
using System.IO ;
using System.Linq ;
using GameCult.Caching ;
using UnityEditor ;
using UnityEditor.SceneManagement ;
using UnityEngine ;
using float2 = CultMath . float2 ;
using float3 = CultMath . float3 ;
// Batch proof that the presenters read the drawn pose through the clock: DrawnPoseTests pins the pose maths headlessly,
2026-10-17 14:14:05 +02:00
// and this is what pins that each presenter passes ActionGameManager.Clock.Lead into it. A zone with a sun and a planet on a moving rail
// is built and the pickup and mine prefabs ARPG.unity's ZoneRenderer names are bound to it as the renderer binds them; a loose Projectile is launched on the
2026-10-16 00:50:44 +02:00
// zone. The zone is stepped once, then the clock is set to leads 0, 0.008 and 0.015 s with no step, and each presenter's
// own Update places its transform. A body is drawn at its last step plus its step velocity times the lead, spelled here
// from the body's fields, so a presenter that reads lead 0 (or half the lead) sits on the step and fails.
//
// Reached: ItemPickup, MineInstance, Projectile.Draw. Not reached: ZoneRenderer.Update (Lower needs a ComputeBuffer, a
// GPU device) and the ActionGameManager markers (they need the ARPG scene running); a Lower split so its record fill is
// pure, or a run with a GPU and ARPG.unity in play mode, would reach them.
//
// Unity -batchmode -nographics -projectPath <project> -executeMethod Aetheria.EditorTools.DrawnPoseWiringVerify.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 DrawnPoseWiringVerify
{
private const string Tag = "[DrawnPoseWiringVerify]" ;
private const string Flag = "DrawnPoseWiringVerify.running" ;
private const string Wiring = "DrawnPoseWiringVerify.wiring" ;
private const double Budget = 300 ; // real seconds
private const float RoundSpeed = 100f , Tolerance = 1e-3f ;
private static IEnumerator _check ;
private static double _started ;
private static string _work ;
static DrawnPoseWiringVerify ( )
{
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 ( ) , $"drawn-pose-wiring-{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 ) ;
}
private static IEnumerator Check ( )
{
var settings = AssetDatabase . LoadAssetAtPath < GameSettings > ( "Assets/Resources/Settings.asset" ) ? . GameplaySettings
? ? throw new InvalidOperationException ( "No Settings.asset." ) ;
2026-10-16 04:24:43 +02:00
settings . FloaterLayerHeight = - 1.5f ; // nonzero, so a presenter that drops the floater layer fails
2026-10-16 00:50:44 +02:00
var wired = SessionState . GetString ( Wiring , "" ) . Split ( '|' ) . Select ( entry = > entry . Split ( '=' ) ) . ToDictionary ( pair = > pair [ 0 ] , pair = > pair [ 1 ] ) ;
var pickupPrefab = AssetDatabase . LoadAssetAtPath < GameObject > ( wired [ "SimpleCommodityPickup" ] ) ? . GetComponent < ItemPickup > ( ) ;
var minePrefab = AssetDatabase . LoadAssetAtPath < GameObject > ( wired [ "MinePrefab" ] ) ? . GetComponent < MineInstance > ( ) ;
Require ( pickupPrefab ! = null , "ZoneRenderer.SimpleCommodityPickup is not an ItemPickup in ARPG.unity." ) ;
Require ( minePrefab ! = null , "ZoneRenderer.MinePrefab is not a MineInstance in ARPG.unity." ) ;
var oneCell = new Shape ( 1 , 1 ) ;
oneCell [ new CultMath . int2 ( 0 , 0 ) ] = true ;
2026-10-17 13:53:09 +02:00
var cache = AetheriaStores . Open ( Path . Combine ( _work , "wiring.cc" ) , Path . Combine ( _work , "wiring-run.cc" ) , catalogWritable : true ) ;
2026-10-16 00:50:44 +02:00
cache . Upsert ( new VerseGrammar { Revision = 1 } ) ;
cache . Upsert ( new SimpleCommodityData { Name = "Wiring Ore" , Shape = oneCell , MaxStack = 100 } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var ledger = new ProvenanceLedger ( ) ;
2026-10-21 19:01:41 +02:00
var items = new ItemManager ( 1 u , cache , ledger , settings , _ = > { } ) ;
2026-10-17 13:47:52 +02:00
// A sun and a planet on a moving rail round it, both near the bodies below: the field under a floater changes with the
// drawn time, so a presenter that samples it at the step's time, drops it, or reads lead 0 for the height draws a different y.
2026-10-17 14:28:27 +02:00
var sunOrbit = cache . Upsert ( new OrbitData { FixedPosition = new float2 ( - 55 , - . 5f ) } ) . Key ;
2026-10-17 14:23:17 +02:00
var planetOrbit = cache . Upsert ( new OrbitData { Parent = new CultRecordRef < OrbitData > ( sunOrbit ) , Distance = 40f , Phase = 0f } ) . Key ;
2026-10-17 13:47:52 +02:00
var pack = new ZonePack { Radius = 5000f , Mass = 10000f } ;
pack . Orbits . Add ( new CultRecordRef < OrbitData > ( sunOrbit ) ) ;
pack . Orbits . Add ( new CultRecordRef < OrbitData > ( planetOrbit ) ) ;
pack . Planets . Add ( new CultRecordRef < BodyData > ( cache . Upsert ( new PlanetData { Name = "Wiring Sun" , Orbit = new CultRecordRef < OrbitData > ( sunOrbit ) , Mass = 400f } ) . Key ) ) ;
2026-10-17 14:28:27 +02:00
pack . Planets . Add ( new CultRecordRef < BodyData > ( cache . Upsert ( new PlanetData { Name = "Wiring Planet" , Orbit = new CultRecordRef < OrbitData > ( planetOrbit ) , Mass = 400f } ) . Key ) ) ;
2026-10-17 13:47:52 +02:00
var curves = new PlanetSettings
{
BodyRadius = new ExponentialCurve ( ) ,
WellStrength = new ExponentialCurve { Multiplier = 1f , Exponent = 1f } ,
2026-10-17 14:23:17 +02:00
RocheLimit = new ExponentialCurve { Constant = 10f }
2026-10-17 13:47:52 +02:00
} ;
var zone = new Zone ( items , curves , pack , null , null ) ;
2026-10-16 00:50:44 +02:00
var rootObject = new GameObject ( "Wiring Zone Root" ) ;
var ore = new SimpleCommodity { Data = items . ItemData . RefOf < ItemData > ( items . ItemData . GetByName < SimpleCommodityData > ( "Wiring Ore" ) ) , Quantity = 7 } ;
2026-10-17 14:28:27 +02:00
var floating = zone . Release ( ore , new float2 ( - 17 , 4 ) , new float2 ( 10 , 0 ) ) ;
2026-10-16 00:50:44 +02:00
var mine = new Mine
{
Body = new KinematicBody { Position = new float2 ( - 20 , 8 ) , Velocity = new float2 ( 0 , 14 ) } ,
ArmingDelay = 1e6f , FuseDelay = 1f , Lifetime = 1e6f , BlastRadius = 1f
} ;
zone . Lay ( mine ) ;
2026-10-17 14:14:05 +02:00
// Bound the way ZoneRenderer.LoadFloatingItem and LoadMine bind them: the prefab ARPG.unity names, Bind(zone, body, settings).
// (A ZoneRenderer would load this zone's sun and planet too, and that needs the planet prefab, the map and a GPU.)
var pickup = UnityEngine . Object . Instantiate ( pickupPrefab , rootObject . transform ) ;
pickup . Bind ( zone , floating , settings ) ;
var mineInstance = UnityEngine . Object . Instantiate ( minePrefab , rootObject . transform ) ;
mineInstance . Bind ( zone , mine , settings ) ;
2026-10-16 00:50:44 +02:00
// A round on its frozen line, no target and no outcome known: drawn at FireOrigin + direction * speed * (T - FireTime).
var shot = new PendingShot
{
ShotId = 1 , FireOrigin = new float3 ( 0 , 0 , 0 ) , FireTime = 0f , TravelDirection = new float2 ( 1 , 0 ) , Speed = RoundSpeed ,
MaxRange = 1e6f , FireRange = 1e6f , CommitTime = 1e5f , ArrivalTime = 2e5f
} ;
var projectile = new GameObject ( "Wiring Round" ) . AddComponent < Projectile > ( ) ;
projectile . Launch ( zone , shot , new Vector3 ( 0 , 0 , 0 ) ) ;
yield return null ;
zone . Update ( . 5f ) ;
var pickupVelocity = floating . Body . TotalVelocity ;
var mineVelocity = mine . Body . TotalVelocity ;
Require ( Mathf . Abs ( pickupVelocity . x ) > 1f & & Mathf . Abs ( mineVelocity . y ) > 1f , "The fixture's bodies stopped: a lead would not move them." ) ;
2026-10-17 13:47:52 +02:00
// The field under each floater really is a function of the drawn time here, by more than the tolerance at the largest lead.
const float LargestLead = . 015f ;
foreach ( var at in new [ ] { floating . Body . Position + pickupVelocity * LargestLead , mine . Body . Position + mineVelocity * LargestLead } )
Require ( Mathf . Abs ( DrawAhead . Height ( zone , at , LargestLead ) - zone . GetHeight ( at , zone . ExactTime ) ) > 5 * Tolerance ,
2026-10-17 14:28:27 +02:00
$"The fixture's field is flat under a floater: height {DrawAhead.Height(zone, at, LargestLead)} at the drawn time, {zone.GetHeight(at, zone.ExactTime)} at the step, planet at {zone.OrbitPositionAt(planetOrbit, zone.ExactTime)}, floater at {at}." ) ;
2026-10-17 13:47:52 +02:00
2026-10-16 00:50:44 +02:00
Vector2 baseline = default ;
foreach ( var leadSeconds in new [ ] { 0f , . 008f , . 015f } )
{
ActionGameManager . Clock . Reset ( ) ;
if ( leadSeconds > 0 ) ActionGameManager . Clock . Advance ( leadSeconds , _ = > throw new InvalidOperationException ( "a step ran" ) ) ;
var lead = ActionGameManager . Clock . Lead ;
Require ( Mathf . Abs ( lead - leadSeconds ) < 1e-6f , $"The clock's lead is {lead}, not {leadSeconds}." ) ;
yield return null ;
var seen = pickup . transform . position ;
var want = floating . Body . Position + pickupVelocity * lead ;
Require ( Mathf . Abs ( seen . x - want . x ) < Tolerance & & Mathf . Abs ( seen . z - want . y ) < Tolerance ,
$"ItemPickup at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}." ) ;
2026-10-17 13:47:52 +02:00
Require ( Mathf . Abs ( seen . y - DrawAhead . FloaterHeight ( zone , settings , new float2 ( want . x , want . y ) , lead ) ) < Tolerance ,
$"ItemPickup y {seen.y} is not on the floater layer." ) ;
2026-10-16 00:50:44 +02:00
seen = mineInstance . transform . position ;
want = mine . Body . Position + mineVelocity * lead ;
Require ( Mathf . Abs ( seen . x - want . x ) < Tolerance & & Mathf . Abs ( seen . z - want . y ) < Tolerance ,
$"MineInstance at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}." ) ;
2026-10-16 04:24:43 +02:00
Require ( Mathf . Abs ( seen . y - DrawAhead . FloaterHeight ( zone , settings , new float2 ( want . x , want . y ) , lead ) ) < Tolerance ,
$"MineInstance y {seen.y} is not on the floater layer." ) ;
2026-10-16 00:50:44 +02:00
// The round's line is a function of the drawn time: it moves RoundSpeed * lead along its direction between leads.
seen = projectile . transform . position ;
if ( leadSeconds = = 0f ) baseline = new Vector2 ( seen . x , seen . z ) ;
var moved = new Vector2 ( seen . x , seen . z ) - baseline ;
Require ( Mathf . Abs ( moved . x - RoundSpeed * lead ) < Tolerance & & Mathf . Abs ( moved . y ) < Tolerance ,
$"Projectile at lead {lead}: drawn {moved.x},{moved.y} past its lead-0 pose, expected {RoundSpeed * lead},0." ) ;
}
ActionGameManager . Clock . Reset ( ) ;
Debug . Log ( $"{Tag} ok: ItemPickup, MineInstance and Projectile draw at the clock's lead (0, 0.008, 0.015 s)" ) ;
}
// What ARPG.unity's ZoneRenderer names in the pickup and mine fields, read from the saved scene before the play session
// (a scene cannot be opened inside one) and carried across the domain reload as asset paths.
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 wired = found [ 0 ] ;
return string . Join ( "|" , new ( string , UnityEngine . Object ) [ ]
{
( "SimpleCommodityPickup" , wired . SimpleCommodityPickup ) , ( "MinePrefab" , wired . MinePrefab )
} . Select ( f = > f . Item1 + "=" + ( f . Item2 = = null ? "" : AssetDatabase . GetAssetPath ( f . Item2 ) ) ) ) ;
}
finally { EditorSceneManager . CloseScene ( scene , true ) ; }
}
}
}