2026-09-26 18:30:02 +02:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
|
|
|
// Builds the one catalog snapshot CultCache needs. Sources remain the shipped catalog and each
|
|
|
|
|
// mod-owned ship.cc; this output is disposable and never becomes an authoring surface.
|
|
|
|
|
public static class ShipModCatalog
|
|
|
|
|
{
|
2026-09-26 19:10:44 +02:00
|
|
|
public sealed class Package
|
|
|
|
|
{
|
2026-09-30 03:06:43 +02:00
|
|
|
public HullData Hull;
|
|
|
|
|
public ShipAuthoring Visual;
|
2026-09-26 19:10:44 +02:00
|
|
|
public string ModelPath;
|
|
|
|
|
public Dictionary<string, uint> NodeIndices;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 18:30:02 +02:00
|
|
|
public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id);
|
|
|
|
|
public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id);
|
|
|
|
|
|
|
|
|
|
public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot)
|
|
|
|
|
{
|
|
|
|
|
var source = Path.GetFullPath(shippedCatalog);
|
|
|
|
|
var output = Path.GetFullPath(outputCatalog);
|
|
|
|
|
var root = Path.GetFullPath(modsRoot);
|
|
|
|
|
if (!File.Exists(source)) throw new FileNotFoundException("Shipped catalog is missing", source);
|
|
|
|
|
if (!Directory.Exists(root)) throw new DirectoryNotFoundException($"Mod root is missing: {root}");
|
|
|
|
|
if (string.Equals(source, output, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
throw new InvalidOperationException("The derived catalog cannot replace the shipped catalog.");
|
|
|
|
|
if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset.");
|
|
|
|
|
|
2026-09-30 03:06:43 +02:00
|
|
|
var packages = new List<Package>();
|
2026-09-26 18:30:02 +02:00
|
|
|
foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
var path = Path.Combine(directory, "ship.cc");
|
|
|
|
|
if (!File.Exists(path)) continue;
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
// ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique.
|
2026-09-30 03:06:43 +02:00
|
|
|
packages.Add(ReadPackage(path));
|
2026-09-26 18:30:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(output));
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
// The store keeps a lock file beside every file it opens, so the working copy gets its own directory and the
|
|
|
|
|
// whole directory goes away, whatever the store left in it.
|
|
|
|
|
var workspace = output + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
|
|
|
|
var temporary = Path.Combine(workspace, "catalog.cc");
|
2026-09-26 18:30:02 +02:00
|
|
|
try
|
|
|
|
|
{
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
Directory.CreateDirectory(workspace);
|
2026-09-26 18:30:02 +02:00
|
|
|
File.Copy(source, temporary);
|
|
|
|
|
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
|
|
|
|
|
{
|
2026-09-30 03:06:43 +02:00
|
|
|
foreach (var package in packages)
|
2026-09-26 18:30:02 +02:00
|
|
|
{
|
2026-09-30 03:06:43 +02:00
|
|
|
var (hull, ship) = (package.Hull, package.Visual);
|
2026-09-26 18:30:02 +02:00
|
|
|
var hullKey = HullKey(ship.Id);
|
|
|
|
|
var authoringKey = AuthoringKey(ship.Id);
|
|
|
|
|
if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey)))
|
|
|
|
|
throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record.");
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
// CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base
|
|
|
|
|
// type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's
|
|
|
|
|
// hull (upserted earlier in this loop), would make name lookups wrong or throw.
|
2026-09-30 03:06:43 +02:00
|
|
|
if (cache.GetAll<ItemData>().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal)))
|
|
|
|
|
throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item.");
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
if (cache.GetAll<ShipAuthoring>().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal)))
|
|
|
|
|
throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record.");
|
2026-09-30 03:06:43 +02:00
|
|
|
// Verbatim: the mod store's own two records, under the keys it already holds them at.
|
|
|
|
|
ShipAuthoringStore.Write(cache, hull, ship);
|
2026-09-26 18:30:02 +02:00
|
|
|
}
|
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
|
|
|
// Shipped hulls too: the derived catalog must not hold a hull that names two bodies.
|
|
|
|
|
foreach (var hull in cache.GetAll<HullData>())
|
|
|
|
|
ShipAuthoringStore.RequireOneBody(hull, hull.Name);
|
2026-09-26 18:30:02 +02:00
|
|
|
cache.FlushAsync().GetAwaiter().GetResult();
|
|
|
|
|
}
|
|
|
|
|
if (File.Exists(output)) File.Replace(temporary, output, null);
|
|
|
|
|
else File.Move(temporary, output);
|
2026-09-30 03:06:43 +02:00
|
|
|
return packages.Count;
|
2026-09-26 18:30:02 +02:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
if (Directory.Exists(workspace)) Directory.Delete(workspace, true);
|
2026-09-26 18:30:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 19:10:44 +02:00
|
|
|
public static Package ReadPackage(string shipPath)
|
|
|
|
|
{
|
|
|
|
|
var path = Path.GetFullPath(shipPath);
|
2026-09-30 03:06:43 +02:00
|
|
|
var (hull, ship) = ShipAuthoringStore.Read(path);
|
2026-09-30 06:43:52 +02:00
|
|
|
return Bind(hull, ship, Path.GetDirectoryName(path), path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The package a catalog hull names: its two records come from the catalog, and only the GLB comes from the mod
|
|
|
|
|
// directory (<modsRoot>/<id>/<ModelAsset>). Gameplay reads a mod ship through this, never through its ship.cc.
|
|
|
|
|
public static Package PackageOf(CultCache catalog, HullData hull, string modsRoot)
|
|
|
|
|
{
|
|
|
|
|
var ship = catalog.Get(hull.Visual) ??
|
|
|
|
|
throw new InvalidOperationException($"{hull.Name}: the catalog holds no visual record {hull.Visual.Key.Value}.");
|
|
|
|
|
ShipAuthoringStore.Validate(hull, ship);
|
|
|
|
|
var directory = Path.Combine(Path.GetFullPath(modsRoot), ship.Id);
|
|
|
|
|
return Bind(hull, ship, directory, Path.Combine(directory, "ship.cc"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The one game-side choice of catalog: the shipped file when the mods root holds no package, otherwise the derived
|
|
|
|
|
// file, composed afresh from the shipped one and the packages. Recomposed every call, so the derived file is never
|
|
|
|
|
// stale and never an authority.
|
|
|
|
|
public static string ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot)
|
|
|
|
|
{
|
|
|
|
|
if (!Directory.Exists(modsRoot) ||
|
|
|
|
|
!Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc"))))
|
|
|
|
|
return shippedCatalog;
|
|
|
|
|
Compose(shippedCatalog, derivedCatalog, modsRoot);
|
|
|
|
|
return derivedCatalog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Package Bind(HullData hull, ShipAuthoring ship, string directory, string path)
|
|
|
|
|
{
|
2026-09-26 19:10:44 +02:00
|
|
|
if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal))
|
|
|
|
|
throw new InvalidOperationException($"{path}: ship ID must match its package directory name.");
|
|
|
|
|
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
|
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
|
|
|
// Containment is ShipAuthoringStore.Validate's job: it refuses rooted and `..` model paths before this line.
|
|
|
|
|
if (!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase))
|
2026-09-26 19:10:44 +02:00
|
|
|
throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package.");
|
|
|
|
|
var modelNodes = ReadNodeIds(modelPath);
|
|
|
|
|
foreach (var anchor in ship.Anchors)
|
|
|
|
|
if (!modelNodes.ContainsKey(anchor.ModelNodeId))
|
|
|
|
|
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
|
2026-09-30 03:06:43 +02:00
|
|
|
CultRecordRefs.Validate(hull);
|
|
|
|
|
return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes };
|
2026-09-26 19:10:44 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-26 18:30:02 +02:00
|
|
|
// GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer.
|
2026-09-26 19:10:44 +02:00
|
|
|
public static Dictionary<string, uint> ReadNodeIds(string path)
|
2026-09-26 18:30:02 +02:00
|
|
|
{
|
|
|
|
|
using var stream = File.OpenRead(path);
|
|
|
|
|
using var reader = new BinaryReader(stream);
|
|
|
|
|
if (stream.Length < 20 || reader.ReadUInt32() != 0x46546C67 || reader.ReadUInt32() != 2 ||
|
|
|
|
|
reader.ReadUInt32() != stream.Length)
|
|
|
|
|
throw new InvalidOperationException($"{path}: invalid GLB 2 header.");
|
|
|
|
|
var chunkLength = reader.ReadUInt32();
|
|
|
|
|
if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position)
|
|
|
|
|
throw new InvalidOperationException($"{path}: missing GLB JSON chunk.");
|
|
|
|
|
var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength)));
|
2026-09-26 19:10:44 +02:00
|
|
|
var ids = new Dictionary<string, uint>(StringComparer.Ordinal);
|
|
|
|
|
var nodes = json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>();
|
|
|
|
|
uint index = 0;
|
|
|
|
|
foreach (var node in nodes)
|
2026-09-26 18:30:02 +02:00
|
|
|
{
|
|
|
|
|
var id = (string)node["extras"]?["aetheria.id"];
|
2026-09-26 19:10:44 +02:00
|
|
|
if (string.IsNullOrEmpty(id)) { index++; continue; }
|
|
|
|
|
if (!ids.TryAdd(id, index))
|
2026-09-26 18:30:02 +02:00
|
|
|
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
|
2026-09-26 19:10:44 +02:00
|
|
|
index++;
|
2026-09-26 18:30:02 +02:00
|
|
|
}
|
|
|
|
|
return ids;
|
|
|
|
|
}
|
|
|
|
|
}
|