Aetheria/Assets/Scripts/Editor/ShipModPlaySmoke.cs
Meta Vens 6709b06e2e Ship preload: a mod ship that fails to load or assemble is skipped, not fatal
PreloadAsync rethrew on the first failing hull, so one bad package faulted
Loading and no mod ship could spawn. Each hull now loads in its own try: a
failure destroys its partial visual, is logged with the hull's name, and the
rest still preload, as Compose skips a bad package. The play smoke installs the
package beside a broken sibling that composes but cannot assemble (its map icon
moved onto a meshless muzzle node) and proves the sibling has no prototype while
the good ship spawns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:38:35 +02:00

121 lines
7.1 KiB
C#

using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using UnityEditor;
using UnityEngine;
// Batch proof that a mod ship survives the game's own path into a zone, without a scene: the derived catalog composed
// with one package, ShipModShips.Preload over it, then ShipModShips.Instantiate, which is what ZoneRenderer.LoadEntity's
// Body() calls for a hull with a Visual. A plain parent transform stands in for ZoneRoot; nothing else in LoadEntity
// touches the body before it takes its ShipInstance.
//
// The package is installed beside a broken sibling: the same ship under another id, with its map-icon anchor moved onto
// its first muzzle's meshless node. The sibling composes, because no compose-time check reads meshes, and then fails
// assembly. The smoke proves the preload skips it and the good package still spawns.
//
// Unity -batchmode -nographics -projectPath <project> -executeMethod ShipModPlaySmoke.Run
// -shipModPath <mods>/<id>/ship.cc [-shippedCatalog GameData/Aetheria.cc] -logFile <log>
//
// Do not pass -quit: the method is async and exits the Editor itself, 0 on success and 1 on any failure.
public static class ShipModPlaySmoke
{
public static async void Run()
{
var args = Environment.GetCommandLineArgs();
string Option(string name, string fallback = null)
{
var at = Array.IndexOf(args, name);
return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback;
}
var packagePath = Option("-shipModPath");
var work = Path.Combine(Path.GetTempPath(), $"ship-mod-play-smoke-{Guid.NewGuid():N}");
var derived = Path.Combine(work, "Aetheria.modded.cc");
var modsRoot = Path.Combine(work, "Mods");
GameObject zone = null;
try
{
if (packagePath == null) throw new InvalidOperationException("-shipModPath <package/ship.cc> is required");
var package = ShipModCatalog.ReadPackage(packagePath);
var broken = InstallWithBrokenSibling(packagePath, package, modsRoot);
var composition = ShipModCatalog.Compose(Path.GetFullPath(Option("-shippedCatalog", "GameData/Aetheria.cc")), derived, modsRoot);
foreach (var id in new[] { package.Visual.Id, broken.Visual.Id })
if (!composition.Included.Contains(id))
throw new InvalidOperationException($"{id} was not composed into the catalog: " +
string.Join("; ", composition.Excluded.Select(excluded => $"{excluded.Package}: {excluded.Reason}")));
using (var catalog = AetheriaStores.Open(derived))
{
await ShipModShips.Preload(catalog, modsRoot);
HullData Hull(string name) => catalog.GetAll<HullData>().Single(candidate => candidate.Visual.IsSet() && candidate.Name == name);
var hull = Hull(package.Hull.Name);
// The broken sibling has no prototype, and Loading did not fault for it (await would have thrown).
var refused = false;
try { ShipModShips.Instantiate(Hull(broken.Hull.Name), null); }
catch (InvalidOperationException error) { refused = error.Message.Contains("no mod ship prototype"); }
if (!refused) throw new InvalidOperationException($"{broken.Visual.Id} assembled, or was refused for the wrong reason.");
zone = new GameObject("Play Smoke Zone");
var ship = ShipModShips.Instantiate(hull, zone.transform).GetComponent<ShipInstance>();
if (ship == null) throw new InvalidOperationException("The mod ship body has no ShipInstance.");
var thrusters = ship.ThrusterHardpoints?.Length ?? 0;
var weapons = ship.WeaponHardpoints?.Length ?? 0;
if (thrusters == 0) throw new InvalidOperationException("ThrusterHardpoints is empty.");
if (weapons == 0 || ship.WeaponHardpoints.Any(node => node.FiringPoint == null || node.FiringPoint.Length == 0))
throw new InvalidOperationException("WeaponHardpoints is empty or a weapon has no muzzle.");
if (ship.HullColliders == null || ship.HullColliders.Length != 1)
throw new InvalidOperationException("The ship needs exactly one hull collider.");
var collider = ship.HullColliders[0].GetComponent<MeshCollider>();
if (collider == null || collider.sharedMesh == null || !collider.sharedMesh.isReadable || collider.sharedMesh.vertexCount == 0)
throw new InvalidOperationException("The hull collider is not a MeshCollider over a readable mesh.");
if (ship.MapIcon == null || ship.MapIcon.sharedMaterial == null || ship.MapIcon.gameObject.layer != LayerMask.NameToLayer("Minimap"))
throw new InvalidOperationException("The map icon is missing its renderer, material or Minimap layer.");
Debug.Log($"SHIP_MOD_PLAY_SMOKE thrusters={thrusters} weapons={weapons} collider=ok mapicon=ok skipped={broken.Visual.Id}");
}
Clean(zone, work);
EditorApplication.Exit(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
Clean(zone, work);
EditorApplication.Exit(1);
}
}
// Copies the package into modsRoot and writes its broken sibling beside it. Returns the sibling's records.
private static (HullData Hull, ShipAuthoring Visual) InstallWithBrokenSibling(string packagePath, ShipModCatalog.Package package, string modsRoot)
{
void Install(string id, string shipFile)
{
var model = Path.Combine(modsRoot, id, package.Visual.ModelAsset);
Directory.CreateDirectory(Path.GetDirectoryName(model));
File.Copy(package.ModelPath, model);
if (shipFile != null) File.Copy(shipFile, Path.Combine(modsRoot, id, "ship.cc"));
}
Install(package.Visual.Id, Path.GetFullPath(packagePath));
var (hull, ship) = ShipAuthoringStore.Read(Path.Combine(modsRoot, package.Visual.Id, "ship.cc"));
ship.Id += ".broken";
hull.Name += " Broken";
hull.Visual = new CultRecordRef<ShipAuthoring>(ShipModCatalog.AuthoringKey(ship.Id));
var mapIcon = ship.Anchors.Single(anchor => anchor.Role == "map-icon");
var muzzle = ship.Anchors.First(anchor => anchor.Role == "weapon-muzzle");
(mapIcon.ModelNodeId, muzzle.ModelNodeId) = (muzzle.ModelNodeId, mapIcon.ModelNodeId);
Install(ship.Id, null);
using (var cache = ShipAuthoringStore.Open(Path.Combine(modsRoot, ship.Id, "ship.cc"), writable: true))
{
ShipAuthoringStore.Write(cache, hull, ship);
cache.FlushAsync().GetAwaiter().GetResult();
}
return (hull, ship);
}
private static void Clean(GameObject zone, string work)
{
if (zone != null) UnityEngine.Object.DestroyImmediate(zone);
try { if (Directory.Exists(work)) Directory.Delete(work, true); }
catch (Exception error) { Debug.LogWarning($"Play smoke left {work}: {error.Message}"); }
}
}