Use CultLib for live reload state

This commit is contained in:
Meta Vens 2026-05-08 12:42:08 +01:00
commit a597fab63e
10 changed files with 447 additions and 23 deletions

View file

@ -35,8 +35,9 @@ Everything else earns its way back in.
## Run
Aquarium currently expects the sibling `CultMath` repo at `E:\Projects\CultMath`
or the equivalent `..\..\..\CultMath` path from the engine project.
Aquarium currently expects the sibling `CultMath` and `CultLib` repos at
`E:\Projects\CultMath` and `E:\Projects\CultLib`, or the equivalent
`..\..\..\CultMath` / `..\..\..\CultLib` paths from the engine project.
```powershell
dotnet build Aquarium.Engine.slnx
@ -50,10 +51,28 @@ runner instead of `dotnet run`:
.\scripts\dev-reload.ps1
```
It builds to a fresh disposable slot under `artifacts\dev-reload`, starts that
slot, records the PID, and replaces only the previous process that the script
itself launched. This keeps MSBuild away from the locked normal
`bin\Debug` output while a live window is still open.
It builds to a fresh disposable slot under `artifacts\dev-reload`, records the
slot/PID in PowerShell CLIXML, and launches the runtime with a MessagePack
CultCache store at `artifacts\dev-reload\cultcache\aquarium-client.msgpack`.
The previous script-owned process is stopped only after the replacement build
succeeds. This keeps MSBuild away from the locked normal `bin\Debug` output
while a live window is still open.
For automatic rebuild/restart on source changes, use the watcher:
```powershell
.\scripts\dev-watch.ps1
```
The watcher polls source files, waits for writes to settle, builds into a new
slot, and only replaces the running Aquarium after the new build succeeds. If a
build fails, the previous good process keeps running and that same broken source
fingerprint is not retried until files change again.
Runtime live state is a typed CultCache document, not a loose sidecar file. The
first document is `epiphany.aquarium.live_state`, currently banking camera
target, yaw, pitch, distance, time, and save generation every few frames so a
reload can rehydrate without pretending memory is vibes.
Stop the script-owned Aquarium process:
@ -73,6 +92,12 @@ Headless reload smoke without touching the normal build output:
.\scripts\dev-reload.ps1 -Headless
```
Headless watch smoke:
```powershell
.\scripts\dev-watch.ps1 -Headless
```
The first cut opens a Win32 window directly and owns a D3D11 swapchain through
Vortice. No Stride runtime, no asset protocol, no borrowed game loop wearing a
fake mustache. See `docs/vortice-spine.md`.

View file

@ -0,0 +1,41 @@
# Cult Runtime Surface
Aquarium uses CultLib directly for runtime identity and reload recovery.
## State Store
`AquariumCultStateStore` opens a `SingleFileMessagePackBackingStore` and attaches
it to a `CultCache`. The dev scripts pass:
```powershell
--cache E:\Projects\Aquarium-Engine\artifacts\dev-reload\cultcache\aquarium-client.msgpack
```
Direct runs can also use `--cache <path>` or `AQUARIUM_CULTCACHE_PATH`.
## Live State
`AquariumLiveState` is a CultCache document:
- schema name: `epiphany.aquarium.live_state`
- schema version: `epiphany.aquarium.live_state.v0`
- record key: `aquarium-client/live-state`
- name: `aquarium-client`
It currently stores the camera target, yaw, pitch, distance, runtime time, and a
monotonic save generation. `AquariumRuntime` writes it every 0.2 seconds and
again during orderly shutdown.
## CultNet Surface
The runtime creates a `CultNetHelloMessage` from the local CultCache registry.
That gives the engine a concrete CultNet identity and a schema catalog surface
before transport work starts:
- runtime kind: `aquarium-engine`
- role: `visual-client`
- display name: `Epiphany Aquarium`
- supported document types: taken from `CultDocumentRegistry.AllDescriptors`
Transport is still the next layer. The important thing for reload is already
true: live state is a typed Cult document backed by CultLib MessagePack storage.

View file

@ -1,7 +1,8 @@
param(
[switch]$Headless,
[switch]$NoStop,
[switch]$BuildOnly
[switch]$BuildOnly,
[int]$RetainSlots = 12
)
$ErrorActionPreference = "Stop"
@ -11,7 +12,9 @@ $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$projectPath = Join-Path $repoRoot "src\Aquarium.Engine\Aquarium.Engine.csproj"
$devRoot = Join-Path $repoRoot "artifacts\dev-reload"
$slotRoot = Join-Path $devRoot "slots"
$statePath = Join-Path $devRoot "state.json"
$statePath = Join-Path $devRoot "state.clixml"
$buildStatePath = Join-Path $devRoot "last-build.clixml"
$cultCachePath = Join-Path $devRoot "cultcache\aquarium-client.msgpack"
$stdoutLogPath = Join-Path $devRoot "latest.out.log"
$stderrLogPath = Join-Path $devRoot "latest.err.log"
@ -22,7 +25,7 @@ function Stop-PreviousOwnedProcess {
return
}
$state = Get-Content -Raw -Path $statePath | ConvertFrom-Json
$state = Import-Clixml -Path $statePath
if (-not $state.pid) {
return
}
@ -50,7 +53,24 @@ function Stop-PreviousOwnedProcess {
Stop-Process -Id ([int]$state.pid) -Force
}
Stop-PreviousOwnedProcess
function Remove-OldSlots {
if ($RetainSlots -lt 1) {
return
}
$slots = Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $RetainSlots
foreach ($slot in $slots) {
try {
Remove-Item -LiteralPath $slot.FullName -Recurse -Force
}
catch {
Write-Host "Could not remove old slot $($slot.FullName): $($_.Exception.Message)"
}
}
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$slotName = "$timestamp-$([guid]::NewGuid().ToString("N").Substring(0, 8))"
@ -71,16 +91,18 @@ if ($BuildOnly) {
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
pid = $null
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | ConvertTo-Json | Set-Content -Path $statePath
} | Export-Clixml -Path $buildStatePath
Write-Host "Build-only slot ready."
Remove-OldSlots
exit 0
}
Stop-PreviousOwnedProcess
$dllPath = Join-Path $slotPath "Aquarium.Engine.dll"
$arguments = @($dllPath)
$arguments = @($dllPath, "--cache", $cultCachePath)
if ($Headless) {
$arguments += "--headless"
}
@ -106,10 +128,13 @@ $process = Start-Process @startProcessParameters
pid = $process.Id
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | ConvertTo-Json | Set-Content -Path $statePath
} | Export-Clixml -Path $statePath
Write-Host "Aquarium running from disposable slot."
Write-Host " PID: $($process.Id)"
Write-Host " CultCache: $cultCachePath"
Write-Host " Stdout: $stdoutLogPath"
Write-Host " Stderr: $stderrLogPath"
Write-Host "Run this script again to replace only this script-owned process."
Remove-OldSlots

View file

@ -2,14 +2,14 @@ $ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$statePath = Join-Path $repoRoot "artifacts\dev-reload\state.json"
$statePath = Join-Path $repoRoot "artifacts\dev-reload\state.clixml"
if (-not (Test-Path $statePath)) {
Write-Host "No dev-reload state file exists."
exit 0
}
$state = Get-Content -Raw -Path $statePath | ConvertFrom-Json
$state = Import-Clixml -Path $statePath
if (-not $state.pid) {
Write-Host "No script-owned Aquarium PID is recorded."
exit 0

156
scripts/dev-watch.ps1 Normal file
View file

@ -0,0 +1,156 @@
param(
[switch]$Headless,
[switch]$NoInitialLaunch,
[switch]$Once,
[int]$IntervalMilliseconds = 1000,
[int]$DebounceMilliseconds = 350,
[int]$RetainSlots = 12
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$reloadScript = Join-Path $PSScriptRoot "dev-reload.ps1"
$watchLogPath = Join-Path $repoRoot "artifacts\dev-reload\watch.log"
New-Item -ItemType Directory -Force -Path (Split-Path $watchLogPath) | Out-Null
function Write-WatchLog([string]$message) {
$line = "$(Get-Date -Format o) $message"
Write-Host $line
Add-Content -Path $watchLogPath -Value $line
}
function Get-WatchedFiles {
$roots = @(
(Join-Path $repoRoot "src"),
(Join-Path $repoRoot "Aquarium.Engine.slnx"),
(Join-Path $repoRoot "global.json")
)
foreach ($root in $roots) {
if (-not (Test-Path $root)) {
continue
}
$item = Get-Item -LiteralPath $root
if (-not $item.PSIsContainer) {
$item
continue
}
Get-ChildItem -LiteralPath $item.FullName -Recurse -File |
Where-Object {
$_.FullName -notmatch "\\bin\\" -and
$_.FullName -notmatch "\\obj\\" -and
$_.Extension -in @(".cs", ".csproj", ".hlsl", ".json", ".slnx")
}
}
}
function Get-SourceFingerprint {
$fingerprintInput = Get-WatchedFiles |
Sort-Object FullName |
ForEach-Object {
"$($_.FullName)|$($_.Length)|$($_.LastWriteTimeUtc.Ticks)"
}
$text = [string]::Join("`n", $fingerprintInput)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hashBytes = $sha.ComputeHash($bytes)
return -join ($hashBytes | ForEach-Object { $_.ToString("x2") })
}
finally {
$sha.Dispose()
}
}
function Wait-StableFingerprint {
$first = Get-SourceFingerprint
Start-Sleep -Milliseconds $DebounceMilliseconds
$second = Get-SourceFingerprint
while ($first -ne $second) {
$first = $second
Start-Sleep -Milliseconds $DebounceMilliseconds
$second = Get-SourceFingerprint
}
return $second
}
function Invoke-Reload {
param([string]$reason)
Write-WatchLog "Reload requested: $reason"
$arguments = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $reloadScript,
"-RetainSlots", "$RetainSlots"
)
if ($Headless) {
$arguments += "-Headless"
}
$process = Start-Process `
-FilePath "powershell" `
-ArgumentList $arguments `
-WorkingDirectory $repoRoot `
-Wait `
-PassThru `
-NoNewWindow
if ($process.ExitCode -ne 0) {
throw "dev-reload failed with exit code $($process.ExitCode)."
}
}
$lastGoodFingerprint = Wait-StableFingerprint
$lastFailedFingerprint = $null
if (-not $NoInitialLaunch) {
try {
Invoke-Reload "initial launch"
$lastGoodFingerprint = Wait-StableFingerprint
$lastFailedFingerprint = $null
}
catch {
$lastFailedFingerprint = $lastGoodFingerprint
Write-WatchLog "Initial launch failed; keeping any previous good process alive. $($_.Exception.Message)"
}
}
Write-WatchLog "Watching Aquarium sources. Press Ctrl+C to stop."
do {
Start-Sleep -Milliseconds $IntervalMilliseconds
$currentFingerprint = Wait-StableFingerprint
if ($currentFingerprint -eq $lastGoodFingerprint -or $currentFingerprint -eq $lastFailedFingerprint) {
if ($Once) {
break
}
continue
}
try {
Invoke-Reload "source change"
$lastGoodFingerprint = $currentFingerprint
$lastFailedFingerprint = $null
}
catch {
$lastFailedFingerprint = $currentFingerprint
Write-WatchLog "Reload failed; previous good process stays alive. $($_.Exception.Message)"
}
if ($Once) {
break
}
} while ($true)

