diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 6b899ee35..300229d3f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -6,6 +6,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; +using GameCult.Caching; using MessagePack; using Newtonsoft.Json; using CultMath; @@ -212,4 +213,7 @@ public abstract class BehaviorData { return base.ToString().FormatTypeName(); } + + // Element identity: minted by the cache on write; "" means unset. + [Key(37)] [CultElementId] public string Id = ""; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index e527606d9..080f42aac 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -84,5 +84,8 @@ public class FactionPlace // treats an invalid entry as absent; AetherDb place-names refuses a table that holds one. public static bool IsValidName(string name) => !string.IsNullOrEmpty(name) && name == name.Trim() && !name.StartsWith("EAC-", StringComparison.OrdinalIgnoreCase); + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 7f0f04ebd..9aefe5a19 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -294,4 +294,7 @@ public abstract class EntityPack return _price; } + + // Element identity: minted by the cache on write; "" means unset. + [Key(23)] [CultElementId] public string Id = ""; } diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index 14f7af57d..87ac72f7d 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -61,6 +61,9 @@ public class ProductRole [Inspectable, JsonProperty("standardDeviation"), Key(2)] public float StandardDeviation = .15f; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } // A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself diff --git a/Assets/Scripts/ServerShared/InputLayout.cs b/Assets/Scripts/ServerShared/InputLayout.cs index a688598b2..7e942c519 100644 --- a/Assets/Scripts/ServerShared/InputLayout.cs +++ b/Assets/Scripts/ServerShared/InputLayout.cs @@ -29,7 +29,10 @@ public class InputLayout Union(0, typeof(InputLayoutRowSpacer)), Union(1, typeof(InputLayoutKeyRow)) ] -public abstract class InputLayoutRow { } +public abstract class InputLayoutRow { + // Element identity: minted by the cache on write; "" means unset. + [Key(1)] [CultElementId] public string Id = ""; +} [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class InputLayoutRowSpacer : InputLayoutRow @@ -52,6 +55,9 @@ public class InputLayoutKeyRow : InputLayoutRow public abstract class InputLayoutColumn { [Key(0), JsonProperty("width")] public float Width; + + // Element identity: minted by the cache on write; "" means unset. + [Key(5)] [CultElementId] public string Id = ""; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 72c07fc9a..ab9c9dcfc 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -348,6 +348,9 @@ public class ItemRole [Inspectable, JsonProperty("count"), Key(1)] public int Count = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject] @@ -635,6 +638,9 @@ public class HardpointData } private static Dictionary _tintColors; + + // Element identity: minted by the cache on write; "" means unset. + [Key(8)] [CultElementId] public string Id = ""; } // What a PerformanceStat reads to resolve one number: the lot it prices quality against, and the per-context @@ -680,6 +686,9 @@ public class StatTerm // The design role whose part quality this term reads; unset reads the item's effective quality (Lot.EffectiveQuality). // Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension. [Inspectable, JsonProperty("role"), Key(2)] public string Role; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 4866aa406..b423eb1a0 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -28,6 +28,9 @@ public class LoadoutSlot [Key(1)] public ItemRotation Rotation; [Key(2)] public CultRecordRef Design; [Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization + + // Element identity: minted by the cache on write; "" means unset. + [Key(4)] [CultElementId] public string Id = ""; } // Why a faction has no boss Loadout to build; see Loadouts.BossOf. diff --git a/Assets/Scripts/ServerShared/Provenance.cs b/Assets/Scripts/ServerShared/Provenance.cs index 4121332d6..25a4a6175 100644 --- a/Assets/Scripts/ServerShared/Provenance.cs +++ b/Assets/Scripts/ServerShared/Provenance.cs @@ -92,6 +92,9 @@ public class Lot foreach (var fill in Roles) sum += Math.Min(fill.Quality, Quality); return sum / Roles.Count; } + + // Element identity: minted by the cache on write; "" means unset. + [Key(5)] [CultElementId] public string Id = ""; } // One filled slot: the design's role name and how good the part that went into it turned out to be. Stats read it through @@ -101,6 +104,9 @@ public class RoleFill { [Key(0)] public string Role; [Key(1)] public float Quality; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // Who built a lot, where, and from what. Terminus mints Attributed placeholders; Produced and Extracted are the diff --git a/Assets/Scripts/ServerShared/Recipes.cs b/Assets/Scripts/ServerShared/Recipes.cs index e434a84a5..f9aa3ee4a 100644 --- a/Assets/Scripts/ServerShared/Recipes.cs +++ b/Assets/Scripts/ServerShared/Recipes.cs @@ -63,6 +63,9 @@ public class CommodityCount [JsonProperty("count"), Key(1)] public int Count = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // A region gate's trade with the world beyond it: what the outside buys and sells, at what price and at most how fast. @@ -96,6 +99,9 @@ public class OutsideMarketRow [JsonProperty("ratePerHour"), Key(4)] public float RatePerHour; + + // Element identity: minted by the cache on write; "" means unset. + [Key(5)] [CultElementId] public string Id = ""; } // The one reader of recipes: which recipe makes a good, a recipe's role slots (RoleInputsOf), which commodities fill a role, and diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 88ff8c9bf..5f42de446 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -78,6 +78,9 @@ public class SavedStanding [Key(1)] public FactionRelationship Relationship; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [MessagePackObject] @@ -88,6 +91,9 @@ public class SavedProvince [Key(1)] public CultRecordRef Type; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records; @@ -301,6 +307,9 @@ public class SavedZone ] public abstract class SavedActionBarBinding { + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [MessagePackObject] diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 3962fa468..8441d0332 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -33,6 +33,9 @@ public sealed class ShipJoint [Key(2)] public float[] Axes; [Key(3)] public float[] Min; [Key(4)] public float[] Max; + + // Element identity, derived from the bone id so it never changes across rewrites. + [Key(5)] [CultElementId(nameof(Id))] public string ElementId = ""; } [MessagePackObject] @@ -50,6 +53,9 @@ public sealed class ShipAnchor [Key(4)] public int Order; // On a weapon-mount anchor: the id of the nearest joint above its node in the model; null or empty is a fixed mount. [Key(5)] public string Joint; + + // Element identity, derived from the anchor id so it never changes across rewrites. + [Key(6)] [CultElementId(nameof(Id))] public string ElementId = ""; } // Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up @@ -64,6 +70,9 @@ public sealed class ShipPolyline [Key(4)] public float[] Opacities; [Key(5)] public bool Cyclic; [Key(6)] public float[] Color; + + // Element identity: minted by the cache on write; "" means unset. + [Key(7)] [CultElementId] public string Id = ""; } public static class ShipAuthoringStore diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index dad1ce2aa..eb2662eee 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -35,6 +35,9 @@ public class StationKindStandIn // The kind whose model this faction builds in place of the owning kind's. [JsonProperty("kind"), Key(1)] public CultRecordRef Kind; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in. diff --git a/Assets/Scripts/ServerShared/VerseGrammar.cs b/Assets/Scripts/ServerShared/VerseGrammar.cs index 00e24833d..1219e70b6 100644 --- a/Assets/Scripts/ServerShared/VerseGrammar.cs +++ b/Assets/Scripts/ServerShared/VerseGrammar.cs @@ -40,6 +40,9 @@ public class VerseRole { [Key(1)] public string Name; [Key(2)] public VerseReferentKind Binds; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } // The actor is the invoking subject and is never a role. diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index a08895aaa..e76963a43 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -67,6 +67,9 @@ public class ChunkWearPack [JsonProperty("brokenUntil"), Key(3)] public double? BrokenUntil; + + // Element identity: minted by the cache on write; "" means unset. + [Key(4)] [CultElementId] public string Id = ""; } [JsonObject(MemberSerialization.OptIn)] @@ -149,6 +152,9 @@ public class FieldYield [JsonProperty("regional"), Key(2)] public bool Regional; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } // Ore geography (rulings provinces-within-regions, ores-are-regional): a kind of ground, authored. A region deals its @@ -182,6 +188,9 @@ public class ProvinceKindWeight [JsonProperty("weight"), Key(1)] public float Weight = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] @@ -192,6 +201,9 @@ public class ProvinceOre [JsonProperty("weight"), Key(1)] public float Weight = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // Mining Cut 3 (Q16 B): the one owner of which kind a belt is. Ore geography: and of what it yields. @@ -485,6 +497,9 @@ public class Asteroid [JsonProperty("rotationSpeed"), Key(3)] public float RotationSpeed; + + // Element identity: minted by the cache on write; "" means unset. + [Key(4)] [CultElementId] public string Id = ""; } [CultDocument("aetheria.orbitdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Directory.Build.props b/Directory.Build.props index 05d5e4d22..62160b34f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,12 +2,12 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - b8fc5eb07720a187a0421424ac2cb3aa2966ca1d + + 7225835d11fd08a3de2a2ef24742fc351b3b08fb $(CULTMATH_ROOT) $(CultLibRoot) - - 7a8933b085468e1fc6f9d3bce4a85e7d101aaca3 + + f1edfa458dd8e7e107214e9d9884254359475b8c C:\Program Files\Git\cmd\git.exe git diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index ebe05cd33..1def7b776 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87d8cbcca252460d1ec2bfeb895c1447ab185172a4a4449b237a297b83ea5057 -size 40059 +oid sha256:0228aa7ec3a2e060bd49e46aa64f52eea4c4cdb25951c164646c1d031ad17851 +size 30457 diff --git a/GameData/Mods/aetheria.wren/ship.cc b/GameData/Mods/aetheria.wren/ship.cc index d81f9bba5..71b71ef5a 100644 --- a/GameData/Mods/aetheria.wren/ship.cc +++ b/GameData/Mods/aetheria.wren/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b1744dfa7dd6c5325ca3bd04fba941408cdc1255c06d1fc0d52fa67c97dc100 -size 10514 +oid sha256:096ea08f7fe85b44c343b57dfbc84fd82b2ec1d450cc1eb49efc3c238f9a9678 +size 10528 diff --git a/Packages/manifest.json b/Packages/manifest.json index 7693bd94f..ef01307a8 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -53,7 +53,7 @@ "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v3.0.0", + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.1" } } diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 919cf94d1..4db845c3b 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -265,20 +265,20 @@ "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v3.0.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultmath": "0.5.0" + "org.gamecult.cultmath": "0.5.1" }, - "hash": "b8fc5eb07720a187a0421424ac2cb3aa2966ca1d" + "hash": "7225835d11fd08a3de2a2ef24742fc351b3b08fb" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.1", "depth": 0, "source": "git", "dependencies": {}, - "hash": "7a8933b085468e1fc6f9d3bce4a85e7d101aaca3" + "hash": "f1edfa458dd8e7e107214e9d9884254359475b8c" }, "com.unity.modules.accessibility": { "version": "1.0.0", diff --git a/docs/aetheria-fauna-map.md b/docs/aetheria-fauna-map.md index 05034c92a..5276db97e 100644 --- a/docs/aetheria-fauna-map.md +++ b/docs/aetheria-fauna-map.md @@ -398,7 +398,7 @@ the lore branch `eureka/aetheria-fauna-lore` `74c7e212`, note `Open Water` (`OW` | # | Question | Result | |---|---|---| | B28 | What does CultMath already give a node library? | Integer PCG hashes `pcg`, `pcg3d`, `pcg4d` (Jarzynski and Olano), "integer-only, so dxc and C# agree bit for bit" (`packages/cultmath/src/CultMath/math.cs:393-426`, `shaders/CultMath.hlsl:396-426`). `cellular(float3)`: Worley F1 and F2 - F1 with analytic gradients, jitter from `pcg3d` of the integer cell, 3D, unweighted (`math.cs:849-975`, `CultMath.hlsl:440-503`). `smin_grad` (`math.cs:831-847`, `CultMath.hlsl:428-437`). `distance_to_segment` in C# only (`math.cs:319`; no HLSL twin). No weighted cellular, no diagram over explicit weighted sites, no site-graph gate. | -| B29 | How does CultMath own parity? | Two legs, one owner each (`packages/cultmath/docs/design.md:202-206`): HLSL against C# by the mirror test, which compiles the text of `CultMath.hlsl` as C# and compares float32 bit patterns (`design.md:118-124`), plus a clean `dxc -T lib_6_3` compile (`design.md:114-116`); GLSL against C# by golden fixtures run on a device. The mirror proves the composition in the text, not that a GPU agrees (`design.md:119-121`); device HLSL runs are consumers' today (Asura `gpu-snoise-parity`; Aetheria `FlowFieldParityVerify`). CultMath ships a Unity compute smoke (`packages/cultmath/scripts/unity-smoke/`). Aetheria pins `cultmath-unity-v0.5.0` (`Packages/manifest.json`). | +| B29 | How does CultMath own parity? | Two legs, one owner each (`packages/cultmath/docs/design.md:202-206`): HLSL against C# by the mirror test, which compiles the text of `CultMath.hlsl` as C# and compares float32 bit patterns (`design.md:118-124`), plus a clean `dxc -T lib_6_3` compile (`design.md:114-116`); GLSL against C# by golden fixtures run on a device. The mirror proves the composition in the text, not that a GPU agrees (`design.md:119-121`); device HLSL runs are consumers' today (Asura `gpu-snoise-parity`; Aetheria `FlowFieldParityVerify`). CultMath ships a Unity compute smoke (`packages/cultmath/scripts/unity-smoke/`). Aetheria pins `cultmath-unity-v0.5.1` (`Packages/manifest.json`). | | B30 | Does Aetheria already keep a math twin outside CultMath? | Yes. `FlowField` uses CultMath's `math`, but its `Tri`, `Tri3` and `Tri3D` are Aetheria functions (`FlowField.cs:66-72`) whose HLSL twin is `Volumetric.cginc`, pinned by Aetheria's own `FlowFieldParityVerify` (`RelativeTolerance` 1.5e-4). That is a second parity owner beside CultMath. | | B31 | What is parked in CultMath that a DAG would use? | The tape: steps 2-5 (expression graph lowered to bytecode with C# evaluators, pruning, an HLSL interpreter, meshing) are parked until Asura is stable (`docs/cultmath-tape-target.md:3-8`, ruling `tape-target-unparks-when-asura-stable`). "Truth and lowerings" (`:84-92`): the field is a typed CultCache document, and the CPU evaluator and the HLSL interpreter are lowerings of it. Step 1 (intervals) and affine forms are on main. | | B32 | Do the candidates make filaments? (`docs/research/2026-10-14-trophic-dag-probe/`, README there; wells orbit, slope terms on, t = 0) | `skein` draws strands between neighbouring wells, bent by the flow (`density_skein.png`); `lattice` draws a fine cellular web, dense on the shoulders, dim elsewhere, with no long links (`density_lattice.png`); `skein-web` draws both: strands from well to well with a web hanging off them (`density_skein-web.png`, `layer_L2-krill.png`). Correlation with the B20 Physarum map at 64x32 blocks (this measures the envelope, not filaments: a block is 62 u, wider than a strand): band .512, lattice .359, skein-web .296, krill .277, skein .225, slack .171. Hosted krill at 16 u: band 2,075, skein-web 1,475, skein 639, lattice 419. | diff --git a/docs/element-ids.md b/docs/element-ids.md new file mode 100644 index 000000000..87446b7e5 --- /dev/null +++ b/docs/element-ids.md @@ -0,0 +1,41 @@ +# Element ids and the CultLib pin (cultlib-unity-v3.0.0) + +CultLib 3.0.0 (Unity tag `cultlib-unity-v3.0.0`, commit `7225835d`, which is the headless pin in `Directory.Build.props`; CultMath `cultmath-unity-v0.5.1`, `f1edfa45`) gives every element of an object list an identity. Every +element type reachable through a list of a `[CultDocument]` type carries one string member with `[Key(n)]` and `[CultElementId]`; +the registry refuses to open otherwise, naming the type and list. + +- **Where the slot goes.** A union base takes the member above its subclasses' highest key. `BehaviorData.Id` is key 37, so an + array-format behavior is 38 slots on the wire (`PaintModeTests`, `ShipRigTests` pin it). A new `BehaviorData` subclass must keep + its keys below 37 or move the slot, which is a wire change. +- **Derived ids.** `ShipJoint` and `ShipAnchor` already had a readable `Id`; CultLib refuses a readable id inside a marked member, + so each gained a trailing `ElementId` marked `[CultElementId(nameof(Id))]`. +- **Minting.** A writer mints random ids for elements that have none. A store written before ids (every file under `GameData/`) + loads with ids minted in memory and persists them on its next write; `CultCache.MintElementIds()` does that in one pass. + `ElementIdUpgradeTests` opens a copy of the shipped catalog, mints, flushes and reopens it. +- **Comparing content.** Random ids differ between two writes of the same content. Tests that compare bytes use + `ElementIdBlind`. +- **One list element is one object.** The same instance twice in a list is refused (`CultElementIdException`). +- **Retyping.** Writing a different type onto a held key is refused unless the record is removed first + (`EconomyAuthor.Write` does). + +## The bump is one-way + +A store holding element ids is format v3 (single file) or v5 (directory). The previous pin (`cultlib-unity-v2.0.1`, `b8fc5eb0`) refuses a v3 store loudly +(`NotSupportedException: Store format cultcache.store.v3 is not readable`; checked by Soul on PR 12). CultLib #98 (`7002c252`) records +that some intermediate pins read a single-file v3 store silently and drop the ids on the next write. So: upgrade readers before +writers, keep a pre-upgrade copy of any real store (`GameData/Aetheria.cc` and the mod packages are tracked in git; player saves and +run stores are not), and do not roll the pin back after a store has been written under the new one. + +The shipped mod packages (`GameData/Mods/aetheria.wren/ship.cc`, `aetheria.nakovalnya/ship.cc`) once held values in the retired +`HullData` slots 21 and 22 (Wwise sound bank and audio stats), which the new CultLib refuses to write over +(`CultSchemaConflictException`). They were regenerated with `AetherDb ship-authoring regenerate apply`, which loads the +records, writes them into a fresh file under the current schema, requires them to read back identical and replaces the file. Both +now hold persisted element ids and nil in slots 21 and 22; `EveryShippedModPackageCanBeWrittenOver` fails if a package regresses. +A mod file that fails the same way is regenerated the same way. A rewritten catalog is about 15% larger: every behavior row is 38 +slots, mostly nil. + +The Unity manifest (`Packages/manifest.json`) pins `org.gamecult.cultlib` at `cultlib-unity-v3.0.0` and `org.gamecult.cultmath` at +`cultmath-unity-v0.5.1` (the version v3.0.0 depends on). `org.gamecult.caching.unity` stays at `caching-unity-v2.0.0`: it is editor +tooling, unchanged in v3.0.0. The v3.0.0 plugin `GameCult.Caching.dll` exports `CultElementIdAttribute` (sealed, fields and +properties, one optional `derivedFrom` argument), the shape `ServerShared` uses. The Unity editor compile of this branch has not +been run; the headless build and tests are the evidence so far. diff --git a/tests/Aetheria.Shared.Tests/ElementIdBlind.cs b/tests/Aetheria.Shared.Tests/ElementIdBlind.cs new file mode 100644 index 000000000..6c340ea8f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ElementIdBlind.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +// Element ids are minted at random when a record is written, so two writes of the same content differ in exactly those slots. +// A test that asks "is this the same content" compares the bytes with every element id blank. +public static class ElementIdBlind +{ + private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); + + // The document's bytes with every [CultElementId] member blank, on a copy: the document is untouched. + public static byte[] Pack(object document, Type type = null) + { + type ??= document.GetType(); + var copy = MessagePackSerializer.Deserialize(type, MessagePackSerializer.Serialize(type, document, Options), Options); + Blank(copy); + return MessagePackSerializer.Serialize(type, copy, Options); + } + + // A stored payload of the schema, re-encoded with every element id blank. + public static byte[] Payload(CultDocumentRegistry registry, string schemaId, byte[] payload) + { + var type = registry.GetRequiredBySchemaId(schemaId).DocumentType; + var value = MessagePackSerializer.Deserialize(type, payload, Options); + Blank(value); + return MessagePackSerializer.Serialize(type, value, Options); + } + + private static void Blank(object value) + { + if (value == null || value is string || value.GetType().IsPrimitive || value.GetType().IsEnum) return; + if (value is IEnumerable items && !(value is string)) + { + foreach (var item in items) Blank(item); + return; + } + for (var type = value.GetType(); type != null && type != typeof(object); type = type.BaseType) + foreach (var member in type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (member is FieldInfo field) + { + if (field.GetCustomAttribute() != null) field.SetValue(value, ""); + else if (!field.FieldType.IsPrimitive && field.GetCustomAttribute() == null && field.GetCustomAttribute() != null) + Blank(field.GetValue(value)); + } + else if (member is PropertyInfo property && property.GetIndexParameters().Length == 0 && property.GetCustomAttribute() != null) + { + if (property.GetCustomAttribute() != null) property.SetValue(value, ""); + else Blank(property.GetValue(value)); + } + } + } +} diff --git a/tests/Aetheria.Shared.Tests/ElementIdUpgradeTests.cs b/tests/Aetheria.Shared.Tests/ElementIdUpgradeTests.cs new file mode 100644 index 000000000..d94222635 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ElementIdUpgradeTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using Xunit; + +// CultLib 3.0 requires element ids and reads the stores written before it (2.0 and older), which carry none. +// The shipped GameData stores are those stores, byte for byte: they have never been rewritten under the new pin. +// Upgrading is one-way: once a store is written under the new pin it holds element ids, and an older pin can read it only by +// dropping them (CultLib #98). Readers upgrade before writers. +public class ElementIdUpgradeTests +{ + private static string Shipped => Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc"); + + [Fact] + public async Task AStoreWrittenBeforeElementIdsOpensMintsThemOnceAndKeepsEveryRecord() + { + using var directory = new TempDirectory(); + var copy = Path.Combine(directory.Path, "Aetheria.cc"); + File.Copy(Shipped, copy); + + int records, minted; + using (var cache = AetheriaStores.Open(copy, catalogWritable: true, registry: TestCatalog.Registry())) + { + records = cache.AllStoredDocuments.Count(); + Assert.True(records > 0); + minted = cache.MintElementIds(); + Assert.True(minted > 0, "the shipped catalog predates element ids; opening it must have minted some"); + Assert.Equal(0, cache.MintElementIds()); + await cache.FlushAsync(); + } + + using var reopened = AetheriaStores.Open(copy, registry: TestCatalog.Registry()); + Assert.Equal(records, reopened.AllStoredDocuments.Count()); + Assert.Equal(0, reopened.MintElementIds()); + foreach (var hull in reopened.GetAll()) + { + var ids = hull.Hardpoints.Select(hardpoint => hardpoint.Id).ToArray(); + Assert.All(ids, id => Assert.False(string.IsNullOrEmpty(id))); + Assert.Equal(ids.Length, ids.Distinct().Count()); + } + } + + // Upgrading assigns ids and changes nothing else: every record of the old-format file decodes, once the id members are + // blank, to the same bytes before and after the mint, flush and reopen. + [Fact] + public async Task UpgradingKeepsEveryFieldOfEveryRecordAndOnlyAddsIds() + { + using var directory = new TempDirectory(); + var copy = Path.Combine(directory.Path, "Aetheria.cc"); + File.Copy(Shipped, copy); + var registry = TestCatalog.Registry(); + Dictionary Content(string path) => + GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records + .ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString(ElementIdBlind.Payload(registry, record.SchemaId, record.Payload))); + var before = Content(copy); + + using (var cache = AetheriaStores.Open(copy, catalogWritable: true, registry: TestCatalog.Registry())) + { + Assert.True(cache.MintElementIds() > 0); + await cache.FlushAsync(); + } + + var after = Content(copy); + Assert.Equal(before.Count, after.Count); + foreach (var record in before) Assert.True(after[record.Key] == record.Value, $"{record.Key} changed content when its element ids were minted"); + } + + [Fact] + public void EveryShippedModPackageStillLoads() + { + var packages = Directory.GetDirectories(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Mods")); + Assert.NotEmpty(packages); + foreach (var package in packages) + { + var (hull, visual, _) = ShipAuthoringStore.Load(Path.Combine(package, "ship.cc")); + Assert.NotNull(hull); + Assert.NotNull(visual); + } + } + + // A package whose file still holds a retired slot reads but cannot be written over (CultSchemaConflictException). + [Fact] + public void EveryShippedModPackageCanBeWrittenOver() + { + foreach (var package in Directory.GetDirectories(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Mods"))) + { + using var directory = new TempDirectory(); + var copy = Path.Combine(directory.Path, "ship.cc"); + File.Copy(Path.Combine(package, "ship.cc"), copy); + var (hull, visual, products) = ShipAuthoringStore.Load(copy); + using (var cache = ShipAuthoringStore.Open(copy, writable: true)) + { + ShipAuthoringStore.Write(cache, hull, visual); + foreach (var product in products) ShipAuthoringStore.WriteProduct(cache, visual.Id, product); + cache.FlushAsync().GetAwaiter().GetResult(); + } + Assert.Equal(visual.Id, ShipAuthoringStore.Load(copy).Visual.Id); + } + } + + // The tool that regenerated the shipped packages: a dry run touches nothing, apply keeps every record's content. + [Fact] + public void RegenerateDryRunLeavesTheFileAndApplyKeepsTheContent() + { + using var directory = new TempDirectory(); + var copy = Path.Combine(directory.Path, "ship.cc"); + File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Mods", "aetheria.wren", "ship.cc"), copy); + var bytes = File.ReadAllBytes(copy); + var (hull, visual, products) = ShipAuthoringStore.Load(copy); + + Assert.Equal(0, ShipAuthoringCommands.Run(new[] { "regenerate", copy })); + Assert.Equal(bytes, File.ReadAllBytes(copy)); + Assert.Equal(0, ShipAuthoringCommands.Run(new[] { "regenerate", copy, "apply" })); + + var (hull2, visual2, products2) = ShipAuthoringStore.Load(copy); + Assert.Equal(ElementIdBlind.Pack(hull), ElementIdBlind.Pack(hull2)); + Assert.Equal(ElementIdBlind.Pack(visual), ElementIdBlind.Pack(visual2)); + Assert.Equal(products.Length, products2.Length); + Assert.Equal(new[] { copy }, Directory.GetFiles(directory.Path).Where(file => !file.EndsWith(".lock")).ToArray()); + } +} diff --git a/tests/Aetheria.Shared.Tests/EmissionTests.cs b/tests/Aetheria.Shared.Tests/EmissionTests.cs index 831f6f520..68ee5854d 100644 --- a/tests/Aetheria.Shared.Tests/EmissionTests.cs +++ b/tests/Aetheria.Shared.Tests/EmissionTests.cs @@ -281,7 +281,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(sensor); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(5, reader.ReadInt32()); - Assert.Equal(14, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); for (var slot = 0; slot < 13; slot++) reader.Skip(); Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, EmissionWire).Min); Assert.Equal(2f, JObject.Parse(JsonConvert.SerializeObject(new SensorData { InterceptFactor = Const(2) }))["interceptFactor"].ToObject().Min); @@ -291,7 +291,7 @@ public sealed partial class ContactRevealTests reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); reader.Skip(); // the arm's union index - Assert.Equal(37, reader.ReadArrayHeader()); // 35 and 36 are the weapon base's EffectiveRange and DamageAtMaxRange + Assert.Equal(38, reader.ReadArrayHeader()); // 35 and 36 are the weapon base's EffectiveRange and DamageAtMaxRange, 37 the element id for (var slot = 0; slot < 34; slot++) reader.Skip(); Assert.Equal(3f, MessagePackSerializer.Deserialize(ref reader, EmissionWire).Min); Assert.Equal(3f, JObject.Parse(JsonConvert.SerializeObject(new LauncherData { InterceptFactor = Const(3) }))["interceptFactor"].ToObject().Min); diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs index e2d453f51..8dbc47873 100644 --- a/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs @@ -22,7 +22,7 @@ public sealed class InspectionScannerCommandTests : IDisposable private readonly string[] _shippedProducts; private static byte[] Pack(GearData design) => - MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly)); + ElementIdBlind.Pack(design, typeof(GearData)); private static string Describe(CultCache cache, FactionProductData product) => $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs index 90c1d3873..47cc9a29e 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs @@ -22,7 +22,7 @@ public sealed class LaunchSuppressorCommandTests : IDisposable private readonly string[] _shippedProducts; private static byte[] Pack(GearData design) => - MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly)); + ElementIdBlind.Pack(design, typeof(GearData)); private static string Describe(CultCache cache, FactionProductData product) => $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs index eb2ed3ab8..d3dd3ae03 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs @@ -299,7 +299,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(49, reader.ReadInt32()); // loot-2 holds 48; a key reused across branches would collide on the wire - Assert.Equal(4, reader.ReadArrayHeader()); // Group is key 0, then the three stats + Assert.Equal(38, reader.ReadArrayHeader()); // array-format union: the base's element id sits at key 37, so every behavior row is 38 long; Group is key 0, then the three stats var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); Assert.Equal(.25f, back.Factor.Min); diff --git a/tests/Aetheria.Shared.Tests/ListenerTests.cs b/tests/Aetheria.Shared.Tests/ListenerTests.cs index 9fe4abf81..1bf1059ae 100644 --- a/tests/Aetheria.Shared.Tests/ListenerTests.cs +++ b/tests/Aetheria.Shared.Tests/ListenerTests.cs @@ -203,7 +203,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(50, reader.ReadInt32()); - Assert.Equal(2, reader.ReadArrayHeader()); // Group is key 0, then Multiple + Assert.Equal(38, reader.ReadArrayHeader()); // array-format union: the base's element id sits at key 37, so every behavior row is 38 long; Group is key 0, then Multiple var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); Assert.Equal(2.5f, back.Multiple.Min); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 89b6fb39f..ab195813f 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -724,7 +724,7 @@ public sealed class PaintModeTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(5, reader.ReadInt32()); - Assert.Equal(14, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); for (var slot = 0; slot < 12; slot++) reader.Skip(); Assert.Equal(90f, MessagePackSerializer.Deserialize(ref reader, WireOptions).Min); diff --git a/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs index b470d2ae0..7184659b2 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -484,7 +484,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(51, reader.ReadInt32()); // sensor-catalog holds 50; a key reused across branches would collide on the wire - Assert.Equal(4, reader.ReadArrayHeader()); // Group is key 0, then the three stats + Assert.Equal(38, reader.ReadArrayHeader()); // array-format union: the base's element id sits at key 37, so every behavior row is 38 long; Group is key 0, then the three stats reader.Skip(); Assert.Equal(200f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 1 Range Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 2 ScanTime diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 850e83dfd..7ed2b84be 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -180,24 +180,20 @@ public sealed class ShipAuthoringTests Assert.IsAssignableFrom(pull.InnerException); } - // A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file. + // A store header with a nil format stamp is refused by the runtime with CultStoreUnreadableException; Load names the file. [Fact] public void LoadNamesTheFileWhoseFormatStampIsNil() { using var directory = new TempDirectory(); var path = Path.Combine(directory.Path, "nilstamp.cc"); - File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot - { - FormatVersion = null, - SchemaCatalog = Array.Empty(), - Records = Array.Empty(), - })); + // The runtime no longer writes a store without a format stamp, so the header is spelled by hand: [nil, [], []]. + File.WriteAllBytes(path, new byte[] { 0x93, 0xC0, 0x90, 0x90 }); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsType(error.InnerException); + Assert.IsType(error.InnerException); } - // A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file. + // A store format this runtime does not know is refused on read with CultStoreUnreadableException from the attach; Load names the file. [Fact] public async Task LoadNamesTheFileWhoseStoreFormatIsNewer() { @@ -213,7 +209,7 @@ public sealed class ShipAuthoringTests File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsType(error.InnerException); + Assert.IsType(error.InnerException); } // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index cbda4266b..f439eecc9 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -624,7 +624,8 @@ public sealed class ShipModCatalogTests : IDisposable ShipModCatalog.Compose(Shipped, second, secondMods); ShipModCatalog.Compose(Shipped, Path.Combine(_directory.Path, "Derived3", "Aetheria.modded.cc"), firstMods); - Assert.Equal(Records(Derived), Records(second)); + // Two packages written separately carry separately minted ids: same content, ids blank. + Assert.Equal(Records(Derived, blindMods: true), Records(second, blindMods: true)); Assert.Equal(Records(Derived), Records(Path.Combine(_directory.Path, "Derived3", "Aetheria.modded.cc"))); Assert.Contains(ShipModCatalog.HullKey("mod.c").Value, Records(Derived).Keys); } @@ -793,10 +794,15 @@ public sealed class ShipModCatalogTests : IDisposable ShipModCatalog.PackageOf(cache, hull, Mods)).Message); } - // Every stored record's key, schema and payload bytes. StoredAt is a write timestamp, not record content. - private static Dictionary Records(string path) => + // Every stored record's key, schema and payload bytes. StoredAt is a write timestamp, not record content. The shipped + // catalog holds no element ids, so each open mints new random ones for the records Compose copies from it; those are + // always compared with ids blank. A mod package's records carry the ids its ship.cc persisted and are compared as stored, + // unless the two sides are packages written separately (blindMods), where each write minted its own. + private static Dictionary Records(string path, bool blindMods = false) => GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records - .ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString(record.Payload)); + .ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString( + !blindMods && ShipModCatalog.IsModKey(new CultRecordKey(record.Key)) ? record.Payload + : ElementIdBlind.Payload(TestCatalog.Registry(), record.SchemaId, record.Payload))); private void AssertOnlyTheDerivedFile() { diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index e14950b0d..82f094e88 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -331,7 +331,7 @@ public sealed class ShipProductTests : IDisposable var path = Package("mod.skiff", "Skiffhull", 0); var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull", - tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType().First())), "ship.cc"); + tweak: ship => ship.Hull.Behaviors.Add(new VelocityLimitData())), "ship.cc"); void RefusedAndUntouched(string file, params string[] flags) { var before = File.ReadAllBytes(file); diff --git a/tests/Aetheria.Shared.Tests/ShipRigTests.cs b/tests/Aetheria.Shared.Tests/ShipRigTests.cs index 4bed1989b..34581f34a 100644 --- a/tests/Aetheria.Shared.Tests/ShipRigTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipRigTests.cs @@ -833,7 +833,7 @@ public sealed class ShipRigTests : IDisposable var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HardpointData).Assembly); var hardpoint = new HardpointData { Type = HardpointType.Thruster, Shape = new Shape(), Transform = "t", FiringArc = 40f, Row = "left" }; var fields = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(hardpoint, options), options); - Assert.Equal(8, fields.Length); + Assert.Equal(9, fields.Length); Assert.Equal(40f, Convert.ToSingle(fields[6])); Assert.Equal("left", fields[7]); Assert.Null(MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(new HardpointData { Shape = new Shape() }, options), options)[7]); @@ -842,7 +842,7 @@ public sealed class ShipRigTests : IDisposable thruster.Thrust.Max = 5f; thruster.Slew.Max = 90f; var slew = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(thruster, options), options); - Assert.Equal(7, slew.Length); + Assert.Equal(38, slew.Length); // the behavior base's element id is key 37 Assert.Equal(MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(thruster.Slew, options), options), MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(slew[6], options), options)); Assert.NotEqual(MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(thruster.Thrust, options), options), diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 50d0bc824..21bdd7644 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -501,7 +501,7 @@ public sealed class SignatureTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(22, reader.ReadInt32()); - Assert.Equal(4, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); reader.Skip(); // Group Assert.Equal(1f, MessagePackSerializer.Deserialize(ref reader, Options).Min); Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, Options).Min); diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs index 40a47eec6..83013d766 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs @@ -21,7 +21,7 @@ public sealed class SmokeRoundsCommandTests : IDisposable private readonly Dictionary _shipped = new Dictionary(); private static byte[] Pack(WeaponItemData design) => - MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly)); + ElementIdBlind.Pack(design, typeof(WeaponItemData)); public SmokeRoundsCommandTests() { diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index 6945de2a8..3cd4ab34a 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -419,7 +419,7 @@ public sealed class VapourCloudTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(42, reader.ReadInt32()); - Assert.Equal(4, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); reader.Skip(); Assert.Equal(1f, MessagePackSerializer.Deserialize(ref reader, options).Min); Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, options).Min); diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index cf536cd9e..83349a1c0 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -45,6 +45,10 @@ public sealed class EconomyAuthor private void Write(Type type, object document, CultRecordKey key) { if (!_touched.Contains(key.Value)) _touched.Add(key.Value); + // A cache refuses to write one type onto a key another type holds (write ownership by written type): a batch that turns + // a compound good into a raw one removes the old record first. + var held = _catalog.Get(key); + if (held != null && held.GetType() != type) _catalog.Remove(key); _catalog.UpsertAsync(type, document, key).GetAwaiter().GetResult(); } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 841da957e..458ae4038 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -1,4 +1,5 @@ using GameCult.Caching; +using GameCult.Caching.MessagePack; using System; using System.Collections.Generic; using System.Globalization; @@ -8,7 +9,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | starter --like | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + private const string Usage = "ship-authoring create [--like ] | starter --like | regenerate [apply] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; @@ -84,6 +85,39 @@ public static class ShipAuthoringCommands Console.WriteLine($"Wrote a flyable starter package {path}; replace its stand-in model through Blender and Package, then set price and product."); return 0; } + if (args[0] == "regenerate" && (args.Length == 2 || args.Length == 3 && args[2] == "apply")) + { + // A package written before a record dropped a persisted slot cannot be written over (CultSchemaConflictException, + // retired slots stay read-only). Its decoded records go into a fresh file under today's schema, which must read + // back identical, and the fresh file replaces the old only with `apply`. Retired slots are the only content lost. + var (hull, ship, products) = ShipAuthoringStore.Load(args[1]); + var fresh = Path.GetFullPath(args[1]) + ".regenerating"; + File.Delete(fresh); + try + { + using (var cache = ShipAuthoringStore.Open(fresh, writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + foreach (var product in products) ShipAuthoringStore.WriteProduct(cache, ship.Id, product); + cache.FlushAsync().Wait(); + } + var (hull2, ship2, products2) = ShipAuthoringStore.Load(fresh); + ShipAuthoringStore.ValidateLoaded(hull2, ship2, products2); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + byte[] Pack(T value) => MessagePack.MessagePackSerializer.Serialize(value, options); + var same = Pack(hull).AsSpan().SequenceEqual(Pack(hull2)) && Pack(ship).AsSpan().SequenceEqual(Pack(ship2)) && + products.Length == products2.Length && + products.OrderBy(p => p.Manufacturer.Key.Value, StringComparer.Ordinal).Zip( + products2.OrderBy(p => p.Manufacturer.Key.Value, StringComparer.Ordinal), + (a, b) => Pack(a).AsSpan().SequenceEqual(Pack(b))).All(equal => equal); + if (!same) throw new InvalidOperationException($"{ship.Id}: the regenerated file does not read back as the records that went in; {args[1]} is untouched."); + Console.WriteLine($"{ship.Id}: {new FileInfo(args[1]).Length} bytes -> {new FileInfo(fresh).Length} bytes, {hull.Hardpoints.Count} hardpoints, {ship.Anchors.Count} anchors, {products.Length} products read back identical."); + if (args.Length == 3) File.Move(fresh, args[1], overwrite: true); + else Console.WriteLine("Dry run; add apply to replace the file."); + } + finally { File.Delete(fresh); File.Delete(fresh + ".lock"); } + return 0; + } if (args[0] == "price" && args.Length == 3) { // Judged before the file is touched: a refusal leaves it byte-identical.