From 519cbddcb05b5b5dcf091c660d4d930af2f7832a Mon Sep 17 00:00:00 2001 From: Gabriel Scarcella Date: Sat, 24 Oct 2026 16:05:37 -0300 Subject: [PATCH] Second pad for scripts and the debug channel: "pad2", press "port", pad "port" The last part of #101: port 1 from the tools that drive a run, not only from the window. - Script (runtime/script.c, tools/replay/run.lua: the same engine on both runners): `"pad2": true` at the top puts a pad in port 1 from the first frame, and a press step with `"port": 1` holds its buttons there; the other steps drive port 0, and a press on port 1 without pad2 is refused at load. run.lua sets PCSX-Redux's pad 2 Connected from pad2 either way (Redux's default is none, and the user's configuration never decides it) and records port 1's inputs from its first frame, as the port does. The held buttons of both ports are part of the script's save state. - Debug channel (runtime/debug.c): `pad` takes `port` (0 or 1; port 1 is connected while the channel owns it), each port owned on its own (the window keeps the other: port_debug_pad_owned is a bitmask now); `pad_free` takes an optional port (none: every port the channel owns); status adds pad2_owner, pad2 and pad2_connected. - MCP (tools/mcp): pad_press/pad_hold/pad_release/pad_free take `port`; game_status gives pad2_buttons; the fake game models both ports. - tests/hello_test.py: a script with pad2 and a press on port 1 traces port 1 in its log and record, and hello draws another picture than with the press on port 0 or none (the game sees it); the two refusals. The refused scripts' crash reports go to the build directory. Closes #101 (phase 3 of 3). Co-Authored-By: Claude Opus 5.5 --- docs/PORT.md | 9 ++- docs/RUNTIME.md | 5 ++ include/psxstack/port_harness.h | 3 +- runtime/debug.c | 136 ++++++++++++++++++++------------ runtime/input.c | 5 +- runtime/script.c | 66 ++++++++++------ tests/hello_test.py | 54 ++++++++++++- tools/mcp/README.md | 4 +- tools/mcp/fake_game.py | 40 +++++----- tools/mcp/game.py | 14 ++-- tools/mcp/selftest.py | 22 ++++++ tools/mcp/server.py | 44 +++++++---- tools/replay/run.lua | 49 +++++++++--- 13 files changed, 315 insertions(+), 136 deletions(-) diff --git a/docs/PORT.md b/docs/PORT.md index 3ee7269..f641692 100644 --- a/docs/PORT.md +++ b/docs/PORT.md @@ -255,8 +255,9 @@ bare binary, the replays and the goldens are unchanged. The ops: - `status`: frame, stage, file, map, paused, pace, pad owner. `pause` / `resume`: hold the game at the next vsync boundary (the pump's pause, shared with the window's pause key). `step frames`: exactly N vsyncs, then pause. - `wait`: run until a host or PS1 read, the stage or the map equals a value, or a timeout in frames; leaves the game - paused. `pad buttons frames release [sync]`: the channel owns pad 1 (`psyq_pad_set` each vsync) until `pad_free`; - a script keeps precedence; the window keeps pad 2. + paused. `pad buttons frames release [sync] [port]`: the channel owns that port (0 by default, 1 the second pad, + connected while owned; `psyq_pad_set` each vsync) until `pad_free`; a script keeps precedence; the window keeps the + ports the channel does not own. - `peek` / `poke`: raw host memory, any range `/proc/self/maps` says is mapped (a bad address never faults the game). `peek_ps1` / `poke_ps1`: a PS1 address, through the adapter's state map first (`game_state_host`, 1/2/4 bytes: the host objects behind PS1 addresses, an overlay's globals in the slots among them), else the arena (from the first @@ -583,8 +584,8 @@ main thread and reports its registers and stack. The `.dmp` opens in WinDbg or V stack overflow gets the text (the filter runs on what the guard page leaves) and perhaps no dump. ## Known limitations -- **Pads:** digital pads on port 0 and, from the window's input only, port 1; no analog mode, no rumble (`PadSetAct` is - accepted and ignored), no multitap. The script and the debug channel drive port 0 only. +- **Pads:** digital pads on port 0 and port 1 (the window's `input.player2`, a script's `pad2`, the debug channel's + `port`); no analog mode, no rumble (`PadSetAct` is accepted and ignored), no multitap. - **No reset key** in the window (the console reset exists only as a script step and a debug-channel op). - **Timing stand-ins:** the CD seek times (3 ticks + 1 per 8192 sectors, at most 40) and `StGetNext`'s 5000 polls per vsync are estimates, not measurements. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 26cce2e..e10fb0b 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -447,6 +447,11 @@ by the stack's CI: it needs a game, its disc and its pinned emulator. such a checkpoint, or lacks them for one with an image, does not belong to the script: the emulator runner and the port test report it as a failure, and do so for an expected file as well. Dump files are numbered by the checkpoint's position (`cp02_x.bin` is the second checkpoint), no-image ones included. +- **The script's second pad:** `"pad2": true` at the script's top level puts a digital pad in port 1 from the first + frame (nothing pressed; without it port 1 is empty on both runners: run.lua sets PCSX-Redux's pad 2 `Connected` + either way), and a `press` step with `"port": 1` holds its buttons there (`{"type": "press", "port": 1, "buttons": + ["CROSS"]}`); the other steps, and a `press` without `port`, drive port 0. A press on port 1 without `pad2` is + refused when the script loads. The records' `inputs` trace port 1 from its first frame (`"port": 1` entries). - **The script's `write_mem` step** writes PS1 memory on both runners, instantly (the next step runs in the same frame): `{"type": "write_mem", "addr": "0x80010000", "value": 1}` (`size` 1, 2 or 4, default 4, little-endian; `"mask": "0x2"` sets only the mask's bits from `value` and keeps the others), or `{"type": "write_mem", "addr": ..., diff --git a/include/psxstack/port_harness.h b/include/psxstack/port_harness.h index 734958e..188f2c5 100644 --- a/include/psxstack/port_harness.h +++ b/include/psxstack/port_harness.h @@ -220,7 +220,8 @@ void port_mods_data_start(int active); * (step, wait, a synced pad), then the socket's requests; a reset it was asked for is requested at its end. * port_debug_poll_paused: the requests only (pump.c's pause loop, every poll). port_debug_resumed: after the pause * loop (a reset asked for while paused runs here). port_debug_close: from port_exit (the socket file unlinked). - * port_debug_pad_owned: the channel owns the pad (a pad op until pad_free): input.c sends nothing to the pad. */ + * port_debug_pad_owned: the ports the channel owns, bit p for port p (a pad op on it until pad_free): input.c sends + * nothing to those. */ extern int port_debug_active; extern int port_debug_pad_owned; void port_debug_open(const char *path); diff --git a/runtime/debug.c b/runtime/debug.c index 7e12d12..b6dddb9 100644 --- a/runtime/debug.c +++ b/runtime/debug.c @@ -26,7 +26,8 @@ * completes; the client sends nothing else meanwhile. * - Ops (args -> result): * status: -> frame, stage, file, map, slot1_word0, paused (0/1), pace, rate, window (0/1), script (0/1), - * pad_owner ("debug"/"script"/"window"/"none"), pad (u16: the buttons last set). + * pad_owner ("debug"/"script"/"window"/"none"), pad (u16: the buttons last set); the same for port 1, the second + * pad: pad2_owner, pad2, and pad2_connected (0/1). * pause: -> frame. Holds the game at the next vsync boundary (right after this frame when handled from * port_frame). Idempotent. resume: -> frame; idempotent. * step: frames (>= 1, default 1) -> frame. Runs exactly `frames` vsyncs then pauses; deferred. @@ -39,11 +40,14 @@ * poke_ps1: addr, data -> len; the same mapping, written. * pad: buttons (u16, PS1 bit order: SELECT=0, L3, R3, START, UP, RIGHT, DOWN, LEFT, L2, R2, L1, R1, TRIANGLE, * CIRCLE, CROSS, SQUARE=15; active high), frames (>= 0; 0 = hold until the next pad op), release (>= 0, default - * 0: frames of 0 after `frames`), sync (bool, default false) -> frame. The channel owns the pad from the first - * pad op until pad_free (window input no longer reaches it; a script keeps precedence: "script owns the pad"); - * each vsync it calls psyq_pad_set(0, 1, value). With sync the answer waits until frames + release vsyncs ran, - * and a game that was paused when the op arrived is paused again then (a driven run stays at a known frame). - * pad_free: releases the pad to the window (or none). + * 0: frames of 0 after `frames`), sync (bool, default false), port (0, the default, or 1: the second pad) -> + * frame. The channel owns the port from its first pad op until pad_free (window input no longer reaches it, and + * still drives the other port; a script keeps precedence: "script owns the pad"); each vsync it calls + * psyq_pad_set(port, 1, value): port 1 has a pad in it while the channel owns it. With sync the answer waits + * until frames + release vsyncs ran, and a game that was paused when the op arrived is paused again then (a + * driven run stays at a known frame). + * pad_free: port (optional) -> releases that port, or every port the channel owns, to the window (or none: port + * 1 then has nothing in it). * wait: one of addr (host) / ps1 (PS1 address) / ps1_stage / ps1_map, with size (1/2/4), signed (bool), value, * timeout (frames, default 600) -> frame, hit (0/1). Runs until the read equals value (checked once before * running, then after each vsync) or timeout frames passed; deferred; leaves the game paused. ps1_stage and @@ -64,7 +68,7 @@ * quit: status (default 0); answers, then port_exit(status, "debug quit"). * - Pause semantics: `paused` means the pump holds the game between vsyncs in its pause loop; the pause key and the * channel share that state. While paused the channel keeps answering peek/poke/screenshot/status/hash; step runs - * frames from there. A client that disconnects drops its pending op, frees the pad and resumes the game, so a dead + * frames from there. A client that disconnects drops its pending op, frees the pads and resumes the game, so a dead * tool never leaves the game held. */ #ifdef _WIN32 /* The Windows build has no channel yet (a Unix socket, poll and /proc/self/maps are POSIX): --debug is refused; the @@ -155,13 +159,18 @@ typedef struct DebugWait { } DebugWait; static DebugWait debug_wait; -/* The pad: `buttons` for `hold` more vsyncs (or forever), then 0 for `release` vsyncs, then 0. Applied once per - * frame (debug_pad_applied_frame), at the op itself and at every later port_debug_frame. */ -static u16 debug_pad_buttons; -static long debug_pad_hold, debug_pad_release; -static int debug_pad_forever; -static int debug_pad_repause; /* the sync pad op found the game paused: pause it again when done */ -static long debug_pad_applied_frame = -1; +/* A port's pad: `buttons` for `hold` more vsyncs (or forever), then 0 for `release` vsyncs, then 0. Applied once per + * frame (applied_frame), at the op itself and at every later port_debug_frame, while port_debug_pad_owned has the + * port's bit. */ +typedef struct DebugPad { + u16 buttons; + long hold, release; + int forever; + long applied_frame; +} DebugPad; +static DebugPad debug_pads[PORT_PADS] = { { 0, 0, 0, 0, -1 }, { 0, 0, 0, 0, -1 } }; +static int debug_pad_sync_port; /* the port of the pending sync pad op */ +static int debug_pad_repause; /* the sync pad op found the game paused: pause it again when done */ static int debug_reset_pending, debug_reset_repause; /* a reset asked for; it was asked for while paused */ static int debug_load_pending, debug_load_repause; /* a load_state asked for; it was asked for while paused */ @@ -170,17 +179,24 @@ static char debug_save_path[4096]; /* DEBUG_SAVE's file */ /* ---- The socket */ +/* The channel lets go of a port it owns: port 0 back to a pad with nothing pressed, port 1 to nothing in it, until its + * next owner (the window) sets it. */ +static void debug_pad_free_port(int port) { + if (port_debug_pad_owned & (1 << port)) { + port_debug_pad_owned &= ~(1 << port); + psyq_pad_set(port, port == 0, 0); + port_framelog_input(port, port == 0, 0); + } +} + static void debug_disconnect(const char *why) { port_log("debug: client disconnected (%s)", why); close(debug_client_fd); debug_client_fd = -1; debug_line_n = 0; debug_pending = DEBUG_NONE; /* its answer has no one to go to */ - if (port_debug_pad_owned) { - port_debug_pad_owned = 0; - psyq_pad_set(0, 1, 0); - port_framelog_input(0, 1, 0); - } + debug_pad_free_port(0); + debug_pad_free_port(1); port_pump_resume_request(); /* a dead tool never leaves the game held */ } @@ -463,29 +479,32 @@ static int debug_wait_hit(void) { /* ---- The pad */ -static void debug_pad_apply(void) { +static void debug_pad_apply(int port) { + DebugPad *d = &debug_pads[port]; u16 v = 0; - if (debug_pad_forever) { - v = debug_pad_buttons; - } else if (debug_pad_hold > 0) { - v = debug_pad_buttons; - debug_pad_hold--; - } else if (debug_pad_release > 0) { - debug_pad_release--; + if (d->forever) { + v = d->buttons; + } else if (d->hold > 0) { + v = d->buttons; + d->hold--; + } else if (d->release > 0) { + d->release--; } - psyq_pad_set(0, 1, v); - port_framelog_input(0, 1, v); - debug_pad_applied_frame = port_frames; + psyq_pad_set(port, 1, v); + port_framelog_input(port, 1, v); + d->applied_frame = port_frames; } /* Once per vsync: a sync whose frames + release vsyncs all ran is answered, then the pad gets this vsync's value * (unless the op itself applied it at this frame: a pad op handled at frame F sets the pad the game reads during * F + 1). */ static void debug_pad_frame(void) { + const DebugPad *sync = &debug_pads[debug_pad_sync_port]; + int p; if (!port_debug_pad_owned) { return; } - if (debug_pending == DEBUG_PAD_SYNC && !debug_pad_forever && debug_pad_hold == 0 && debug_pad_release == 0) { + if (debug_pending == DEBUG_PAD_SYNC && !sync->forever && sync->hold == 0 && sync->release == 0) { debug_pending = DEBUG_NONE; if (debug_pad_repause) { port_pump_pause_request(); /* a press from the paused game leaves it paused: the tool's next call finds @@ -493,18 +512,20 @@ static void debug_pad_frame(void) { } debug_ok(&debug_pending_req, "\"frame\": %ld", port_frames); } - if (debug_pad_applied_frame != port_frames) { - debug_pad_apply(); + for (p = 0; p < PORT_PADS; p++) { + if ((port_debug_pad_owned & (1 << p)) && debug_pads[p].applied_frame != port_frames) { + debug_pad_apply(p); + } } } /* ---- The ops */ -static const char *debug_pad_owner(void) { +static const char *debug_pad_owner(int port) { if (port_script_active) { return "script"; } - if (port_debug_pad_owned) { + if (port_debug_pad_owned & (1 << port)) { return "debug"; } return port_window ? "window" : "none"; @@ -513,10 +534,12 @@ static const char *debug_pad_owner(void) { static void debug_op_status(const DebugReq *req) { debug_ok(req, "\"frame\": %ld, \"stage\": %d, \"file\": %d, \"map\": %u, \"slot1_word0\": %u, \"paused\": %d, " - "\"pace\": %ld, \"rate\": %ld, \"window\": %d, \"script\": %d, \"pad_owner\": \"%s\", \"pad\": %u", + "\"pace\": %ld, \"rate\": %ld, \"window\": %d, \"script\": %d, \"pad_owner\": \"%s\", \"pad\": %u, " + "\"pad2_owner\": \"%s\", \"pad2\": %u, \"pad2_connected\": %d", port_frames, game_state_stage(), game_state_file(), (u32)game_state_map(), port_state_slot1_word0(), port_pump_paused(), port_pace_get(), port_rate, port_window != 0, port_script_active != 0, - debug_pad_owner(), port_framelog_last_input(0)); + debug_pad_owner(0), port_framelog_last_input(0), debug_pad_owner(1), port_framelog_last_input(1), + psyq_pad_present(1)); } static void debug_op_step(const DebugReq *req, const PortJson *obj) { @@ -572,7 +595,8 @@ static void debug_op_poke(const DebugReq *req, const PortJson *obj, int ps1) { } static void debug_op_pad(const DebugReq *req, const PortJson *obj) { - long long buttons, frames, release; + long long buttons, frames, release, port; + DebugPad *d; int sync; if (port_script_active) { debug_error(req, "script owns the pad"); @@ -580,17 +604,20 @@ static void debug_op_pad(const DebugReq *req, const PortJson *obj) { } if (!debug_int_req(req, obj, "buttons", 0, 0xFFFF, &buttons) || !debug_int_opt(req, obj, "frames", 0, 0, 0x7FFFFFFF, &frames) || - !debug_int_opt(req, obj, "release", 0, 0, 0x7FFFFFFF, &release) || !debug_bool_opt(req, obj, "sync", &sync)) { + !debug_int_opt(req, obj, "release", 0, 0, 0x7FFFFFFF, &release) || !debug_bool_opt(req, obj, "sync", &sync) || + !debug_int_opt(req, obj, "port", 0, 0, PORT_PADS - 1, &port)) { return; } - port_debug_pad_owned = 1; - debug_pad_buttons = (u16)buttons; - debug_pad_forever = frames == 0; - debug_pad_hold = (long)frames; - debug_pad_release = (long)release; - debug_pad_apply(); - if (sync && !debug_pad_forever) { + port_debug_pad_owned |= 1 << port; + d = &debug_pads[port]; + d->buttons = (u16)buttons; + d->forever = frames == 0; + d->hold = (long)frames; + d->release = (long)release; + debug_pad_apply((int)port); + if (sync && !d->forever) { debug_pending = DEBUG_PAD_SYNC; + debug_pad_sync_port = (int)port; debug_pending_req = *req; debug_pad_repause = port_pump_paused(); port_pump_resume_request(); @@ -599,11 +626,16 @@ static void debug_op_pad(const DebugReq *req, const PortJson *obj) { debug_ok(req, "\"frame\": %ld", port_frames); } -static void debug_op_pad_free(const DebugReq *req) { - if (port_debug_pad_owned) { - port_debug_pad_owned = 0; - psyq_pad_set(0, 1, 0); - port_framelog_input(0, 1, 0); +static void debug_op_pad_free(const DebugReq *req, const PortJson *obj) { + long long port; + if (!debug_int_opt(req, obj, "port", -1, 0, PORT_PADS - 1, &port)) { + return; + } + if (port < 0 || port == 0) { + debug_pad_free_port(0); + } + if (port < 0 || port == 1) { + debug_pad_free_port(1); } debug_ok(req, NULL); } @@ -859,7 +891,7 @@ static void debug_handle(const char *line, size_t n) { } else if (strcmp(op->string, "pad") == 0) { debug_op_pad(&req, root); } else if (strcmp(op->string, "pad_free") == 0) { - debug_op_pad_free(&req); + debug_op_pad_free(&req, root); } else if (strcmp(op->string, "wait") == 0) { debug_op_wait(&req, root); } else if (strcmp(op->string, "screenshot") == 0) { diff --git a/runtime/input.c b/runtime/input.c index dfbad38..1f735c0 100644 --- a/runtime/input.c +++ b/runtime/input.c @@ -1397,8 +1397,11 @@ void port_input_frame(void) { input_poll(); if (!port_script_active) { int p; - for (p = port_debug_pad_owned ? 1 : 0; p < PORT_PADS; p++) { /* the debug channel drives port 0 */ + for (p = 0; p < PORT_PADS; p++) { int connected = input_connected(p); + if (port_debug_pad_owned & (1 << p)) { + continue; /* the debug channel drives that port */ + } u16 bits = connected ? input_buttons(p) : 0; psyq_pad_set(p, connected, bits); port_framelog_input(p, connected, bits); diff --git a/runtime/script.c b/runtime/script.c index b44a50d..ce60858 100644 --- a/runtime/script.c +++ b/runtime/script.c @@ -14,7 +14,9 @@ * * The buttons are the physical pad's (PCSX.CONSTS.PAD.BUTTON numbers them as the PS1 pad's bits, which psyq_pad_set * takes, active high); the game itself rotates the face buttons for non-Japanese languages (pad_read_buttons), so they - * pass through unchanged. Numbers in a script may be JSON numbers or hex strings ("0x2D7"). Anything a step does not + * pass through unchanged. The script owns both ports: port 1 is empty unless the script says `"pad2": true` (a pad + * there from the start, nothing pressed), and then a `press` step with `"port": 1` holds its buttons there (run.lua's + * second pad, SIO0 slot 2); the other steps drive port 0. Numbers in a script may be JSON numbers or hex strings ("0x2D7"). Anything a step does not * define (`comment`, ...) is ignored, as run.lua ignores it; everything it defines is checked when the script is * loaded (run.lua would fail only on reaching the step), except a wait_mem or write_mem address, which is fatal (status * 1) only when its step runs if the port does not map it, so the steps before it still run. A step's start goes to @@ -87,6 +89,7 @@ typedef struct ScriptStep { int has_repeat; long repeat; /* press */ u16 buttons; /* press */ + int port; /* press: 0, or 1 (the script's pad2) */ double x, y, tol; /* walk */ char *name; /* checkpoint, vram (default "unnamed") */ int no_image; /* checkpoint with "image": false: the image is not dumped nor hashed */ @@ -104,7 +107,8 @@ static long script_max_frames; static int script_index; /* the current step (0-based; run.lua's step_index - 1) */ static long script_step_started = -1; /* the frame the current step started, -1 before it runs */ -static u16 script_held; /* the buttons held (run.lua's `held`), applied every frame */ +static int script_pad2; /* the script's "pad2": a pad in port 1 */ +static u16 script_held[PORT_PADS]; /* the buttons held per port (run.lua's `held`, `held2`), applied every frame */ static int script_reset_pending; /* a `reset` step ran this frame: the console resets at the frame's end */ static long script_frame0; /* the frame the script started at: 0, or a loaded state's (max_frames) */ @@ -332,6 +336,10 @@ static void script_load_step(const PortJson *obj, ScriptStep *s, long default_ti break; case SCRIPT_PRESS: s->buttons = script_buttons(obj, step); + s->port = (int)script_frames(obj, "port", 0, 0, step); + if (s->port > 1 || (s->port == 1 && !script_pad2)) { + script_error(step, "press: `port` is 0, or 1 with the script's \"pad2\": true"); + } s->frames = script_frames(obj, "frames", 2, 0, step); s->release = script_frames(obj, "release", 2, 0, step); if (s->frames + s->release == 0) { @@ -394,7 +402,7 @@ void port_script_load(const char *path) { long size; char err[256]; PortJson *root; - const PortJson *steps, *name; + const PortJson *steps, *name, *pad2; long long v; long default_timeout; size_t i; @@ -435,6 +443,12 @@ void port_script_load(const char *path) { if (script_int(root, "default_timeout", &v, 0, 0)) { default_timeout = (long)v; } + if ((pad2 = port_json_get(root, "pad2")) != NULL) { + if (pad2->type != PORT_JSON_BOOL) { + script_error(0, "`pad2`: true or false"); + } + script_pad2 = pad2->boolean; + } steps = port_json_get(root, "steps"); if (steps == NULL || steps->type != PORT_JSON_ARRAY) { script_error(0, "`steps` (an array) missing"); @@ -454,8 +468,12 @@ void port_script_load(const char *path) { /* ---- Running */ static void script_state(char *buf, size_t size) { - snprintf(buf, size, "frame=%ld stage=%d file=%d map=0x%X rnd=%d held=0x%04X", port_frames, game_state_stage(), - game_state_file(), (u32)game_state_map(), game_state_random_index(), script_held); + int n = snprintf(buf, size, "frame=%ld stage=%d file=%d map=0x%X rnd=%d held=0x%04X", port_frames, + game_state_stage(), game_state_file(), (u32)game_state_map(), game_state_random_index(), + script_held[0]); + if (script_pad2 && n > 0 && (size_t)n < size) { + snprintf(buf + n, size - (size_t)n, " held2=0x%04X", script_held[1]); + } } /* run.lua's fail(): the run ends with status 5 and the message. */ @@ -593,7 +611,7 @@ static int script_run_step(const ScriptStep *s, int *instant) { /* run.lua: held = {}, PCSX.hardResetEmulator() (RAM cleared, the memory cards kept), done but not instant: the * frame ends as any other (the released pad, the input trace, the end of the script), then port_script_frame * resets the console (port_reset_request: back to main(), game_main again). */ - script_held = 0; + memset(script_held, 0, sizeof(script_held)); script_reset_pending = 1; return 1; case SCRIPT_PRESS: { @@ -601,7 +619,7 @@ static int script_run_step(const ScriptStep *s, int *instant) { long phase = elapsed % cycle, count = elapsed / cycle; if (s->has_until) { if (script_cond_met(&s->cond)) { - script_held = 0; + script_held[s->port] = 0; *instant = 1; return 1; } @@ -611,10 +629,10 @@ static int script_run_step(const ScriptStep *s, int *instant) { } } if (phase < s->frames) { - script_held = s->buttons; + script_held[s->port] = s->buttons; return 0; } - script_held = 0; + script_held[s->port] = 0; if (s->has_until && !s->has_repeat) { return 0; } @@ -623,7 +641,7 @@ static int script_run_step(const ScriptStep *s, int *instant) { case SCRIPT_WALK: { double x, y; int found = game_state_player_pos(&x, &y); /* run.lua player_pos, the adapter's */ - script_held = 0; + script_held[0] = 0; if (found && fabs(s->x - x) <= s->tol && fabs(s->y - y) <= s->tol) { *instant = 1; return 1; @@ -640,14 +658,14 @@ static int script_run_step(const ScriptStep *s, int *instant) { } if (found) { if (s->x - x > s->tol) { - script_held |= SCRIPT_BUTTON_RIGHT; + script_held[0] |= SCRIPT_BUTTON_RIGHT; } else if (x - s->x > s->tol) { - script_held |= SCRIPT_BUTTON_LEFT; + script_held[0] |= SCRIPT_BUTTON_LEFT; } if (s->y - y > s->tol) { - script_held |= SCRIPT_BUTTON_DOWN; + script_held[0] |= SCRIPT_BUTTON_DOWN; } else if (y - s->y > s->tol) { - script_held |= SCRIPT_BUTTON_UP; + script_held[0] |= SCRIPT_BUTTON_UP; } } return 0; @@ -690,12 +708,13 @@ void port_script_frame(void) { break; } } - /* run.lua apply_pad: the held buttons on pad 1, every frame; the record's input trace gets the changes. The script - * owns both ports: port 1 has nothing connected (so a state saved with a second pad in goes on without it) */ - psyq_pad_set(0, 1, script_held); - port_framelog_input(0, 1, script_held); - psyq_pad_set(1, 0, 0); - port_framelog_input(1, 0, 0); + /* run.lua apply_pad: the held buttons on each pad, every frame; the record's input trace gets the changes. The + * script owns both ports: port 1 has a pad only with "pad2" (so a state saved with a second pad in goes on + * without it) */ + psyq_pad_set(0, 1, script_held[0]); + port_framelog_input(0, 1, script_held[0]); + psyq_pad_set(1, script_pad2, script_held[1]); + port_framelog_input(1, script_pad2, script_held[1]); if (script_index >= script_step_count) { port_exit(0, "script complete"); } @@ -713,7 +732,8 @@ void port_script_state(PortState *s) { char name[256]; int index = script_index, reset_pending = script_reset_pending; long started = script_step_started, frame0 = script_frame0; - u16 held = script_held; + u16 held[PORT_PADS]; + memcpy(held, script_held, sizeof(held)); memset(name, 0, sizeof(name)); if (!port_state_loading(s) && script_name != NULL) { snprintf(name, sizeof(name), "%s", script_name); @@ -730,14 +750,14 @@ void port_script_state(PortState *s) { if (name[0] != '\0' && strncmp(name, script_name, sizeof(name) - 1) == 0) { script_index = index; script_step_started = started; - script_held = held; + memcpy(script_held, held, sizeof(held)); script_reset_pending = reset_pending; script_frame0 = frame0; port_log("script: %s resumed at step %d of %d", script_name, script_index + 1, script_step_count); } else { script_index = 0; script_step_started = -1; - script_held = 0; + memset(script_held, 0, sizeof(script_held)); script_reset_pending = 0; script_frame0 = port_frames; port_log("script: %s starts at the loaded frame %ld", script_name, port_frames); diff --git a/tests/hello_test.py b/tests/hello_test.py index a1111cb..086c3dc 100644 --- a/tests/hello_test.py +++ b/tests/hello_test.py @@ -10,7 +10,10 @@ compares the PPM's SHA-1 with EXPECTED; --record prints the hash to put there. Then the script check: a script's write_mem steps (a value, a masked value, `data`; through the example's adapter probe, examples/hello/adapter.c, and into the arena) read back by wait_mem in the same frame (timeout 0), and a write to an address nothing maps ends the -run with status 1. Exit 0 pass, 1 fail, 2 missing tool. +run with status 1. Then the second pad (PAD2): a script with "pad2" and a press on port 1 traces port 1 in its log and +record, and hello (which tints its gradient from pad 2) draws another picture than with the press on port 0 or none; a +press on port 1 without "pad2", and a "pad2" that is not a boolean, are refused when the script loads. Exit 0 pass, 1 +fail, 2 missing tool. --exe tests a given build instead (CI: the Windows cross-build with -DPSXSTACK_SDL=ON); --wine runs it through `wine` (the prefix build/wine-prefix unless WINEPREFIX names one); --gpu also takes the hardware renderer's picture of the same vsync (an SDL build), which must be the software one byte for byte when a device opens (on Windows, Direct3D 12 @@ -45,6 +48,54 @@ ] +# The second pad (#101): the same press on port 1 (with "pad2") and on port 0, and no press; hello shows a press 4 +# vsyncs after it (its read at the next vsync, its double buffer), so vsync 8 has the press of vsync 4. +PAD2_PRESS = [{"type": "wait_frames", "frames": 3}, {"type": "press", "buttons": ["CROSS"], "frames": 4, "release": 2}] +PAD2 = { + "port1": {"pad2": True, "steps": [dict(PAD2_PRESS[0]), dict(PAD2_PRESS[1], port=1)]}, + "port0": {"steps": PAD2_PRESS}, + "none": {"steps": [{"type": "wait_frames", "frames": 9}]}, +} +PAD2_LOG = ["I 1 port 1 buttons 0x0000", "I 4 port 1 buttons 0x4000", "I 8 port 1 buttons 0x0000"] +PAD2_BAD = { + "port1_without_pad2": ({"steps": [{"type": "press", "port": 1, "buttons": ["CROSS"]}]}, "`port` is 0, or 1"), + "pad2_not_bool": ({"pad2": 1, "steps": []}, "`pad2`: true or false"), +} + + +def pad2_check(runner, exe, build, env): + """PAD2: port 1 from a script, traced and seen by the game; the refusals.""" + ok, shots = True, {} + for name, script in PAD2.items(): + path, log, rec, shot = (build / f"pad2_{name}{ext}" for ext in (".json", ".log", ".record.json", ".ppm")) + path.write_text(json.dumps(dict(script, name=f"pad2_{name}", max_frames=100))) + shot.unlink(missing_ok=True) + r = subprocess.run([*runner, str(exe), "--script", str(path), "--log", str(log), "--record", str(rec), + "--screenshot", f"8:{shot}"], capture_output=True, text=True, env=env, timeout=600) + good = r.returncode == 0 and shot.exists() + shots[name] = shot.read_bytes() if shot.exists() else None + if name == "port1" and good: + lines = log.read_text().splitlines() + entries = [e for e in json.loads(rec.read_text())["inputs"] if e.get("port") == 1] + good = all(l in lines for l in PAD2_LOG) and [e["frame"] for e in entries] == [1, 4, 8] + print(("ok " if good else "FAIL ") + f"script pad2_{name}: status {r.returncode}" + + ("; port 1 in the log and the record" if name == "port1" else "")) + ok &= good + differ = shots["port1"] not in (shots["port0"], shots["none"]) + print(("ok " if differ else "FAIL ") + "the press on port 1 reaches the game (vsync 8: another picture than " + "port 0's and none's)") + ok &= differ + for name, (script, msg) in PAD2_BAD.items(): + path = build / f"pad2_{name}.json" + path.write_text(json.dumps(dict(script, name=name))) + r = subprocess.run([*runner, str(exe), "--script", str(path), "--crash-dir", str(build / f"pad2_{name}_crash")], + capture_output=True, text=True, env=env, timeout=600) + good = r.returncode == 1 and msg in r.stderr + print(("ok " if good else "FAIL ") + f"script {name}: refused (status {r.returncode})") + ok &= good + return ok + + def script_check(runner, exe, build, env): """write_mem against wait_mem in the same frame; an unmapped address is status 1.""" ok = True @@ -276,6 +327,7 @@ def main(): else: print(f"skip the hardware renderer's picture: {device}") ok &= script_check(runner, exe, build, env) + ok &= pad2_check(runner, exe, build, env) print("hello: " + ("pass" if ok else "FAIL")) return 0 if ok else 1 diff --git a/tools/mcp/README.md b/tools/mcp/README.md index 6e09d2e..d949257 100644 --- a/tools/mcp/README.md +++ b/tools/mcp/README.md @@ -26,9 +26,9 @@ symbol files under `fixtures/`; `_MCP_GAME` in the environment (`PSXSTAC |---|---| | `game_start(window, disc, cd_speed, pace, memcard1, memcard2, config, mods, extra_args, build, hold)` | Start the port with `--debug` and connect (stops a running one first). Headless and unthrottled by default; `hold=True` starts it paused at frame 1 (`--debug-hold`: reproducible frames and hashes); `window=True` uses the SDL binary when present and paces at 50; `build=True` runs CMake first. | | `game_attach(socket_path)` | A game the user started with `--debug SOCKET`. | -| `game_stop()`, `game_status()`, `game_log(lines)` | Quit (kill after 3 s); frame/stage/map/paused/pad owner; the game's stderr tail. | +| `game_stop()`, `game_status()`, `game_log(lines)` | Quit (kill after 3 s); frame/stage/map/paused/pad owner (and the second pad's: `pad2_owner`, `pad2_buttons`, `pad2_connected`); the game's stderr tail. | | `game_pause()`, `game_resume()`, `game_step(frames)`, `game_reset()`, `game_pace(fps)` | Execution control; `step` runs exactly N vsyncs then pauses. | -| `pad_press(buttons, frames, release)`, `pad_hold(buttons)`, `pad_release()`, `pad_free()` | The pad (names: SELECT L3 R3 START UP RIGHT DOWN LEFT L2 R2 L1 R1 TRIANGLE CIRCLE CROSS SQUARE). The channel owns the pad from the first call until `pad_free`. `pad_press` returns after the frames ran. | +| `pad_press(buttons, frames, release, port)`, `pad_hold(buttons, port)`, `pad_release(port)`, `pad_free(port)` | The pad (names: SELECT L3 R3 START UP RIGHT DOWN LEFT L2 R2 L1 R1 TRIANGLE CIRCLE CROSS SQUARE). `port` 0 (the default) or 1, the second pad: player 2, connected while the channel owns it. The channel owns a port from its first call until `pad_free` (without a port: every port it owns); the window keeps the other. `pad_press` returns after the frames ran. | | `mem_read(target, size, count, signed, fmt)`, `mem_write(target, values, size)` | Memory by target (below); `fmt` int/hex/str; `values` a list of ints or a hex string. | | `symbol_lookup(name)`, `symbol_search(pattern, limit)` | Host address and size (`nm` on the ELF, cached by mtime), PS1 address/size/type (the game's EXE and overlay symbol files). | | `wait_until(target, value, size, signed, timeout_frames)`, `wait_stage(stage, timeout_frames)`, `wait_map(map, timeout_frames)` | Run until a read, the stage (overlay slot, as the pad scripts' `wait_stage`) or the map matches, or the timeout; leaves the game paused; returns `{frame, hit}`. | diff --git a/tools/mcp/fake_game.py b/tools/mcp/fake_game.py index 938e25a..5476760 100644 --- a/tools/mcp/fake_game.py +++ b/tools/mcp/fake_game.py @@ -28,10 +28,8 @@ def __init__(self): self.pace = 0 self.arena = bytearray(ARENA_SIZE) self.state_word = bytearray(4) - self.pad_owner = "none" - self.pad = 0 - self.pad_frames = 0 - self.pad_release = 0 + # per port (0, and 1: the second pad): owner, buttons, frames left held, frames left released + self.pads = [{"owner": "none", "buttons": 0, "frames": 0, "release": 0} for _ in range(2)] self.want_reset = False self.want_load = None self.want_quit = None @@ -46,13 +44,14 @@ def map(self): def tick(self): self.frame += 1 - if self.pad_owner == "debug": - if self.pad_frames > 0: - self.pad_frames -= 1 - if self.pad_frames == 0: - self.pad = 0 - elif self.pad_release > 0: - self.pad_release -= 1 + for p in self.pads: + if p["owner"] == "debug": + if p["frames"] > 0: + p["frames"] -= 1 + if p["frames"] == 0: + p["buttons"] = 0 + elif p["release"] > 0: + p["release"] -= 1 def read(self, ps1, addr, n): if ps1: @@ -101,7 +100,9 @@ def handle(m: Model, req: dict) -> dict: if op == "status": return {"frame": m.frame, "stage": m.stage, "file": 0, "map": m.map, "slot1_word0": 0, "paused": int(m.paused), "pace": m.pace, "rate": 50, "window": 0, "script": 0, - "pad_owner": m.pad_owner, "pad": m.pad} + "pad_owner": m.pads[0]["owner"], "pad": m.pads[0]["buttons"], + "pad2_owner": m.pads[1]["owner"], "pad2": m.pads[1]["buttons"], + "pad2_connected": int(m.pads[1]["owner"] == "debug")} if op == "pause": m.paused = True return {"frame": m.frame} @@ -126,16 +127,19 @@ def handle(m: Model, req: dict) -> dict: m.write(op == "poke_ps1", num(req["addr"]), data) return {"len": len(data)} if op == "pad": - m.pad_owner = "debug" - m.pad = num(req["buttons"]) & 0xFFFF - m.pad_frames = num(req.get("frames", 0)) - m.pad_release = num(req.get("release", 0)) + port = num(req.get("port", 0)) + if port not in (0, 1): + raise ValueError("port: 0 or 1") + p = m.pads[port] + p.update(owner="debug", buttons=num(req["buttons"]) & 0xFFFF, frames=num(req.get("frames", 0)), + release=num(req.get("release", 0))) if req.get("sync"): - for _ in range(m.pad_frames + m.pad_release): + for _ in range(p["frames"] + p["release"]): m.tick() return {"frame": m.frame} if op == "pad_free": - m.pad_owner, m.pad = "none", 0 + for port in ((0, 1) if "port" not in req else (num(req["port"]),)): + m.pads[port].update(owner="none", buttons=0) return {} if op == "wait": value = num(req["value"]) diff --git a/tools/mcp/game.py b/tools/mcp/game.py index 950daed..21c1da4 100644 --- a/tools/mcp/game.py +++ b/tools/mcp/game.py @@ -288,14 +288,16 @@ def poke_ps1(self, addr: int, data: bytes) -> int: return self.request("poke_ps1", addr=int(addr), data=bytes(data).hex())["len"] def pad(self, buttons, frames: int = 0, release: int = 0, sync: bool = False, - timeout: float | None = None) -> int: - """Own the pad and press `buttons` (a mask or names) for `frames` vsyncs (0 = until the next pad op), then - `release` frames of nothing. With sync the call returns after frames + release vsyncs (deferred).""" + timeout: float | None = None, port: int = 0) -> int: + """Own the pad on `port` (0, or 1: the second pad, connected while owned) and press `buttons` (a mask or + names) for `frames` vsyncs (0 = until the next pad op), then `release` frames of nothing. With sync the call + returns after frames + release vsyncs (deferred).""" return self.request("pad", timeout_s=timeout if sync else 10.0, buttons=button_mask(buttons), - frames=int(frames), release=int(release), sync=bool(sync))["frame"] + frames=int(frames), release=int(release), sync=bool(sync), port=int(port))["frame"] - def pad_free(self) -> dict: - return self.request("pad_free") + def pad_free(self, port: int | None = None) -> dict: + """Give `port` back to the window (or to nobody), or every port the channel owns.""" + return self.request("pad_free") if port is None else self.request("pad_free", port=int(port)) def wait(self, addr: int | None = None, ps1: int | None = None, ps1_stage: int | None = None, ps1_map: int | None = None, value: int | None = None, size: int = 4, signed: bool = False, diff --git a/tools/mcp/selftest.py b/tools/mcp/selftest.py index 325d906..365cb30 100644 --- a/tools/mcp/selftest.py +++ b/tools/mcp/selftest.py @@ -55,6 +55,17 @@ def test_client(): check(g.status()["pad_owner"] == "debug", "pad owner is debug") g.pad_free() check(g.status()["pad_owner"] == "none", "pad_free") + f = g.pad(["CROSS"], frames=1, release=1, sync=True, port=1) + st = g.status() + check(f == 12 and st["pad2_owner"] == "debug" and st["pad2_connected"] == 1 and st["pad_owner"] == "none", + f"pad on port 1: frame {f}, {st}") + g.pad(["START"], port=0) + g.pad_free(port=1) + st = g.status() + check(st["pad2_owner"] == "none" and st["pad2_connected"] == 0 and st["pad_owner"] == "debug", + f"pad_free port 1 keeps port 0: {st}") + g.pad_free() + check(g.status()["pad_owner"] == "none", "pad_free frees every port") r = g.wait(ps1_stage=3, timeout=100) check(r["hit"] == 1 and r["frame"] == 30, f"wait ps1_stage 3 -> {r}") r = g.wait(ps1_map=0xE02, timeout=100) @@ -156,6 +167,17 @@ def test_server(): check(server.pad_hold(["cross", "UP"])["mask"] == (1 << 14) | (1 << 4), "pad_hold") check(server.pad_release()["frame"] == 7, "pad_release") check(server.pad_free()["pad_owner"] == "freed", "pad_free") + r = server.pad_press(["circle"], 1, 1, port=1) + st = server.game_status() + check(r["port"] == 1 and st["pad2_owner"] == "debug" and st["pad2_buttons"] == [], f"pad_press port 1 {r} {st}") + check(server.pad_hold(["l1"], port=1)["mask"] == 1 << 10 and server.game_status()["pad2_buttons"] == ["L1"], + "pad_hold port 1") + check(server.pad_free(port=1)["port"] == 1 and server.game_status()["pad2_owner"] == "none", "pad_free port 1") + try: + server.pad_press(["START"], port=2) + check(False, "port 2 is a tool error") + except ToolError as e: + check("port 2" in str(e), f"port 2 -> ToolError({e})") r = server.mem_write("ps1:0x80082CC0", [1, -2], 4) check(r["written"] == 8, f"mem_write ints {r}") r = server.mem_read("ps1:0x80082CC0", 4, 2, signed=True) diff --git a/tools/mcp/server.py b/tools/mcp/server.py index 5fa597d..3a90549 100644 --- a/tools/mcp/server.py +++ b/tools/mcp/server.py @@ -167,6 +167,7 @@ def _status_dict(g: Game) -> dict: d = {"running": True, "pid": g.pid, "socket": g.socket_path, "command": g.command} d.update(_call(g.status)) d["pad_buttons"] = button_names(int(d.get("pad", 0))) + d["pad2_buttons"] = button_names(int(d.get("pad2", 0))) return d @@ -252,7 +253,8 @@ def game_stop() -> dict: @mcp.tool() def game_status() -> dict: """The game's status: frame, stage (overlay slot index), file (overlay file id), map, paused, pace, rate, window, - script, pad_owner (debug/script/window/none), pad mask and names; running=false when no game is up.""" + script, pad_owner (debug/script/window/none), pad mask and names, the same as pad2_* for the second pad (and + pad2_connected); running=false when no game is up.""" if _game is None: return {"running": False} if not _game.alive: @@ -319,43 +321,53 @@ def game_pace(fps: int) -> dict: # ---- the pad ---- +def _port(port: int) -> int: + if port not in (0, 1): + raise ToolError(f"port {port}: 0 (the first pad) or 1 (the second)") + return port + + @mcp.tool() -def pad_press(buttons: list[str], frames: int = 2, release: int = 2) -> dict: +def pad_press(buttons: list[str], frames: int = 2, release: int = 2, port: int = 0) -> dict: """Press buttons for `frames` vsyncs then release for `release` vsyncs; returns after they ran. Names: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT, L2, R2, L1, R1, TRIANGLE, CIRCLE, CROSS, SQUARE (any case; several at - once). The channel owns the pad from the first pad_* call until pad_free. A menu needs ~2 frames pressed; the - opening movie's skip needs a long hold (30/30).""" + once). `port` 1 is the second pad (player 2; connected while the channel owns it). The channel owns a port from + its first pad_* call until pad_free. A menu needs ~2 frames pressed; the opening movie's skip needs a long hold + (30/30).""" g = _require() try: mask = button_mask(buttons) except ValueError as e: raise ToolError(str(e)) from e - frame = _call(g.pad, mask, frames, release, True) - return {"frame": frame, "buttons": button_names(mask), "mask": mask} + frame = _call(g.pad, mask, frames, release, True, port=_port(port)) + return {"frame": frame, "buttons": button_names(mask), "mask": mask, "port": port} @mcp.tool() -def pad_hold(buttons: list[str]) -> dict: - """Hold buttons down until the next pad_* call (returns at once; use game_step / wait_* to run frames).""" +def pad_hold(buttons: list[str], port: int = 0) -> dict: + """Hold buttons down on `port` (0, or 1: the second pad) until the next pad_* call on it (returns at once; use + game_step / wait_* to run frames).""" g = _require() try: mask = button_mask(buttons) except ValueError as e: raise ToolError(str(e)) from e - return {"frame": _call(g.pad, mask, 0, 0, False), "buttons": button_names(mask), "mask": mask} + return {"frame": _call(g.pad, mask, 0, 0, False, port=_port(port)), "buttons": button_names(mask), "mask": mask, + "port": port} @mcp.tool() -def pad_release() -> dict: - """Release every button (the channel keeps owning the pad).""" - return {"frame": _call(_require().pad, 0, 0, 0, False)} +def pad_release(port: int = 0) -> dict: + """Release every button on `port` (the channel keeps owning it).""" + return {"frame": _call(_require().pad, 0, 0, 0, False, port=_port(port)), "port": port} @mcp.tool() -def pad_free() -> dict: - """Give the pad back to the window (or to nobody).""" - _call(_require().pad_free) - return {"pad_owner": "freed"} +def pad_free(port: int | None = None) -> dict: + """Give `port` back to the window (or to nobody: the second pad then has nothing in it), or every port the + channel owns when none is given.""" + _call(_require().pad_free, None if port is None else _port(port)) + return {"pad_owner": "freed", "port": "all" if port is None else port} # ---- memory ---- diff --git a/tools/replay/run.lua b/tools/replay/run.lua index d744e81..b4a34b9 100644 --- a/tools/replay/run.lua +++ b/tools/replay/run.lua @@ -21,7 +21,8 @@ -- Every GPU vsync (one game frame) this script: -- 1. records transitions of (stage, file) and of the map, with the frame number; -- 2. applies the pad buttons the current step holds (SIO0 slot 1 pad 1 override; a pressed button is a cleared --- override bit, PCSX.CONSTS.PAD.BUTTON gives the bit numbers, the PS1 pad's own); +-- override bit, PCSX.CONSTS.PAD.BUTTON gives the bit numbers, the PS1 pad's own); with the script's `pad2`, a +-- second pad is connected in slot 2 (PCSX-Redux's default has none) and a press with `port = 1` holds there; -- 3. advances the step list: press (hold buttons for N frames, then release for `release` frames; optionally -- `repeat`ed or repeated `until` a wait condition holds), wait_frames, wait_stage / wait_map / wait_mem (with a -- timeout), walk (hold the d-pad toward a field position), reset (reboot the console), write_mem (write the @@ -65,6 +66,10 @@ if speed then PCSX.settings.spu.Speed = speed end print('replay: spu.Speed = ' .. tostring(PCSX.settings.spu.Speed)) local pad = PCSX.SIO0.slots[1].pads[1] +-- The second pad (runtime/script.c's port 1): connected only with the script's `pad2` (set here, so the user's +-- PCSX-Redux configuration never decides it), before the game's first poll. +PCSX.settings.pads[2].Connected = script.pad2 == true +local pad2 = script.pad2 == true and PCSX.SIO0.slots[2].pads[1] or nil -- Buttons are the controller's physical buttons (PCSX.CONSTS.PAD.BUTTON, the PS1 pad's bit numbers: 0 SELECT, 3 START, -- 4 UP, 5 RIGHT, 6 DOWN, 7 LEFT, 8 L2, 9 R2, 10 L1, 11 R1, 12 TRIANGLE, 13 CIRCLE, 14 CROSS, 15 SQUARE). A game may -- remap them itself (the first game rotates the face buttons for non-Japanese languages): a script names the @@ -77,6 +82,7 @@ local step_index = 1 local step_started = nil -- frame the current step started local step_phase = nil -- for press: 'hold' or 'release' local held = {} -- button name -> true, applied each frame +local held2 = {} -- the same on the second pad (port 1) local result = { script = script.name or '?', frames = 0, @@ -84,10 +90,12 @@ local result = { checkpoints = {}, overlay_sequence = {}, -- { frame, stage, file } at every change of (stage, file) map_sequence = {}, -- { frame, map } at every change of the map - inputs = {}, -- { frame, buttons } at every change of the held set (the per-frame pad trace) + inputs = {}, -- { frame, buttons } at every change of the held set (the per-frame pad trace); the + -- second pad's { frame, port = 1, buttons } from its first frame on } local last_stage, last_file, last_map = nil, nil, nil local last_input_key = '' +local last_input_key2 = nil -- nil: the second pad's first frame is recorded (connected, as the port's record) -- Minimal JSON writer for the result (numbers, strings, booleans, arrays, string-keyed tables). local function is_array(t) @@ -180,18 +188,30 @@ local function write_mem(step) end end -local function apply_pad() +-- One pad's overrides from a held set; its sorted names and their key (the record's change detection). +local function apply_held(p, h) for name, bitnum in pairs(BUTTON) do - if held[name] then pad.setOverride(bitnum) else pad.clearOverride(bitnum) end + if h[name] then p.setOverride(bitnum) else p.clearOverride(bitnum) end end local names = {} - for name in pairs(held) do names[#names + 1] = name end + for name in pairs(h) do names[#names + 1] = name end table.sort(names) - local key = table.concat(names, '+') + return names, table.concat(names, '+') +end + +local function apply_pad() + local names, key = apply_held(pad, held) if key ~= last_input_key then result.inputs[#result.inputs + 1] = { frame = frame, buttons = names } last_input_key = key end + if pad2 then + names, key = apply_held(pad2, held2) + if key ~= last_input_key2 then + result.inputs[#result.inputs + 1] = { frame = frame, port = 1, buttons = names } + last_input_key2 = key + end + end end local function checkpoint(step) @@ -248,7 +268,7 @@ local function run_step(step) elseif t == 'reset' then -- Reboot the console (PCSX-Redux hardResetEmulator: RAM cleared, the memory cards stay): back to the BIOS and -- the title, e.g. to load a save made earlier in the same script. The frame count goes on. - held = {} + held, held2 = {}, {} PCSX.hardResetEmulator() print('replay: reset ' .. state_string()) return true, false @@ -256,7 +276,11 @@ local function run_step(step) -- One press is `frames` held + `release` released. Optional: `repeat` N presses in a row; `until` a wait -- condition ({type = 'wait_stage' | 'wait_map' | 'wait_mem', ...}) checked every frame, which releases the -- buttons and ends the step on the frame it holds (a mash through dialogue of unknown length, or a tap until - -- the game reacts; fails after `timeout` frames). With `until`, `repeat` is a cap, not a count. + -- the game reacts; fails after `timeout` frames). With `until`, `repeat` is a cap, not a count. `port = 1`: + -- on the second pad (the script's `pad2`); the other pad is left as it is. + local port = step.port or 0 + assert(port == 0 or (port == 1 and pad2), 'press: port is 0, or 1 with the script\'s pad2') + local function set_held(t) if port == 1 then held2 = t else held = t end end local hold = step.frames or 2 local release = step.release or 2 local cycle = hold + release @@ -264,7 +288,7 @@ local function run_step(step) local phase = elapsed % cycle local count = math.floor(elapsed / cycle) if step['until'] then - if condition_met(step['until']) then held = {}; return true, true end + if condition_met(step['until']) then set_held({}); return true, true end if phase == 0 and elapsed >= (step.timeout or default_timeout) then fail(string.format('step %d (press until %s) timed out after %d frames', step_index, step['until'].type, elapsed)) @@ -272,14 +296,15 @@ local function run_step(step) end if phase < hold then if verbose and phase == hold - 1 then print('replay: pressing ' .. state_string()) end - held = {} + local h = {} for _, b in ipairs(step.buttons) do assert(BUTTON[b], 'unknown button ' .. tostring(b)) - held[b] = true + h[b] = true end + set_held(h) return false end - held = {} + set_held({}) if step['until'] and not step['repeat'] then return false end return phase == cycle - 1 and count >= (step['repeat'] or 1) - 1, false elseif t == 'walk' then