diff --git a/.gitignore b/.gitignore index a9f6298..555f412 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ toolchain/lpeg scratch toolchain/libpsn00b +scripts/pcsx_debug_helper.zip diff --git a/.vscode/launch.json b/.vscode/launch.json index ba3c6d1..f92a4f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -177,6 +177,39 @@ "tbreak main", "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" + ] } ] } diff --git a/code/hello_camera/hello_camera.c b/code/hello_camera/hello_camera.c index 4fa7021..4f8845d 100644 --- a/code/hello_camera/hello_camera.c +++ b/code/hello_camera/hello_camera.c @@ -290,6 +290,19 @@ void gp_display_frame(DoubleBuffer* screen_buf, S4* active_buf_id, U4* ordering_ } 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) { smem = (SMemory){0}; @@ -323,14 +336,7 @@ int main(void) tb_emit(& tb, gp_screen_init); } } - 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); - }; + hot_reload_entry(); return 0; } GCC_OPTIMIZATION_ENABLE diff --git a/scripts/build_psyq.ps1 b/scripts/build_psyq.ps1 index 2c8321a..051767d 100644 --- a/scripts/build_psyq.ps1 +++ b/scripts/build_psyq.ps1 @@ -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_build = join-path $path_root 'build' $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 } +# --- 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 --- # Assumes 'mipsel-none-elf' toolchain is in your system's PATH. $Prefix = "mipsel-none-elf" @@ -214,17 +316,17 @@ function link-modules { param([string[]]$link_modules, [string] $elf, [string[] function make-binary { param([string]$elf, [string]$exe) Write-Host "--- Creating Binary ---" -ForegroundColor Cyan write-host "Converting $elf to PS-EXE -> '$exe'" - $objcopy_args = ($f_objcopy_format + "binary"), $elf, $exe + $objcopy_args = ($f_objcopy_format + "binary"), $elf, $exe & $Objcopy $objcopy_args if ($LASTEXITCODE -ne 0) { Write-Error "Objcopy failed. Aborting."; exit 1 } } function ps1-meta { param( - [string]$unity_root, + [string] $unity_root, [string[]]$sources, [Parameter(Mandatory=$true)][string]$metadata, - [string]$out_root = (join-path $path_build 'gen'), - [string[]]$passes = @('--pre-link'), + [string] $out_root = (join-path $path_build 'gen'), + [string[]]$passes = @('--pre-link'), [string[]]$extra_args = @() ) # `--unity-root` and `--source` are @@ -236,12 +338,46 @@ function ps1-meta { param( write-error 'ps1-meta: -unity_root and -sources are mutually exclusive' exit 2 } - } + } elseif ($null -eq $sources -or $sources.Count -eq 0) { write-error 'ps1-meta: either -unity_root or -sources is required' exit 2 } + # --- Defensive attribute clear on tracked gen files ------------------------ + # Git tracks code//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' $input_summary = if ($null -ne $unity_root -and $unity_root -ne '') { "unity=$unity_root" @@ -270,14 +406,14 @@ function inject-dwarf { param( [string]$path_gen ) $base_name = [System.IO.Path]::GetFileNameWithoutExtension($elf) - $path_dwarf_line_bin = join-path $path_gen "$base_name.dwarf_line.bin" - $path_dwarf_aranges_bin = join-path $path_gen "$base_name.dwarf_aranges.bin" - $path_dwarf_rnglists_bin = join-path $path_gen "$base_name.dwarf_rnglists.bin" - $path_dwarf_info_bin = join-path $path_gen "$base_name.dwarf_info.bin" - $path_dwarf_abbrev_bin = join-path $path_gen "$base_name.dwarf_abbrev.bin" - $path_dwarf_str_bin = join-path $path_gen "$base_name.dwarf_str.bin" - $path_dwarf_loc_bin = join-path $path_gen "$base_name.dwarf_loc.bin" - $path_dwarf_loclists_bin = join-path $path_gen "$base_name.dwarf_loclists.bin" + $path_dwarf_line_bin = join-path $path_gen "$base_name.dwarf_line.bin" + $path_dwarf_aranges_bin = join-path $path_gen "$base_name.dwarf_aranges.bin" + $path_dwarf_rnglists_bin = join-path $path_gen "$base_name.dwarf_rnglists.bin" + $path_dwarf_info_bin = join-path $path_gen "$base_name.dwarf_info.bin" + $path_dwarf_abbrev_bin = join-path $path_gen "$base_name.dwarf_abbrev.bin" + $path_dwarf_str_bin = join-path $path_gen "$base_name.dwarf_str.bin" + $path_dwarf_loc_bin = join-path $path_gen "$base_name.dwarf_loc.bin" + $path_dwarf_loclists_bin = join-path $path_gen "$base_name.dwarf_loclists.bin" $path_inject_elf = join-path $path_build "$base_name.dwarf-injected.elf" if (-not (Test-Path $path_dwarf_line_bin)) { return } @@ -564,44 +700,122 @@ function build-hello_camera { } build-hello_camera -# NO idea if this works yet... -function Send-ToEmulator { param( [string]$exePath ) - $uri = "http://localhost:8080/api/v1/load-exec" +# ── Helper-zip + reload helpers (Task 8) ── +# Defined right after the final build-hello_camera function so they're in scope for the post-build calls below. +# 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 - $absolutePath = [System.IO.Path]::GetFullPath($exePath) + $dest = if ([string]::IsNullOrEmpty($OutputPath)) { + join-path $path_scripts 'pcsx_debug_helper.zip' + } + else { + $OutputPath + } - # Create JSON payload pointing to your compiled .ps-exe - $body = @{ filename = $absolutePath } | ConvertTo-Json + $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-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 - try { - $response = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json" - Write-Host "Hot-reload successful!" -ForegroundColor Green - } catch { - Write-Warning "Could not connect to PCSX-Redux web server. Ensure the emulator is running and Web Server is enabled." - } + # --- 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 { + # 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 $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 -# Send-ToEmulator (join-path $path_build 'hello_gte.ps-exe') +# Invokes reload.ps1 as a child pwsh instead of POSTing to the nonexistent /api/v1/load-exec endpoint. +# 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 --- -# $exe_path = join-path $path_build 'hello_gte.ps-exe' -# $absolute_path = [System.IO.Path]::GetFullPath($exe_path) + $reloadScript = join-path $path_scripts 'reload.ps1' + if (-not (test-path -LiteralPath $reloadScript)) { + write-error "[build] reload.ps1 not found at $reloadScript" + exit 1 + } -# PCSX-Redux expects the file location in the URL query string? -# We URL-encode the path to ensure backslashes and spaces don't break the HTTP request? -# $encoded_path = [uri]::EscapeDataString($absolute_path) -# $uri = "http://localhost:8080/api/v1/load-exec?path=$encoded_path" + write-host "[build] hot-reloading $ElfPath via reload.ps1" -ForegroundColor Magenta + & pwsh -NoProfile -File $reloadScript -Mode elf -Target hello_camera -ElfPath $ElfPath + if ($LASTEXITCODE -ne 0) { + write-error "[build] reload.ps1 failed (exit $LASTEXITCODE)" + exit $LASTEXITCODE + } +} -# Write-Host "Pushing hot-reload to PCSX-Redux..." -ForegroundColor Magenta -# try { -# # Send the request with the query string included -# Invoke-RestMethod -Uri $uri -Method Post -# Write-Host "Hot-reload successful!" -ForegroundColor Green -# } 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 -# } +# Post-build: Regenerate the helper zip (canonical output) and, if -Reload was passed, kick a hot-reload against the just-built ELF. +# 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. +Make-HelperZip +if ($Reload) { + Send-ToEmulator +} diff --git a/scripts/launch_pcsx_debug.ps1 b/scripts/launch_pcsx_debug.ps1 index 10952d8..5a3e911 100644 --- a/scripts/launch_pcsx_debug.ps1 +++ b/scripts/launch_pcsx_debug.ps1 @@ -1,41 +1,77 @@ # scripts/launch_pcsx_debug.ps1 # # 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 -# so external CLI tools (gdb's `shell` command, etc.) -# can read GTE state via http://localhost:8080/api/v1/lua/gte -# (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 +# loaded, the gdb stub enabled, the web server enabled, AND the +# pcsx_debug_helper Lua plugin loaded so external CLI tools can drive +# reloads via http://localhost:8080/api/v1/lua/reload. # # After launch: # - 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). [CmdletBinding()] param( [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'), [int] $GdbPort = 3333, - [int] $WebPort = 8080 + [int] $WebPort = 8080, + [ValidateSet('interpreter', 'dynarec')][string]$Cpu = 'interpreter' ) $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 ── -foreach ($p in @($PcsxPath, $ExePath, $HelperZip)) { - if (-not (Test-Path $p)) { +foreach ($p in @($PcsxPath, $ExePath, $ElfPath, $HelperZip)) { + if (-not (Test-Path -LiteralPath $p)) { Write-Error "Missing: $p" 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. Get-Process pcsx-redux -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Seconds 2 @@ -44,17 +80,23 @@ Start-Sleep -Seconds 2 $absExe = [System.IO.Path]::GetFullPath($ExePath) $absZip = [System.IO.Path]::GetFullPath($HelperZip) +$cpuFlag = if ($Cpu -eq 'dynarec') { '-dynarec' } else { '-interpreter' } + $args = @( '-gdb', '-run' '-loadexe', "`"$absExe`"" '-archive', "`"$absZip`"" + '-webserver' + $cpuFlag ) Write-Host "Launching pcsx-redux..." -ForegroundColor Cyan Write-Host " ps-exe : $absExe" +Write-Host " elf : $ElfPath" Write-Host " helper zip: $absZip" 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 "" 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 } +# ── 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 "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 \ No newline at end of file diff --git a/scripts/make_helper_zip.ps1 b/scripts/make_helper_zip.ps1 new file mode 100644 index 0000000..ca0d6cc --- /dev/null +++ b/scripts/make_helper_zip.ps1 @@ -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 + } +} \ No newline at end of file diff --git a/scripts/pcsx_debug_helper.zip b/scripts/pcsx_debug_helper.zip deleted file mode 100644 index 10826c8..0000000 Binary files a/scripts/pcsx_debug_helper.zip and /dev/null differ diff --git a/scripts/pcsx_debug_helper/autoexec.lua b/scripts/pcsx_debug_helper/autoexec.lua index 7dc2ab2..3df9bde 100644 --- a/scripts/pcsx_debug_helper/autoexec.lua +++ b/scripts/pcsx_debug_helper/autoexec.lua @@ -16,36 +16,60 @@ -- Companion: scripts/gdb/gdb_tape_atoms.gdb (covers GPRs + atom-aware stepping). local function register_handlers() - if not PCSX.WebServer then PCSX.WebServer = {} end - if not PCSX.WebServer.Handlers then PCSX.WebServer.Handlers = {} end + if not PCSX.WebServer then PCSX.WebServer = {} end + if not PCSX.WebServer.Handlers then PCSX.WebServer.Handlers = {} end - -- ── GTE state ── - PCSX.WebServer.Handlers.gte = function(req) - local r = PCSX.getRegisters() - local out = { "pc=0x" .. string.format("%x", r.pc) } - for i = 0, 31 do - out[#out + 1] = string.format("D[%d]=0x%08x C[%d]=0x%08x", - i, r.CP2D.r[i], i, r.CP2C.r[i]) - end - return table.concat(out, "\n") - end + -- ── GTE state ── + PCSX.WebServer.Handlers.gte = function(req) + local r = PCSX.getRegisters() + local out = { "pc=0x" .. string.format("%x", r.pc) } + for i = 0, 31 do + out[#out + 1] = string.format("D[%d]=0x%08x C[%d]=0x%08x", + i, r.CP2D.r[i], i, r.CP2C.r[i]) + end + return table.concat(out, "\n") + end - -- ── GP state (pointer to existing endpoints) ── - -- pcsx-redux's Lua GPU API exposes only takeScreenShot(); no GPUSTAT / GP0 / GP1 command log / display state. - -- We point to the existing web endpoints that DO expose those (when the emulator is actually rendering. Paused-at-BP frames won't have a fresh frame). - PCSX.WebServer.Handlers.gp = function(req) - local out = { - "gpu_screenshot_png=http://localhost:8080/api/v1/state/still", - "vram_raw=http://localhost:8080/api/v1/gpu/vram/raw (1MB VRAM)", - "gpustat=NOT_AVAILABLE_VIA_LUA", - "gp_command_log=NOT_AVAILABLE_VIA_LUA (use pcsx-redux Debug > GPU Logger)", - "hint_run_emulator_unpaused_for_screenshot", - } - return table.concat(out, "\n") - end + -- ── GP state (pointer to existing endpoints) ── + -- pcsx-redux's Lua GPU API exposes only takeScreenShot(); no GPUSTAT / GP0 / GP1 command log / display state. + -- We point to the existing web endpoints that DO expose those (when the emulator is actually rendering. Paused-at-BP frames won't have a fresh frame). + PCSX.WebServer.Handlers.gp = function(req) + local out = { + "gpu_screenshot_png=http://localhost:8080/api/v1/state/still", + "vram_raw=http://localhost:8080/api/v1/gpu/vram/raw (1MB VRAM)", + "gpustat=NOT_AVAILABLE_VIA_LUA", + "gp_command_log=NOT_AVAILABLE_VIA_LUA (use pcsx-redux Debug > GPU Logger)", + "hint_run_emulator_unpaused_for_screenshot", + } + return table.concat(out, "\n") + end end local ok, err = pcall(register_handlers) if ok then print("[pcsx_debug_helper] handlers registered: gte, gp") else print("[pcsx_debug_helper] registration failed: " .. tostring(err)) 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 diff --git a/scripts/pcsx_debug_helper/reload.lua b/scripts/pcsx_debug_helper/reload.lua new file mode 100644 index 0000000..36bf292 --- /dev/null +++ b/scripts/pcsx_debug_helper/reload.lua @@ -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= +-- POST /api/v1/lua/reload?mode=elf&target=hello_camera&path= +-- 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 \ No newline at end of file diff --git a/scripts/reload.ps1 b/scripts/reload.ps1 new file mode 100644 index 0000000..77b5f57 --- /dev/null +++ b/scripts/reload.ps1 @@ -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" +}