mirror of
https://github.com/Ed94/pikuma_ps1.git
synced 2026-08-14 11:38:14 +00:00
hot-reload attempt (unreviewed, not working)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user