From 285b57713fd56b025a3ef8fc93b11443ffc5f514 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 12 Sep 2026 02:05:33 +0200 Subject: [PATCH] Clear dangling boss hull links, and stop the tool writing on read Eight factions pointed at one boss hull GUID that no longer resolves, shared wreckage from the database loss. PlaceFactionsMain gives a boss zone to every faction carrying a BossHull, so each dangling link claimed a chokepoint that could never spawn a boss. AetherDb.Open no longer registers the name file store. CultCache subscribes Add to a store EntryAdded, MultiFileBackingStore.PullAll re-emits what it loads, and Add pushes to that type store, whose Push writes a file: opening the cache rewrote all twelve name files, 4.4 MB, on every command. They were byte identical, and it only surfaced as an error because Unity had one of them memory mapped. Name files now load only on request, and the scope doc records the underlying smell. Adds commands: factions, clear-boss-hulls, save, loadout (runs the real LoadoutGenerator against every hull), hardpoint-fit. Co-Authored-By: Claude Opus 5 --- GameData/AetherDB.msgpack | 2 +- docs/three-gates-scope.md | 9 ++ tools/AetherDb/AetherDb.cs | 19 +++- tools/AetherDb/Program.cs | 213 +++++++++++++++++++++++++++++++++++-- 4 files changed, 230 insertions(+), 13 deletions(-) diff --git a/GameData/AetherDB.msgpack b/GameData/AetherDB.msgpack index 9347cabbb..c61b7845e 100644 --- a/GameData/AetherDB.msgpack +++ b/GameData/AetherDB.msgpack @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7d40c602ea1942158f706add32ed13cff21c15374460831e2a6499d519761e2 +oid sha256:8963a68670ae8e8e0ac411c96de7a39322802293b91f53f4d3f50717b06429aa size 46150 diff --git a/docs/three-gates-scope.md b/docs/three-gates-scope.md index f7abbf3f8..67a59f679 100644 --- a/docs/three-gates-scope.md +++ b/docs/three-gates-scope.md @@ -58,6 +58,15 @@ AI share it, so AI accuracy is no longer superhuman by construction. - **Deferred Unity-physics surfaces:** ship collision (`HullCollider`), loot pickup (`ShieldManager`), `TractorBeam`, `Mine`. Not blockers. +**Known ownership smell in CultCache: loading writes.** `AddBackingStore` +subscribes `Add` to a store's `EntryAdded`; `MultiFileBackingStore.PullAll` +re-emits every entry it loads; `Add` pushes to the store registered for that +type, and that `Push` writes a file. So a cache with a second store rewrites +that store's files on open, and the game does this to `GameData/NameFile` at +every startup. Nothing distinguishes "loaded this" from "changed this". +Whoever next owns persistence should separate the two; until then, readers +must not register a writable store they only mean to read. + **Invariant: game simulation runs independently of Unity.** `ServerShared` cannot reference UnityEngine (`Aetheria.Shared.Unity.asmdef` sets `noEngineReferences`), and `Aetheria.Shared/Aetheria.Shared.csproj` builds it diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index c31fcf6ee..9d7bc3e39 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -1,15 +1,20 @@ using System; using System.IO; -// Opens GameData/AetherDB.msgpack through the game's own CultCache and data types, for inspection and editing scripts. -// Close the editor's Database Tools before saving from a script, or its next Save will overwrite the script's changes. +// Opens GameData/AetherDB.msgpack through the game's own CultCache and data types, for inspection and editing. +// Close the editor's Database Tools before saving, or its next Save will overwrite what was written here. +// +// Reads do not write. That needs saying because CultCache makes it easy to get wrong: AddBackingStore subscribes +// Add to a store's EntryAdded, MultiFileBackingStore.PullAll re-emits every entry it loads, and Add pushes to the +// store registered for that type, whose Push writes a file. So registering a second store makes merely opening the +// cache rewrite its files. Name files are therefore loaded only when a command asks for them. public sealed class AetherDb { public string Root { get; } public CultCache Cache { get; } private readonly SingleFileMessagePackBackingStore _store; - private AetherDb(string root) + private AetherDb(string root, bool withNameFiles) { Root = root; RegisterResolver.Register(); @@ -17,12 +22,16 @@ public sealed class AetherDb _store = new SingleFileMessagePackBackingStore(Path.Combine(gameData, "AetherDB.msgpack")); Cache = new CultCache(); Cache.AddBackingStore(_store); - Cache.AddBackingStore(new MultiFileMessagePackBackingStore(gameData), typeof(NameFile)); + if (withNameFiles) Cache.AddBackingStore(new MultiFileMessagePackBackingStore(gameData), typeof(NameFile)); Cache.PullAllBackingStores(); } // With no root given, walks up from the working directory to the repository - public static AetherDb Open(string root = null) => new AetherDb(root ?? FindRoot()); + public static AetherDb Open(string root = null) => new AetherDb(root ?? FindRoot(), false); + + // Galaxy generation needs the name files; loading them rewrites them, so only ask when they are needed, + // and not while the editor holds them open. + public static AetherDb OpenWithNameFiles(string root = null) => new AetherDb(root ?? FindRoot(), true); // Writes every entry back to AetherDB.msgpack, as the editor's Save button does public void Save() => _store.PushAll(); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index df12739c3..d4e8df34e 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -7,6 +7,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using MessagePack; +using Unity.Mathematics; +using Random = Unity.Mathematics.Random; // Commands over the game database, run with: dotnet run --project tools/AetherDb -- public static class Program @@ -19,6 +21,11 @@ public static class Program case "doctor": return Doctor(); case "census": return Census(); case "station-fit": return StationFit(); + case "hardpoint-fit": return HardpointFit(); + case "loadout": return Loadout(args.Skip(1).FirstOrDefault()); + case "save": return Save(); + case "factions": return Factions(); + case "clear-boss-hulls": return ClearBossHulls(args.Contains("apply")); case "migrate-products": return MigrateProducts(args.Contains("apply")); default: Console.WriteLine("commands: doctor, census, station-fit, migrate-products [apply]"); @@ -78,7 +85,9 @@ public static class Program } // Some docking bay fits every station hull, which LoadoutGenerator.GenerateStationLoadout requires. - // Worst case: hardpoint gear is equipped first, leaving only the non-hardpoint interior free. + // Models the rule the game applies: an interior cell is usable when no gear occupies it, empty hardpoint + // cells included. Reports both the empty hull and the case where every hardpoint is already filled, because + // generation equips hardpoint gear first and only the first of those is guaranteed. private static int StationFit() { var db = AetherDb.Open(); @@ -86,19 +95,209 @@ public static class Program var failures = 0; foreach (var hull in db.Cache.GetAll().Where(h => h.HullType == HullType.Station)) { - var worstCase = new Shape(hull.Shape.Width, hull.Shape.Height); - foreach (var v in hull.InteriorCells.Coordinates) worstCase[v] = true; + var empty = new Shape(hull.Shape.Width, hull.Shape.Height); + foreach (var v in hull.InteriorCells.Coordinates) empty[v] = true; + + var hardpointsFilled = new Shape(hull.Shape.Width, hull.Shape.Height); + foreach (var v in hull.InteriorCells.Coordinates) hardpointsFilled[v] = true; foreach (var hardpoint in hull.Hardpoints) foreach (var v in hardpoint.Shape.Coordinates) - worstCase[hardpoint.Position + v] = false; + hardpointsFilled[hardpoint.Position + v] = false; - var fitting = bays.Where(b => b.Shape.FitsWithin(worstCase, out _, out _)).Select(b => b.Name).ToArray(); - if (fitting.Length == 0) failures++; - Console.WriteLine($"{hull.Name}: {(fitting.Length == 0 ? "NO docking bay fits" : string.Join(", ", fitting))}"); + var onEmpty = bays.Where(b => b.Shape.FitsWithin(empty, out _, out _)).Select(b => b.Name).ToArray(); + var onFilled = bays.Where(b => b.Shape.FitsWithin(hardpointsFilled, out _, out _)).Select(b => b.Name).ToArray(); + if (onEmpty.Length == 0) failures++; + Console.WriteLine($"{hull.Name}: empty hull fits [{string.Join(", ", onEmpty)}], every hardpoint filled fits [{string.Join(", ", onFilled)}]"); + if (onEmpty.Length > 0 && onFilled.Length == 0) + Console.WriteLine(" depends on hardpoint gear leaving cells free; generation can still fail here"); } return failures; } + // Every hardpoint on every hull, and which designs can fill it: LoadoutGenerator requires a matching + // HardpointType, a shape that fits the hardpoint, and exactly as many cells as the hardpoint has. A + // hardpoint no product can fill is gear the game cannot place. + private static int HardpointFit() + { + var db = AetherDb.Open(); + var gear = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().ToArray(); + var unfillable = 0; + foreach (var hull in db.Cache.GetAll().OrderBy(h => h.HullType).ThenBy(h => h.Name)) + { + Console.WriteLine($"\n{hull.Name} ({hull.HullType}):"); + foreach (var hardpoint in hull.Hardpoints.OrderByDescending(h => h.Shape.Coordinates.Length)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var matches = gear.Where(g => + g.HardpointType == hardpoint.Type && + g.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && + g.Shape.Coordinates.Length == cells).ToArray(); + var sold = matches.Where(m => products.Any(p => p.Design == m.ID)).ToArray(); + if (sold.Length == 0) unfillable++; + Console.WriteLine($" {hardpoint.Type,-14} {cells,2} cells: {matches.Length} designs match, {sold.Length} sold" + + (matches.Length == 0 ? " <- no design of that size" : sold.Length == 0 ? " <- designs exist but no product sells them" : "")); + foreach (var match in matches) + Console.WriteLine($" {match.Name,-28} {match.Shape.Coordinates.Length} cells" + + (products.Any(p => p.Design == match.ID) ? "" : " (unsold)")); + } + } + Console.WriteLine($"\n{unfillable} hardpoints no product can fill"); + return unfillable; + } + + // Clears boss hull links that resolve to nothing. Galaxy.PlaceFactionsMain gives a boss zone to every faction + // carrying a BossHull, so a dangling link claims a chokepoint that can never spawn a boss. Dry run unless + // passed "apply". + private static int ClearBossHulls(bool apply) + { + var db = AetherDb.Open(); + var dangling = db.Cache.GetAll() + .Where(f => f.BossHull != Guid.Empty && db.Cache.Get(f.BossHull) == null) + .OrderBy(f => f.Name) + .ToArray(); + + Console.WriteLine($"{dangling.Length} factions point at a boss hull that does not exist:"); + foreach (var faction in dangling) + { + Console.WriteLine($" {faction.Name,-26} {faction.BossHull}"); + if (apply) faction.BossHull = Guid.Empty; + } + + if (apply && dangling.Length > 0) + { + db.Save(); + Console.WriteLine($"\nCleared {dangling.Length} boss hull links in AetherDB.msgpack"); + } + else if (!apply && dangling.Length > 0) Console.WriteLine("\nDry run. Pass \"apply\" to clear them."); + return 0; + } + + // Each faction's generation-critical links, read through the loaded cache so the multi-file NameFile store + // counts. Galaxy.GenerateNames dereferences the geoname file without a guard, so a dangling link there stops + // galaxy generation outright. + private static int Factions() + { + // Name files are not loaded: reporting a geoname link needs only the link's target id, and loading that + // store rewrites it. A dangling geoname therefore reads as "not loaded" here rather than as DANGLING. + var db = AetherDb.Open(); + var products = db.Cache.GetAll().ToArray(); + Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} products"); + var broken = 0; + foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) + { + var geonames = faction.GeonameFile == Guid.Empty ? "UNSET" : "set"; + var boss = faction.BossHull == Guid.Empty + ? "none" + : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; + if (geonames == "UNSET" || boss == "DANGLING") broken++; + Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} " + + products.Count(p => p.Manufacturer == faction.ID)); + } + Console.WriteLine($"\n{broken} factions carry a link that would break generation"); + return broken; + } + + // What the saved run actually holds per zone. A zone carrying orbits but no orbital entities was packed by a + // generation run that failed partway, and is reused on revisit rather than regenerated, so a fixed generator + // does not repopulate it. + private static int Save() + { + var db = AetherDb.Open(); + var path = Path.Combine(db.Root, "GameData", "PlayerSettings.msgpack"); + if (!File.Exists(path)) + { + Console.WriteLine($"no save at {path}"); + return 0; + } + + PlayerSettings settings; + try + { + settings = MessagePackSerializer.Deserialize(File.ReadAllBytes(path)); + } + catch (Exception e) + { + Console.WriteLine($"save does not deserialize: {e.GetType().Name}: {e.Message}"); + return 1; + } + + var run = settings.SavedRun; + if (run?.Zones == null) + { + Console.WriteLine($"save holds no run (player {settings.Name}, tutorial passed: {settings.TutorialPassed})"); + return 0; + } + + Console.WriteLine($"player {settings.Name}, {run.Zones.Length} zones, current zone {run.CurrentZone}\n"); + var suspect = 0; + for (var i = 0; i < run.Zones.Length; i++) + { + var zone = run.Zones[i]; + if (zone.Contents == null) continue; + var stations = zone.Contents.Entities.Count(e => e is OrbitalEntityPack); + var ships = zone.Contents.Entities.Count(e => e is ShipPack); + var owner = zone.Owner >= 0 && zone.Owner < run.Factions.Length + ? db.Cache.Get(run.Factions[zone.Owner])?.ShortName ?? "(unknown)" + : "(none)"; + var packedEmpty = zone.Contents.Orbits.Count > 0 && stations == 0; + if (packedEmpty) suspect++; + Console.WriteLine($" [{i,3}] {zone.Name,-18} owner {owner,-12} orbits {zone.Contents.Orbits.Count,3} " + + $"stations {stations,2} ships {ships,2}{(packedEmpty ? " <- packed with orbits but no stations" : "")}"); + } + + Console.WriteLine($"\n{suspect} visited zones packed with orbits but no stations"); + return 0; + } + + // Runs the real LoadoutGenerator against every hull, so a generation failure reproduces here instead of on a + // flight to a populated sector. No galaxy, so this covers placement, roles and products but NOT availability + // filtering or distance weighting, which need a real galaxy. Seeded: a failure repeats. + private static int Loadout(string seedArgument) + { + var db = AetherDb.Open(); + var seed = uint.TryParse(seedArgument, out var parsed) ? parsed : 1u; + var settings = new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + var log = new List(); + var itemManager = new ItemManager(db.Cache, settings, log.Add); + var failures = 0; + + foreach (var hull in db.Cache.GetAll().OrderBy(h => h.HullType).ThenBy(h => h.Name)) + { + var random = new Random(seed); + var generator = new LoadoutGenerator(ref random, itemManager, null, null, null, .5f); + log.Clear(); + string outcome; + try + { + var pack = hull.HullType switch + { + HullType.Ship => generator.GenerateShipLoadout(candidate => candidate.ID == hull.ID), + HullType.Turret => generator.GenerateTurretLoadout(), + _ => generator.GenerateStationLoadout() + }; + outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; + if (pack == null) failures++; + } + catch (Exception e) + { + outcome = $"THREW {e.GetType().Name}: {e.Message}"; + failures++; + } + + Console.WriteLine($"{hull.Name,-12} ({hull.HullType,-7}) {outcome}"); + foreach (var line in log.Distinct()) Console.WriteLine($" {line}"); + } + + Console.WriteLine($"\n{failures} hulls failed to generate a loadout (seed {seed})"); + return failures; + } + // Mints a product for every design that still carries a manufacturer, copying its name and description. private static int MigrateProducts(bool apply) {