From 6709b06e2efbea3995313b635e1755060cdadf00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:38:35 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Editor/ShipModPlaySmoke.cs | 68 ++++++++++++++++++++--- Assets/Scripts/Gameplay/ShipModShips.cs | 15 +++-- Assets/Scripts/UI/MainMenu.cs | 2 +- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/Editor/ShipModPlaySmoke.cs b/Assets/Scripts/Editor/ShipModPlaySmoke.cs index a4fb708b5..f0753e4e6 100644 --- a/Assets/Scripts/Editor/ShipModPlaySmoke.cs +++ b/Assets/Scripts/Editor/ShipModPlaySmoke.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using GameCult.Caching; using UnityEditor; using UnityEngine; @@ -9,6 +10,10 @@ using UnityEngine; // 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 -executeMethod ShipModPlaySmoke.Run // -shipModPath //ship.cc [-shippedCatalog GameData/Aetheria.cc] -logFile // @@ -24,22 +29,32 @@ public static class ShipModPlaySmoke return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; } var packagePath = Option("-shipModPath"); - var derived = Path.Combine(Path.GetTempPath(), $"ship-mod-play-smoke-{Guid.NewGuid():N}.cc"); + 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 is required"); var package = ShipModCatalog.ReadPackage(packagePath); - var modsRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(packagePath))); + var broken = InstallWithBrokenSibling(packagePath, package, modsRoot); var composition = ShipModCatalog.Compose(Path.GetFullPath(Option("-shippedCatalog", "GameData/Aetheria.cc")), derived, modsRoot); - if (!composition.Included.Contains(package.Visual.Id)) - throw new InvalidOperationException($"{package.Visual.Id} was not composed into the catalog: " + - string.Join("; ", composition.Excluded.Select(excluded => $"{excluded.Package}: {excluded.Reason}"))); + 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); - var hull = catalog.GetAll().Single(candidate => candidate.Visual.IsSet() && candidate.Name == package.Hull.Name); + HullData Hull(string name) => catalog.GetAll().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(); @@ -56,16 +71,51 @@ public static class ShipModPlaySmoke 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"); + Debug.Log($"SHIP_MOD_PLAY_SMOKE thrusters={thrusters} weapons={weapons} collider=ok mapicon=ok skipped={broken.Visual.Id}"); } - if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + Clean(zone, work); EditorApplication.Exit(0); } catch (Exception exception) { Debug.LogException(exception); - if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + 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(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}"); } + } } diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index 7bc64bd37..94177e0a5 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -14,7 +14,9 @@ public static class ShipModShips private static readonly Dictionary Prototypes = new Dictionary(); private static CultCache _catalog; - // Set by Preload; complete when every prototype is ready, faulted when any mod ship failed to import or assemble. + // Set by Preload; complete when every hull that could be built has its prototype. A mod ship that fails to import or + // assemble is skipped and logged, as Compose skips a bad package, so it never stops the rest; Loading faults only + // when the preload itself cannot run. public static Task Loading { get; private set; } = Task.CompletedTask; public static Task Preload(CultCache catalog, string modsRoot) @@ -39,18 +41,19 @@ public static class ShipModShips if (Application.isPlaying) Object.DontDestroyOnLoad(root); foreach (var hull in hulls) { - var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot); - var visual = await ShipModVisual.LoadAsync(package, root.transform); + ShipModVisual.Instance visual = null; try { + var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot); + visual = await ShipModVisual.LoadAsync(package, root.transform); visual.Root.SetActive(false); Assemble(visual, package.Hull, package.Visual, template); Prototypes[catalog.RefOf(hull).Key] = visual.Root; } - catch + catch (Exception error) { - visual.Destroy(); - throw; + visual?.Destroy(); + Debug.LogError($"Mod ship {hull.Name} skipped: {error.Message}"); } } } diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9d17af138..859d17c95 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -193,7 +193,7 @@ public class MainMenu : MonoBehaviour } // The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them, - // and a failed import stops here with its reason rather than in the first zone. + // and a preload that could not run at all stops here with its reason rather than in the first zone. private void EnterGame() => StartCoroutine(EnterGameWhenModShipsAreReady()); private IEnumerator EnterGameWhenModShipsAreReady()