From c53c70459d5520218f49eb6792f85f6f6abacdae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:46:49 +0000 Subject: [PATCH 1/6] Run the pipeline on Linux Paths default to $NOSRECOMP_WORK (~/re/work off Windows) and emcc comes from PATH. decompile.sh runs Ghidra alone, since IDR is Windows-only. To make up for IDR's seeds, the lifter takes unit initialization and finalization routines from the init table the entry point passes to @InitExe, and walks relocated method pointers up to 256 instructions before giving up on them. iterate.py is a seed loop over the browser build for machines without the native one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUMqSCt7JBTq4HTzuc --- README.md | 18 +++++++++++++ recomp/lift.py | 26 ++++++++++++++++++- recomp/wasm/compile_all.py | 8 +++--- recomp/web/drive.mjs | 11 +++++--- recomp/web/iterate.py | 52 ++++++++++++++++++++++++++++++++++++++ recomp/web/serve.py | 3 ++- tools/decompile.sh | 16 ++++++++++++ tools/packet_table.py | 4 ++- tools/xref.py | 3 ++- 9 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 recomp/web/iterate.py create mode 100755 tools/decompile.sh diff --git a/README.md b/README.md index 57de113..e45e68b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,24 @@ python recomp\web\serve.py --game "C:\Program Files (x86)\Nostale" --user `. +### Linux + +The same pipeline runs on Linux (Python 3 with `capstone` and `pefile`, Emscripten's `emcc` on `PATH`, Ghidra 12 with +its JDK, Node with Playwright). Generated code and builds go to `$NOSRECOMP_WORK` (default `~/re/work`). + +```bash +GHIDRA_HOME=/opt/ghidra tools/decompile.sh ~/Nostale/NosCore.exe # Ghidra only: IDR needs Windows +python3 recomp/lift.py ~/re/work/input/NosCore.exe ~/re/work/functions.txt ~/re/work/recomp/gen \ + --seeds ~/re/work/recomp/seeds.txt +python3 recomp/wasm/compile_all.py --web +python3 recomp/web/serve.py --game ~/Nostale --user --password +python3 recomp/web/iterate.py # seeds each function the client stops on, re-lifts and rebuilds +``` + +Without IDR's seeds the function list is shorter; the lifter takes unit initialization and finalization routines +from the client's init table, and `iterate.py` is the browser counterpart of `iterate.ps1` for whatever is still +only reached indirectly. + ### Scripted runs `node recomp/web/drive.mjs [--maps 2,20,145] [--trace] [--dump-frame]` (Playwright with the installed Chrome) logs in, diff --git a/recomp/lift.py b/recomp/lift.py index b6a08f7..fa75199 100644 --- a/recomp/lift.py +++ b/recomp/lift.py @@ -938,7 +938,7 @@ def entry_shape(t): if t in stored or covered(t) or not entry_shape(t): continue pos, ok = t, False - for _ in range(24): + for _ in range(256): dec = L.disasm_range(pos, pos + 1) if not dec or dec[0].mnemonic not in SANE: break @@ -952,6 +952,29 @@ def entry_shape(t): return out +def init_table_seeds(L): + """Unit initialization and finalization routines, listed in the PackageInfo the entry point hands to @InitExe + ('mov eax, PackageInfo; call @InitExe'). They are only reached through that table, and a long one never reaches + the ret that reloc_code_seeds' sanity walk looks for.""" + entry = L.base + L.pe.OPTIONAL_HEADER.AddressOfEntryPoint + dec = L.disasm_range(entry, entry + 128) + for a, b in zip(dec, dec[1:]): + if b.mnemonic != "call": + continue + if a.mnemonic == "mov" and a.op_str.startswith("eax, 0x") and a.operands[1].type == X.X86_OP_IMM: + info = a.operands[1].imm & 0xFFFFFFFF + count, table = (struct.unpack(" { @@ -18,7 +20,8 @@ const arg = (name, fallback) => { }; const flag = (name) => process.argv.includes(name); -const out = arg('--out', 'C:/tools/re/work/drive'); +const work = process.env.NOSRECOMP_WORK || (windows ? 'C:/tools/re/work' : join(homedir(), 're/work')); +const out = arg('--out', join(work, 'drive')); // Message boxes answer themselves with their default button so nothing waits on a human. const url = arg('--url', 'http://127.0.0.1:8077/') + '?autodismiss=1' + (flag('--trace') ? '&trace=1' : '') + (arg('--params', '') ? '&' + arg('--params', '') : ''); mkdirSync(out, { recursive: true }); @@ -32,8 +35,8 @@ const GROUND = [1000, 520]; const browser = await chromium.launch({ headless: !flag('--headed'), - channel: process.env.DRIVE_CHANNEL || 'chrome', - args: ['--use-angle=d3d11', '--enable-gpu', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader'], + channel: process.env.DRIVE_CHANNEL || (windows ? 'chrome' : undefined), + args: [...(windows ? ['--use-angle=d3d11'] : []), '--enable-gpu', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader'], }); let page, profiler; // --cpuprofile samples the worker from entering the world to the end of the run into worker.cpuprofile (open it in diff --git a/recomp/web/iterate.py b/recomp/web/iterate.py new file mode 100644 index 0000000..c25d401 --- /dev/null +++ b/recomp/web/iterate.py @@ -0,0 +1,52 @@ +"""Seed loop for the browser build: run drive.mjs, seed the first function nobody discovered, re-lift, rebuild, repeat. + +The browser counterpart of iterate.ps1, for machines without the native MSVC build. serve.py must be running. + +usage: iterate.py [--rounds 25] [--url http://127.0.0.1:8077/] [-- extra drive.mjs arguments] +""" +import argparse +import os +import re +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +RECOMP = os.path.dirname(HERE) +WORK = os.environ.get("NOSRECOMP_WORK", r"C:\tools\re\work" if os.name == "nt" else os.path.expanduser("~/re/work")) +MISSING = re.compile(r"no recompiled code for (?:guest address|indirect jump target) ([0-9a-f]{8})") + + +def main(): + argv = sys.argv[1:] + extra = argv[argv.index("--") + 1:] if "--" in argv else [] + ap = argparse.ArgumentParser() + ap.add_argument("--rounds", type=int, default=25) + ap.add_argument("--url", default="http://127.0.0.1:8077/") + a = ap.parse_args(argv[:argv.index("--")] if "--" in argv else argv) + + gen = os.path.join(WORK, "recomp", "gen") + seeds = os.path.join(WORK, "recomp", "seeds.txt") + out = os.path.join(WORK, "drive", "iterate") + open(seeds, "a").close() + for rnd in range(1, a.rounds + 1): + subprocess.run(["node", os.path.join(HERE, "drive.mjs"), "--url", a.url, "--out", out, *extra]) + with open(os.path.join(out, "full.log"), encoding="utf-8", errors="replace") as f: + m = MISSING.search(f.read()) + if not m: + print(f"round {rnd}: no missing-code stop") + return + addr = m.group(1) + print(f"round {rnd}: missing {addr}") + with open(seeds) as f: + if addr in f.read().split(): + print(f"already seeded {addr}, giving up") + sys.exit(1) + with open(seeds, "a") as f: + f.write(addr + "\n") + subprocess.run([sys.executable, os.path.join(RECOMP, "lift.py"), os.path.join(WORK, "input", "NosCore.exe"), + os.path.join(WORK, "functions.txt"), gen, "--seeds", seeds], check=True) + subprocess.run([sys.executable, os.path.join(RECOMP, "wasm", "compile_all.py"), "--web"], check=True) + + +if __name__ == "__main__": + main() diff --git a/recomp/web/serve.py b/recomp/web/serve.py index 719ec4b..116c4b2 100644 --- a/recomp/web/serve.py +++ b/recomp/web/serve.py @@ -257,7 +257,8 @@ def do_HEAD(self): def main(): ap = argparse.ArgumentParser() ap.add_argument("--game", required=True) - ap.add_argument("--build", default=r"C:\tools\re\work\wasm\web") + work = os.environ.get("NOSRECOMP_WORK", r"C:\tools\re\work" if os.name == "nt" else os.path.expanduser("~/re/work")) + ap.add_argument("--build", default=os.path.join(work, "wasm", "web")) ap.add_argument("--port", type=int, default=8077) ap.add_argument("--auth-url", default="https://127.0.0.1:7001") ap.add_argument("--user", help="local NosCore account used to mint _NC_AUTH_CODE (dev only)") diff --git a/tools/decompile.sh b/tools/decompile.sh new file mode 100755 index 0000000..f9bbe05 --- /dev/null +++ b/tools/decompile.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Linux counterpart of decompile.ps1: Ghidra alone produces functions.txt. IDR (Windows-only) is skipped; the lifter +# recovers what it would have seeded (unit init routines from the init table, methods from relocated pointers). +# +# usage: tools/decompile.sh env: GHIDRA_HOME (contains support/analyzeHeadless), NOSRECOMP_WORK +set -euo pipefail +exe=$1 +work=${NOSRECOMP_WORK:-$HOME/re/work} +ghidra=${GHIDRA_HOME:?set GHIDRA_HOME to the Ghidra install} +here=$(cd "$(dirname "$0")" && pwd) +mkdir -p "$work/input" "$work/ghidra" +cp "$exe" "$work/input/NosCore.exe" +"$ghidra/support/analyzeHeadless" "$work/ghidra" NosTaleClient \ + -import "$work/input/NosCore.exe" -overwrite -processor x86:LE:32:default -cspec borlanddelphi \ + -scriptPath "$here/ghidra" -postScript ExportFunctions.java "$work/functions.txt" +echo "$(wc -l < "$work/functions.txt") functions in $work/functions.txt" diff --git a/tools/packet_table.py b/tools/packet_table.py index 7017010..998e189 100644 --- a/tools/packet_table.py +++ b/tools/packet_table.py @@ -1,5 +1,6 @@ """Extract the receive-packet handler table (header -> handler address) from the decompiled registration function.""" import argparse +import os import re import pefile @@ -18,7 +19,8 @@ def read_cstring(pe, va): def main(): p = argparse.ArgumentParser() p.add_argument("exe") - p.add_argument("--dir", default=r"C:\tools\re\work\decomp") + work = os.environ.get("NOSRECOMP_WORK", r"C:\tools\re\work" if os.name == "nt" else os.path.expanduser("~/re/work")) + p.add_argument("--dir", default=os.path.join(work, "decomp")) p.add_argument("--registrar", default="FUN_0055a628") a = p.parse_args() diff --git a/tools/xref.py b/tools/xref.py index 5e1ce21..27d07da 100644 --- a/tools/xref.py +++ b/tools/xref.py @@ -40,7 +40,8 @@ def resolve(funcs, key): def main(): p = argparse.ArgumentParser() - p.add_argument("--dir", default=r"C:\tools\re\work\decomp") + work = os.environ.get("NOSRECOMP_WORK", r"C:\tools\re\work" if os.name == "nt" else os.path.expanduser("~/re/work")) + p.add_argument("--dir", default=os.path.join(work, "decomp")) p.add_argument("cmd", choices=["callers", "callees", "body", "grep"]) p.add_argument("target") p.add_argument("-d", "--depth", type=int, default=1) From de8b869d694f493d7401073a3116db7f8ad53f68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:12:15 +0000 Subject: [PATCH 2/6] Seed relocated methods by their Delphi prologue A long method only referenced from a VMT never reached its ret within the sanity walk and was joined to the routine before it, so a call through the VMT landed on a label instead of an entry. An aligned relocated pointer to a push-register prologue is now an entry outright. iterate.py takes --before to reset the servers between rounds and prints the stop when it is not a missing function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUMqSCt7JBTq4HTzuc --- recomp/lift.py | 11 +++++++++++ recomp/web/iterate.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/recomp/lift.py b/recomp/lift.py index fa75199..dadfc6e 100644 --- a/recomp/lift.py +++ b/recomp/lift.py @@ -880,6 +880,9 @@ def memval(op): "fwait", "wait", "sete", "setne"} | set(JCC) +PROLOGUE_REGS = {"ebp", "ebx", "esi", "edi"} + + def reloc_code_seeds(L, fns): """Relocated pointers into the code section that no function covers and that decode as plausible code (interface thunks, method pointers only referenced from data).""" @@ -937,6 +940,14 @@ def entry_shape(t): for t in sorted(L.reloc_targets): if t in stored or covered(t) or not entry_shape(t): continue + # An aligned pointer to a Delphi prologue is an entry however long the routine is before its ret. + head = L.disasm_range(t, t + 2) + if t % 4 == 0 and len(head) == 2 and head[0].mnemonic == "push" and head[0].op_str in PROLOGUE_REGS and ( + head[1].mnemonic == "push" and head[1].op_str in PROLOGUE_REGS + or (head[1].mnemonic, head[1].op_str) == ("mov", "ebp, esp") + or head[1].mnemonic in ("add", "sub") and head[1].op_str.startswith("esp, ")): + out.append(t) + continue pos, ok = t, False for _ in range(256): dec = L.disasm_range(pos, pos + 1) diff --git a/recomp/web/iterate.py b/recomp/web/iterate.py index c25d401..ae2d4fd 100644 --- a/recomp/web/iterate.py +++ b/recomp/web/iterate.py @@ -2,7 +2,10 @@ The browser counterpart of iterate.ps1, for machines without the native MSVC build. serve.py must be running. -usage: iterate.py [--rounds 25] [--url http://127.0.0.1:8077/] [-- extra drive.mjs arguments] +usage: iterate.py [--rounds 25] [--url http://127.0.0.1:8077/] [--before ""] [-- extra drive.mjs arguments] + +--before runs a shell command ahead of each round, for instance restarting the NosCore servers so the world server +does not still hold the previous round's session ("account name is currently in use"). """ import argparse import os @@ -22,6 +25,7 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--rounds", type=int, default=25) ap.add_argument("--url", default="http://127.0.0.1:8077/") + ap.add_argument("--before") a = ap.parse_args(argv[:argv.index("--")] if "--" in argv else argv) gen = os.path.join(WORK, "recomp", "gen") @@ -29,11 +33,15 @@ def main(): out = os.path.join(WORK, "drive", "iterate") open(seeds, "a").close() for rnd in range(1, a.rounds + 1): + if a.before: + subprocess.run(a.before, shell=True, check=True) subprocess.run(["node", os.path.join(HERE, "drive.mjs"), "--url", a.url, "--out", out, *extra]) with open(os.path.join(out, "full.log"), encoding="utf-8", errors="replace") as f: - m = MISSING.search(f.read()) + log = f.read() + m = MISSING.search(log) if not m: print(f"round {rnd}: no missing-code stop") + print("\n".join(l for l in log.splitlines() if l.startswith("FATAL"))[:2000]) return addr = m.group(1) print(f"round {rnd}: missing {addr}") From 51412251baf044e83833d57fd0d022741c276be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:55:38 +0000 Subject: [PATCH 3/6] Stop the relocation walk at zero bytes A walk that starts in data decoded 00 00 as add [eax], al and accepted the run as a function, swallowing the real function that follows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUMqSCt7JBTq4HTzuc --- recomp/lift.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/recomp/lift.py b/recomp/lift.py index dadfc6e..1882f0d 100644 --- a/recomp/lift.py +++ b/recomp/lift.py @@ -951,7 +951,8 @@ def entry_shape(t): pos, ok = t, False for _ in range(256): dec = L.disasm_range(pos, pos + 1) - if not dec or dec[0].mnemonic not in SANE: + # 'add byte ptr [eax], al' is two zero bytes: the walk has run into data (a VMT or RTTI field). + if not dec or dec[0].mnemonic not in SANE or dec[0].bytes[:2] == b"\0\0": break if dec[0].mnemonic in ("jmp", "ret"): ok = True From bdf036d64b0d2fdabd9c65426783baffe75ec7d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:07:36 +0000 Subject: [PATCH 4/6] Reject a relocation walk that runs into the next routine's prologue A pointer to a string literal Delphi placed before a routine decoded as code and swallowed that routine. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUMqSCt7JBTq4HTzuc --- recomp/lift.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/recomp/lift.py b/recomp/lift.py index 1882f0d..aac3357 100644 --- a/recomp/lift.py +++ b/recomp/lift.py @@ -954,6 +954,11 @@ def entry_shape(t): # 'add byte ptr [eax], al' is two zero bytes: the walk has run into data (a VMT or RTTI field). if not dec or dec[0].mnemonic not in SANE or dec[0].bytes[:2] == b"\0\0": break + # An aligned 'push ebp; mov ebp, esp' ahead is the next routine: the walk started in a string literal or + # other data Delphi places before a routine. + if any(a != t and a % 4 == 0 and L.code[a - L.code_lo: a - L.code_lo + 3] == b"\x55\x8b\xec" + for a in range(pos, pos + dec[0].size)): + break if dec[0].mnemonic in ("jmp", "ret"): ok = True break From 4a15649d57e56367b6e02814ae108b0f53528d41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:07:36 +0000 Subject: [PATCH 5/6] Time double-clicks by when the page saw the clicks Input reaches the game one state change per frame, so at a frame every second the second click of a double-click was processed well past the 500 ms window. The ring now carries the page's event time and the double-click test uses it. PORT_LOG_INPUT and PORT_LOG_RECV log every input event and every read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUMqSCt7JBTq4HTzuc --- recomp/port/hle_misc.c | 2 +- recomp/port/hle_user32.c | 22 +++++++++++----------- recomp/web/index.html | 6 +++--- recomp/web/worker.js | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/recomp/port/hle_misc.c b/recomp/port/hle_misc.c index 216dfbb..f01d8ce 100644 --- a/recomp/port/hle_misc.c +++ b/recomp/port/hle_misc.c @@ -442,7 +442,7 @@ static void ws_recv(CPU* c) { memmove(k->rx, k->rx + n, k->rx_len - n); k->rx_len -= n; static int logged; - if (logged++ < 20) { + if (logged++ < 20 || getenv("PORT_LOG_RECV")) { char hex[3 * 64 + 1] = {0}; for (uint32_t i = 0; i < n && i < 64; i++) snprintf(hex + 3 * i, 4, "%02x ", M8(buf + i)); port_log("recv %08x: %u bytes %s", ARG(0), n, hex); diff --git a/recomp/port/hle_user32.c b/recomp/port/hle_user32.c index 85aaf8d..c4a235a 100644 --- a/recomp/port/hle_user32.c +++ b/recomp/port/hle_user32.c @@ -2066,8 +2066,8 @@ EM_JS(int, js_input_poll, (int* out), { if (!ring) return 0; const head = Atomics.load(ring, 0), tail = Atomics.load(ring, 1); if (head === tail) return 0; - const cap = (ring.length - 2) >> 2, at = 2 + (tail % cap) * 4; - for (let i = 0; i < 4; i++) HEAP32[(out >> 2) + i] = ring[at + i]; + const cap = ((ring.length - 2) / 5) | 0, at = 2 + (tail % cap) * 5; + for (let i = 0; i < 5; i++) HEAP32[(out >> 2) + i] = ring[at + i]; Atomics.store(ring, 1, tail + 1); return 1; }); @@ -2076,7 +2076,7 @@ EM_JS(int, js_input_peek_change, (void), { if (!ring) return 0; const head = Atomics.load(ring, 0), tail = Atomics.load(ring, 1); if (head === tail) return 0; - const type = ring[2 + (tail % ((ring.length - 2) >> 2)) * 4]; + const type = ring[2 + (tail % (((ring.length - 2) / 5) | 0)) * 5]; return type === 2 || type === 3 || type === 5 || type === 6 ? 1 : 0; }); /* Blocks until the page writes an input event or the timeout passes; returns 0 when there is no ring to wait on. */ @@ -2112,7 +2112,7 @@ void hle_screen_to_page(int* x, int* y) { static struct { uint32_t hwnd, msg, time; int x, y; } last_click; /* Mouse input goes to the capture window or the one under the cursor, after the hit test and cursor messages. */ -static void mouse_event(CPU* c, int kind, int button, int sx, int sy, int wheel) { +static void mouse_event(CPU* c, int kind, int button, int sx, int sy, int wheel, uint32_t time) { static const uint32_t vk[3] = {1, 4, 2}, down_msg[3] = {0x201, 0x207, 0x204}; cursor_x = sx; cursor_y = sy; @@ -2171,13 +2171,13 @@ static void mouse_event(CPU* c, int kind, int button, int sx, int sy, int wheel) if (hit == HTCLIENT) { int ox, oy; client_origin(t, &ox, &oy); - uint32_t now = (uint32_t)port_now_ms(); if (kind == 2 && (t->cls->style & 8) && last_click.hwnd == target && last_click.msg == msg && - now - last_click.time <= 500 && abs(sx - last_click.x) <= 2 && abs(sy - last_click.y) <= 2) { + time - last_click.time <= 500 && abs(sx - last_click.x) <= 2 && abs(sy - last_click.y) <= 2) { msg += 2; last_click.hwnd = 0; + if (getenv("PORT_LOG_INPUT")) port_log("double click -> %08x", target); } else if (kind == 2) { - last_click.hwnd = target; last_click.msg = msg; last_click.time = now; last_click.x = sx; last_click.y = sy; + last_click.hwnd = target; last_click.msg = msg; last_click.time = time; last_click.x = sx; last_click.y = sy; } post(target, msg, mouse_keys(), ((uint32_t)(sy - oy) & 0xffff) << 16 | ((uint32_t)(sx - ox) & 0xffff)); } else if (hit != HTNOWHERE && hit != HTTRANSPARENT) { @@ -2192,21 +2192,21 @@ enum { IN_MOVE = 1, IN_DOWN, IN_UP, IN_WHEEL, IN_KEYDOWN, IN_KEYUP, IN_CHAR, IN_ static uint32_t last_change_frame = 0xffffffffu; static void pump_input(CPU* c) { - int e[4]; + int e[5]; while (display_hwnd) { int change = js_input_peek_change(); if (change && last_change_frame == port_frames) break; if (!js_input_poll(e)) break; if (change) last_change_frame = port_frames; static int logged; - if (e[0] != IN_MOVE && e[0] != IN_CHAR && logged++ < 10) port_log("input %d %d (%d,%d) -> %08x", e[0], e[1], e[2], e[3], display_hwnd); + if (e[0] != IN_MOVE && e[0] != IN_CHAR && (logged++ < 10 || getenv("PORT_LOG_INPUT"))) port_log("input %d %d (%d,%d) -> %08x", e[0], e[1], e[2], e[3], display_hwnd); int sx, sy; page_to_screen(e[2], e[3], &sx, &sy); switch (e[0]) { - case IN_MOVE: case IN_UP: case IN_WHEEL: mouse_event(c, e[0], e[1], sx, sy, e[1]); break; + case IN_MOVE: case IN_UP: case IN_WHEEL: mouse_event(c, e[0], e[1], sx, sy, e[1], (uint32_t)e[4]); break; case IN_DOWN: if (getenv("PORT_TRACE_CLICK")) { port_trace_budget = 1500; port_fn_trace_start(c); } - mouse_event(c, e[0], e[1], sx, sy, 0); + mouse_event(c, e[0], e[1], sx, sy, 0, (uint32_t)e[4]); break; case IN_KEYDOWN: key_event(c, (uint32_t)e[1] & 0xff, 1, 0); break; case IN_KEYUP: key_event(c, (uint32_t)e[1] & 0xff, 0, 0); break; diff --git a/recomp/web/index.html b/recomp/web/index.html index 067be57..e9b9cd6 100644 --- a/recomp/web/index.html +++ b/recomp/web/index.html @@ -365,10 +365,10 @@ let ring = null; function send(type, a, x = 0, y = 0) { if (!ring) return; - const head = Atomics.load(ring, 0), cap = (ring.length - 2) >> 2; + const head = Atomics.load(ring, 0), cap = ((ring.length - 2) / 5) | 0; if (head - Atomics.load(ring, 1) >= cap) return; - const at = 2 + (head % cap) * 4; - ring[at] = type; ring[at + 1] = a; ring[at + 2] = x; ring[at + 3] = y; + const at = 2 + (head % cap) * 5; + ring[at] = type; ring[at + 1] = a; ring[at + 2] = x; ring[at + 3] = y; ring[at + 4] = Math.round(performance.now()); Atomics.store(ring, 0, head + 1); Atomics.notify(ring, 0); } diff --git a/recomp/web/worker.js b/recomp/web/worker.js index 2ce1b55..eb80592 100644 --- a/recomp/web/worker.js +++ b/recomp/web/worker.js @@ -2,9 +2,9 @@ // /game/ (mountGameFile), so nothing of the client is bundled with the page. const params = new URLSearchParams(self.location.search); -// Input ring shared with the page: [0] = events written, [1] = events read, then 4 ints per event. The worker never +// Input ring shared with the page: [0] = events written, [1] = events read, then 5 ints per event (type, argument, x, y, page time in ms). The worker never // yields, so postMessage could not deliver input; the host drains this from PeekMessage. -self.inputRing = new Int32Array(new SharedArrayBuffer((2 + 4 * 1024) * 4)); +self.inputRing = new Int32Array(new SharedArrayBuffer((2 + 5 * 1024) * 4)); // audioDone[sample] = generation of the last sound on that sample handle that finished playing (hle_audio.c). self.audioDone = new Int32Array(new SharedArrayBuffer(128 * 4)); // Text pasted in the page: [0] = paste sequence, [1] = length, then UTF-16 code units (hle_menu.c). From bca4e60c257a2720bc6fd2803843aa8c38d4ae98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:22:02 +0000 Subject: [PATCH 6/6] Address review: event timestamps, fresh iterate logs, README work path Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUMqSCt7JBTq4HTzuc --- README.md | 4 ++-- recomp/web/drive.mjs | 1 + recomp/web/index.html | 10 +++++----- recomp/web/iterate.py | 14 ++++++++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e45e68b..8ffa593 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ its JDK, Node with Playwright). Generated code and builds go to `$NOSRECOMP_WORK ```bash GHIDRA_HOME=/opt/ghidra tools/decompile.sh ~/Nostale/NosCore.exe # Ghidra only: IDR needs Windows -python3 recomp/lift.py ~/re/work/input/NosCore.exe ~/re/work/functions.txt ~/re/work/recomp/gen \ - --seeds ~/re/work/recomp/seeds.txt +W=${NOSRECOMP_WORK:-~/re/work} +python3 recomp/lift.py $W/input/NosCore.exe $W/functions.txt $W/recomp/gen --seeds $W/recomp/seeds.txt python3 recomp/wasm/compile_all.py --web python3 recomp/web/serve.py --game ~/Nostale --user --password python3 recomp/web/iterate.py # seeds each function the client stops on, re-lifts and rebuilds diff --git a/recomp/web/drive.mjs b/recomp/web/drive.mjs index 82c6ba0..9850c8e 100644 --- a/recomp/web/drive.mjs +++ b/recomp/web/drive.mjs @@ -217,6 +217,7 @@ try { } } catch (e) { console.log('error:', e.message); + process.exitCode = 1; } finally { if (profiler) { const { profile } = await profiler('Profiler.stop'); diff --git a/recomp/web/index.html b/recomp/web/index.html index e9b9cd6..ca3b812 100644 --- a/recomp/web/index.html +++ b/recomp/web/index.html @@ -363,12 +363,12 @@ // Same event codes as pump_input in hle_user32.c. const IN = { move: 1, down: 2, up: 3, wheel: 4, keydown: 5, keyup: 6, char: 7, dumpFrame: 8 }; let ring = null; - function send(type, a, x = 0, y = 0) { + function send(type, a, x = 0, y = 0, time = performance.now()) { if (!ring) return; const head = Atomics.load(ring, 0), cap = ((ring.length - 2) / 5) | 0; if (head - Atomics.load(ring, 1) >= cap) return; const at = 2 + (head % cap) * 5; - ring[at] = type; ring[at + 1] = a; ring[at + 2] = x; ring[at + 3] = y; ring[at + 4] = Math.round(performance.now()); + ring[at] = type; ring[at + 1] = a; ring[at + 2] = x; ring[at + 3] = y; ring[at + 4] = Math.round(time); Atomics.store(ring, 0, head + 1); Atomics.notify(ring, 0); } @@ -376,9 +376,9 @@ const r = canvas.getBoundingClientRect(); return [Math.round((e.clientX - r.left) * canvas.width / r.width), Math.round((e.clientY - r.top) * canvas.height / r.height)]; } - canvas.addEventListener('mousemove', (e) => send(IN.move, 0, ...pos(e))); - canvas.addEventListener('mousedown', (e) => { canvas.focus(); send(IN.down, e.button, ...pos(e)); e.preventDefault(); }); - canvas.addEventListener('mouseup', (e) => send(IN.up, e.button, ...pos(e))); + canvas.addEventListener('mousemove', (e) => send(IN.move, 0, ...pos(e), e.timeStamp)); + canvas.addEventListener('mousedown', (e) => { canvas.focus(); send(IN.down, e.button, ...pos(e), e.timeStamp); e.preventDefault(); }); + canvas.addEventListener('mouseup', (e) => send(IN.up, e.button, ...pos(e), e.timeStamp)); canvas.addEventListener('wheel', (e) => { send(IN.wheel, e.deltaY < 0 ? 120 : -120, ...pos(e)); e.preventDefault(); }, { passive: false }); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); const charKeys = { Enter: 13, Backspace: 8, Tab: 9, Escape: 27 }; diff --git a/recomp/web/iterate.py b/recomp/web/iterate.py index ae2d4fd..99641fb 100644 --- a/recomp/web/iterate.py +++ b/recomp/web/iterate.py @@ -35,10 +35,20 @@ def main(): for rnd in range(1, a.rounds + 1): if a.before: subprocess.run(a.before, shell=True, check=True) - subprocess.run(["node", os.path.join(HERE, "drive.mjs"), "--url", a.url, "--out", out, *extra]) - with open(os.path.join(out, "full.log"), encoding="utf-8", errors="replace") as f: + log_path = os.path.join(out, "full.log") + if os.path.exists(log_path): + os.remove(log_path) + drive = subprocess.run(["node", os.path.join(HERE, "drive.mjs"), "--url", a.url, "--out", out, *extra]) + if not os.path.exists(log_path): + print(f"round {rnd}: drive.mjs wrote no log (exit {drive.returncode})") + sys.exit(1) + with open(log_path, encoding="utf-8", errors="replace") as f: log = f.read() m = MISSING.search(log) + if not m and drive.returncode: + print(f"round {rnd}: drive.mjs failed (exit {drive.returncode}) before any missing-code stop") + print("\n".join(l for l in log.splitlines() if l.startswith("FATAL"))[:2000]) + sys.exit(1) if not m: print(f"round {rnd}: no missing-code stop") print("\n".join(l for l in log.splitlines() if l.startswith("FATAL"))[:2000])