-- 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