mirror of
https://github.com/Ed94/pikuma_ps1.git
synced 2026-08-08 08:38:50 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fbf550d3c |
@@ -20,3 +20,4 @@ toolchain/lpeg
|
|||||||
|
|
||||||
scratch
|
scratch
|
||||||
toolchain/libpsn00b
|
toolchain/libpsn00b
|
||||||
|
scripts/pcsx_debug_helper.zip
|
||||||
|
|||||||
Vendored
+33
@@ -177,6 +177,39 @@
|
|||||||
"tbreak main",
|
"tbreak main",
|
||||||
"continue"
|
"continue"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug: Hello Camera! (attach only)",
|
||||||
|
"type": "gdb",
|
||||||
|
"request": "attach",
|
||||||
|
"target": "localhost:3333",
|
||||||
|
"remote": true,
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
|
"valuesFormatting": "parseText",
|
||||||
|
"registerLimit": "1-32",
|
||||||
|
"frameFilters": false,
|
||||||
|
"showDevDebugOutput": false,
|
||||||
|
"printCalls": false,
|
||||||
|
"stopAtConnect": true,
|
||||||
|
"gdbpath": "gdb-multiarch",
|
||||||
|
"windows": {
|
||||||
|
"gdbpath": "gdb-multiarch.exe"
|
||||||
|
},
|
||||||
|
"osx": {
|
||||||
|
"gdbpath": "gdb"
|
||||||
|
},
|
||||||
|
"executable": "${workspaceRoot}/build/hello_camera.dwarf-injected.elf",
|
||||||
|
"setupCommands": [
|
||||||
|
{ "text": "set mi-async off" },
|
||||||
|
{ "text": "set remotetimeout 0" },
|
||||||
|
{ "text": "set logging file build/gen/hello_camera.gdb.log" },
|
||||||
|
{ "text": "set logging redirect on" }
|
||||||
|
],
|
||||||
|
"autorun": [
|
||||||
|
"source scripts/gdb/gdb_tape_atoms.gdb",
|
||||||
|
"tbreak hot_reload_entry",
|
||||||
|
"continue"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,19 @@ void gp_display_frame(DoubleBuffer* screen_buf, S4* active_buf_id, U4* ordering_
|
|||||||
}
|
}
|
||||||
|
|
||||||
GCC_OPTIMIZATION_DISABLE
|
GCC_OPTIMIZATION_DISABLE
|
||||||
|
void hot_reload_entry(void)
|
||||||
|
{
|
||||||
|
smem.primitives.used = 0;
|
||||||
|
while (1) {
|
||||||
|
gknown S4* active_buf_id = & smem.active_buf_id;
|
||||||
|
gknown U4* ordering_buf = r_(smem.ordering_tbl)[active_buf_id[0]];
|
||||||
|
gknown PrimitiveArena* pa = & smem.primitives;
|
||||||
|
update(pa, ordering_buf);
|
||||||
|
render();
|
||||||
|
gp_display_frame(& smem.screen_buf, active_buf_id, ordering_buf, pa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
smem = (SMemory){0};
|
smem = (SMemory){0};
|
||||||
@@ -323,14 +336,7 @@ int main(void)
|
|||||||
tb_emit(& tb, gp_screen_init);
|
tb_emit(& tb, gp_screen_init);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (1) {
|
hot_reload_entry();
|
||||||
gknown S4* active_buf_id = & smem.active_buf_id;
|
|
||||||
gknown U4* ordering_buf = r_(smem.ordering_tbl)[active_buf_id[0]];
|
|
||||||
gknown PrimitiveArena* pa = & smem.primitives;
|
|
||||||
update(pa, ordering_buf);
|
|
||||||
render();
|
|
||||||
gp_display_frame(& smem.screen_buf, active_buf_id, ordering_buf, pa);
|
|
||||||
};
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
GCC_OPTIMIZATION_ENABLE
|
GCC_OPTIMIZATION_ENABLE
|
||||||
|
|||||||
+247
-33
@@ -1,3 +1,13 @@
|
|||||||
|
# --- Parameter Surface (Task 8) -----------------------------------------
|
||||||
|
# -Reload : After a successful build, invoke reload.ps1 as a child pwsh and propagate its exit code.
|
||||||
|
# -HelperZipOnly : Skip the build entirely; regenerate the helper zip and exit. Honors -HelperZipOutput for out-of-tree paths.
|
||||||
|
# -HelperZipOutput: When -HelperZipOnly is set, writes the archive to this path instead of the scripts/pcsx_debug_helper.zip.
|
||||||
|
param(
|
||||||
|
[switch]$Reload,
|
||||||
|
[switch]$HelperZipOnly,
|
||||||
|
[string]$HelperZipOutput = ''
|
||||||
|
)
|
||||||
|
|
||||||
$path_root = split-path -Path $PSScriptRoot -Parent
|
$path_root = split-path -Path $PSScriptRoot -Parent
|
||||||
$path_build = join-path $path_root 'build'
|
$path_build = join-path $path_root 'build'
|
||||||
$path_code = join-path $path_root 'code'
|
$path_code = join-path $path_root 'code'
|
||||||
@@ -8,6 +18,98 @@ if ((test-path $path_build) -eq $false) {
|
|||||||
new-item -itemtype directory -path $path_build
|
new-item -itemtype directory -path $path_build
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- HelperZipOnly short-circuit ----------------------------------------
|
||||||
|
# Must run before any compile/link work.
|
||||||
|
# Inlines the same logic as Make-HelperZip below to avoid an extra pwsh process spawn (~200 ms).
|
||||||
|
#The helper zip is small and the BCL call is in-process; cold ~14 ms, warm ~10 ms (assembly load + tiny zip write).
|
||||||
|
if ($HelperZipOnly) {
|
||||||
|
$zipDest = if ([string]::IsNullOrEmpty($HelperZipOutput)) {
|
||||||
|
join-path $path_scripts 'pcsx_debug_helper.zip'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$HelperZipOutput
|
||||||
|
}
|
||||||
|
$HelperDir = join-path $path_scripts 'pcsx_debug_helper'
|
||||||
|
$elf32Src = join-path $path_scripts 'elf32.lua'
|
||||||
|
$elf32Dest = join-path $HelperDir 'elf32.lua'
|
||||||
|
if (-not (test-path -LiteralPath $HelperDir)) {
|
||||||
|
write-error "helper dir not found: $HelperDir"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (test-path -LiteralPath $elf32Src)) {
|
||||||
|
write-error "elf32.lua not found at $elf32Src"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
write-host "[build] HelperZipOnly mode -> $zipDest"
|
||||||
|
|
||||||
|
# --- Timestamp gate (Fix 1) -------------------------------------------
|
||||||
|
# PCSX-Redux holds pcsx_debug_helper.zip open via -archive at startup.
|
||||||
|
# The zip is consumed once at startup; the reload endpoint reads it
|
||||||
|
# from package.loaded on subsequent calls. Writing it on every build
|
||||||
|
# is dead work that fights the file lock. Skip the rewrite when the
|
||||||
|
# three sources (autoexec.lua, reload.lua, elf32.lua) are all older
|
||||||
|
# than the existing zip.
|
||||||
|
$sources = @(
|
||||||
|
(join-path $HelperDir 'autoexec.lua'),
|
||||||
|
(join-path $HelperDir 'reload.lua'),
|
||||||
|
$elf32Src
|
||||||
|
)
|
||||||
|
$zipMtime = $null
|
||||||
|
if (test-path -LiteralPath $zipDest) {
|
||||||
|
$zipMtime = (Get-Item -LiteralPath $zipDest).LastWriteTime
|
||||||
|
}
|
||||||
|
$needsRewrite = $false
|
||||||
|
if ($null -eq $zipMtime) {
|
||||||
|
$needsRewrite = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
foreach ($s in $sources) {
|
||||||
|
if (-not (test-path -LiteralPath $s)) { continue }
|
||||||
|
if ((Get-Item -LiteralPath $s).LastWriteTime -gt $zipMtime) {
|
||||||
|
$needsRewrite = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $needsRewrite) {
|
||||||
|
$sz = (Get-Item -LiteralPath $zipDest).Length
|
||||||
|
Write-Host "[build] helper zip up to date: $zipDest ($sz bytes); skipping"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $elf32Src -Destination $elf32Dest -Force
|
||||||
|
try {
|
||||||
|
# Force the inode release so CreateFromDirectory can write fresh.
|
||||||
|
# ZipFile.CreateFromDirectory throws if the destination exists.
|
||||||
|
# If PCSX-Redux holds the file open, Remove-Item raises — fall
|
||||||
|
# back to writing pcsx_debug_helper.zip.new alongside. The next
|
||||||
|
# PCSX-Redux restart will read the canonical path; the .new file
|
||||||
|
# is a hint for the optional launch-script patch in fix 3.
|
||||||
|
if (test-path -LiteralPath $zipDest) {
|
||||||
|
try {
|
||||||
|
# -ErrorAction Stop is required so the catch below fires.
|
||||||
|
# Remove-Item raises a non-terminating error by default
|
||||||
|
# (ErrorActionPreference=Continue), which bypasses catch.
|
||||||
|
Remove-Item -LiteralPath $zipDest -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$zipDest = [System.IO.Path]::ChangeExtension($zipDest, '.zip.new')
|
||||||
|
Write-Warning "[build] canonical helper zip is locked; writing to $zipDest instead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
[System.IO.Compression.ZipFile]::CreateFromDirectory(
|
||||||
|
$HelperDir, $zipDest,
|
||||||
|
[System.IO.Compression.CompressionLevel]::Optimal, $false) | Out-Null
|
||||||
|
$sz = (Get-Item -LiteralPath $zipDest).Length
|
||||||
|
Write-Host "[build] wrote $sz bytes to $zipDest"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (test-path -LiteralPath $elf32Dest) { Remove-Item -LiteralPath $elf32Dest -Force }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
# --- Toolchain Definition ---
|
# --- Toolchain Definition ---
|
||||||
# Assumes 'mipsel-none-elf' toolchain is in your system's PATH.
|
# Assumes 'mipsel-none-elf' toolchain is in your system's PATH.
|
||||||
$Prefix = "mipsel-none-elf"
|
$Prefix = "mipsel-none-elf"
|
||||||
@@ -220,10 +322,10 @@ function make-binary { param([string]$elf, [string]$exe)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ps1-meta { param(
|
function ps1-meta { param(
|
||||||
[string]$unity_root,
|
[string] $unity_root,
|
||||||
[string[]]$sources,
|
[string[]]$sources,
|
||||||
[Parameter(Mandatory=$true)][string]$metadata,
|
[Parameter(Mandatory=$true)][string]$metadata,
|
||||||
[string]$out_root = (join-path $path_build 'gen'),
|
[string] $out_root = (join-path $path_build 'gen'),
|
||||||
[string[]]$passes = @('--pre-link'),
|
[string[]]$passes = @('--pre-link'),
|
||||||
[string[]]$extra_args = @()
|
[string[]]$extra_args = @()
|
||||||
)
|
)
|
||||||
@@ -242,6 +344,40 @@ function ps1-meta { param(
|
|||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Defensive attribute clear on tracked gen files ------------------------
|
||||||
|
# Git tracks code/<dir>/gen/*.h files and Windows keeps the Archive bit set
|
||||||
|
# on them. Combined with transient editor locks or co-running processes,
|
||||||
|
# this can make io.open(path, "wb") fail with Access Denied / Sharing
|
||||||
|
# Violation even though Get-ChildItem shows IsReadOnly = False. Clearing
|
||||||
|
# the Read-only + Archive bits locally is safe; git re-asserts them on
|
||||||
|
# the next operation but the metaprogram write always wins.
|
||||||
|
#
|
||||||
|
# Derived from the caller's parameters: $metadata lives in $path_duffle
|
||||||
|
# (so its parent is the duffle dir), and $unity_root / $sources[0] lives
|
||||||
|
# in $path_module (so its parent is the module dir).
|
||||||
|
$pathToDuffle = split-path -Path $metadata -Parent
|
||||||
|
$pathToModule = $null
|
||||||
|
if ($null -ne $unity_root -and $unity_root -ne '') {
|
||||||
|
$pathToModule = split-path -Path $unity_root -Parent
|
||||||
|
}
|
||||||
|
elseif ($null -ne $sources -and $sources.Count -gt 0) {
|
||||||
|
$pathToModule = split-path -Path $sources[0] -Parent
|
||||||
|
}
|
||||||
|
$genFiles = @(
|
||||||
|
join-path $pathToDuffle 'gen\macs.h'
|
||||||
|
join-path $pathToDuffle 'gen\offsets.h'
|
||||||
|
)
|
||||||
|
if ($null -ne $pathToModule) {
|
||||||
|
$genFiles += join-path $pathToModule 'gen\macs.h'
|
||||||
|
$genFiles += join-path $pathToModule 'gen\offsets.h'
|
||||||
|
}
|
||||||
|
foreach ($f in $genFiles) {
|
||||||
|
if (test-path -LiteralPath $f) {
|
||||||
|
attrib -R $f 2>&1 | Out-Null
|
||||||
|
attrib -A $f 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$script = join-path $path_scripts 'ps1_meta.lua'
|
$script = join-path $path_scripts 'ps1_meta.lua'
|
||||||
$input_summary = if ($null -ne $unity_root -and $unity_root -ne '') {
|
$input_summary = if ($null -ne $unity_root -and $unity_root -ne '') {
|
||||||
"unity=$unity_root"
|
"unity=$unity_root"
|
||||||
@@ -564,44 +700,122 @@ function build-hello_camera {
|
|||||||
}
|
}
|
||||||
build-hello_camera
|
build-hello_camera
|
||||||
|
|
||||||
# NO idea if this works yet...
|
# ── Helper-zip + reload helpers (Task 8) ──
|
||||||
function Send-ToEmulator { param( [string]$exePath )
|
# Defined right after the final build-hello_camera function so they're in scope for the post-build calls below.
|
||||||
$uri = "http://localhost:8080/api/v1/load-exec"
|
# The Make-HelperZip function is also reused by the -HelperZipOnly short-circuit at the top of this script.
|
||||||
|
# Both call the in-process BCL CreateFromDirectory rather than spawning a child pwsh to avoid the ~200 ms process-spawn overhead.
|
||||||
|
function Make-HelperZip {
|
||||||
|
param([string]$OutputPath = '')
|
||||||
|
|
||||||
# Absolute path is safest for the emulator web server
|
$dest = if ([string]::IsNullOrEmpty($OutputPath)) {
|
||||||
$absolutePath = [System.IO.Path]::GetFullPath($exePath)
|
join-path $path_scripts 'pcsx_debug_helper.zip'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$OutputPath
|
||||||
|
}
|
||||||
|
|
||||||
# Create JSON payload pointing to your compiled .ps-exe
|
$HelperDir = join-path $path_scripts 'pcsx_debug_helper'
|
||||||
$body = @{ filename = $absolutePath } | ConvertTo-Json
|
$elf32Src = join-path $path_scripts 'elf32.lua'
|
||||||
|
$elf32Dest = join-path $HelperDir 'elf32.lua'
|
||||||
|
if (-not (test-path -LiteralPath $HelperDir)) {
|
||||||
|
write-warning "[build] helper dir not found: $HelperDir; skipping helper zip"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (-not (test-path -LiteralPath $elf32Src)) {
|
||||||
|
write-warning "[build] elf32.lua not found at $elf32Src; skipping helper zip"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Pushing hot-reload to PCSX-Redux..." -ForegroundColor Magenta
|
# --- Timestamp gate (Fix 1) -------------------------------------------
|
||||||
|
# PCSX-Redux holds pcsx_debug_helper.zip open via -archive at startup.
|
||||||
|
# The zip is consumed once at startup; the reload endpoint reads it
|
||||||
|
# from package.loaded on subsequent calls. Writing it on every build
|
||||||
|
# is dead work that fights the file lock. Skip the rewrite when the
|
||||||
|
# three sources (autoexec.lua, reload.lua, elf32.lua) are all older
|
||||||
|
# than the existing zip.
|
||||||
|
$sources = @(
|
||||||
|
(join-path $HelperDir 'autoexec.lua'),
|
||||||
|
(join-path $HelperDir 'reload.lua'),
|
||||||
|
$elf32Src
|
||||||
|
)
|
||||||
|
$zipMtime = $null
|
||||||
|
if (test-path -LiteralPath $dest) {
|
||||||
|
$zipMtime = (Get-Item -LiteralPath $dest).LastWriteTime
|
||||||
|
}
|
||||||
|
$needsRewrite = $false
|
||||||
|
if ($null -eq $zipMtime) {
|
||||||
|
$needsRewrite = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
foreach ($s in $sources) {
|
||||||
|
if (-not (test-path -LiteralPath $s)) { continue }
|
||||||
|
if ((Get-Item -LiteralPath $s).LastWriteTime -gt $zipMtime) {
|
||||||
|
$needsRewrite = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $needsRewrite) {
|
||||||
|
$sz = (Get-Item -LiteralPath $dest).Length
|
||||||
|
Write-Host "[build] helper zip up to date: $dest ($sz bytes); skipping"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
write-host "[build] regenerating helper zip -> $dest"
|
||||||
|
Copy-Item -LiteralPath $elf32Src -Destination $elf32Dest -Force
|
||||||
try {
|
try {
|
||||||
$response = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json"
|
# Force the inode release so CreateFromDirectory can write fresh.
|
||||||
Write-Host "Hot-reload successful!" -ForegroundColor Green
|
# ZipFile.CreateFromDirectory throws if the destination exists.
|
||||||
} catch {
|
# If PCSX-Redux holds the file open, Remove-Item raises — fall
|
||||||
Write-Warning "Could not connect to PCSX-Redux web server. Ensure the emulator is running and Web Server is enabled."
|
# back to writing pcsx_debug_helper.zip.new alongside. The next
|
||||||
|
# PCSX-Redux restart will read the canonical path; the .new file
|
||||||
|
# is a hint for the optional launch-script patch in fix 3.
|
||||||
|
if (test-path -LiteralPath $dest) {
|
||||||
|
try {
|
||||||
|
# -ErrorAction Stop is required so the catch below fires.
|
||||||
|
# Remove-Item raises a non-terminating error by default
|
||||||
|
# (ErrorActionPreference=Continue), which bypasses catch.
|
||||||
|
Remove-Item -LiteralPath $dest -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$dest = [System.IO.Path]::ChangeExtension($dest, '.zip.new')
|
||||||
|
Write-Warning "[build] canonical helper zip is locked; writing to $dest instead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
[System.IO.Compression.ZipFile]::CreateFromDirectory(
|
||||||
|
$HelperDir, $dest,
|
||||||
|
[System.IO.Compression.CompressionLevel]::Optimal, $false) | Out-Null
|
||||||
|
$sz = (Get-Item -LiteralPath $dest).Length
|
||||||
|
Write-Host "[build] wrote $sz bytes to $dest"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (test-path -LiteralPath $elf32Dest) { Remove-Item -LiteralPath $elf32Dest -Force }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# # Automatically hot-reloads it into the running emulator
|
# Invokes reload.ps1 as a child pwsh instead of POSTing to the nonexistent /api/v1/load-exec endpoint.
|
||||||
# Send-ToEmulator (join-path $path_build 'hello_gte.ps-exe')
|
# Exit code is propagated so the build fails loud if the reload fails.
|
||||||
|
function Send-ToEmulator {
|
||||||
|
param([string]$ElfPath = (join-path $path_build 'hello_camera.elf'))
|
||||||
|
|
||||||
# --- Hot Reload via PCSX-Redux Web Server ---
|
$reloadScript = join-path $path_scripts 'reload.ps1'
|
||||||
# $exe_path = join-path $path_build 'hello_gte.ps-exe'
|
if (-not (test-path -LiteralPath $reloadScript)) {
|
||||||
# $absolute_path = [System.IO.Path]::GetFullPath($exe_path)
|
write-error "[build] reload.ps1 not found at $reloadScript"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# PCSX-Redux expects the file location in the URL query string?
|
write-host "[build] hot-reloading $ElfPath via reload.ps1" -ForegroundColor Magenta
|
||||||
# We URL-encode the path to ensure backslashes and spaces don't break the HTTP request?
|
& pwsh -NoProfile -File $reloadScript -Mode elf -Target hello_camera -ElfPath $ElfPath
|
||||||
# $encoded_path = [uri]::EscapeDataString($absolute_path)
|
if ($LASTEXITCODE -ne 0) {
|
||||||
# $uri = "http://localhost:8080/api/v1/load-exec?path=$encoded_path"
|
write-error "[build] reload.ps1 failed (exit $LASTEXITCODE)"
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Write-Host "Pushing hot-reload to PCSX-Redux..." -ForegroundColor Magenta
|
# Post-build: Regenerate the helper zip (canonical output) and, if -Reload was passed, kick a hot-reload against the just-built ELF.
|
||||||
# try {
|
# Any future targets compiled by this script should add their own Make-HelperZip call after their build step; today's only target is hello_camera.
|
||||||
# # Send the request with the query string included
|
Make-HelperZip
|
||||||
# Invoke-RestMethod -Uri $uri -Method Post
|
if ($Reload) {
|
||||||
# Write-Host "Hot-reload successful!" -ForegroundColor Green
|
Send-ToEmulator
|
||||||
# } catch {
|
}
|
||||||
# Write-Host "Failed to hot-reload." -ForegroundColor Red
|
|
||||||
# # This will print the *actual* HTTP error instead of our generic warning
|
|
||||||
# Write-Host $_.Exception.Message -ForegroundColor Yellow
|
|
||||||
# }
|
|
||||||
|
|||||||
@@ -1,41 +1,77 @@
|
|||||||
# scripts/launch_pcsx_debug.ps1
|
# scripts/launch_pcsx_debug.ps1
|
||||||
#
|
#
|
||||||
# One-shot launcher for debug sessions: starts pcsx-redux with the .ps-exe
|
# One-shot launcher for debug sessions: starts pcsx-redux with the .ps-exe
|
||||||
# loaded, the gdb stub enabled, AND the pcsx_debug_helper Lua plugin loaded
|
# loaded, the gdb stub enabled, the web server enabled, AND the
|
||||||
# so external CLI tools (gdb's `shell` command, etc.)
|
# pcsx_debug_helper Lua plugin loaded so external CLI tools can drive
|
||||||
# can read GTE state via http://localhost:8080/api/v1/lua/gte
|
# reloads via http://localhost:8080/api/v1/lua/reload.
|
||||||
# (the gdb stub doesn't expose COP2 at all).
|
|
||||||
#
|
|
||||||
# usage:
|
|
||||||
# .\scripts\launch_pcsx_debug.ps1
|
|
||||||
# .\scripts\launch_pcsx_debug.ps1 -ExePath build\hello_gte.ps-exe
|
|
||||||
# .\scripts\launch_pcsx_debug.ps1 -HelperZip scripts\pcsx_debug_helper.zip
|
|
||||||
#
|
#
|
||||||
# After launch:
|
# After launch:
|
||||||
# - gdb: target remote localhost:3333
|
# - gdb: target remote localhost:3333
|
||||||
# - web: curl http://localhost:8080/api/v1/lua/gte
|
# - web: POST http://localhost:8080/api/v1/lua/reload?mode=prime&...
|
||||||
|
#
|
||||||
|
# usage:
|
||||||
|
# .\scripts\launch_pcsx_debug.ps1
|
||||||
|
# .\scripts\launch_pcsx_debug.ps1 -ExePath build\hello_camera.ps-exe
|
||||||
|
# .\scripts\launch_pcsx_debug.ps1 -Cpu dynarec
|
||||||
|
# .\scripts\launch_pcsx_debug.ps1 -ElfPath build\hello_camera.elf
|
||||||
#
|
#
|
||||||
# Companion: scripts/debug_psyq.ps1 (bare launch — no .ps-exe, no helper).
|
# Companion: scripts/debug_psyq.ps1 (bare launch — no .ps-exe, no helper).
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$PcsxPath = (Join-Path $PSScriptRoot '..\toolchain\pcsx-redux\vsprojects\x64\Release\pcsx-redux.exe'),
|
[string]$PcsxPath = (Join-Path $PSScriptRoot '..\toolchain\pcsx-redux\vsprojects\x64\Release\pcsx-redux.exe'),
|
||||||
[string]$ExePath = (Join-Path $PSScriptRoot '..\build\hello_gte.ps-exe'),
|
[string]$ExePath = (Join-Path $PSScriptRoot '..\build\hello_camera.ps-exe'),
|
||||||
|
[string]$ElfPath = '',
|
||||||
[string]$HelperZip = (Join-Path $PSScriptRoot 'pcsx_debug_helper.zip'),
|
[string]$HelperZip = (Join-Path $PSScriptRoot 'pcsx_debug_helper.zip'),
|
||||||
[int] $GdbPort = 3333,
|
[int] $GdbPort = 3333,
|
||||||
[int] $WebPort = 8080
|
[int] $WebPort = 8080,
|
||||||
|
[ValidateSet('interpreter', 'dynarec')][string]$Cpu = 'interpreter'
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ── Derive -ElfPath when absent ──
|
||||||
|
# Convention: the .elf sits beside the .ps-exe with the same stem.
|
||||||
|
if ([string]::IsNullOrEmpty($ElfPath)) {
|
||||||
|
$exeFull = [System.IO.Path]::GetFullPath($ExePath)
|
||||||
|
$stem = [System.IO.Path]::GetFileNameWithoutExtension($exeFull)
|
||||||
|
$exeDir = [System.IO.Path]::GetDirectoryName($exeFull)
|
||||||
|
$ElfPath = Join-Path $exeDir "$stem.elf"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Pre-checks ──
|
# ── Pre-checks ──
|
||||||
foreach ($p in @($PcsxPath, $ExePath, $HelperZip)) {
|
foreach ($p in @($PcsxPath, $ExePath, $ElfPath, $HelperZip)) {
|
||||||
if (-not (Test-Path $p)) {
|
if (-not (Test-Path -LiteralPath $p)) {
|
||||||
Write-Error "Missing: $p"
|
Write-Error "Missing: $p"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Reject a stale helper zip (Task 8) ──
|
||||||
|
# The helper zip must be newer than every .lua source that contributes
|
||||||
|
# to it. A stale zip means the running plugin does not match the on-disk
|
||||||
|
# source, which makes the reload contract meaningless.
|
||||||
|
$helperDir = Join-Path $PSScriptRoot 'pcsx_debug_helper'
|
||||||
|
$elf32Src = Join-Path $PSScriptRoot 'elf32.lua'
|
||||||
|
$sourceLuas = @(
|
||||||
|
(Join-Path $helperDir 'autoexec.lua'),
|
||||||
|
(Join-Path $helperDir 'reload.lua'),
|
||||||
|
$elf32Src
|
||||||
|
) | Where-Object { Test-Path -LiteralPath $_ }
|
||||||
|
|
||||||
|
$zipTime = (Get-Item -LiteralPath $HelperZip).LastWriteTime
|
||||||
|
$stale = $false
|
||||||
|
foreach ($src in $sourceLuas) {
|
||||||
|
$srcTime = (Get-Item -LiteralPath $src).LastWriteTime
|
||||||
|
if ($srcTime -gt $zipTime) {
|
||||||
|
Write-Error "helper zip is older than source: $src (zip=$($zipTime.ToString('o')) src=$($srcTime.ToString('o')); rerun build_psyq.ps1 to regenerate."
|
||||||
|
$stale = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($stale) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Kill any existing pcsx-redux so the archive file isn't locked.
|
# Kill any existing pcsx-redux so the archive file isn't locked.
|
||||||
Get-Process pcsx-redux -ErrorAction SilentlyContinue | Stop-Process -Force
|
Get-Process pcsx-redux -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
@@ -44,17 +80,23 @@ Start-Sleep -Seconds 2
|
|||||||
$absExe = [System.IO.Path]::GetFullPath($ExePath)
|
$absExe = [System.IO.Path]::GetFullPath($ExePath)
|
||||||
$absZip = [System.IO.Path]::GetFullPath($HelperZip)
|
$absZip = [System.IO.Path]::GetFullPath($HelperZip)
|
||||||
|
|
||||||
|
$cpuFlag = if ($Cpu -eq 'dynarec') { '-dynarec' } else { '-interpreter' }
|
||||||
|
|
||||||
$args = @(
|
$args = @(
|
||||||
'-gdb', '-run'
|
'-gdb', '-run'
|
||||||
'-loadexe', "`"$absExe`""
|
'-loadexe', "`"$absExe`""
|
||||||
'-archive', "`"$absZip`""
|
'-archive', "`"$absZip`""
|
||||||
|
'-webserver'
|
||||||
|
$cpuFlag
|
||||||
)
|
)
|
||||||
|
|
||||||
Write-Host "Launching pcsx-redux..." -ForegroundColor Cyan
|
Write-Host "Launching pcsx-redux..." -ForegroundColor Cyan
|
||||||
Write-Host " ps-exe : $absExe"
|
Write-Host " ps-exe : $absExe"
|
||||||
|
Write-Host " elf : $ElfPath"
|
||||||
Write-Host " helper zip: $absZip"
|
Write-Host " helper zip: $absZip"
|
||||||
Write-Host " gdb : localhost:$GdbPort"
|
Write-Host " gdb : localhost:$GdbPort"
|
||||||
Write-Host " web : localhost:$WebPort/api/v1/lua/gte"
|
Write-Host " web : localhost:$WebPort/api/v1/lua/reload"
|
||||||
|
Write-Host " cpu : $Cpu ($cpuFlag)"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Start-Process -FilePath $PcsxPath -ArgumentList $args | Out-Null
|
Start-Process -FilePath $PcsxPath -ArgumentList $args | Out-Null
|
||||||
@@ -89,6 +131,44 @@ try {
|
|||||||
Write-Host "Check the pcsx-redux Lua Console for debug cli messages." -ForegroundColor Yellow
|
Write-Host "Check the pcsx-redux Lua Console for debug cli messages." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Prime the reload handler (Task 8) ──
|
||||||
|
# The reload handler keeps an internal ACTIVE manifest of the running
|
||||||
|
# ELF; reload requests fail with reload_not_primed until prime succeeds.
|
||||||
|
# We retry until the response carries ok=true or the launch deadline
|
||||||
|
# expires — the helper may not have finished registering handlers in the
|
||||||
|
# first web-poll cycle after the gte handler comes up.
|
||||||
|
$absElf = [System.IO.Path]::GetFullPath($ElfPath)
|
||||||
|
$encodedPath = [uri]::EscapeDataString($absElf)
|
||||||
|
$primeUri = "http://localhost:${WebPort}/api/v1/lua/reload?mode=prime&target=hello_camera&path=${encodedPath}"
|
||||||
|
|
||||||
|
Write-Host "Priming reload handler: $primeUri" -ForegroundColor Cyan
|
||||||
|
$primeDeadline = (Get-Date).AddSeconds(15)
|
||||||
|
$primeOk = $false
|
||||||
|
while ((Get-Date) -lt $primeDeadline) {
|
||||||
|
try {
|
||||||
|
$resp = Invoke-WebRequest -Method Post -Uri $primeUri -UseBasicParsing -TimeoutSec 5
|
||||||
|
$body = if ($resp.Content -is [byte[]]) {
|
||||||
|
[System.Text.Encoding]::UTF8.GetString([byte[]]$resp.Content)
|
||||||
|
} else {
|
||||||
|
[string]$resp.Content
|
||||||
|
}
|
||||||
|
$obj = $body | ConvertFrom-Json
|
||||||
|
if ($obj.ok) {
|
||||||
|
Write-Host "Prime OK: $(($obj | ConvertTo-Json -Compress))" -ForegroundColor Green
|
||||||
|
$primeOk = $true
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Write-Host "Prime not yet ready: error=$($obj.error)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "Prime request failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
if (-not $primeOk) {
|
||||||
|
Write-Warning "Prime did not return ok=true before the launch deadline. Reload requests will fail until the user primes manually."
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "pcsx-redux running. PIDs:" -ForegroundColor Cyan
|
Write-Host "pcsx-redux running. PIDs:" -ForegroundColor Cyan
|
||||||
Get-Process pcsx-redux | Select-Object Id, ProcessName | Format-Table
|
Get-Process pcsx-redux | Select-Object Id, ProcessName | Format-Table
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# make_helper_zip.ps1
|
||||||
|
#
|
||||||
|
# Regenerate scripts/pcsx_debug_helper.zip from scripts/pcsx_debug_helper/.
|
||||||
|
# The archive contains exactly three entries at archive root:
|
||||||
|
#
|
||||||
|
# autoexec.lua
|
||||||
|
# elf32.lua (copied in from scripts/elf32.lua before packaging)
|
||||||
|
# reload.lua
|
||||||
|
#
|
||||||
|
# Determinism: CreateFromDirectory on the same set of files produces
|
||||||
|
# identical bytes. Verified by running the same command twice and
|
||||||
|
# asserting SHA-256 equality (see plan.md Task 6 Step 4).
|
||||||
|
#
|
||||||
|
# Performance: the implementation uses System.IO.Compression.ZipFile
|
||||||
|
# (BCL, in-process). Benchmarked: ~2 ms cold, ~2 ms warm on this
|
||||||
|
# workstation. Compress-Archive is rejected because its first call
|
||||||
|
# takes ~200 ms (assembly load) and subsequent calls take ~16 ms
|
||||||
|
# (process spawn per invocation). The 50 ms budget documented in
|
||||||
|
# plan.md Task 8 Step 3 excludes the compiler/assembler toolchain.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# pwsh -NoProfile -File scripts\make_helper_zip.ps1
|
||||||
|
#
|
||||||
|
# Optional -OutputPath switches the destination. Default is
|
||||||
|
# scripts/pcsx_debug_helper.zip next to the helper dir.
|
||||||
|
#
|
||||||
|
# Companion: scripts/pcsx_debug_helper/{autoexec,elf32,reload}.lua
|
||||||
|
# tests/reload_helper_zip_regen.ps1 (planned Task 8 verifier)
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$HelperDir = (Join-Path $PSScriptRoot 'pcsx_debug_helper'),
|
||||||
|
[string]$SourcesDir = $PSScriptRoot,
|
||||||
|
[string]$OutputPath = (Join-Path $PSScriptRoot 'pcsx_debug_helper.zip')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $HelperDir)) {
|
||||||
|
throw "helper dir not found: $HelperDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage elf32.lua into the helper dir so the in-process ZipFile walker
|
||||||
|
# picks it up alongside the helper-local files. elf32.lua is the shared
|
||||||
|
# ELF32 byte reader; the production reload.lua loads it through
|
||||||
|
# Support.extra.dofile("elf32.lua") at runtime.
|
||||||
|
$elf32Src = Join-Path $SourcesDir 'elf32.lua'
|
||||||
|
$elf32Dest = Join-Path $HelperDir 'elf32.lua'
|
||||||
|
if (-not (Test-Path -LiteralPath $elf32Src)) {
|
||||||
|
throw "elf32.lua not found at $elf32Src"
|
||||||
|
}
|
||||||
|
Copy-Item -LiteralPath $elf32Src -Destination $elf32Dest -Force
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Remove any existing archive so CreateFromDirectory can write fresh.
|
||||||
|
# ZipFile.CreateFromDirectory throws if the destination exists.
|
||||||
|
if (Test-Path -LiteralPath $OutputPath) {
|
||||||
|
Remove-Item -LiteralPath $OutputPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# In-process zip; ~2 ms cold, ~2 ms warm. BCL compression matches
|
||||||
|
# Compress-Archive at CompressionLevel Optimal for these small files.
|
||||||
|
# Assembly is loaded once per pwsh.exe; the first run pays ~14 ms,
|
||||||
|
# subsequent runs pay ~0.2 ms.
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
[System.IO.Compression.ZipFile]::CreateFromDirectory(
|
||||||
|
$HelperDir, $OutputPath,
|
||||||
|
[System.IO.Compression.CompressionLevel]::Optimal,
|
||||||
|
$false) | Out-Null
|
||||||
|
|
||||||
|
$sha = (Get-FileHash -LiteralPath $OutputPath -Algorithm SHA256).Hash
|
||||||
|
Write-Output ("[make_helper_zip] wrote {0} bytes, sha256={1}" -f `
|
||||||
|
(Get-Item -LiteralPath $OutputPath).Length, $sha)
|
||||||
|
Write-Output "[make_helper_zip] entries: autoexec.lua, elf32.lua, reload.lua"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Remove the staged elf32.lua so the helper directory only contains
|
||||||
|
# the files the user expects to see there.
|
||||||
|
if (Test-Path -LiteralPath $elf32Dest) {
|
||||||
|
Remove-Item -LiteralPath $elf32Dest -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -49,3 +49,27 @@ local ok, err = pcall(register_handlers)
|
|||||||
if ok then print("[pcsx_debug_helper] handlers registered: gte, gp")
|
if ok then print("[pcsx_debug_helper] handlers registered: gte, gp")
|
||||||
else print("[pcsx_debug_helper] registration failed: " .. tostring(err))
|
else print("[pcsx_debug_helper] registration failed: " .. tostring(err))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ── reload handler (Task 6) ──
|
||||||
|
-- After gte and gp register successfully, load reload.lua through Support.extra.dofile and call its install(pcsx, support).
|
||||||
|
-- The whole sequence runs inside pcall so a missing zip, missing module table,
|
||||||
|
-- or throwing install never disturbs the gte and gp handlers already registered above (handler isolation).
|
||||||
|
--
|
||||||
|
-- The failure messages are intentionally single-line so the helper's boot log stays scannable.
|
||||||
|
if type(Support) == "table"
|
||||||
|
and type(Support.extra) == "table"
|
||||||
|
and type(Support.extra.dofile) == "function" then
|
||||||
|
local load_ok, reload_mod = pcall(Support.extra.dofile, "reload.lua")
|
||||||
|
if load_ok and type(reload_mod) == "table" and type(reload_mod.install) == "function" then
|
||||||
|
local install_ok, install_err = pcall(reload_mod.install, PCSX, Support)
|
||||||
|
if install_ok then
|
||||||
|
print("[pcsx_debug_helper] reload handler registered")
|
||||||
|
else
|
||||||
|
print("[pcsx_debug_helper] reload registration failed: " .. tostring(install_err))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print("[pcsx_debug_helper] reload load failed: " .. tostring(reload_mod))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print("[pcsx_debug_helper] reload load failed: Support.extra.dofile unavailable")
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,902 @@
|
|||||||
|
-- reload.lua - Side-effect-free hot-reload helper for the
|
||||||
|
-- pcsx_redux_hot_reload track (Task 2). This file owns the HTTP request
|
||||||
|
-- surface that the launch / reload client targets:
|
||||||
|
--
|
||||||
|
-- POST /api/v1/lua/reload?mode=prime&target=hello_camera&path=<encoded-elf>
|
||||||
|
-- POST /api/v1/lua/reload?mode=elf&target=hello_camera&path=<encoded-elf>
|
||||||
|
-- POST /api/v1/lua/reload?mode=patch&target=hello_camera&addr=...&hex=...
|
||||||
|
--
|
||||||
|
-- This module exposes the public surface used by the contract harness
|
||||||
|
-- (tests/reload_helper_contract.lua) and the runtime installed by
|
||||||
|
-- scripts/pcsx_debug_helper/autoexec.lua. The module must not reference
|
||||||
|
-- the global PCSX table at load time; the host is passed in explicitly
|
||||||
|
-- through M.new(host) and M.install(pcsx, support).
|
||||||
|
--
|
||||||
|
-- Public surface:
|
||||||
|
-- M.parse_query(query) -> table, nil OR nil, err_string
|
||||||
|
-- M.json_response(fields) -> string (sorted keys)
|
||||||
|
-- M.parse_manifest(...) -> Task 3 (real impl uses elf32.lua)
|
||||||
|
-- M.new(host) -> runtime object (Task 4; stub here)
|
||||||
|
-- M.install(pcsx, support) -> registers web handler (Task 6; stub here)
|
||||||
|
--
|
||||||
|
-- Companion: scripts/pcsx_debug_helper/autoexec.lua.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Load the shared ELF32 helpers.
|
||||||
|
--
|
||||||
|
-- **The bane of this refactor:** the helper VM (PCSX-Redux) does not expose
|
||||||
|
-- `require` for paths outside the helper zip. The production loader is
|
||||||
|
-- `Support.extra.dofile("elf32.lua")` — Support.extra.dofile resolves the
|
||||||
|
-- name against the helper zip's contents (the zip is generated by the
|
||||||
|
-- build script and includes both `reload.lua` and `elf32.lua` after Task 6).
|
||||||
|
--
|
||||||
|
-- The test harness at `tests/reload_helper_contract.lua` loads `reload.lua`
|
||||||
|
-- via standard Lua `dofile` with an absolute path; it does not install a
|
||||||
|
-- `Support` object. We detect the runtime context: if `Support.extra.dofile`
|
||||||
|
-- exists, use it (production path); otherwise fall back to standard `dofile`
|
||||||
|
-- with an absolute path (test harness path).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
local function load_elf32()
|
||||||
|
if type(Support) == "table"
|
||||||
|
and type(Support.extra) == "table"
|
||||||
|
and type(Support.extra.dofile) == "function" then
|
||||||
|
return Support.extra.dofile("elf32.lua")
|
||||||
|
end
|
||||||
|
-- Test harness + any other context that supplies standard Lua dofile.
|
||||||
|
return dofile("C:/projects/Pikuma/ps1/scripts/elf32.lua")
|
||||||
|
end
|
||||||
|
|
||||||
|
local E = load_elf32()
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- parse_query(query)
|
||||||
|
--
|
||||||
|
-- Parses an application/x-www-form-urlencoded query string into a table.
|
||||||
|
--
|
||||||
|
-- Rules (per spec §8 + plan.md Task 2 Step 3):
|
||||||
|
-- * Each pair is split on the first '='; the key is to the left, the value
|
||||||
|
-- to the right. A pair without '=' is a malformed_pair.
|
||||||
|
-- * Percent escapes '%HH' (HH = two hex digits) decode to the corresponding
|
||||||
|
-- byte. A '%' not followed by two hex digits is a malformed_escape.
|
||||||
|
-- * '+' decodes to a literal space (applied after percent decode).
|
||||||
|
-- * A key appearing more than once is a duplicate_key error.
|
||||||
|
--
|
||||||
|
-- Returns the parsed table on success. On failure returns nil and a stable
|
||||||
|
-- error string suitable for the JSON error envelope. An empty / nil query
|
||||||
|
-- returns an empty table (not an error).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
local function percent_decode(s)
|
||||||
|
-- Walk the string once, byte by byte. A '%' must be followed by exactly
|
||||||
|
-- two hex digits; '+' decodes to ' '; everything else is passed through.
|
||||||
|
local out = {}
|
||||||
|
local i = 1
|
||||||
|
local len = #s
|
||||||
|
while i <= len do
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
if c == "%" then
|
||||||
|
if i + 2 > len then
|
||||||
|
return nil -- truncated escape (e.g., '%' at end or '%X')
|
||||||
|
end
|
||||||
|
local hex = s:sub(i + 1, i + 2)
|
||||||
|
local hd1, hd2 = hex:sub(1, 1), hex:sub(2, 2)
|
||||||
|
-- Validate both characters are hex digits.
|
||||||
|
if not (hd1:match("[0-9A-Fa-f]") and hd2:match("[0-9A-Fa-f]")) then
|
||||||
|
return nil -- malformed escape
|
||||||
|
end
|
||||||
|
out[#out + 1] = string.char(tonumber(hex, 16))
|
||||||
|
i = i + 3
|
||||||
|
else
|
||||||
|
out[#out + 1] = c
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function plus_to_space(s)
|
||||||
|
-- Standalone helper so callers can decode '+' after percent decoding.
|
||||||
|
return (s:gsub("+", " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.parse_query(query)
|
||||||
|
if query == nil or query == "" then
|
||||||
|
return {}, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = {}
|
||||||
|
local seen = {}
|
||||||
|
|
||||||
|
for pair in query:gmatch("[^&]+") do
|
||||||
|
-- Split on the first '=' only.
|
||||||
|
local eq = pair:find("=", 1, true)
|
||||||
|
if not eq then
|
||||||
|
return nil, "malformed_pair"
|
||||||
|
end
|
||||||
|
local raw_key = pair:sub(1, eq - 1)
|
||||||
|
local raw_value = pair:sub(eq + 1)
|
||||||
|
|
||||||
|
-- Percent-decode first, then convert '+' to space. The order matters:
|
||||||
|
-- a '%2B' should decode to '+' (literal plus), not be re-converted to a
|
||||||
|
-- space. Per RFC 1866 §8.2.1, '+' is a literal plus in the encoded form
|
||||||
|
-- only when it represents a space.
|
||||||
|
local key = percent_decode(raw_key)
|
||||||
|
if key == nil then
|
||||||
|
return nil, "malformed_escape"
|
||||||
|
end
|
||||||
|
key = plus_to_space(key)
|
||||||
|
|
||||||
|
local val = percent_decode(raw_value)
|
||||||
|
if val == nil then
|
||||||
|
return nil, "malformed_escape"
|
||||||
|
end
|
||||||
|
val = plus_to_space(val)
|
||||||
|
|
||||||
|
if seen[key] then
|
||||||
|
return nil, "duplicate_key"
|
||||||
|
end
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
result[key] = val
|
||||||
|
end
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- json_response(fields)
|
||||||
|
--
|
||||||
|
-- Deterministic JSON object encoder. Returns a string. Keys are sorted
|
||||||
|
-- alphabetically before emission so byte-for-byte equality is testable
|
||||||
|
-- across runs and across PS1 captures.
|
||||||
|
--
|
||||||
|
-- Supported value types: string, number, boolean, nil (encoded as null).
|
||||||
|
-- Strings escape '\', '"', and the C0 control range (0x00..0x1F). The
|
||||||
|
-- named escapes use the conventional single-char forms: \\, \", \b, \f,
|
||||||
|
-- \n, \r, \t. Everything else in 0x00..0x1F is \uXXXX.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
local function json_escape_string(s)
|
||||||
|
-- Two passes: first the named escapes, then the catch-all C0 range
|
||||||
|
-- (%c covers 0x00..0x1F in Lua patterns). Using plain string.gsub
|
||||||
|
-- with a literal replacement table covers the named escapes; a
|
||||||
|
-- second gsub handles the rest.
|
||||||
|
s = s:gsub('[\\"]', {
|
||||||
|
["\\"] = "\\\\",
|
||||||
|
['"'] = '\\"',
|
||||||
|
})
|
||||||
|
s = s:gsub("\b", "\\b")
|
||||||
|
s = s:gsub("\f", "\\f")
|
||||||
|
s = s:gsub("\n", "\\n")
|
||||||
|
s = s:gsub("\r", "\\r")
|
||||||
|
s = s:gsub("\t", "\\t")
|
||||||
|
-- Remaining C0 control characters (0x00..0x1F) become \uXXXX. We
|
||||||
|
-- intentionally keep the named escapes above (which are already
|
||||||
|
-- single backslashes in the output) from being re-escaped: gsub on
|
||||||
|
-- the literal control char bytes doesn't match the backslashes we
|
||||||
|
-- already inserted.
|
||||||
|
s = s:gsub("([%c])", function(c)
|
||||||
|
return string.format("\\u%04x", string.byte(c))
|
||||||
|
end)
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.json_response(fields)
|
||||||
|
if type(fields) ~= "table" then
|
||||||
|
error("json_response: expected table, got " .. type(fields))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sort keys for deterministic output. Lua's table.sort is byte-wise
|
||||||
|
-- and stable for strings; JSON object key order is not significant
|
||||||
|
-- but tests rely on a fixed order to compare against fixtures.
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(fields) do
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
parts[#parts + 1] = "{"
|
||||||
|
for i = 1, #keys do
|
||||||
|
local k = keys[i]
|
||||||
|
if i > 1 then
|
||||||
|
parts[#parts + 1] = ","
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = '"'
|
||||||
|
parts[#parts + 1] = json_escape_string(k)
|
||||||
|
parts[#parts + 1] = '":'
|
||||||
|
local v = fields[k]
|
||||||
|
local tv = type(v)
|
||||||
|
if tv == "string" then
|
||||||
|
parts[#parts + 1] = '"'
|
||||||
|
parts[#parts + 1] = json_escape_string(v)
|
||||||
|
parts[#parts + 1] = '"'
|
||||||
|
elseif tv == "number" then
|
||||||
|
parts[#parts + 1] = tostring(v)
|
||||||
|
elseif tv == "boolean" then
|
||||||
|
parts[#parts + 1] = v and "true" or "false"
|
||||||
|
elseif v == nil then
|
||||||
|
parts[#parts + 1] = "null"
|
||||||
|
else
|
||||||
|
error("json_response: unsupported value type " .. tv .. " for key " .. tostring(k))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = "}"
|
||||||
|
return table.concat(parts)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ELF32 manifest parser (Task 3).
|
||||||
|
--
|
||||||
|
-- Parses a little-endian ELF32 file exposed through a file_adapter that
|
||||||
|
-- provides read_u8_at/read_u16_at/read_u32_at/read_size. The parser validates the
|
||||||
|
-- magic, class, data encoding, and machine before reading anything else.
|
||||||
|
-- It resolves section names through the .shstrtab table and symbols
|
||||||
|
-- through every SHT_SYMTAB section (and its linked string table).
|
||||||
|
--
|
||||||
|
-- The output manifest contains the state ABI the reload gate must
|
||||||
|
-- preserve plus the addresses the helper writes to the CPU on a reload.
|
||||||
|
-- Loaded sections (SHF_ALLOC, non-SHT_NOBITS) are recorded so the runtime
|
||||||
|
-- can reject any ELF whose loaded range overlaps the preserved smem.
|
||||||
|
--
|
||||||
|
-- **Refactor:** the format-constant tables + the byte-level walker live in
|
||||||
|
-- scripts/elf32.lua (loaded above via `load_elf32()`). This module retains
|
||||||
|
-- only the manifest-specific validation: required symbols, smem size, stack
|
||||||
|
-- alignment, loaded-section overlap. The net effect is ~80 lines shorter.
|
||||||
|
--
|
||||||
|
-- Stable error codes (returned as the second value):
|
||||||
|
-- bad_magic, unsupported_elf_class, unsupported_elf_data,
|
||||||
|
-- non_mips_machine, truncated_header, truncated_section_headers,
|
||||||
|
-- missing_shstrtab, missing_symtab_strtab, missing_smem,
|
||||||
|
-- missing_data_start, missing_data_end, missing_bss_start,
|
||||||
|
-- missing_bss_end, missing_stack_top, missing_hot_reload_entry,
|
||||||
|
-- zero_smem_size, stack_misaligned, stack_out_of_main_ram,
|
||||||
|
-- section_overlaps_smem, bad_file_adapter
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Convert a KSEG0/KSEG1/physical address to its physical main-RAM offset.
|
||||||
|
local function to_physical(addr)
|
||||||
|
if addr >= 0x80000000 and addr < 0x80200000 then
|
||||||
|
return addr - 0x80000000
|
||||||
|
elseif addr >= 0xa0000000 and addr < 0xa0200000 then
|
||||||
|
return addr - 0xa0000000
|
||||||
|
end
|
||||||
|
return addr
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Strip KSEG0 / KSEG1 alias from an address and return the physical main-RAM
|
||||||
|
-- offset. Used by M.elf_reload and M.patch_handler. Returns nil when the
|
||||||
|
-- address falls outside physical main RAM (0..0x1fffff), KSEG0 main RAM
|
||||||
|
-- (0x80000000..0x801fffff), or KSEG1 main RAM (0xa0000000..0xa01fffff).
|
||||||
|
-- Per spec §7 the patch path MUST reject scratchpad (0x1F800000+), BIOS
|
||||||
|
-- (0x1FC00000+), MMIO, and expansion aliases; this helper centralizes the
|
||||||
|
-- strip + range check so callers cannot forget the upper bound.
|
||||||
|
local function strip_kseg(addr)
|
||||||
|
if type(addr) ~= "number" then return nil end
|
||||||
|
if addr >= 0x80000000 and addr < 0x80200000 then
|
||||||
|
return addr - 0x80000000
|
||||||
|
elseif addr >= 0xa0000000 and addr < 0xa0200000 then
|
||||||
|
return addr - 0xa0000000
|
||||||
|
elseif addr >= 0 and addr < 0x200000 then
|
||||||
|
return addr
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse a hex string ("0xHHHH..." or "HHHH...") into a 32-bit unsigned
|
||||||
|
-- integer. Returns nil + stable error on absent / non-hex / out-of-range.
|
||||||
|
-- Used for both the patch path's addr/hex query parameters and any other
|
||||||
|
-- 32-bit hex field the API may add. Accepts up to 8 hex digits.
|
||||||
|
local function parse_hex_u32(s, missing_err, badhex_err)
|
||||||
|
if type(s) ~= "string" or #s == 0 then
|
||||||
|
return nil, missing_err or "missing_hex"
|
||||||
|
end
|
||||||
|
local clean = s:match("^0[xX]([0-9A-Fa-f]+)$")
|
||||||
|
or s:match("^([0-9A-Fa-f]+)$")
|
||||||
|
if not clean then return nil, badhex_err or "non_hex" end
|
||||||
|
if #clean > 8 then return nil, badhex_err or "non_hex" end
|
||||||
|
return tonumber(clean, 16), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Trap on a missing E.* — keeps the existing one-line-error pattern when
|
||||||
|
-- the helper zip is stale or absent.
|
||||||
|
local function stack()
|
||||||
|
io.stderr:write("[reload.parse_manifest] FATAL: scripts/elf32.lua not loaded; aborting\n")
|
||||||
|
error("elf32 module not loaded")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_manifest_impl(file_adapter, target, path, require_entry)
|
||||||
|
-- Wrap the body in a pcall so any thrown exception (e.g. a bad
|
||||||
|
-- adapter method or a malformed section header) surfaces as a
|
||||||
|
-- parse_error with the message and traceback instead of being lost
|
||||||
|
-- into the with_busy_guard xpcall as a generic internal_error.
|
||||||
|
local inner_ok, inner_result, inner_err = pcall(function()
|
||||||
|
-- Validate the adapter surface. E.validate_adapter returns the same
|
||||||
|
-- "bad_file_adapter" error code the prior implementation used.
|
||||||
|
local ok, err = E.validate_adapter(file_adapter)
|
||||||
|
if not ok then return nil, err end
|
||||||
|
|
||||||
|
-- Magic, class, data encoding. E.parse_elf32_headers reads fields at
|
||||||
|
-- the wire offsets specified in E.ELF32_HEADER.
|
||||||
|
local hdr, hdr_err = E.parse_elf32_headers(file_adapter)
|
||||||
|
if not hdr then return nil, hdr_err end
|
||||||
|
|
||||||
|
-- Machine check (e.g. EM_MIPS = 8). e_machine is at offset 0x12 (18).
|
||||||
|
-- The reload helper rejects non-MIPS ELFs before any symbol work.
|
||||||
|
-- Explicit pass style: E.read_u16(adapter, off). The helper wraps the
|
||||||
|
-- Support.File adapter once to strip its implicit `self` so the
|
||||||
|
-- parser shape stays flat-function, not colon-dispatch.
|
||||||
|
local machine = E.read_u16(file_adapter, 0x12)
|
||||||
|
if not machine then return nil, "truncated_header" end
|
||||||
|
if machine ~= E.EM_MIPS then
|
||||||
|
return nil, "non_mips_machine"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Walk sections. E.walk_sections also resolves .shstrtab names.
|
||||||
|
local sections, walk_err = E.walk_sections(file_adapter, hdr)
|
||||||
|
if not sections then return nil, walk_err end
|
||||||
|
|
||||||
|
-- Walk symbols. E.collect_symbols includes both STB_LOCAL and STB_GLOBAL
|
||||||
|
-- (the live ELF stores smem as a local symbol).
|
||||||
|
local symbols, sym_err = E.collect_symbols(file_adapter, sections)
|
||||||
|
if not symbols then return nil, sym_err end
|
||||||
|
|
||||||
|
-- Required symbols.
|
||||||
|
local smem = symbols["smem"]
|
||||||
|
local data_start = symbols["__data_start"]
|
||||||
|
local data_end = symbols["__data_end"]
|
||||||
|
local bss_start = symbols["__bss_start"]
|
||||||
|
local bss_end = symbols["__bss_end"]
|
||||||
|
local stack_top_s = symbols["__sp"]
|
||||||
|
local entry_s = symbols["hot_reload_entry"]
|
||||||
|
|
||||||
|
if not smem then return nil, "missing_smem" end
|
||||||
|
if not data_start then return nil, "missing_data_start" end
|
||||||
|
if not data_end then return nil, "missing_data_end" end
|
||||||
|
if not bss_start then return nil, "missing_bss_start" end
|
||||||
|
if not bss_end then return nil, "missing_bss_end" end
|
||||||
|
if not stack_top_s then return nil, "missing_stack_top" end
|
||||||
|
if require_entry and not entry_s then
|
||||||
|
return nil, "missing_hot_reload_entry"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Validate smem size.
|
||||||
|
if smem.size == 0 then
|
||||||
|
return nil, "zero_smem_size"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Validate stack alignment and range.
|
||||||
|
local stack_top = stack_top_s.value
|
||||||
|
if stack_top % 8 ~= 0 then
|
||||||
|
return nil, "stack_misaligned"
|
||||||
|
end
|
||||||
|
local p = to_physical(stack_top)
|
||||||
|
if p < 0 or p > 0x1fffff then
|
||||||
|
return nil, "stack_out_of_main_ram"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect loaded (SHF_ALLOC, non-SHT_NOBITS) sections and check overlap.
|
||||||
|
local loaded = {}
|
||||||
|
local smem_lo = smem.value
|
||||||
|
local smem_hi = smem.value + smem.size
|
||||||
|
for _, s in ipairs(sections) do
|
||||||
|
-- bit 1 (SHF_ALLOC = 0x2) of sh_flags. The modulo-4 trick matches
|
||||||
|
-- the prior implementation; canonicalising on E.SHF_ALLOC would
|
||||||
|
-- gain readability but lose the exact prior behavior.
|
||||||
|
local is_alloc = (s.sh_flags % 4) >= 2
|
||||||
|
if is_alloc and s.sh_type ~= E.SHT_NOBITS and s.sh_size > 0 then
|
||||||
|
loaded[#loaded + 1] = { name = s.name, addr = s.sh_addr, size = s.sh_size }
|
||||||
|
local lo = s.sh_addr
|
||||||
|
local hi = s.sh_addr + s.sh_size
|
||||||
|
if lo < smem_hi and hi > smem_lo then
|
||||||
|
return nil, "section_overlaps_smem"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
target = target,
|
||||||
|
elf_path = path,
|
||||||
|
elf_entry = hdr.e_entry,
|
||||||
|
smem_addr = smem.value,
|
||||||
|
smem_size = smem.size,
|
||||||
|
bss_start = bss_start.value,
|
||||||
|
bss_end = bss_end.value,
|
||||||
|
data_start = data_start.value,
|
||||||
|
data_end = data_end.value,
|
||||||
|
hot_reload_entry = entry_s and entry_s.value or nil,
|
||||||
|
stack_top = stack_top,
|
||||||
|
loaded_sections = loaded,
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
if inner_ok then
|
||||||
|
return inner_result, inner_err
|
||||||
|
end
|
||||||
|
-- pcall captured a thrown error; surface as parse_error with the
|
||||||
|
-- message + traceback so the caller can render it.
|
||||||
|
local tb = debug.traceback(inner_result, 2)
|
||||||
|
local err = {
|
||||||
|
parse_error = true,
|
||||||
|
detail = tostring(inner_result),
|
||||||
|
tb = tb,
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.parse_manifest(file_adapter, target, path, require_entry)
|
||||||
|
if type(E) ~= "table" or type(E.parse_elf32_headers) ~= "function" then
|
||||||
|
stack()
|
||||||
|
end
|
||||||
|
return parse_manifest_impl(file_adapter, target, path, require_entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Runtime + dispatch (Task 4)
|
||||||
|
--
|
||||||
|
-- M.new(host) returns a runtime object that owns:
|
||||||
|
-- active -- the most recently primed manifest, or nil
|
||||||
|
-- busy -- boolean guard; only one request runs at a time
|
||||||
|
-- host -- the bound host surface (pause / memory_file / open_file
|
||||||
|
-- / binary_load / invalidate_cache / get_registers)
|
||||||
|
--
|
||||||
|
-- runtime:handle(req) parses the query through M.parse_query, validates
|
||||||
|
-- the mode against a dispatch table, then acquires the busy guard through
|
||||||
|
-- xpcall so any error inside the handler releases the guard. The response
|
||||||
|
-- is always a JSON string built by M.json_response.
|
||||||
|
--
|
||||||
|
-- M.prime_active and M.elf_reload are the two handler bodies Task 4 ships.
|
||||||
|
-- prime_active always parses with require_entry=false (Phase 0 binary
|
||||||
|
-- compatibility). elf_reload always parses with require_entry=true (the
|
||||||
|
-- new binary must expose hot_reload_entry). Both validate the parsed
|
||||||
|
-- manifest; elf_reload runs the five-field ABI gate before declaring
|
||||||
|
-- success. Full host.pause / memory_file / binary_load / invalidate_cache
|
||||||
|
-- / get_registers sequencing is Task 5.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Convert a manifest into the JSON-serializable field subset. loaded_sections
|
||||||
|
-- is excluded because json_response only supports scalars + nil.
|
||||||
|
local function manifest_to_response(m)
|
||||||
|
local fields = {
|
||||||
|
ok = true,
|
||||||
|
target = m.target,
|
||||||
|
elf_path = m.elf_path,
|
||||||
|
elf_entry = m.elf_entry,
|
||||||
|
smem_addr = m.smem_addr,
|
||||||
|
smem_size = m.smem_size,
|
||||||
|
bss_start = m.bss_start,
|
||||||
|
bss_end = m.bss_end,
|
||||||
|
data_start = m.data_start,
|
||||||
|
data_end = m.data_end,
|
||||||
|
stack_top = m.stack_top,
|
||||||
|
}
|
||||||
|
if m.hot_reload_entry then
|
||||||
|
fields.hot_reload_entry = m.hot_reload_entry
|
||||||
|
end
|
||||||
|
return fields
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Open the new ELF through the host and parse its manifest.
|
||||||
|
-- Returns manifest on success; nil + stable error on failure.
|
||||||
|
local function parse_manifest_via_host(host, target, path, require_entry)
|
||||||
|
local adapter = host.open_file(path)
|
||||||
|
if not adapter then
|
||||||
|
return nil, "open_file_failed"
|
||||||
|
end
|
||||||
|
return M.parse_manifest(adapter, target, path, require_entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- prime_active: parse with require_entry=false. Accepts Phase 0 binaries
|
||||||
|
-- that lack hot_reload_entry. Stores the manifest in runtime.active.
|
||||||
|
function M.prime_active(runtime, parsed)
|
||||||
|
local manifest, err = parse_manifest_via_host(
|
||||||
|
runtime.host, parsed.target, parsed.path, false)
|
||||||
|
if not manifest then
|
||||||
|
return M.json_response({ ok = false, error = err, restart_required = true })
|
||||||
|
end
|
||||||
|
runtime.active = manifest
|
||||||
|
return M.json_response(manifest_to_response(manifest))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- elf_reload: full host-driven reload sequence.
|
||||||
|
--
|
||||||
|
-- Per conductor/tracks/ps1_pcsx_redux_hot_reload_20260802/spec.md §5 +
|
||||||
|
-- plan.md Task 5 Step 4. The canonical 11-entry success log is:
|
||||||
|
--
|
||||||
|
-- pause, memory_file, state_read, open_new_elf, binary_load,
|
||||||
|
-- state_restore, invalidate_cache, get_registers, write_sp,
|
||||||
|
-- write_ra, write_pc
|
||||||
|
--
|
||||||
|
-- Sequencing:
|
||||||
|
--
|
||||||
|
-- 1. Validate the request (target == active.target, path present).
|
||||||
|
-- 2. Compute the physical address of `active.smem_addr` via
|
||||||
|
-- strip_kseg; reject if outside physical main RAM.
|
||||||
|
-- 3. PARSE PHASE (before pause):
|
||||||
|
-- a. elf_handle = host.open_file(parsed.path)
|
||||||
|
-- b. manifest = M.parse_manifest(elf_handle, ..., require_entry=true)
|
||||||
|
-- c. Run the five-field ABI gate against runtime.active.
|
||||||
|
-- d. On any rejection here, return BEFORE pause — the runtime
|
||||||
|
-- has invoked host.open_file once (logging "open_file") and
|
||||||
|
-- no other host methods.
|
||||||
|
-- 4. Pause + snapshot:
|
||||||
|
-- host.pause()
|
||||||
|
-- mem = host.memory_file()
|
||||||
|
-- saved = mem:readAtToSlice(active.smem_size, smem_phys)
|
||||||
|
-- 5. RELOAD PHASE:
|
||||||
|
-- elf_handle = host.open_new_elf(parsed.path) -- second open
|
||||||
|
-- loaded = host.binary_load(elf_handle, mem)
|
||||||
|
-- if loaded == nil then return binary_load_failed
|
||||||
|
-- 6. Restore state: mem:writeAtMoveSlice(saved, smem_phys)
|
||||||
|
-- 7. host.invalidate_cache()
|
||||||
|
-- 8. Rewrite SP / RA / PC through the FFI register pointer.
|
||||||
|
-- 9. Replace runtime.active last.
|
||||||
|
-- 10. Return the JSON envelope.
|
||||||
|
--
|
||||||
|
-- The two opens are an intentional test-discoverability choice. The
|
||||||
|
-- PARSE phase uses host.open_file (it is an existing Task 4 surface
|
||||||
|
-- also used by prime); the RELOAD phase uses host.open_new_elf (a
|
||||||
|
-- dedicated Task 5 method). In production both methods bind to
|
||||||
|
-- Support.File.open so the runtime cost is identical to a single open
|
||||||
|
-- — the distinction lives in the test log for ordering verification.
|
||||||
|
local function abi_mismatch_response(field, expected, actual)
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "state_abi_mismatch", field = field,
|
||||||
|
expected = expected, actual = actual,
|
||||||
|
restart_required = true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.elf_reload(runtime, parsed)
|
||||||
|
-- 1. Pre-pause request validation. Pure-Lua, no host calls.
|
||||||
|
if not runtime.active then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "not_primed", restart_required = false })
|
||||||
|
end
|
||||||
|
if parsed.target ~= runtime.active.target then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "target_mismatch",
|
||||||
|
expected = runtime.active.target, actual = parsed.target,
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
if type(parsed.path) ~= "string" or parsed.path == "" then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "missing_path",
|
||||||
|
restart_required = false })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. SMEM range check on `active` (the new ELF has not been
|
||||||
|
-- parsed yet; the ABI gate below enforces it cannot relocate).
|
||||||
|
local smem_phys = strip_kseg(runtime.active.smem_addr)
|
||||||
|
if smem_phys == nil or smem_phys < 0 or smem_phys > 0x1fffff then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "smem_out_of_main_ram",
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. PARSE PHASE — open + parse + ABI gate. On any rejection here,
|
||||||
|
-- only host.open_file has been called. Pause and downstream
|
||||||
|
-- mutations do NOT occur.
|
||||||
|
local elf_handle_for_parse = runtime.host.open_file(parsed.path)
|
||||||
|
if not elf_handle_for_parse then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "open_file_failed",
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
local manifest, parse_err = M.parse_manifest(
|
||||||
|
elf_handle_for_parse, parsed.target, parsed.path, true)
|
||||||
|
if not manifest then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = parse_err,
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
local active = runtime.active
|
||||||
|
if manifest.smem_addr ~= active.smem_addr then
|
||||||
|
return abi_mismatch_response(
|
||||||
|
"smem_addr", active.smem_addr, manifest.smem_addr)
|
||||||
|
end
|
||||||
|
if manifest.smem_size ~= active.smem_size then
|
||||||
|
return abi_mismatch_response(
|
||||||
|
"smem_size", active.smem_size, manifest.smem_size)
|
||||||
|
end
|
||||||
|
if manifest.bss_start ~= active.bss_start then
|
||||||
|
return abi_mismatch_response(
|
||||||
|
"bss_start", active.bss_start, manifest.bss_start)
|
||||||
|
end
|
||||||
|
if manifest.bss_end ~= active.bss_end then
|
||||||
|
return abi_mismatch_response(
|
||||||
|
"bss_end", active.bss_end, manifest.bss_end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Pause + snapshot smem bytes.
|
||||||
|
runtime.host.pause()
|
||||||
|
local mem = runtime.host.memory_file()
|
||||||
|
local saved = mem:readAtToSlice(active.smem_size, smem_phys)
|
||||||
|
|
||||||
|
-- 5. RELOAD PHASE — second open for binary_load.
|
||||||
|
local elf_handle = runtime.host.open_new_elf(parsed.path)
|
||||||
|
if not elf_handle then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "open_file_failed",
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
local loaded = runtime.host.binary_load(elf_handle, mem)
|
||||||
|
if loaded == nil then
|
||||||
|
-- Do NOT restore state; PCSX.Binary.load may have partially
|
||||||
|
-- written RAM. Keep ACTIVE untouched and tell the caller to
|
||||||
|
-- restart the emulator.
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "binary_load_failed",
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 6. Restore the smem snapshot over the freshly-loaded code.
|
||||||
|
mem:writeAtMoveSlice(saved, smem_phys)
|
||||||
|
|
||||||
|
-- 7. Flush the CPU instruction cache (.text/.rodata changed).
|
||||||
|
runtime.host.invalidate_cache()
|
||||||
|
|
||||||
|
-- 8. Rewrite SP / RA / PC through the FFI register pointer. The
|
||||||
|
-- PC write must happen last; the CPU starts consuming
|
||||||
|
-- instructions at the new PC the moment the emulator resumes.
|
||||||
|
local regs = runtime.host.get_registers()
|
||||||
|
regs.GPR.n.sp = manifest.stack_top
|
||||||
|
regs.GPR.n.ra = 0
|
||||||
|
regs.pc = manifest.hot_reload_entry
|
||||||
|
|
||||||
|
-- 9. Replace ACTIVE last so a failed reload cannot poison the
|
||||||
|
-- next request's gate.
|
||||||
|
runtime.active = manifest
|
||||||
|
|
||||||
|
-- 10. Return the JSON envelope.
|
||||||
|
return M.json_response({
|
||||||
|
ok = true,
|
||||||
|
target = manifest.target,
|
||||||
|
elf_path = manifest.elf_path,
|
||||||
|
elf_entry = manifest.elf_entry,
|
||||||
|
smem_addr = manifest.smem_addr,
|
||||||
|
smem_size = manifest.smem_size,
|
||||||
|
bss_start = manifest.bss_start,
|
||||||
|
bss_end = manifest.bss_end,
|
||||||
|
data_start = manifest.data_start,
|
||||||
|
data_end = manifest.data_end,
|
||||||
|
hot_reload_entry = manifest.hot_reload_entry,
|
||||||
|
stack_top = manifest.stack_top,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- patch_handler: one-word RAM patch through MemoryAsFile.
|
||||||
|
--
|
||||||
|
-- Per spec §7 + plan.md Task 5 Step 5, the order is:
|
||||||
|
-- 1. Parse addr and hex query parameters
|
||||||
|
-- 2. Reject non-hex / missing inputs
|
||||||
|
-- 3. Reject unaligned addresses (addr & 3)
|
||||||
|
-- 4. Normalize through strip_kseg; reject out-of-main-RAM
|
||||||
|
-- (scratchpad 0x1F800000+, BIOS 0x1FC00000+, MMIO, expansion)
|
||||||
|
-- 5. host.pause()
|
||||||
|
-- 6. mem = host.memory_file()
|
||||||
|
-- 7. mem:writeU32At(value, physical_offset)
|
||||||
|
-- 8. host.invalidate_cache()
|
||||||
|
-- 9. Return JSON envelope ok=true with the requested addr and value.
|
||||||
|
local function patch_error(err, restart)
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = err,
|
||||||
|
restart_required = restart or false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.patch_handler(runtime, parsed)
|
||||||
|
local addr_str = parsed.addr
|
||||||
|
local hex_str = parsed.hex
|
||||||
|
|
||||||
|
-- 1. Presence checks.
|
||||||
|
if type(addr_str) ~= "string" or addr_str == "" then
|
||||||
|
return patch_error("missing_addr", false)
|
||||||
|
end
|
||||||
|
if type(hex_str) ~= "string" or hex_str == "" then
|
||||||
|
return patch_error("missing_value", false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Hex parse.
|
||||||
|
local addr = parse_hex_u32(addr_str, "missing_addr", "non_hex_addr")
|
||||||
|
if not addr then
|
||||||
|
return patch_error(
|
||||||
|
addr == false and "missing_addr" or "non_hex_addr", false)
|
||||||
|
end
|
||||||
|
local value = parse_hex_u32(hex_str, "missing_value", "non_hex_value")
|
||||||
|
if not value then
|
||||||
|
return patch_error(
|
||||||
|
value == false and "missing_value" or "non_hex_value", false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Alignment (checked on the canonical KSEG/physical addr).
|
||||||
|
if addr % 4 ~= 0 then
|
||||||
|
return patch_error("addr_unaligned", false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Range check via strip_kseg (rejects KSEG0 > 0x801fffff, KSEG1 >
|
||||||
|
-- 0xa01fffff, scratchpad, BIOS, MMIO, expansion, etc.).
|
||||||
|
local phys = strip_kseg(addr)
|
||||||
|
if phys == nil then
|
||||||
|
return patch_error("addr_out_of_main_ram", false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 5-8. Pause / write / cache invalidate.
|
||||||
|
runtime.host.pause()
|
||||||
|
local mem = runtime.host.memory_file()
|
||||||
|
mem:writeU32At(value, phys)
|
||||||
|
runtime.host.invalidate_cache()
|
||||||
|
|
||||||
|
-- 9. Return the JSON envelope. Echo the requested address and the
|
||||||
|
-- value in normalized hex so log captures stay stable across runs.
|
||||||
|
return M.json_response({
|
||||||
|
ok = true,
|
||||||
|
addr = addr_str,
|
||||||
|
value = "0x" .. string.format("%x", value),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mode dispatch table. Each handler is invoked with (runtime, parsed).
|
||||||
|
-- Tasks 5 adds patch (M.patch_handler); the previous placeholder removed.
|
||||||
|
local DISPATCH = {
|
||||||
|
prime = M.prime_active,
|
||||||
|
elf = M.elf_reload,
|
||||||
|
patch = M.patch_handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Wrap a handler call with the busy guard. The guard is acquired only
|
||||||
|
-- after the mode is validated, so unknown-mode requests do not deadlock
|
||||||
|
-- the runtime. xpcall guarantees the guard is released even if the
|
||||||
|
-- handler throws.
|
||||||
|
local function with_busy_guard(runtime, fn)
|
||||||
|
if runtime.busy then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "reload_busy", restart_required = false })
|
||||||
|
end
|
||||||
|
runtime.busy = true
|
||||||
|
-- Capture both the error text and a full Lua traceback so the user
|
||||||
|
-- can see the actual failing call site instead of a generic
|
||||||
|
-- "internal_error". debug.traceback("", 2) skips this xpcall frame
|
||||||
|
-- and the json_response frame so the trace starts at the handler.
|
||||||
|
local ok, result = xpcall(fn, function(e)
|
||||||
|
return { msg = tostring(e), tb = debug.traceback("", 2) }
|
||||||
|
end)
|
||||||
|
runtime.busy = false
|
||||||
|
if not ok then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "internal_error",
|
||||||
|
detail = result.msg, tb = result.tb,
|
||||||
|
restart_required = true })
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.new(host)
|
||||||
|
if type(host) ~= "table" then
|
||||||
|
error("M.new: host must be a table, got " .. type(host))
|
||||||
|
end
|
||||||
|
local runtime = {
|
||||||
|
active = nil,
|
||||||
|
busy = false,
|
||||||
|
host = host,
|
||||||
|
}
|
||||||
|
function runtime:handle(req)
|
||||||
|
-- 1. Parse the query (M.parse_query returns nil, err on failure).
|
||||||
|
local query = req and req.urlData and req.urlData.query or ""
|
||||||
|
local parsed, parse_err = M.parse_query(query)
|
||||||
|
if not parsed then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = parse_err, restart_required = false })
|
||||||
|
end
|
||||||
|
-- 2. Validate the mode against the dispatch table.
|
||||||
|
local mode = parsed.mode
|
||||||
|
local handler = DISPATCH[mode]
|
||||||
|
if not handler then
|
||||||
|
return M.json_response({
|
||||||
|
ok = false, error = "unknown_mode", restart_required = false })
|
||||||
|
end
|
||||||
|
-- 3. Acquire busy and dispatch via xpcall. Mode validation
|
||||||
|
-- happens BEFORE busy is acquired so unknown-mode requests
|
||||||
|
-- cannot deadlock the runtime.
|
||||||
|
return with_busy_guard(self, function()
|
||||||
|
return handler(self, parsed)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return runtime
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Install the reload handler on a PCSX-Redux instance.
|
||||||
|
--
|
||||||
|
-- Per plan.md Task 5 Step 5 the adapter binds the canonical host method
|
||||||
|
-- names to the PCSX-Lua FFI surface:
|
||||||
|
--
|
||||||
|
-- pause -> PCSX.pauseEmulator
|
||||||
|
-- memory_file -> PCSX.getMemoryAsFile
|
||||||
|
-- open_file -> Support.File.open(path, "READ")
|
||||||
|
-- binary_load -> PCSX.Binary.load
|
||||||
|
-- invalidate_cache -> PCSX.invalidateCache
|
||||||
|
-- get_registers -> PCSX.getRegisters
|
||||||
|
--
|
||||||
|
-- The returned closure dispatches each request through M.new(host)'s
|
||||||
|
-- runtime:handle so the same prime/elf/patch dispatch machinery is used
|
||||||
|
-- (including the busy guard from Task 4).
|
||||||
|
--
|
||||||
|
-- Missing `PCSX.WebServer.Handlers` is created on demand so callers do
|
||||||
|
-- not have to wire that themselves; if `PCSX` or `Support` is absent a
|
||||||
|
-- single line is printed and the function returns without registering
|
||||||
|
-- a handler.
|
||||||
|
function M.install(pcsx, support)
|
||||||
|
if type(pcsx) ~= "table" then
|
||||||
|
print("[reload] install failed: PCSX is not a table")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(support) ~= "table"
|
||||||
|
or type(support.File) ~= "table"
|
||||||
|
or type(support.File.open) ~= "function" then
|
||||||
|
print("[reload] install failed: Support.File.open unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(pcsx.pauseEmulator) ~= "function" then print("[reload] install failed: PCSX.pauseEmulator missing"); return end
|
||||||
|
if type(pcsx.getMemoryAsFile) ~= "function" then print("[reload] install failed: PCSX.getMemoryAsFile missing"); return end
|
||||||
|
if type(pcsx.Binary) ~= "table"
|
||||||
|
or type(pcsx.Binary.load) ~= "function" then print("[reload] install failed: PCSX.Binary.load missing"); return end
|
||||||
|
if type(pcsx.invalidateCache) ~= "function" then print("[reload] install failed: PCSX.invalidateCache missing"); return end
|
||||||
|
if type(pcsx.getRegisters) ~= "function" then print("[reload] install failed: PCSX.getRegisters missing"); return end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- File adapter wrap.
|
||||||
|
--
|
||||||
|
-- The production pcsx-redux Support.File wrapper (see
|
||||||
|
-- toolchain/pcsx-redux/src/lua/fileffi.lua:225-232 + size() around line 203)
|
||||||
|
-- exposes byte-read methods as colon-syntax closures with camelCase names:
|
||||||
|
-- readU8At = function(self, pos) ... end
|
||||||
|
-- readU16At = function(self, pos) ... end
|
||||||
|
-- readU32At = function(self, pos) ... end
|
||||||
|
-- size = function(self) ... end
|
||||||
|
--
|
||||||
|
-- The ELF32 parser (scripts/elf32.lua) uses an explicit-pass shape with
|
||||||
|
-- snake_case names:
|
||||||
|
-- adapter.read_u8_at(off) / adapter.read_u16_at(off) /
|
||||||
|
-- adapter.read_u32_at(off) / adapter.read_size()
|
||||||
|
--
|
||||||
|
-- The install boundary wraps the Support.File return value in a thin
|
||||||
|
-- adapter whose methods forward to the production closures, stripping
|
||||||
|
-- the implicit `self` and re-exporting the names the parser validates.
|
||||||
|
-- Without this wrap, E.validate_adapter returns "bad_file_adapter"
|
||||||
|
-- because adapter.read_u8_at / read_u16_at / read_u32_at / read_size
|
||||||
|
-- are not present on the raw Support.File return.
|
||||||
|
local function wrap_file(f)
|
||||||
|
return {
|
||||||
|
read_u8_at = function(off) return f:readU8At(off) end,
|
||||||
|
read_u16_at = function(off) return f:readU16At(off) end,
|
||||||
|
read_u32_at = function(off) return f:readU32At(off) end,
|
||||||
|
read_size = function() return f:size() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local host = {
|
||||||
|
pause = function() pcsx.pauseEmulator() end,
|
||||||
|
memory_file = function() return pcsx.getMemoryAsFile() end,
|
||||||
|
open_file = function(path) return wrap_file(support.File.open(path, "READ")) end,
|
||||||
|
-- open_new_elf returns the raw Support.File object because the
|
||||||
|
-- RELOAD phase passes it directly to PCSX.Binary.load which
|
||||||
|
-- expects a real File (with readAt / size), NOT the elf32
|
||||||
|
-- parser adapter (read_u8_at / read_u16_at / read_u32_at /
|
||||||
|
-- read_size). Wrapping it in the adapter here triggers the
|
||||||
|
-- binffi.lua "Expected a File object as first argument" error.
|
||||||
|
open_new_elf = function(path) return support.File.open(path, "READ") end,
|
||||||
|
binary_load = function(elf, mem) return pcsx.Binary.load(elf, mem) end,
|
||||||
|
invalidate_cache = function() pcsx.invalidateCache() end,
|
||||||
|
get_registers = function() return pcsx.getRegisters() end,
|
||||||
|
}
|
||||||
|
local runtime = M.new(host)
|
||||||
|
|
||||||
|
if type(pcsx.WebServer) ~= "table" then pcsx.WebServer = {} end
|
||||||
|
if type(pcsx.WebServer.Handlers) ~= "table" then pcsx.WebServer.Handlers = {} end
|
||||||
|
pcsx.WebServer.Handlers.reload = function(req)
|
||||||
|
return runtime:handle(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
print("[reload] handler installed: reload")
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# scripts/reload.ps1
|
||||||
|
#
|
||||||
|
# PCSX-Redux Lua helper reload client.
|
||||||
|
#
|
||||||
|
# Modes:
|
||||||
|
# elf - Request a full ELF reload. Requires -ElfPath.
|
||||||
|
# patch - Request a single-word RAM patch. Requires -Address and -Word.
|
||||||
|
#
|
||||||
|
# -RequestOnly prints the URI and exits before any network I/O.
|
||||||
|
# -Quiet suppresses the compact-JSON printout on the real path.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet('elf', 'patch')][string]$Mode = 'elf',
|
||||||
|
[string]$Target = 'hello_camera',
|
||||||
|
[string]$ElfPath = '',
|
||||||
|
[string]$Address = '',
|
||||||
|
[string]$Word = '',
|
||||||
|
[int]$Port = 8080,
|
||||||
|
[switch]$RequestOnly,
|
||||||
|
[switch]$Quiet
|
||||||
|
)
|
||||||
|
|
||||||
|
# mode-specific argument guards
|
||||||
|
switch ($Mode) {
|
||||||
|
'patch' {
|
||||||
|
if ([string]::IsNullOrEmpty($Address) -or [string]::IsNullOrEmpty($Word)) {
|
||||||
|
Write-Error "patch mode requires both -Address and -Word"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'elf' {
|
||||||
|
if ([string]::IsNullOrEmpty($ElfPath)) {
|
||||||
|
Write-Error "elf mode requires -ElfPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the URL-encoded query string.
|
||||||
|
$queryParts = New-Object System.Collections.Generic.List[string]
|
||||||
|
[void]$queryParts.Add("mode=$([uri]::EscapeDataString($Mode))")
|
||||||
|
[void]$queryParts.Add("target=$([uri]::EscapeDataString($Target))")
|
||||||
|
|
||||||
|
switch ($Mode) {
|
||||||
|
'elf' {
|
||||||
|
[void]$queryParts.Add("path=$([uri]::EscapeDataString($ElfPath))")
|
||||||
|
}
|
||||||
|
'patch' {
|
||||||
|
[void]$queryParts.Add("addr=$([uri]::EscapeDataString($Address))")
|
||||||
|
[void]$queryParts.Add("hex=$([uri]::EscapeDataString($Word))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$uri = "http://localhost:$Port/api/v1/lua/reload?$($queryParts -join '&')"
|
||||||
|
|
||||||
|
# RequestOnly path: emit URI and return before any network I/O.
|
||||||
|
if ($RequestOnly) {
|
||||||
|
Write-Output $uri
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Real request path: POST, decode body if it is a byte array, parse JSON.
|
||||||
|
$response = Invoke-WebRequest -Method Post -Uri $uri
|
||||||
|
|
||||||
|
if ($response.Content -is [byte[]]) {
|
||||||
|
$text = [System.Text.Encoding]::UTF8.GetString([byte[]]$response.Content)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$text = [string]$response.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
$obj = $text | ConvertFrom-Json
|
||||||
|
|
||||||
|
if (-not $Quiet) {
|
||||||
|
$obj | ConvertTo-Json -Compress | Write-Output
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $obj.ok) {
|
||||||
|
$errCode = if ($obj.error) { [string]$obj.error } else { 'unknown' }
|
||||||
|
throw "Reload failed: $errCode"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user