561 lines
18 KiB
PowerShell
561 lines
18 KiB
PowerShell
param(
|
|
[switch]$Headless,
|
|
[switch]$NoInitialLaunch,
|
|
[switch]$ReopenWhenClosed,
|
|
[switch]$Once,
|
|
[string]$ClientProject = "src\Aquarium.Fensalir\Aquarium.Fensalir.csproj",
|
|
[int]$IntervalMilliseconds = 1000,
|
|
[int]$DebounceMilliseconds = 350,
|
|
[int]$RetainSlots = 12,
|
|
[int]$LiveReloadAckTimeoutSeconds = 6
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
Set-StrictMode -Version Latest
|
|
|
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
|
$reloadScript = Join-Path $PSScriptRoot "dev-reload.ps1"
|
|
$liveProjectPath = if ([System.IO.Path]::IsPathRooted($ClientProject)) { $ClientProject } else { Join-Path $repoRoot $ClientProject }
|
|
$liveProjectPath = (Resolve-Path $liveProjectPath).Path
|
|
$liveAssemblyName = [System.IO.Path]::GetFileNameWithoutExtension($liveProjectPath)
|
|
$clientSourceRoot = Split-Path $liveProjectPath
|
|
$watchLogPath = Join-Path $repoRoot "artifacts\dev-reload\watch.log"
|
|
$liveSlotRoot = Join-Path $repoRoot "artifacts\dev-reload\live-slots"
|
|
$liveReloadPointerPath = Join-Path $repoRoot "artifacts\dev-reload\live-current.txt"
|
|
$shaderPath = Join-Path $repoRoot "src\Aquarium.Engine\Render\Shaders\Aquarium.hlsl"
|
|
$engineShaderRoot = Join-Path $repoRoot "src\Aquarium.Engine\Render\Shaders"
|
|
$clientShaderRoot = Join-Path $clientSourceRoot "Shaders"
|
|
$visibleStatePath = Join-Path $repoRoot "artifacts\dev-reload\state.clixml"
|
|
$headlessStatePath = Join-Path $repoRoot "artifacts\dev-reload\headless-state.clixml"
|
|
$ownedProcessStatePath = if ($Headless) { $headlessStatePath } else { $visibleStatePath }
|
|
|
|
New-Item -ItemType Directory -Force -Path (Split-Path $watchLogPath) | Out-Null
|
|
New-Item -ItemType Directory -Force -Path $liveSlotRoot | Out-Null
|
|
|
|
if ($Headless -and $ReopenWhenClosed) {
|
|
throw "-ReopenWhenClosed is for the visible dev window; do not combine it with -Headless."
|
|
}
|
|
|
|
function Write-WatchLog([string]$message) {
|
|
$line = "$(Get-Date -Format o) $message"
|
|
Write-Host $line
|
|
Add-Content -Path $watchLogPath -Value $line
|
|
}
|
|
|
|
function Get-SourceFiles {
|
|
$roots = @(
|
|
(Join-Path $repoRoot "src"),
|
|
(Join-Path $repoRoot "scripts"),
|
|
(Join-Path $repoRoot "Fensalir.sln"),
|
|
(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
|
|
$_.FullName -notmatch "\\artifacts\\" -and
|
|
(
|
|
$_.Extension -in @(".cs", ".csproj", ".json", ".sln", ".ps1") -or
|
|
$_.FullName -like (Join-Path $repoRoot "src\Aquarium.Engine\Assets\*")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-ShaderFiles {
|
|
foreach ($root in @($engineShaderRoot, $clientShaderRoot)) {
|
|
if (-not (Test-Path $root)) {
|
|
continue
|
|
}
|
|
|
|
Get-ChildItem -LiteralPath $root -Recurse -File |
|
|
Where-Object { $_.Extension -in @(".hlsl", ".hlsli") }
|
|
}
|
|
}
|
|
|
|
function Get-LiveFiles {
|
|
Get-SourceFiles | Where-Object {
|
|
$_.FullName -like (Join-Path $clientSourceRoot "*")
|
|
}
|
|
}
|
|
|
|
function Get-RestartFiles {
|
|
Get-SourceFiles | Where-Object {
|
|
$_.FullName -notlike (Join-Path $clientSourceRoot "*")
|
|
}
|
|
}
|
|
|
|
function Get-Fingerprint($files) {
|
|
$fingerprintInput = $files |
|
|
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 Get-Hash([string]$text) {
|
|
$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 Get-AquariumFrameContractText {
|
|
if (-not (Test-Path $shaderPath)) {
|
|
return ""
|
|
}
|
|
|
|
$text = Get-Content -Raw -LiteralPath $shaderPath
|
|
$match = [regex]::Match(
|
|
$text,
|
|
"cbuffer\s+AquariumFrame\s*:\s*register\(b0\)\s*\{(?<body>.*?)\};",
|
|
[System.Text.RegularExpressions.RegexOptions]::Singleline)
|
|
if (-not $match.Success) {
|
|
return ""
|
|
}
|
|
|
|
return $match.Value
|
|
}
|
|
|
|
function Get-SourceFingerprint {
|
|
$fileFingerprint = Get-Fingerprint (Get-SourceFiles)
|
|
$contractFingerprint = Get-Hash (Get-AquariumFrameContractText)
|
|
Get-Hash "$fileFingerprint`nAquariumFrame:$contractFingerprint"
|
|
}
|
|
|
|
function Get-ShaderFingerprint {
|
|
Get-Fingerprint (Get-ShaderFiles)
|
|
}
|
|
|
|
function Get-AllFingerprint {
|
|
$sourceFingerprint = Get-SourceFingerprint
|
|
$shaderFingerprint = Get-ShaderFingerprint
|
|
Get-Hash "$sourceFingerprint`nShaders:$shaderFingerprint"
|
|
}
|
|
|
|
function Get-LiveFingerprint {
|
|
Get-Fingerprint (Get-LiveFiles)
|
|
}
|
|
|
|
function Get-RestartFingerprint {
|
|
$fileFingerprint = Get-Fingerprint (Get-RestartFiles)
|
|
$contractFingerprint = Get-Hash (Get-AquariumFrameContractText)
|
|
Get-Hash "$fileFingerprint`nAquariumFrame:$contractFingerprint"
|
|
}
|
|
|
|
function Wait-StableFingerprints {
|
|
$first = @{
|
|
all = Get-AllFingerprint
|
|
live = Get-LiveFingerprint
|
|
restart = Get-RestartFingerprint
|
|
shaders = Get-ShaderFingerprint
|
|
}
|
|
Start-Sleep -Milliseconds $DebounceMilliseconds
|
|
$second = @{
|
|
all = Get-AllFingerprint
|
|
live = Get-LiveFingerprint
|
|
restart = Get-RestartFingerprint
|
|
shaders = Get-ShaderFingerprint
|
|
}
|
|
|
|
while ($first.all -ne $second.all) {
|
|
$first = $second
|
|
Start-Sleep -Milliseconds $DebounceMilliseconds
|
|
$second = @{
|
|
all = Get-AllFingerprint
|
|
live = Get-LiveFingerprint
|
|
restart = Get-RestartFingerprint
|
|
shaders = Get-ShaderFingerprint
|
|
}
|
|
}
|
|
|
|
return $second
|
|
}
|
|
|
|
function Invoke-Reload {
|
|
param([string]$reason)
|
|
|
|
Write-WatchLog "Reload requested: $reason"
|
|
|
|
$arguments = @(
|
|
"-NoProfile",
|
|
"-ExecutionPolicy", "Bypass",
|
|
"-File", $reloadScript,
|
|
"-ClientProject", $liveProjectPath,
|
|
"-RetainSlots", "$RetainSlots"
|
|
)
|
|
|
|
if ($Headless) {
|
|
$arguments += "-Headless"
|
|
}
|
|
|
|
Push-Location $repoRoot
|
|
try {
|
|
& powershell @arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "dev-reload failed with exit code $LASTEXITCODE."
|
|
}
|
|
}
|
|
finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
|
|
function Get-OwnedProcessState {
|
|
if (-not (Test-Path $ownedProcessStatePath)) {
|
|
return $null
|
|
}
|
|
|
|
try {
|
|
return Import-Clixml -Path $ownedProcessStatePath
|
|
}
|
|
catch {
|
|
Write-WatchLog "Could not read script-owned process state. $($_.Exception.Message)"
|
|
return $null
|
|
}
|
|
}
|
|
|
|
function Test-RecordedProcessRunning {
|
|
$state = Get-OwnedProcessState
|
|
if (-not $state -or -not $state.pid -or -not $state.slot) {
|
|
return $false
|
|
}
|
|
|
|
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
|
|
if (-not $process) {
|
|
return $false
|
|
}
|
|
|
|
$recordedPath = [string]$state.slot
|
|
$commandLine = $null
|
|
try {
|
|
$commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
|
|
}
|
|
catch {
|
|
$commandLine = $null
|
|
}
|
|
|
|
return [bool]($commandLine -and $commandLine -like "*$recordedPath*")
|
|
}
|
|
|
|
function Test-OwnedProcessRunning {
|
|
if ($NoInitialLaunch) {
|
|
return $true
|
|
}
|
|
|
|
return Test-RecordedProcessRunning
|
|
}
|
|
|
|
function Invoke-Reopen {
|
|
param([string]$reason)
|
|
|
|
if ($Headless) {
|
|
Invoke-Reload $reason
|
|
return
|
|
}
|
|
|
|
Write-WatchLog "Reopen requested: $reason"
|
|
|
|
$arguments = @(
|
|
"-NoProfile",
|
|
"-ExecutionPolicy", "Bypass",
|
|
"-File", $reloadScript,
|
|
"-Reopen",
|
|
"-ClientProject", $liveProjectPath,
|
|
"-RetainSlots", "$RetainSlots"
|
|
)
|
|
|
|
Push-Location $repoRoot
|
|
try {
|
|
& powershell @arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "dev-reload -Reopen failed with exit code $LASTEXITCODE."
|
|
}
|
|
}
|
|
finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
|
|
function Invoke-LiveReload {
|
|
param([string]$reason)
|
|
|
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
$liveSlot = Join-Path $liveSlotRoot "$timestamp-$([guid]::NewGuid().ToString("N").Substring(0, 8))"
|
|
New-Item -ItemType Directory -Force -Path $liveSlot | Out-Null
|
|
|
|
Write-WatchLog "Live reload requested: $reason"
|
|
dotnet build $liveProjectPath -c Debug -o $liveSlot
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "client runtime build failed with exit code $LASTEXITCODE."
|
|
}
|
|
|
|
$liveAssemblyPath = Join-Path $liveSlot "$liveAssemblyName.dll"
|
|
if (-not (Test-Path $liveAssemblyPath)) {
|
|
throw "Expected client runtime assembly was not produced: $liveAssemblyPath"
|
|
}
|
|
|
|
Set-Content -Path $liveReloadPointerPath -Value $liveAssemblyPath -Encoding UTF8
|
|
Write-WatchLog "Live reload pointer updated: $liveAssemblyPath"
|
|
Wait-LiveReloadAcknowledged $liveAssemblyPath
|
|
}
|
|
|
|
function Copy-ShaderTree {
|
|
param(
|
|
[string]$SourceRoot,
|
|
[string]$DestinationRoot
|
|
)
|
|
|
|
if (-not (Test-Path $SourceRoot)) {
|
|
return
|
|
}
|
|
|
|
Get-ChildItem -LiteralPath $SourceRoot -Recurse -File |
|
|
Where-Object { $_.Extension -in @(".hlsl", ".hlsli") } |
|
|
ForEach-Object {
|
|
$relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $_.FullName)
|
|
$destinationPath = Join-Path $DestinationRoot $relativePath
|
|
$destinationDirectory = Split-Path $destinationPath
|
|
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
|
|
Copy-Item -LiteralPath $_.FullName -Destination $destinationPath -Force
|
|
}
|
|
}
|
|
|
|
function Read-LogTextAfterLength {
|
|
param(
|
|
[string]$Path,
|
|
[long]$StartLength
|
|
)
|
|
|
|
if (-not (Test-Path $Path)) {
|
|
return ""
|
|
}
|
|
|
|
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
|
try {
|
|
if ($StartLength -gt 0 -and $StartLength -lt $stream.Length) {
|
|
$stream.Seek($StartLength, [System.IO.SeekOrigin]::Begin) | Out-Null
|
|
}
|
|
elseif ($StartLength -ge $stream.Length) {
|
|
return ""
|
|
}
|
|
|
|
$reader = [System.IO.StreamReader]::new($stream)
|
|
try {
|
|
return $reader.ReadToEnd()
|
|
}
|
|
finally {
|
|
$reader.Dispose()
|
|
}
|
|
}
|
|
finally {
|
|
$stream.Dispose()
|
|
}
|
|
}
|
|
|
|
function Wait-ShaderHotReloadAcknowledged {
|
|
param(
|
|
[string]$StdoutLog,
|
|
[string]$StderrLog,
|
|
[long]$StdoutStartLength,
|
|
[long]$StderrStartLength
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $LiveReloadAckTimeoutSeconds))
|
|
while ((Get-Date) -lt $deadline) {
|
|
$stdoutText = Read-LogTextAfterLength -Path $StdoutLog -StartLength $StdoutStartLength
|
|
if ($stdoutText -match "D3D12 shader hot reload applied") {
|
|
Write-WatchLog "Shader hot reload acknowledged by renderer."
|
|
return
|
|
}
|
|
|
|
$stderrText = Read-LogTextAfterLength -Path $StderrLog -StartLength $StderrStartLength
|
|
if ($stderrText -match "D3D12 shader hot reload failed") {
|
|
throw "renderer reported shader hot reload failure. See $StderrLog"
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 250
|
|
}
|
|
|
|
throw "shader files were copied, but renderer did not acknowledge hot reload within $LiveReloadAckTimeoutSeconds seconds. Check $StdoutLog and $StderrLog."
|
|
}
|
|
|
|
function Invoke-ShaderHotReload {
|
|
param([string]$reason)
|
|
|
|
Write-WatchLog "Shader hot reload requested: $reason"
|
|
|
|
$state = Get-OwnedProcessState
|
|
if (-not $state -or -not $state.slot) {
|
|
throw "no script-owned process slot is recorded for shader hot reload."
|
|
}
|
|
|
|
if (-not (Test-RecordedProcessRunning)) {
|
|
throw "no script-owned process is running to receive shader hot reload."
|
|
}
|
|
|
|
$slotPath = [string]$state.slot
|
|
$shaderDestinationRoot = Join-Path $slotPath "Render\Shaders"
|
|
New-Item -ItemType Directory -Force -Path $shaderDestinationRoot | Out-Null
|
|
|
|
$stdoutLog = if ($state.PSObject.Properties.Name -contains "stdoutLog" -and $state.stdoutLog) { [string]$state.stdoutLog } else { $stdoutLogPath }
|
|
$stderrLog = if ($state.PSObject.Properties.Name -contains "stderrLog" -and $state.stderrLog) { [string]$state.stderrLog } else { $stderrLogPath }
|
|
$stdoutStartLength = if (Test-Path $stdoutLog) { (Get-Item -LiteralPath $stdoutLog).Length } else { 0 }
|
|
$stderrStartLength = if (Test-Path $stderrLog) { (Get-Item -LiteralPath $stderrLog).Length } else { 0 }
|
|
|
|
Copy-ShaderTree -SourceRoot $engineShaderRoot -DestinationRoot $shaderDestinationRoot
|
|
Copy-ShaderTree -SourceRoot $clientShaderRoot -DestinationRoot $shaderDestinationRoot
|
|
Write-WatchLog "Shader files copied into running slot: $shaderDestinationRoot"
|
|
|
|
Wait-ShaderHotReloadAcknowledged -StdoutLog $stdoutLog -StderrLog $stderrLog -StdoutStartLength $stdoutStartLength -StderrStartLength $stderrStartLength
|
|
}
|
|
|
|
function Wait-LiveReloadAcknowledged {
|
|
param([string]$liveAssemblyPath)
|
|
|
|
if (-not (Test-RecordedProcessRunning)) {
|
|
throw "live reload pointer was updated, but no script-owned process is running to load it."
|
|
}
|
|
|
|
$state = Get-OwnedProcessState
|
|
if (-not $state -or -not $state.stdoutLog) {
|
|
Write-WatchLog "Live reload acknowledgement skipped: no running process log is recorded."
|
|
return
|
|
}
|
|
|
|
$stdoutLog = [string]$state.stdoutLog
|
|
$stderrLog = if ($state.stderrLog) { [string]$state.stderrLog } else { $null }
|
|
$timeoutSeconds = [Math]::Max(1, $LiveReloadAckTimeoutSeconds)
|
|
$deadline = (Get-Date).AddSeconds($timeoutSeconds)
|
|
$appliedLine = "Client runtime reload applied: $liveAssemblyPath"
|
|
|
|
while ((Get-Date) -lt $deadline) {
|
|
if (Test-Path $stdoutLog) {
|
|
$stdoutTail = (Get-Content -Path $stdoutLog -Tail 120 -ErrorAction SilentlyContinue) -join "`n"
|
|
if ($stdoutTail.Contains($appliedLine)) {
|
|
Write-WatchLog "Live reload acknowledged by host: $liveAssemblyPath"
|
|
return
|
|
}
|
|
}
|
|
|
|
if ($stderrLog -and (Test-Path $stderrLog)) {
|
|
$stderrTail = (Get-Content -Path $stderrLog -Tail 120 -ErrorAction SilentlyContinue) -join "`n"
|
|
if ($stderrTail -match "Client runtime reload failed") {
|
|
throw "host reported client runtime reload failure. See $stderrLog"
|
|
}
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 250
|
|
}
|
|
|
|
throw "live reload pointer was updated, but host did not acknowledge loading $liveAssemblyPath within $timeoutSeconds seconds. Check $stdoutLog and $stderrLog."
|
|
}
|
|
|
|
$lastGoodFingerprint = Wait-StableFingerprints
|
|
$lastFailedFingerprint = $null
|
|
|
|
if (-not $NoInitialLaunch) {
|
|
try {
|
|
Invoke-Reload "initial launch"
|
|
$lastGoodFingerprint = Wait-StableFingerprints
|
|
$lastFailedFingerprint = $null
|
|
}
|
|
catch {
|
|
$lastFailedFingerprint = $lastGoodFingerprint.all
|
|
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-StableFingerprints
|
|
|
|
if (-not (Test-OwnedProcessRunning)) {
|
|
try {
|
|
if ($ReopenWhenClosed) {
|
|
Invoke-Reopen "script-owned process is not running"
|
|
}
|
|
else {
|
|
Invoke-Reload "script-owned process is not running"
|
|
}
|
|
|
|
$lastGoodFingerprint = Wait-StableFingerprints
|
|
$lastFailedFingerprint = $null
|
|
$currentFingerprint = $lastGoodFingerprint
|
|
}
|
|
catch {
|
|
$lastFailedFingerprint = $currentFingerprint.all
|
|
Write-WatchLog "Relaunch failed; watcher remains alive. $($_.Exception.Message)"
|
|
}
|
|
|
|
if ($Once) {
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($currentFingerprint.all -eq $lastGoodFingerprint.all -or $currentFingerprint.all -eq $lastFailedFingerprint) {
|
|
if ($Once) {
|
|
break
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
try {
|
|
if ($currentFingerprint.restart -eq $lastGoodFingerprint.restart -and
|
|
$currentFingerprint.live -eq $lastGoodFingerprint.live -and
|
|
$currentFingerprint.shaders -ne $lastGoodFingerprint.shaders) {
|
|
Invoke-ShaderHotReload "shader source change"
|
|
}
|
|
elseif ($currentFingerprint.restart -eq $lastGoodFingerprint.restart -and $currentFingerprint.live -ne $lastGoodFingerprint.live) {
|
|
Invoke-LiveReload "live source change"
|
|
if ($currentFingerprint.shaders -ne $lastGoodFingerprint.shaders) {
|
|
Invoke-ShaderHotReload "shader source change accompanying live reload"
|
|
}
|
|
}
|
|
else {
|
|
Invoke-Reload "host/core/content source change"
|
|
}
|
|
|
|
$lastGoodFingerprint = $currentFingerprint
|
|
$lastFailedFingerprint = $null
|
|
}
|
|
catch {
|
|
$lastFailedFingerprint = $currentFingerprint.all
|
|
Write-WatchLog "Reload failed; previous good process stays alive. $($_.Exception.Message)"
|
|
}
|
|
|
|
if ($Once) {
|
|
break
|
|
}
|
|
} while ($true)
|