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 { public sealed class Package { public HullData Hull; public ShipAuthoring Visual; public string ModelPath; public Dictionary NodeIndices; // The package's own products, read from its ship.cc; empty for a package taken from the catalog, where gameplay reads products. public FactionProductData[] Products = Array.Empty(); } public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id); public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id); public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value); // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. public static bool IsModKey(CultRecordKey key) => key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal)); // A package left out of the composition, named by its directory, with every reason it was left out. public sealed class Exclusion { public string Package; public string Reason; public override string ToString() => $"{Package}: {Reason}"; } public sealed class Composition { // Ship IDs of the packages composed into the derived catalog, in directory order. public string[] Included; // Packages that failed on their own or collided with the catalog or with each other, in directory order. public Exclusion[] Excluded; } // One bad package never stops the rest. Each package is read and validated on its own; one that fails is excluded and // named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved, // since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages. // Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed. public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot, CultDocumentRegistry registry = null) { 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."); var reasons = new SortedDictionary>(StringComparer.Ordinal); void Exclude(string package, string reason) { if (!reasons.TryGetValue(package, out var list)) reasons[package] = list = new List(); list.Add(reason); } var packages = new List<(string Directory, Package Package)>(); foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; var name = Path.GetFileName(directory); // A package can fail in any way a hostile or broken file can, so its failure is quarantined whatever it is. try { packages.Add((name, ReadPackage(path))); } catch (Exception error) { Exclude(name, error.Message); } } Directory.CreateDirectory(Path.GetDirectoryName(output)); // 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"); try { Directory.CreateDirectory(workspace); File.Copy(source, temporary); Composition composition; using (var cache = AetheriaStores.Open(temporary, catalogWritable: true, registry: registry)) { // Collisions are judged against the shipped records alone, before any package is written, so the // verdict does not depend on directory order. 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 like another mod's hull, would make name lookups wrong or throw. var keys = new HashSet(cache.AllStoredDocuments.Select(record => record.Key)); var itemNames = new HashSet(cache.GetAll().Select(item => item.Name), StringComparer.Ordinal); var shipIds = new HashSet(cache.GetAll().Select(existing => existing.Id), StringComparer.Ordinal); var factionKeys = new HashSet(cache.GetAll().Select(faction => cache.TryGetHandle(faction).Value.Key)); var productNames = new HashSet(cache.GetAll().Select(product => product.Name), StringComparer.Ordinal); foreach (var (directory, package) in packages) { var (hull, ship) = (package.Hull, package.Visual); if (keys.Contains(HullKey(ship.Id)) || keys.Contains(AuthoringKey(ship.Id))) Exclude(directory, $"a mod key collides with an existing catalog record."); if (itemNames.Contains(hull.Name)) Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); if (shipIds.Contains(ship.Id)) Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault); foreach (var product in package.Products) { if (!factionKeys.Contains(product.Manufacturer.Key)) Exclude(directory, $"product '{product.Name}' names a manufacturer the shipped catalog holds no faction for."); if (keys.Contains(ProductKey(ship.Id, product.Manufacturer.Key))) Exclude(directory, $"a mod product key collides with an existing catalog record."); if (productNames.Contains(product.Name)) Exclude(directory, $"product name '{product.Name}' collides with an existing catalog product."); } } foreach (var claim in packages.GroupBy(package => package.Package.Hull.Name, StringComparer.Ordinal).Where(group => group.Count() > 1)) foreach (var (directory, package) in claim) Exclude(directory, $"hull name '{claim.Key}' is also claimed by " + string.Join(", ", claim.Where(other => other.Directory != directory).Select(other => other.Directory)) + "."); foreach (var claim in packages.SelectMany(package => package.Package.Products.Select(product => (package.Directory, product.Name))) .GroupBy(entry => entry.Name, StringComparer.Ordinal).Where(group => group.Select(entry => entry.Directory).Distinct().Count() > 1)) foreach (var directory in claim.Select(entry => entry.Directory).Distinct()) Exclude(directory, $"product name '{claim.Key}' is also claimed by " + string.Join(", ", claim.Select(entry => entry.Directory).Where(other => other != directory).Distinct()) + "."); var included = packages.Where(package => !reasons.ContainsKey(package.Directory)).Select(package => package.Package).ToArray(); foreach (var package in included) { // Verbatim: the mod store's own records, under the keys it already holds them at. ShipAuthoringStore.Write(cache, package.Hull, package.Visual); foreach (var product in package.Products) ShipAuthoringStore.WriteProduct(cache, package.Visual.Id, product); } // Shipped hulls too: the derived catalog must not hold a hull that names two bodies. foreach (var hull in cache.GetAll()) ShipAuthoringStore.RequireOneBody(hull, hull.Name); cache.FlushAsync().GetAwaiter().GetResult(); composition = new Composition { Included = included.Select(package => package.Visual.Id).ToArray(), Excluded = reasons.Select(entry => new Exclusion { Package = entry.Key, Reason = string.Join("; ", entry.Value) }).ToArray() }; } if (File.Exists(output)) File.Replace(temporary, output, null); else File.Move(temporary, output); return composition; } finally { if (Directory.Exists(workspace)) Directory.Delete(workspace, true); } } public static Package ReadPackage(string shipPath) { var path = Path.GetFullPath(shipPath); var (hull, ship, products) = ShipAuthoringStore.Read(path); return Bind(hull, ship, products, 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 (//). 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, Array.Empty(), directory, Path.Combine(directory, "ship.cc")); } // The one game-side choice of catalog: the shipped file when no package is installed or none survives composition, // otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every // call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu. public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot, CultDocumentRegistry registry = null) { if (!Directory.Exists(modsRoot) || !Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc")))) return (shippedCatalog, Array.Empty()); Composition composition; // A failure of the composition itself (an unwritable derived path, say) is still a mod problem, never a reason not to boot. try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot, registry); } catch (Exception error) { return (shippedCatalog, new[] { new Exclusion { Package = "(all mods)", Reason = error.Message } }); } return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded); } // The one uninstall: presence under Mods is the only enablement (MR1), so a package leaves by moving, never by deletion, // to /RemovedMods/ (' (2)' and on when taken). Moving it back reinstalls it. // Returns the destination. Compose and ResolveCatalog never read RemovedMods. public static string RemovePackage(string modsRoot, string package) { var root = Path.GetFullPath(modsRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // Containment, not a blacklist: the package must resolve to a strict child of the mods folder whose final name is // the name given. A name the filesystem rewrites ('...', 'a.', 'a ', 'CON', 'a:b') resolves to something else. if (!IsPackageName(package)) throw new ArgumentException("Not a mod package name.", nameof(package)); var source = ChildOf(root, package) ?? throw new ArgumentException("Not a mod package name.", nameof(package)); var info = new DirectoryInfo(source); if (!info.Exists) throw new DirectoryNotFoundException("The package is not a directory under the mods folder."); if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new ArgumentException("A package that is a link is not removed.", nameof(package)); var removed = RemovedRoot(modsRoot); Directory.CreateDirectory(removed); var destination = ChildOf(removed, package); for (var copy = 2; destination != null && (Directory.Exists(destination) || File.Exists(destination)); copy++) destination = ChildOf(removed, $"{package} ({copy})"); if (destination == null) throw new ArgumentException("Not a mod package name.", nameof(package)); Directory.Move(source, destination); return destination; } private static readonly string[] ReservedDeviceNames = { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }; // A name Windows keeps as written: no separator, stream colon or wildcard, no trailing dot or space, and // not a device name (with or without an extension). Held on every platform so a mod moves the same way everywhere. private static bool IsPackageName(string name) => !string.IsNullOrEmpty(name) && name != "(all mods)" && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && name.IndexOfAny(new[] { ':', '*', '?', '"', '<', '>', '|', '/', '\\' }) < 0 && name.All(character => character >= ' ') && !name.EndsWith('.') && !name.EndsWith(' ') && !ReservedDeviceNames.Contains(name.Split('.')[0].TrimEnd(' '), StringComparer.OrdinalIgnoreCase); // The full path of `name` directly under `root`, or null when the filesystem would resolve it anywhere else or under // another name. The comparison is of resolved path segments, never of a string prefix ('Mods2' is not inside 'Mods'). private static string ChildOf(string root, string name) { var full = Path.GetFullPath(Path.Combine(root, name)); return string.Equals(Path.GetDirectoryName(full), root, StringComparison.Ordinal) && string.Equals(Path.GetFileName(full), name, StringComparison.Ordinal) ? full : null; } private static string RemovedRoot(string modsRoot) => Path.Combine(Path.GetDirectoryName(Path.GetFullPath(modsRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)), "RemovedMods"); private static Package Bind(HullData hull, ShipAuthoring ship, FactionProductData[] products, string directory, string path) { 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)); // 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.Ordinal)) 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}."); // Assemble reads a renderer or collider mesh from these anchors' nodes and refuses without one, and reads the shield // node's scale as three positive radii (ruling thrusters-radiators-are-meshes). These refusals stand in for Assemble's // throws, so a package Compose includes is one the importer's assembler can build; the exhaust emission surface and // the glowing radiator are meshes by that same ruling. A weapon mount and a tractor may be empties. foreach (var anchor in ship.Anchors) if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "map-icon" || anchor.Role == "hull-collider") && !modelNodes[anchor.ModelNodeId].HasMesh) throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none."); foreach (var anchor in ship.Anchors) if (anchor.Role == "shield" && !modelNodes[anchor.ModelNodeId].PositiveScale) throw new InvalidOperationException($"{ship.Id}: shield anchor {anchor.Id} needs three positive scale radii on model node {anchor.ModelNodeId}, with no matrix."); BindRig(ship, modelNodes); CultRecordRefs.Validate(hull); return new Package { Hull = hull, Visual = ship, Products = products, ModelPath = modelPath, NodeIndices = modelNodes.ToDictionary(node => node.Key, node => node.Value.Index, StringComparer.Ordinal) }; } // The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's // Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a radiator // mesh sits under no joint. private static void BindRig(ShipAuthoring ship, Dictionary nodes) { var joints = new HashSet((ship.Joints ?? new List()).Select(joint => joint.Id), StringComparer.Ordinal); var idAt = nodes.ToDictionary(node => (int)node.Value.Index, node => node.Key); // Parent is already the nearest ancestor that carries an id, so the walk up is the chain of those. string JointAbove(string nodeId) { // glTF forbids cycles; the bound keeps a malformed model from hanging the binder. var parent = nodes[nodeId].Parent; for (var steps = 0; parent >= 0 && steps <= nodes.Count; steps++, parent = nodes[idAt[parent]].Parent) if (joints.Contains(idAt[parent])) return idAt[parent]; return null; } foreach (var joint in ship.Joints ?? new List()) { if (!nodes.ContainsKey(joint.Id)) throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={joint.Id} for a joint."); if (!string.Equals(JointAbove(joint.Id) ?? "", joint.Parent ?? "", StringComparison.Ordinal)) throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} must name the nearest joint above its model node as its Parent."); } foreach (var anchor in ship.Anchors.Where(anchor => RigChains.Actuates(anchor.Role) || anchor.Role == "radiator-mesh")) { var above = JointAbove(anchor.ModelNodeId); if (string.Equals(above ?? "", anchor.Joint ?? "", StringComparison.Ordinal)) continue; throw new InvalidOperationException(anchor.Role == "radiator-mesh" ? $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model." : $"{ship.Id}: {(anchor.Role == "weapon-mount" ? "weapon mount" : "thruster emitter")} {anchor.Id} must name the nearest joint above its model node as its Joint."); } } // No matrix, and every scale component (default 1) above zero: what Assemble's shield radii need. private static bool PositiveScale(JObject node) => node["matrix"] == null && (node["scale"] == null || node["scale"].Select(component => (double)component).All(component => component > 0)); // GLB is the xenos asset boundary. Only its node extras, whether each node names a mesh, and its nearest identified ancestor's node index // (-1 for none) are read here, plus whether the node's scale is three positive radii with no matrix; geometry belongs to the runtime importer. public static Dictionary ReadNodeIds(string path) { 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))); var ids = new Dictionary(StringComparer.Ordinal); var nodes = (json["nodes"]?.OfType() ?? Enumerable.Empty()).ToArray(); var parents = Enumerable.Repeat(-1, nodes.Length).ToArray(); for (var parent = 0; parent < nodes.Length; parent++) foreach (var child in nodes[parent]["children"]?.Select(token => (int)token) ?? Enumerable.Empty()) if (child >= 0 && child < nodes.Length) parents[child] = parent; uint index = 0; foreach (var node in nodes) { var id = (string)node["extras"]?["aetheria.id"]; if (string.IsNullOrEmpty(id)) { index++; continue; } if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, IdentifiedAncestor(parents, nodes, (int)index), PositiveScale(node)))) throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); index++; } return ids; } private static int IdentifiedAncestor(int[] parents, JObject[] nodes, int index) { var parent = parents[index]; for (var steps = 0; parent >= 0 && steps <= nodes.Length; steps++, parent = parents[parent]) if (!string.IsNullOrEmpty((string)nodes[parent]["extras"]?["aetheria.id"])) return parent; return -1; } }