Aetheria/Assets/Scripts/Editor/MapPresenterSmoke.cs
Meta Vens b70372963a Delete the minimap asteroids setting; minimap belts always draw
Ruling minimap-asteroids-setting-deleted. PlayerGraphicsSettings key 1 stays
retired so old saves load; OldPlayerSettingsLoad now writes the old 3-element
array by hand. MapPresenterSmoke asserts a belt draws with the map screen
closed. Not built or tested yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:36:13 +02:00

233 lines
14 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using UnityEditor;
using UnityEngine;
using CultMath;
using CultMath.UnityBridge;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using float4 = CultMath.float4;
// Batch proof of MapPresenter's contract on a real Zone, without a scene: a sun with a very deep well, a belt, and ships
// registered through EntityInstance.SetEntity as the game does. Planet icons draw at the map plane inside both map
// cameras' clip range however deep the well, a rolled and pitched hull's icon turns by yaw alone, every kind registers one
// draw and leaves no icon child behind, a belt is one instanced draw of every asteroid, and an entity's fade reaches its
// icon the frame it is set. Not reached: prefab, mod ship, station and turret instances (they register through the same
// call), and the rendered pixels (a GPU capture).
//
// Unity -batchmode -projectPath <project> -executeMethod MapPresenterSmoke.Run -logFile <log>
//
// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
public static class MapPresenterSmoke
{
private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
private const int Asteroids = 24;
public static void Run()
{
var work = Path.Combine(Path.GetTempPath(), $"map-presenter-smoke-{Guid.NewGuid():N}");
try
{
Directory.CreateDirectory(work);
Check(work);
Debug.Log("[MapPresenterSmoke] ok");
EditorApplication.Exit(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
private static void Require(bool condition, string what)
{
if (!condition) throw new InvalidOperationException(what);
}
private static void Awaken(Component component) => component.GetType().GetMethod("Awake", Any)?.Invoke(component, null);
// flat: authored lying on the map plane (facing up), as a real icon is; the smoke's own icons are authored upright
// so that their rotation is exactly the yaw.
private static MeshRenderer NewIcon(Transform parent, bool flat)
{
var icon = GameObject.CreatePrimitive(PrimitiveType.Quad);
icon.layer = LayerMask.NameToLayer("Minimap");
icon.transform.SetParent(parent, false);
if (flat) icon.transform.localRotation = Quaternion.Euler(90, 0, 0);
var renderer = icon.GetComponent<MeshRenderer>();
renderer.sharedMaterial = new Material(Shader.Find("Unlit/Color")) { color = new Color(1, 1, 1, .8f) };
return renderer;
}
// What Build leaves registered with a presenter, for the checks here and for MapPresenterCapture.
internal sealed class Fixture
{
public Zone Zone;
public MapPresenter Map;
public Ship Ship, Other;
public EntityInstance ShipInstance, OtherInstance;
public CultRecordKey SunKey, BeltKey;
public float4[] Belt;
public Transform Roots;
}
private static void Check(string work)
{
var f = Build(work, flatIcons: false);
Checks(f);
}
internal static Fixture Build(string work, bool flatIcons)
{
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")
?? throw new InvalidOperationException("No Settings.asset.");
var planetSettings = new PlanetSettings
{
BodyRadius = new ExponentialCurve(),
WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },
RocheLimit = new ExponentialCurve { Constant = 2f },
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 cache = AetheriaStores.Open(Path.Combine(work, "c.cc"), Path.Combine(work, "r.cc"), catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
var shape = new Shape(5, 4);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
cache.Upsert(new HullData { Name = "Smoke Hull", HullType = HullType.Ship, Shape = shape, Durability = 1000, Mass = 1000, Armor = 0 });
var sunOrbit = cache.Upsert(new OrbitData()).Key;
var sunKey = cache.Upsert(new PlanetData { Name = "Sun", Orbit = new CultRecordRef<OrbitData>(sunOrbit), Mass = 1e6f }).Key;
var rootOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef<OrbitData>(sunOrbit), Distance = 4000f, Phase = .17f }).Key;
var beltOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef<OrbitData>(rootOrbit), Distance = 900f, Phase = .42f }).Key;
var asteroids = Enumerable.Range(0, Asteroids).Select(i => new Asteroid
{ Distance = 120f + i * 11.3f, Phase = frac(i * .618f), Size = frac(i * .37f + .1f), RotationSpeed = .15f + i * .037f }).ToArray();
var beltKey = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbit), Asteroids = asteroids }).Key;
var pack = new ZonePack
{
Orbits = { new CultRecordRef<OrbitData>(sunOrbit), new CultRecordRef<OrbitData>(rootOrbit), new CultRecordRef<OrbitData>(beltOrbit) },
Planets = { new CultRecordRef<BodyData>(sunKey), new CultRecordRef<BodyData>(beltKey) },
Radius = 5000f, Mass = 10000f, Time = 0
};
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings.GameplaySettings, _ => { });
var zone = new Zone(items, planetSettings, pack, new GalaxyZone { Name = "Smoke", Owner = null }, null);
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings());
ship.Position = float3(300, 50, 200);
other.Position = float3(-300, -90, 100);
zone.Admit(ship, piloted: false);
zone.Admit(other, piloted: false);
// A hull heading 40 degrees from +z, pitched up 89.9 and rolled 30: its forward is almost vertical, where the pose's
// eulerAngles.y is the gimbal decomposition (yaw and roll folded together), not the heading. The heading is the
// forward projected onto the map plane, 40. The fixture asserts the two differ, so a yaw read from eulerAngles
// cannot pass.
var pose = Quaternion.Euler(0, 40, 0) * Quaternion.Euler(-89.9f, 0, 30);
var flatForward = pose * Vector3.forward;
Require(Mathf.Abs(Mathf.DeltaAngle(Mathf.Atan2(flatForward.x, flatForward.z) * Mathf.Rad2Deg, 40f)) < .5f, "The fixture's forward does not head 40.");
Require(Mathf.Abs(Mathf.DeltaAngle(pose.eulerAngles.y, 40f)) > 10f, $"The fixture's eulerAngles.y {pose.eulerAngles.y} is aligned with its heading.");
ship.Direction = float2(Mathf.Sin(40 * Mathf.Deg2Rad), Mathf.Cos(40 * Mathf.Deg2Rad));
typeof(Ship).GetProperty("Rotation", Any).GetSetMethod(true).Invoke(ship, new object[] { new CultMath.quaternion(pose.x, pose.y, pose.z, pose.w) });
var map = new MapPresenter(new Material[0], AssetDatabase.LoadAssetAtPath<Material>("Assets/Materials/UI/MapAsteroid.mat"), 4, 4, 3f);
var roots = new GameObject("Smoke Roots").transform;
EntityInstance Bound(Entity entity)
{
var body = new GameObject(entity.Name);
body.transform.SetParent(roots);
var instance = body.AddComponent<EntityInstance>();
Awaken(instance);
instance.HullColliders = new HullCollider[0];
instance.RadiatorHardpoints = new RadiatorHardpoint[0];
instance.ThrusterHardpoints = new ThrusterHardpoint[0];
instance.WeaponHardpoints = new WeaponHardpoint[0];
instance.ArticulationPoints = new ArticulationPoint[0];
instance.MapIcon = NewIcon(body.transform, flatIcons);
var zoneRenderer = new GameObject("Smoke Zone Renderer").AddComponent<ZoneRenderer>();
zoneRenderer.Settings = settings;
zoneRenderer.MapGravityMaterials = new Material[0];
Awaken(zoneRenderer);
typeof(ZoneRenderer).GetProperty("Map").GetSetMethod(true).Invoke(zoneRenderer, new object[] { map });
instance.SetEntity(zoneRenderer, entity);
return instance;
}
var shipInstance = Bound(ship);
var otherInstance = Bound(other);
var sunRoot = new GameObject("Smoke Sun").transform;
sunRoot.SetParent(roots);
map.RegisterPlanet(sunKey, NewIcon(sunRoot, flatIcons), sunRoot);
var belt = new float4[Asteroids];
map.RegisterBelt(beltKey, belt);
zone.EvaluateBelt(beltKey, belt, DrawAhead.Time(zone, 0));
map.SetFade(ship, 1f);
map.SetFade(other, 1f);
map.SetMinimapScale(2f);
map.Draw(zone, 0);
return new Fixture { Zone = zone, Map = map, Ship = ship, Other = other, ShipInstance = shipInstance,
OtherInstance = otherInstance, SunKey = sunKey, BeltKey = beltKey, Belt = belt, Roots = roots };
}
private static void Checks(Fixture f)
{
var (zone, map, ship, other, sunKey, beltKey, belt, roots) = (f.Zone, f.Map, f.Ship, f.Other, f.SunKey, f.BeltKey, f.Belt, f.Roots);
// Deep planet: the sun's field is deeper than 2000 below the plane, and its icon sits on the map plane inside both
// map cameras' clip range (minimap at y 10, map screen at y 1; near 0.3, far 1000, looking down).
var well = zone.GetHeight(DrawAhead.Orbit(zone, zone.PlanetInstances[sunKey].BodyData.Orbit.Key, 0), 0);
Require(well < -2000f, $"The fixture sun's well is {well}, not below -2000.");
Require(map.TryDrawn(sunKey, out var sunIcon), "The sun registered no icon.");
var y = sunIcon.GetPosition().y;
Require(y < 1f - .3f && y > 10f - 1000f && Mathf.Abs(y - MapPresenter.PlaneY) < .5f, $"The sun's icon is drawn at y {y}, not on the map plane.");
// Axes: each icon sits at its sim position's planar x and z (a ship at (300, 200), the other at (-300, 100), the sun at
// its orbit), never swapped, and never at the 3D height (the ships ride at y 50 and -90, the sun's well is below -2000).
var shipAt = DrawAhead.Position(ship, 0);
Require(Mathf.Abs(shipAt.x - 300f) < 1e-3f && Mathf.Abs(shipAt.z - 200f) < 1e-3f, $"The ship's sim position is ({shipAt.x}, {shipAt.z}), not (300, 200).");
var sunAt = DrawAhead.Orbit(zone, zone.PlanetInstances[sunKey].BodyData.Orbit.Key, 0);
Require(map.TryDrawn(ship, out var shipIcon, out _), "The ship registered no icon.");
Require(Mathf.Abs(shipIcon.GetPosition().x - 300f) < 1e-3f && Mathf.Abs(shipIcon.GetPosition().z - 200f) < 1e-3f,
$"The ship's icon is at ({shipIcon.GetPosition().x}, {shipIcon.GetPosition().z}), not its sim position (300, 200).");
Require(map.TryDrawn(other, out var otherIcon, out _), "The second ship registered no icon.");
Require(Mathf.Abs(otherIcon.GetPosition().x + 300f) < 1e-3f && Mathf.Abs(otherIcon.GetPosition().z - 100f) < 1e-3f,
$"The second ship's icon is at ({otherIcon.GetPosition().x}, {otherIcon.GetPosition().z}), not (-300, 100).");
Require(Mathf.Abs(sunIcon.GetPosition().x - sunAt.x) < 1e-3f && Mathf.Abs(sunIcon.GetPosition().z - sunAt.y) < 1e-3f,
$"The sun's icon is at ({sunIcon.GetPosition().x}, {sunIcon.GetPosition().z}), not its orbit ({sunAt.x}, {sunAt.y}).");
Require(Mathf.Abs(otherIcon.GetPosition().y - (MapPresenter.PlaneY + MapPresenter.ShipOffset)) < 1e-4f, "The second ship's icon took the 3D height.");
// Tilted hull: the icon turns by the heading projected onto the map plane (40) times its authored rotation (identity here), with no roll or pitch.
var want = Quaternion.Euler(0, 40, 0);
Require(Quaternion.Angle(shipIcon.rotation, want) < .1f, $"The icon is rotated {shipIcon.rotation.eulerAngles}, not yaw 40 alone.");
Require(Vector3.Angle(shipIcon.rotation * Vector3.up, Vector3.up) < .1f, "The icon's up axis tilted with the hull.");
Require(Mathf.Abs(shipIcon.GetPosition().y - (MapPresenter.PlaneY + MapPresenter.ShipOffset)) < 1e-4f, "The ship's icon left the map plane.");
// Every kind registers once: three icons drew, and no icon child remains under any registered transform.
Require(map.IconCount == 3 && map.IconDraws == 3, $"{map.IconCount} icons, {map.IconDraws} drawn; expected 3 and 3.");
Require(roots.GetComponentsInChildren<MeshRenderer>(true).Length == 0, "An icon child remains under an entity or planet transform.");
// A belt is one instanced draw of every asteroid, and its buffer holds this frame's floats.
Require(map.BeltDraws == 1 && map.LastBeltInstances == belt.Length, $"{map.BeltDraws} belt draws of {map.LastBeltInstances} instances; expected 1 of {belt.Length}.");
var read = new float4[belt.Length];
map.ReadBelt(beltKey, read);
for (var i = 0; i < belt.Length; i++)
Require(read[i].x == belt[i].x && read[i].y == belt[i].y && read[i].z == belt[i].z && read[i].w == belt[i].w, $"Belt buffer differs from the frame's floats at {i}.");
// Belts draw on the minimap with the map screen closed.
map.OpenMapScreen(false);
map.Draw(zone, 0);
Require(map.BeltDraws == 1, "A belt did not draw on the minimap with the map screen closed.");
// Fade: half faded reaches the icon's colour alpha on the next draw; faded out draws nothing.
map.SetFade(ship, .5f);
map.Draw(zone, 0);
Require(map.TryDrawn(ship, out _, out var alpha) && Mathf.Abs(alpha - .4f) < 1e-3f, $"Icon alpha {alpha}, expected 0.8 * 0.5.");
map.SetFade(ship, 0f);
map.Draw(zone, 0);
Require(map.IconDraws == 2, $"{map.IconDraws} icons drew with one fully faded out; expected 2.");
}
}