View file

@ -16,6 +16,8 @@
<ItemGroup>
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Networking\GameCult.Networking.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -9,7 +9,7 @@ public static class AquariumHost
{
public static int Run(string[] args)
{
var runtime = new AquariumRuntime(new AquariumRuntimeOptions(ParseHeadless(args)));
using var runtime = new AquariumRuntime(new AquariumRuntimeOptions(ParseHeadless(args), ParseCachePath(args)));
var input = new InputState();
var width = runtime.Options.Headless ? 640 : 1280;
var height = runtime.Options.Headless ? 360 : 720;
@ -50,4 +50,18 @@ public static class AquariumHost
{
return args.Any(arg => string.Equals(arg, "--headless", StringComparison.OrdinalIgnoreCase));
}
private static string? ParseCachePath(IReadOnlyCollection<string> args)
{
var values = args.ToArray();
for (var index = 0; index < values.Length - 1; index++)
{
if (string.Equals(values[index], "--cache", StringComparison.OrdinalIgnoreCase))
{
return values[index + 1];
}
}
return Environment.GetEnvironmentVariable("AQUARIUM_CULTCACHE_PATH");
}
}

View file

@ -1,23 +1,31 @@
using System.Numerics;
using Aquarium.Engine.Input;
using Aquarium.Engine.State;
namespace Aquarium.Engine;
public sealed class AquariumRuntime
public sealed class AquariumRuntime : IDisposable
{
private const float StateSaveIntervalSeconds = 0.20f;
private readonly OrbitCameraRig cameraRig;
private readonly AquariumCultStateStore stateStore;
private GridFrame gridFrame;
private float timeSeconds;
private float secondsSinceStateSave;
public AquariumRuntime(AquariumRuntimeOptions options)
{
Options = options;
stateStore = AquariumCultStateStore.Open(options.CultCachePath);
var liveState = stateStore.LoadOrCreate();
cameraRig = new OrbitCameraRig(
target: System.Numerics.Vector2.Zero,
yawRadians: Angle.DegreesToRadians(35.0f),
pitchRadians: Angle.DegreesToRadians(42.0f),
distance: 24.0f);
target: new Vector2(liveState.CameraTargetX, liveState.CameraTargetY),
yawRadians: liveState.CameraYawRadians,
pitchRadians: liveState.CameraPitchRadians,
distance: liveState.CameraDistance);
timeSeconds = liveState.TimeSeconds;
gridFrame = GridFrame.FromCamera(cameraRig);
}
@ -29,6 +37,8 @@ public sealed class AquariumRuntime
{
Console.WriteLine("Aquarium Engine booted.");
Console.WriteLine($"Grid center: {gridFrame.Center}, radius: {gridFrame.Radius:0.00}");
Console.WriteLine($"CultCache: {stateStore.CachePath}");
Console.WriteLine($"CultNet hello: {stateStore.Hello.RuntimeKind} / {stateStore.Hello.DisplayName}");
Console.WriteLine("Vortice D3D11 is present as the host shell; Aquarium owns the invariants.");
}
@ -37,9 +47,34 @@ public sealed class AquariumRuntime
timeSeconds += Math.Max(deltaSeconds, 0.0f);
cameraRig.ApplyInput(input, deltaSeconds);
gridFrame = GridFrame.FromCamera(cameraRig);
secondsSinceStateSave += Math.Max(deltaSeconds, 0.0f);
if (secondsSinceStateSave >= StateSaveIntervalSeconds)
{
SaveLiveState();
secondsSinceStateSave = 0.0f;
}
}
public void Dispose()
{
SaveLiveState();
stateStore.Dispose();
}
private void SaveLiveState()
{
stateStore.Save(new AquariumLiveState
{
TimeSeconds = timeSeconds,
CameraTargetX = cameraRig.Target.X,
CameraTargetY = cameraRig.Target.Y,
CameraYawRadians = cameraRig.YawRadians,
CameraPitchRadians = cameraRig.PitchRadians,
CameraDistance = cameraRig.Distance
});
}
}
public readonly record struct AquariumRuntimeOptions(bool Headless);
public readonly record struct AquariumRuntimeOptions(bool Headless, string? CultCachePath);
public readonly record struct AquariumFrame(GridFrame Grid, System.Numerics.Vector3 CameraPosition, float TimeSeconds);

