Merge eureka/aetheria-release-cultlib-2-0-adopt: Aetheria on CultLib 2.0.1 and CultMath 0.5.0; LockWeaponData is a BehaviorData arm; 1.0.x saves open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 01:41:50 +02:00
commit bd628de3bf
12 changed files with 143 additions and 25 deletions

View file

@ -198,6 +198,7 @@ public interface IPowerConsumer
Union(49, typeof(LaunchSuppressorData)),
Union(50, typeof(ListenerData)),
Union(51, typeof(ScannerData)),
Union(52, typeof(LockWeaponData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -11,12 +11,7 @@ using Newtonsoft.Json;
using CultMath;
using static CultMath.math;
[Inspectable,
Union(0, typeof(InstantWeaponData)),
Union(1, typeof(LauncherData)),
Union(2, typeof(ConstantWeaponData)),
Union(3, typeof(ChargedWeaponData)),
RuntimeInspectable]
[Inspectable, RuntimeInspectable]
public abstract class WeaponData : BehaviorData
{
[Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable]

View file

@ -72,6 +72,8 @@ public class NoiseSettings
[Union(0, typeof(PowerBrush)),
Union(1, typeof(SimplexBrush)),
Union(2, typeof(AnimatedSimplexBrush)),
Union(3, typeof(RadialWaveBrush)),
Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public abstract class Brush
{

View file

@ -136,10 +136,14 @@ public static class ShipAuthoringStore
{
RefuseLegacyEmbeddedHull(path);
try { return LoadRecords(path); }
catch (MessagePackSerializationException error)
catch (Exception error) when (error is InvalidDataException or MessagePackSerializationException)
{
throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error);
}
catch (NotSupportedException error)
{
throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error);
}
}
// Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record
@ -152,7 +156,7 @@ public static class ShipAuthoringStore
if (bytes.Length == 0) return;
CultPersistedStoreSnapshot snapshot;
try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); }
catch (MessagePackSerializationException error)
catch (Exception error) when (error is MessagePackSerializationException or NotSupportedException)
{
throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error);
}

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-v1.4.0 / cultlib-unity-v1.0.60 in Packages/manifest.json. -->
<CultLibRevision>45c2f4006f5f7dab357426cabd3197e8d0a17f66</CultLibRevision>
<!-- CultCache: caching-unity-v2.0.0 / cultlib-unity-v2.0.1 in Packages/manifest.json. -->
<CultLibRevision>b8fc5eb07720a187a0421424ac2cb3aa2966ca1d</CultLibRevision>
<CultMathRoot Condition="'$(CultMathRoot)' == '' and '$(CULTMATH_ROOT)' != ''">$(CULTMATH_ROOT)</CultMathRoot>
<CultMathRoot Condition="'$(CultMathRoot)' == ''">$(CultLibRoot)</CultMathRoot>
<!-- CultMath: cultmath-unity-v0.4.1 in Packages/manifest.json. -->
<CultMathRevision>9c1b06cd703bfa44352050e819b0d0659cf5978a</CultMathRevision>
<!-- CultMath: cultmath-unity-v0.5.0 in Packages/manifest.json. -->
<CultMathRevision>7a8933b085468e1fc6f9d3bce4a85e7d101aaca3</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>

View file

@ -52,8 +52,8 @@
"com.unity.modules.vr": "1.0.0",
"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-v1.4.0",
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60",
"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1"
"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"
}
}

View file

