2026-05-08 12:15:46 +01:00
param (
[ switch ] $Headless ,
[ switch ] $NoStop ,
2026-05-08 12:42:08 +01:00
[ switch ] $BuildOnly ,
2026-05-08 20:28:19 +01:00
[ switch ] $Reopen ,
2026-05-23 18:54:26 +01:00
[ string ] $ClientProject = " src\Aquarium.Fensalir\Aquarium.Fensalir.csproj " ,
2026-05-08 14:05:42 +01:00
[ int ] $RetainSlots = 12 ,
2026-05-09 03:05:23 +01:00
[ int ] $StartupTimeoutSeconds = 5 ,
2026-05-12 03:45:18 +01:00
[ int ] $HeadlessTimeoutSeconds = 60
2026-05-08 12:15:46 +01:00
)
$ErrorActionPreference = " Stop "
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path ( Join-Path $PSScriptRoot " .. " )
$projectPath = Join-Path $repoRoot " src\Aquarium.Engine\Aquarium.Engine.csproj "
2026-05-17 23:37:20 +01:00
$liveProjectPath = if ( [ System.IO.Path ] :: IsPathRooted ( $ClientProject ) ) { $ClientProject } else { Join-Path $repoRoot $ClientProject }
$liveProjectPath = ( Resolve-Path $liveProjectPath ) . Path
$liveAssemblyName = [ System.IO.Path ] :: GetFileNameWithoutExtension ( $liveProjectPath )
2026-05-08 12:15:46 +01:00
$devRoot = Join-Path $repoRoot " artifacts\dev-reload "
2026-11-07 10:57:09 +00:00
# Everything the host may load as client code lives under $assemblyRoot (slots here, live-slots from dev-watch);
# it is the host's trusted root, so state, cache and logs in $devRoot are not part of it.
$assemblyRoot = Join-Path $devRoot " assemblies "
$slotRoot = Join-Path $assemblyRoot " slots "
2026-11-07 11:04:00 +00:00
$liveSlotRoot = Join-Path $assemblyRoot " live-slots "
2026-05-08 20:28:19 +01:00
$visibleStatePath = Join-Path $devRoot " state.clixml "
$headlessStatePath = Join-Path $devRoot " headless-state.clixml "
$statePath = if ( $Headless ) { $headlessStatePath } else { $visibleStatePath }
2026-05-08 12:42:08 +01:00
$buildStatePath = Join-Path $devRoot " last-build.clixml "
2026-05-08 16:04:52 +01:00
$liveReloadPointerPath = Join-Path $devRoot " live-current.txt "
2026-05-09 20:14:57 +01:00
$cultCacheDirectory = if ( $Headless ) { " headless-cultcache " } else { " cultcache " }
$cultCachePath = Join-Path $devRoot " $cultCacheDirectory \aquarium-client.msgpack "
2026-05-08 20:28:19 +01:00
$logName = if ( $Headless ) { " headless " } else { " latest " }
$stdoutLogPath = Join-Path $devRoot " $logName .out.log "
$stderrLogPath = Join-Path $devRoot " $logName .err.log "
2026-05-08 12:15:46 +01:00
New-Item -ItemType Directory -Force -Path $slotRoot | Out-Null
2026-05-09 20:14:57 +01:00
New-Item -ItemType Directory -Force -Path ( Split-Path $cultCachePath ) | Out-Null
2026-05-08 12:15:46 +01:00
2026-05-08 20:28:19 +01:00
if ( $Reopen -and $Headless ) {
throw " -Reopen is for the visible dev window; do not combine it with -Headless. "
}
2026-05-08 12:15:46 +01:00
function Stop-PreviousOwnedProcess {
if ( $NoStop -or -not ( Test-Path $statePath ) ) {
return
}
2026-05-08 12:42:08 +01:00
$state = Import-Clixml -Path $statePath
2026-05-09 03:05:23 +01:00
if ( -not ( $state . PSObject . Properties . Name -contains " pid " ) ) {
return
}
2026-05-08 12:15:46 +01:00
if ( -not $state . pid ) {
return
}
$process = Get-Process -Id ( [ int ] $state . pid ) -ErrorAction SilentlyContinue
if ( -not $process ) {
return
}
$recordedPath = [ string ] $state . slot
$commandLine = $null
try {
$commandLine = ( Get-CimInstance Win32_Process -Filter " ProcessId = $( $process . Id ) " ) . CommandLine
}
catch {
$commandLine = $null
}
if ( -not $commandLine -or $commandLine -notlike " * $recordedPath * " ) {
Write-Host " Previous PID $( $state . pid ) no longer belongs to the recorded slot; leaving it alone. "
return
}
Write-Host " Stopping previous script-owned Aquarium process $( $state . pid ) . "
Stop-Process -Id ( [ int ] $state . pid ) -Force
}
2026-05-08 12:42:08 +01:00
function Remove-OldSlots {
if ( $RetainSlots -lt 1 ) {
return
}
2026-11-07 11:04:00 +00:00
# Both roots hold client code the host may load; dev-watch adds a live slot per change and never prunes.
$slots = foreach ( $root in @ ( $slotRoot , $liveSlotRoot ) ) {
Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $RetainSlots
}
2026-05-08 12:42:08 +01:00
foreach ( $slot in $slots ) {
try {
Remove-Item -LiteralPath $slot . FullName -Recurse -Force
}
catch {
Write-Host " Could not remove old slot $( $slot . FullName ) : $( $_ . Exception . Message ) "
}
}
}
2026-05-08 12:15:46 +01:00
2026-05-08 14:05:42 +01:00
function Get-StderrTail {
if ( -not ( Test-Path $stderrLogPath ) ) {
return " <stderr log does not exist> "
}
$tail = Get-Content -Path $stderrLogPath -Tail 80 -ErrorAction SilentlyContinue
if ( -not $tail ) {
return " <stderr log is empty> "
}
return ( $tail -join [ Environment ] :: NewLine )
}
function Wait-ForStartedAquarium {
param (
[ System.Diagnostics.Process ] $Process ,
[ bool ] $ExpectWindow
)
$deadline = ( Get-Date ) . AddSeconds ( [ Math ] :: Max ( 1 , $StartupTimeoutSeconds ) )
while ( ( Get-Date ) -lt $deadline ) {
$Process . Refresh ( )
if ( $Process . HasExited ) {
throw " Aquarium process exited during startup with code $( $Process . ExitCode ) . `n $( Get-StderrTail ) "
}
if ( -not $ExpectWindow ) {
return
}
if ( $Process . MainWindowHandle -ne [ IntPtr ] :: Zero ) {
return
}
Start-Sleep -Milliseconds 100
}
if ( $ExpectWindow ) {
try {
Stop-Process -Id $Process . Id -Force -ErrorAction SilentlyContinue
}
catch {
# Best effort cleanup before surfacing the broken reload.
}
throw " Aquarium process started but did not open a visible window within $StartupTimeoutSeconds seconds. `n $( Get-StderrTail ) "
}
}
2026-05-09 03:05:23 +01:00
function Wait-ForHeadlessAquarium {
param (
[ System.Diagnostics.Process ] $Process
)
$deadline = ( Get-Date ) . AddSeconds ( [ Math ] :: Max ( 1 , $HeadlessTimeoutSeconds ) )
while ( ( Get-Date ) -lt $deadline ) {
$Process . Refresh ( )
if ( $Process . HasExited ) {
$Process . WaitForExit ( )
$exitCode = [ int ] $Process . ExitCode
if ( $exitCode -ne 0 ) {
throw " Headless Aquarium exited with code $exitCode . `n $( Get-StderrTail ) "
}
return
}
Start-Sleep -Milliseconds 100
}
try {
Stop-Process -Id $Process . Id -Force -ErrorAction SilentlyContinue
}
catch {
# Best effort cleanup before surfacing the broken smoke test.
}
throw " Headless Aquarium did not exit within $HeadlessTimeoutSeconds seconds; killed PID $( $Process . Id ) . `n $( Get-StderrTail ) "
}
2026-05-08 20:28:19 +01:00
function Start-AquariumProcess {
param (
[ string ] $SlotPath ,
[ string ] $LiveAssemblyPath ,
[ bool ] $RunHeadless
)
$exePath = Join-Path $SlotPath " Aquarium.Engine.exe "
if ( -not ( Test-Path $exePath ) ) {
throw " Expected apphost does not exist: $exePath "
}
if ( -not ( Test-Path $LiveAssemblyPath ) ) {
2026-05-12 02:13:45 +01:00
throw " Expected client runtime assembly does not exist: $LiveAssemblyPath "
2026-05-08 20:28:19 +01:00
}
Set-Content -Path $liveReloadPointerPath -Value $LiveAssemblyPath -Encoding UTF8
$arguments = @ (
" --cache " , $cultCachePath ,
2026-05-12 04:37:18 +01:00
" --shader-source " , ( Join-Path $SlotPath " Render\Shaders\D3D12HeightField.hlsl " ) ,
2026-05-12 02:13:45 +01:00
" --client-assembly " , $LiveAssemblyPath ,
2026-11-07 10:45:23 +00:00
" --client-reload-pointer " , $liveReloadPointerPath ,
2026-11-07 10:57:09 +00:00
" --client-trusted-root " , $assemblyRoot
2026-05-08 20:28:19 +01:00
)
if ( $RunHeadless ) {
$arguments + = " --headless "
}
$startProcessParameters = @ {
FilePath = $exePath
ArgumentList = $arguments
WorkingDirectory = $SlotPath
RedirectStandardOutput = $stdoutLogPath
RedirectStandardError = $stderrLogPath
PassThru = $true
}
if ( $RunHeadless ) {
$startProcessParameters . WindowStyle = " Hidden "
}
return Start-Process @startProcessParameters
}
function Reopen-VisibleProcess {
if ( -not ( Test-Path $visibleStatePath ) ) {
throw " No visible dev-reload state file exists. Run scripts\dev-reload.ps1 once to create a launch slot. "
}
$state = Import-Clixml -Path $visibleStatePath
if ( -not $state . slot ) {
throw " Visible dev-reload state does not record a slot. "
}
if ( $state . PSObject . Properties . Name -contains " pid " -and $state . pid ) {
$existing = Get-Process -Id ( [ int ] $state . pid ) -ErrorAction SilentlyContinue
if ( $existing ) {
Write-Host " Visible Aquarium process $( $state . pid ) is already running. "
return
}
}
$slotPath = [ string ] $state . slot
2026-05-17 23:37:20 +01:00
$liveAssemblyPath = Join-Path $slotPath " $liveAssemblyName .dll "
2026-05-08 20:28:19 +01:00
if ( $state . PSObject . Properties . Name -contains " liveAssembly " -and $state . liveAssembly ) {
$liveAssemblyPath = [ string ] $state . liveAssembly
}
2026-11-07 11:04:00 +00:00
# Stale state fails here, in words, instead of as a refused load inside the host.
$assemblyRootPrefix = [ System.IO.Path ] :: GetFullPath ( $assemblyRoot ) . TrimEnd ( '\' ) + '\'
foreach ( $recorded in @ ( $slotPath , $liveAssemblyPath ) ) {
if ( -not [ System.IO.Path ] :: GetFullPath ( $recorded ) . StartsWith ( $assemblyRootPrefix , [ System.StringComparison ] :: OrdinalIgnoreCase ) ) {
throw " Visible dev-reload state names ' $recorded ', outside the assemblies root ' $assemblyRoot ' (state from before the trusted-root layout, or the root moved). Run dev-reload without -Reopen to rebuild. "
}
}
if ( -not ( Test-Path -LiteralPath $liveAssemblyPath ) ) {
throw " Visible dev-reload state names ' $liveAssemblyPath ', which no longer exists (pruned slot). Run dev-reload without -Reopen to rebuild. "
}
2026-05-08 20:28:19 +01:00
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless: $false
Wait-ForStartedAquarium -Process $process -ExpectWindow: $true
@ {
slot = $slotPath
builtAt = $state . builtAt
reopenedAt = ( Get-Date ) . ToString ( " o " )
pid = $process . Id
liveAssembly = $liveAssemblyPath
headless = $false
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $visibleStatePath
Write-Host " Aquarium reopened from last visible slot. "
Write-Host " PID: $( $process . Id ) "
Write-Host " Slot: $slotPath "
return
}
if ( $Reopen ) {
Reopen-VisibleProcess
exit 0
}
2026-05-08 12:15:46 +01:00
$timestamp = Get-Date -Format " yyyyMMdd-HHmmss "
$slotName = " $timestamp - $( [ guid ] :: NewGuid ( ) . ToString ( " N " ) . Substring ( 0 , 8 ) ) "
$slotPath = Join-Path $slotRoot $slotName
New-Item -ItemType Directory -Force -Path $slotPath | Out-Null
Write-Host " Building Aquarium into disposable slot: "
Write-Host " $slotPath "
dotnet build $projectPath `
-c Debug `
-o $slotPath `
2026-05-08 15:10:33 +01:00
/ p: UseAppHost = true
2026-05-08 12:15:46 +01:00
if ( $LASTEXITCODE -ne 0 ) {
throw " dotnet build failed with exit code $LASTEXITCODE . "
}
2026-05-08 16:04:52 +01:00
dotnet build $liveProjectPath `
-c Debug `
-o $slotPath
if ( $LASTEXITCODE -ne 0 ) {
2026-05-12 02:13:45 +01:00
throw " client runtime build failed with exit code $LASTEXITCODE . "
2026-05-08 16:04:52 +01:00
}
2026-05-08 12:15:46 +01:00
if ( $BuildOnly ) {
@ {
slot = $slotPath
builtAt = ( Get-Date ) . ToString ( " o " )
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
2026-05-08 12:42:08 +01:00
} | Export-Clixml -Path $buildStatePath
2026-05-08 12:15:46 +01:00
Write-Host " Build-only slot ready. "
2026-05-08 12:42:08 +01:00
Remove-OldSlots
2026-05-08 12:15:46 +01:00
exit 0
}
2026-05-08 12:42:08 +01:00
Stop-PreviousOwnedProcess
2026-05-08 15:10:33 +01:00
$exePath = Join-Path $slotPath " Aquarium.Engine.exe "
2026-05-17 23:37:20 +01:00
$liveAssemblyPath = Join-Path $slotPath " $liveAssemblyName .dll "
2026-05-08 15:10:33 +01:00
if ( -not ( Test-Path $exePath ) ) {
throw " Expected apphost was not produced: $exePath "
}
2026-05-08 16:04:52 +01:00
if ( -not ( Test-Path $liveAssemblyPath ) ) {
2026-05-12 02:13:45 +01:00
throw " Expected client runtime assembly was not produced: $liveAssemblyPath "
2026-05-08 16:04:52 +01:00
}
2026-05-08 20:28:19 +01:00
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless: $Headless
2026-05-09 03:05:23 +01:00
if ( $Headless ) {
Wait-ForHeadlessAquarium -Process $process
@ {
slot = $slotPath
builtAt = ( Get-Date ) . ToString ( " o " )
liveAssembly = $liveAssemblyPath
headless = $true
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $statePath
Write-Host " Headless Aquarium completed from disposable slot. "
Write-Host " Slot: $slotPath "
Write-Host " CultCache: $cultCachePath "
Write-Host " Stdout: $stdoutLogPath "
Write-Host " Stderr: $stderrLogPath "
Remove-OldSlots
exit 0
}
Wait-ForStartedAquarium -Process $process -ExpectWindow: $true
2026-05-08 12:15:46 +01:00
@ {
slot = $slotPath
builtAt = ( Get-Date ) . ToString ( " o " )
pid = $process . Id
2026-05-08 20:28:19 +01:00
liveAssembly = $liveAssemblyPath
headless = [ bool ] $Headless
2026-05-08 12:15:46 +01:00
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
2026-05-08 12:42:08 +01:00
} | Export-Clixml -Path $statePath
2026-05-08 12:15:46 +01:00
Write-Host " Aquarium running from disposable slot. "
Write-Host " PID: $( $process . Id ) "
2026-05-08 12:42:08 +01:00
Write-Host " CultCache: $cultCachePath "
2026-05-08 12:15:46 +01:00
Write-Host " Stdout: $stdoutLogPath "
Write-Host " Stderr: $stderrLogPath "
Write-Host " Run this script again to replace only this script-owned process. "
2026-05-08 20:28:19 +01:00
if ( -not $Headless ) {
Write-Host " Run with -Reopen to reopen this visible slot without rebuilding. "
}
2026-05-08 12:42:08 +01:00
Remove-OldSlots