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