@ -256,27 +256,29 @@
"url": "https://packages.unity.com"
},
"org.gamecult.caching.unity": {
"version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0",
"version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.cultlib": "1.0.60"
"org.gamecult.cultlib": "2.0.0"
},
"hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66"
"hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3"
},
"org.gamecult.cultlib": {
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60",
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66"
"dependencies": {
"org.gamecult.cultmath": "0.5.0"
},
"hash": "b8fc5eb07720a187a0421424ac2cb3aa2966ca1d"
},
"org.gamecult.cultmath": {
"version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1",
"version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "9c1b06cd703bfa44352050e819b0d0659cf5978a"
"hash": "7a8933b085468e1fc6f9d3bce4a85e7d101aaca3"
},
"com.unity.modules.accessibility": {
"version": "1.0.0",

View file

@ -0,0 +1,38 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
// CultLib 2.0 refuses a union subtype no arm names (it used to write nil). These keep that refusal a build-time failure.
public sealed class CultLibAdoptionTests
{
[Fact]
public void EveryConcreteSubtypeOfAUnionBaseIsAnArmOfIt()
{
var types = typeof(BehaviorData).Assembly.GetTypes();
var missing = new System.Collections.Generic.List<string>();
foreach (var root in types)
{
var arms = root.GetCustomAttributes<UnionAttribute>(false).Select(u => u.SubType).ToHashSet();
if (arms.Count == 0) continue;
foreach (var subtype in types.Where(t => t != root && !t.IsAbstract && !t.IsInterface && root.IsAssignableFrom(t)))
if (!arms.Contains(subtype)) missing.Add($"{subtype.Name} is not an arm of {root.Name}");
}
Assert.Empty(missing);
}
[Fact]
public void NoShippedGearHoldsANullBehavior()
{
using var cache = new CultCache(TestCatalog.Registry());
cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes);
var gear = cache.GetAll<GearData>().ToArray();
Assert.NotEmpty(gear);
var bad = gear.Where(g => g.Behaviors != null && g.Behaviors.Any(b => b == null)).Select(g => g.Name).ToArray();
Assert.Empty(bad);
}
}

View file

@ -0,0 +1,33 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
// tests/vectors/cultlib-1.0.60/run.cc is a run.cc written by Aetheria on CultLib 1.0.60 (64 zones, factions, one entity).
// A player's save outlives the runtime that wrote it, so the current runtime must open it as compatible drift.
public sealed class RunSaveCompatibilityTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runcompat-" + Guid.NewGuid().ToString("N"));
public RunSaveCompatibilityTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
[Fact]
public void ARunWrittenByCultLib1060OpensWithEveryZoneAndFactionResolving()
{
var run = Path.Combine(_root, "run.cc");
File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "tests", "vectors", "cultlib-1.0.60", "run.cc"), run);
using var cache = new CultCache(TestCatalog.Registry());
cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes);
cache.AddBackingStore(new SingleFileMessagePackBackingStore(run), AetheriaStores.RunTypes);
var saved = cache.GetGlobal<SavedGame>();
Assert.NotNull(saved);
Assert.NotEmpty(saved.Zones);
Assert.NotEmpty(saved.Factions);
Assert.All(saved.Zones, zone => Assert.NotNull(cache.Get(zone)));
Assert.All(saved.Factions, faction => Assert.NotNull(cache.Get(faction)));
}
}

View file

@ -173,7 +173,47 @@ public sealed class ShipAuthoringTests
RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false });
var error = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsAssignableFrom<MessagePackSerializationException>(error.InnerException);
// The pull names the record key and schema; the MessagePack failure is its cause.
var pull = Assert.IsType<InvalidDataException>(error.InnerException);
Assert.Contains("Record '" + ShipModCatalog.HullKey("mod.skiff") + "'", pull.Message);
Assert.Contains("schema", pull.Message);
Assert.IsAssignableFrom<MessagePackSerializationException>(pull.InnerException);
}
// A store header with a nil format stamp is refused by the runtime with NotSupportedException; 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>(),
}));
var error = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsType<NotSupportedException>(error.InnerException);
}
// A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file.
[Fact]
public async Task LoadNamesTheFileWhoseStoreFormatIsNewer()
{
using var directory = new TempDirectory();
var path = Path.Combine(directory.Path, "newer.cc");
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual);
await cache.FlushAsync();
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
snapshot.FormatVersion = "cultcache.store.v9";
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var error = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsType<NotSupportedException>(error.InnerException);
}
// Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again.

Binary file not shown.

View file

@ -119,7 +119,10 @@ class ShipFile(NamedTuple):
def read(path: str, cultlib_packages: str) -> ShipFile:
cultcache, msgpack = _libraries(cultlib_packages)
store = cultcache.SingleFileMessagePackBackingStore(path)
envelopes = store.pull_all()
try:
envelopes = store.pull_all()
except OSError as error:
raise ValueError(f"The ship file cannot be read: {error.strerror}") from None
ships = [envelope for envelope in envelopes if envelope.type == SCHEMA]
hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA]
# Before S1 the hull lived inside the ship record. Saving such a file would preserve the stale hull under a ship that