View file

@ -0,0 +1,90 @@
using System.Numerics;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Networking;
namespace Aquarium.Engine.State;
public sealed class AquariumCultStateStore : IDisposable
{
public const string DefaultRelativePath = "artifacts/dev-reload/cultcache/aquarium-client.msgpack";
private static readonly CultRecordKey LiveStateKey = new("aquarium-client/live-state");
private readonly CultCache cache;
private readonly SingleFileMessagePackBackingStore backingStore;
private AquariumCultStateStore(string path)
{
CachePath = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!);
cache = new CultCache();
backingStore = new SingleFileMessagePackBackingStore(CachePath);
cache.AddBackingStore(backingStore);
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Hello = CreateHello(cache.Registry);
}
public string CachePath { get; }
public CultNetHelloMessage Hello { get; }
public static AquariumCultStateStore Open(string? path)
{
return new AquariumCultStateStore(string.IsNullOrWhiteSpace(path) ? DefaultRelativePath : path);
}
public AquariumLiveState LoadOrCreate()
{
var state = cache.Get<AquariumLiveState>(LiveStateKey)
?? cache.GetByName<AquariumLiveState>(AquariumLiveState.PrimaryName);
if (state != null)
{
return state;
}
state = new AquariumLiveState();
Save(state);
return state;
}
public void Save(AquariumLiveState state)
{
state.Name = AquariumLiveState.PrimaryName;
state.SaveGeneration++;
cache.AddAsync(state, new CultRecordHandle<AquariumLiveState>(LiveStateKey)).GetAwaiter().GetResult();
backingStore.PushAll();
}
public void Dispose()
{
cache.Dispose();
}
private static CultNetHelloMessage CreateHello(CultDocumentRegistry registry)
{
return new CultNetHelloMessage
{
RuntimeId = "aquarium-client",
RuntimeKind = "aquarium-engine",
Role = "visual-client",
DisplayName = "Epiphany Aquarium",
SupportsSchemaCatalog = true,
SupportedDocumentTypes = registry.AllDescriptors
.Select(descriptor => descriptor.SchemaName)
.Order(StringComparer.Ordinal)
.ToArray(),
SupportedMessageVersions =
[
CultNetSchemaVersions.Hello,
CultNetSchemaVersions.DocumentPutRaw,
CultNetSchemaVersions.DocumentDelete,
CultNetSchemaVersions.SnapshotRequest,
CultNetSchemaVersions.SnapshotResponseRaw,
CultNetSchemaVersions.SchemaCatalogRequest,
CultNetSchemaVersions.SchemaCatalogResponse
]
};
}
}

View file

@ -0,0 +1,36 @@
using GameCult.Caching;
using MessagePack;
namespace Aquarium.Engine.State;
[CultDocument("epiphany.aquarium.live_state", "epiphany.aquarium.live_state.v0")]
[MessagePackObject]
public sealed class AquariumLiveState
{
public const string PrimaryName = "aquarium-client";
[Key(0)]
[CultName]
public string Name { get; set; } = PrimaryName;
[Key(1)]
public float TimeSeconds { get; set; }
[Key(2)]
public float CameraTargetX { get; set; }
[Key(3)]
public float CameraTargetY { get; set; }
[Key(4)]
public float CameraYawRadians { get; set; } = Angle.DegreesToRadians(35.0f);
[Key(5)]
public float CameraPitchRadians { get; set; } = Angle.DegreesToRadians(42.0f);
[Key(6)]
public float CameraDistance { get; set; } = 24.0f;
[Key(7)]
public long SaveGeneration { get; set; }
}