Pin CultLib abd1a92e: element ids on every list element type #12

Merged
nibu merged 5 commits from nibu/cultlib-pin-abd1a92e into master 2026-11-11 13:44:29 +00:00
39 changed files with 390 additions and 46 deletions

View file

@ -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 = "";
}

View file

@ -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 = "";
}

View file

@ -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 = "";
}

View file

@ -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

View file

@ -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)]

View file

@ -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<HardpointType, float3> _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)]

View file

@ -28,6 +28,9 @@ public class LoadoutSlot
[Key(1)] public ItemRotation Rotation;
[Key(2)] public CultRecordRef<EquippableItemData> Design;
[Key(3)] public CultRecordRef<FactionProductData> 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.

View file

@ -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

View file

@ -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

View file

@ -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<ProvinceData> 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]

View file

@ -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

View file

@ -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<StationKindData> 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.

View file

@ -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.

View file

@ -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)]

View file

@ -2,12 +2,12 @@
<PropertyGroup>
<CultLibRoot Condition="'$(CultLibRoot)' == '' and '$(CULTLIB_ROOT)' != ''">$(CULTLIB_ROOT)</CultLibRoot>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\CultLib</CultLibRoot>
<!-- CultCache: caching-unity-v2.0.0 / cultlib-unity-v2.0.1 in Packages/manifest.json. -->
<CultLibRevision>b8fc5eb07720a187a0421424ac2cb3aa2966ca1d</CultLibRevision>
<!-- CultCache: cultlib-unity-v3.0.0 in Packages/manifest.json. -->
<CultLibRevision>7225835d11fd08a3de2a2ef24742fc351b3b08fb</CultLibRevision>
<CultMathRoot Condition="'$(CultMathRoot)' == '' and '$(CULTMATH_ROOT)' != ''">$(CULTMATH_ROOT)</CultMathRoot>
<CultMathRoot Condition="'$(CultMathRoot)' == ''">$(CultLibRoot)</CultMathRoot>
<!-- CultMath: cultmath-unity-v0.5.0 in Packages/manifest.json. -->
<CultMathRevision>7a8933b085468e1fc6f9d3bce4a85e7d101aaca3</CultMathRevision>
<!-- CultMath: cultmath-unity-v0.5.1 in Packages/manifest.json. -->
<CultMathRevision>f1edfa458dd8e7e107214e9d9884254359475b8c</CultMathRevision>
<GitExecutable Condition="'$(GitExecutable)' == '' and Exists('C:\Program Files\Git\cmd\git.exe')">C:\Program Files\Git\cmd\git.exe</GitExecutable>
<GitExecutable Condition="'$(GitExecutable)' == ''">git</GitExecutable>
</PropertyGroup>

BIN
GameData/Mods/aetheria.nakovalnya/ship.cc (Stored with Git LFS)

Binary file not shown.

BIN
GameData/Mods/aetheria.wren/ship.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -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"
}
}

View file

@ -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",

View file

@ -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. |

41
docs/element-ids.md Normal file
View file

@ -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 <ship.cc> 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.

View file

@ -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<CultElementIdAttribute>() != null) field.SetValue(value, "");
else if (!field.FieldType.IsPrimitive && field.GetCustomAttribute<IgnoreMemberAttribute>() == null && field.GetCustomAttribute<KeyAttribute>() != null)
Blank(field.GetValue(value));
}
else if (member is PropertyInfo property && property.GetIndexParameters().Length == 0 && property.GetCustomAttribute<KeyAttribute>() != null)
{
if (property.GetCustomAttribute<CultElementIdAttribute>() != null) property.SetValue(value, "");
else Blank(property.GetValue(value));
}
}
}
}

View file

@ -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<HullData>())
{
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<string, string> 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());
}
}

View file

@ -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<PerformanceStat>(ref reader, EmissionWire).Min);
Assert.Equal(2f, JObject.Parse(JsonConvert.SerializeObject(new SensorData { InterceptFactor = Const(2) }))["interceptFactor"].ToObject<PerformanceStat>().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<PerformanceStat>(ref reader, EmissionWire).Min);
Assert.Equal(3f, JObject.Parse(JsonConvert.SerializeObject(new LauncherData { InterceptFactor = Const(3) }))["interceptFactor"].ToObject<PerformanceStat>().Min);

View file

@ -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} | " +

View file

@ -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} | " +

View file

@ -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<LaunchSuppressorData>(MessagePackSerializer.Deserialize<BehaviorData>(bytes, options));
Assert.Equal(.25f, back.Factor.Min);

View file

@ -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<ListenerData>(MessagePackSerializer.Deserialize<BehaviorData>(bytes, options));
Assert.Equal(2.5f, back.Multiple.Min);

View file

@ -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<PerformanceStat>(ref reader, WireOptions).Min);

View file

@ -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<PerformanceStat>(ref reader, options).Min); // key 1 Range
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min); // key 2 ScanTime

View file

@ -180,24 +180,20 @@ public sealed class ShipAuthoringTests
Assert.IsAssignableFrom<MessagePackSerializationException>(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<CultSchemaCatalogEntry>(),
Records = Array.Empty<CultPersistedRecord>(),
}));
// 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<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsType<NotSupportedException>(error.InnerException);
Assert.IsType<CultStoreUnreadableException>(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<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsType<NotSupportedException>(error.InnerException);
Assert.IsType<CultStoreUnreadableException>(error.InnerException);
}
// Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again.

View file

@ -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<string, string> 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<string, string> 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()
{

View file

@ -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<VelocityLimitData>().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);

View file

@ -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<object[]>(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<object[]>(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<object[]>(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),

View file

@ -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<PerformanceStat>(ref reader, Options).Min);
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, Options).Min);

View file

@ -21,7 +21,7 @@ public sealed class SmokeRoundsCommandTests : IDisposable
private readonly Dictionary<string, byte[]> _shipped = new Dictionary<string, byte[]>();
private static byte[] Pack(WeaponItemData design) =>
MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly));
ElementIdBlind.Pack(design, typeof(WeaponItemData));
public SmokeRoundsCommandTests()
{

View file

@ -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<PerformanceStat>(ref reader, options).Min);
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);

View file

@ -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();
}
}

View file

@ -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 <ship.cc> <stable-id> <name> [--like <shipped hull name>] | starter <mods-dir> <stable-id> <name> --like <shipped hull name> | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | starter <mods-dir> <stable-id> <name> --like <shipped hull name> | regenerate <ship.cc> [apply] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
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>(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.