Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,14 @@ every change waits for Steam to be closed; the launcher asks, it never stops Ste
launcher (the AppImage path the self-update keeps), so an update needs no change in Steam. The artwork is the game's
choice (game.json `launcher.steam_art`, optional): embedded in the launcher at build time, so a release stays the
launcher and the game with nothing beside them, and written as the files Steam's own grid folder holds per appid.

## Player 2: a gamepad by connection order, off by default, never a hotkey (2026-10-24)

A second pad (issue #101) is one gamepad chosen by `input.player2` (`"first"` or `"second"`, by SDL's instance ids,
which grow with each connection), not by SDL's player index: Linux's evdev and uinput devices, the virtual pads a
streaming host (Sunshine) creates among them, usually have none. The default stays `"off"`, so a one-pad setup and
every existing record are unchanged and port 1 is connected only while player 2's gamepad is there. Player 2 shares
the gamepad map and never reaches a hotkey: the host's player owns pause, the menu and the save states. Whoever owns
the pad (the window, a script) sets both ports every vsync, since the shim's "connected" is part of a save state. The
runtime's players are arrays of `PORT_PADS` and the setting is `player<n>`, so a multitap can add players without a
new format.
9 changes: 7 additions & 2 deletions docs/LAUNCHER.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ and absolute paths and exits 0, or exits 64 naming the bad key.

### Input bindings

`input` has three members; `--print-settings` prints it back as it was given.
`input` has four members; `--print-settings` prints it back as it was given.

- **`keyboard`**: PS1 button -> one key name or a list of them. **`gamepad`**: PS1 button -> one gamepad input name
or a list. A button named replaces all of that button's entries of that kind; a button that is absent keeps its
Expand All @@ -126,7 +126,12 @@ and absolute paths and exits 0, or exits 64 naming the bad key.
- **Gamepad input names are ours** (SDL's positional buttons): `south east west north back guide start leftstick
rightstick leftshoulder rightshoulder dpup dpdown dpleft dpright misc1 paddle1 paddle2 paddle3 paddle4 touchpad`,
and the axes past half way: `lefttrigger righttrigger leftx- leftx+ lefty- lefty+ rightx- rightx+ righty- righty+`
(`lefty-` is the left stick up). Every connected gamepad is ORed into pad 1.
(`lefty-` is the left stick up). Every connected gamepad is ORed into pad 1, unless it is player 2's.
- **`player2`**: `"off"` (the default), `"first"` or `"second"`: the gamepad, by connection order, that is player 2.
It drives pad 2 (the PS1's port 1) with the `gamepad` map; port 1 has a pad in it only while that gamepad is there
(a game that reads port 0 alone is unaffected); the keyboard and every other gamepad stay player 1's, and player 2's inputs never
fire a hotkey. `"first"` suits a player 1 on the keyboard. A gamepad unplugged and plugged in again counts as the
newest. Anything else ends the game with status 64.
- **Default map** (the runtime's `input.c`): arrows the D-pad, `X` cross, `C` circle, `Z` square, `S` triangle,
`Return` and `Keypad Enter` START, `Backspace` and `Right Shift` SELECT, `Q`/`E` L1/R1, `1`/`3` L2/R2; gamepads:
south cross, east circle, west square, north triangle, back SELECT, start START, the shoulders L1/R1, the triggers
Expand Down
12 changes: 7 additions & 5 deletions docs/PORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ bare binary, the replays and the goldens are unchanged. The ops:
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.
a script keeps precedence; the window keeps pad 2.
- `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
Expand Down Expand Up @@ -343,7 +343,7 @@ and the shim lacks stops the run with `port_unimplemented(name)` (status 3) and
| LIBPRESS | MDEC movie decoding (`libpress.c`, `mdec.c`) |
| LIBSND | The sequencer and voices over the SPU core (`libsnd*.c`; `docs/SOUND.md` in the first game): SEPs and SEQs, ticked by the game (`SS_NOTICK`) or by `SsStart` once per vsync |
| LIBSPU | The voice and common attributes and the reverb work area a game sets itself (`libspu.c`) |
| LIBPAD | A digital pad on port 0; actuator calls accepted and ignored |
| LIBPAD | A digital pad on port 0, and on port 1 while the runtime connects one (`input.player2`); actuator calls accepted and ignored |
| LIBMCRD | Real, over `.mcd` images (`libmcrd.c`), whose card store LIBCARD shares |
| LIBAPI | Events, root counter 3 (the vblank: its event's handler runs inside the vsync tick and may preempt a fiber), critical sections (`libapi.c`); the BIOS's file calls on the memory cards (`bu00:`, `bu10:`) with LIBCARD (`libcard.c`). `open`, `read`, `write`, `lseek`, `close` and `EnterCriticalSection`/`ExitCriticalSection` are also the host's names: the game's units get `include/psxstack/psyq_names.h` forced in, which renames them to the shim's `psyq_api_*` (and LIBC2's `rand`/`srand` to `psyq_c2_*`), so the runtime and libc keep the host's |
| LIBCARD | `InitCARD`, `StartCARD`, `_bu_init`, `_card_info`/`_load`/`_clear`/`_format`: real over the `.mcd` images, completing on the vsync tick with SwCARD/HwCARD events (`libcard.c`) |
Expand Down Expand Up @@ -498,7 +498,9 @@ same with it on or off.

## Input
- **Window input** (`runtime/input.c`): the keyboard and every gamepad SDL sees are ORed into pad 1, a digital pad,
once per vsync. Bindings are by key position and SDL's positional (PlayStation-layout) gamepad buttons, rebindable
once per vsync; with `input.player2` one gamepad (by connection order) drives pad 2 instead, port 1 connected while
it is there, and never a hotkey. Whoever owns the pad sets both ports every vsync (a script leaves port 1 empty), so
a save state never carries a second pad into a run that has none. Bindings are by key position and SDL's positional (PlayStation-layout) gamepad buttons, rebindable
in the settings file; hotkeys (fullscreen, pause, the quick state's save and load, the in-game menu, the mods')
never reach the pad. The mapping and defaults are in `docs/RUNTIME.md` "The window".
- **The in-game menu** (`include/psxstack/menu.h`, `launcher/ui/menu.cpp`; `docs/LAUNCHER.md` "In-game menu"): the
Expand Down Expand Up @@ -581,8 +583,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:** one digital pad on port 0; no analog mode, no rumble (`PadSetAct` is accepted and ignored), no second
port or multitap.
- **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.
- **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.
Expand Down
16 changes: 11 additions & 5 deletions docs/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,21 +133,25 @@ time between vsyncs changes: the log and the record of a window run are the head
window nothing is paced.

**Input** (`runtime/input.c`): the keyboard and every gamepad SDL sees are ORed into pad 1, a digital pad (`psyq_pad_set`),
once per vsync. Keys (by position): arrows the D-pad; X cross, C circle, Z square, S triangle; Enter (or keypad Enter)
once per vsync. With `input.player2` (`"first"` or `"second"`: that gamepad by connection order; docs/LAUNCHER.md
"Input bindings") one gamepad is player 2 instead: it drives pad 2 (port 1) with the same gamepad map, port 1 is
connected while it is there, and it never fires a hotkey (player 2's Back+Start reaches the game). The default, `"off"`,
leaves port 1 empty, as a one-pad console. Keys (by position): arrows the D-pad; X cross, C circle, Z square, S triangle; Enter (or keypad Enter)
START; Backspace (or right Shift) SELECT; Q L1, E R1, 1 L2, 3 R2; F11 fullscreen, P pause. Gamepads (SDL's positional
buttons, the PlayStation layout): south cross, east circle, west square, north triangle, back SELECT, start START,
shoulders L1/R1, triggers L2/R2, the D-pad and the left stick the D-pad. All of it is rebindable in the settings
(`input.keyboard`, `input.gamepad`, `input.hotkeys`; docs/LAUNCHER.md "Input bindings"), and the mods add hotkeys. A
hotkey never reaches the pad: a completed trigger masks its inputs until they are all released. **The pause** stops the
game between two vsyncs (the window keeps presenting the last image, the audio device pauses) until the key again.
**The quick state**: F5 saves the whole game to the quick slot, F9 goes on from it ("Save states" below). Every change of the pad goes to the log's `I` lines and the
**The quick state**: F5 saves the whole game to the quick slot, F9 goes on from it ("Save states" below). Every change of a pad goes to the log's `I` lines and the
record's `inputs`, as a script's do. With `--script` the script owns the pad: the window's input never reaches it.
Closing the window (or SIGINT/SIGTERM, which SDL turns into a quit) ends the run: `port_exit(0, "window closed")`, the
log and the record written. `--input-test` checks the path: it injects every key of the map (`SDL_PushEvent`), then
attaches a virtual SDL gamepad and presses each of its buttons, triggers and stick directions, and a chord, one per
frame with its release in the next, then every hotkey trigger, and requires the buttons sent to `psyq_pad_set` to be the
map's (none for a hotkey's, which must fire; with `--script`: none sent, and the script's log stays byte-identical to a
run without the test), then (without a script) the pause's round trip, the quick slot's, and the in-game menu's
run without the test), then the players: a second virtual gamepad, a gamepad input on each and on both, the gamepad-only
hotkey triggers on the second, then each unplugged, with both ports as `input.player2` says after every poll, then (without a script) the pause's round trip, the quick slot's, and the in-game menu's
(a build with `PSXSTACK_MENU`; docs/LAUNCHER.md "In-game menu"); exit 0 when all the checks pass, 6 otherwise. With `--config` it tests the settings' map. The first game's CI runs it on SDL's offscreen driver, with the defaults and with a rebound settings file.

**The mods** (`runtime/mods.c`; docs/LAUNCHER.md "Mod manifest", "Mod runtime"): the registry (the stack's
Expand Down Expand Up @@ -394,15 +398,17 @@ far (an event between two ticks carries the last tick's number, as an emulator's
| `M <frame> map 0x<map>` | A map-sequence entry: the map changed (frame 1 always) |
| `L <frame> tier <t> file 0x<id> <name> word0 0x<8 hex> size 0x<n>` | A file copied into a slot (`port_overlay_load`): the overlay's name, or `(data)`; its first word; its size |
| `C <frame> <name> stage <stage> map 0x<map> rnd <index> sha1 <40 hex> stable <40 hex>` | A checkpoint; `... rnd <index> image false` for a checkpoint step with `"image": false` (no hashes) |
| `I <frame> buttons 0x<4 hex>` | The script's pad changed (`port_framelog_input`; PS1 bit order, active high) |
| `I <frame> buttons 0x<4 hex>` | Pad 1 changed (the script's, the window's or the debug channel's: `port_framelog_input`; PS1 bit order, active high) |
| `I <frame> port 1 buttons 0x<4 hex>`, `I <frame> port 1 none` | Pad 2 changed, or was unplugged (`none`); a run that never connects one has no such line |
| `R <frame> reset` | The console's reset (the script's `reset` step): the next frame runs the game from `main()` again |
| `X <frame> status <status> <reason>` | The exit (`port_exit`) |

## The record (`--record FILE`)
JSON written at exit, with the keys of the first game's emulator records (its `replay.py` reads it as it is):
`runner` (`"port"`), `status` (the exit status), `reason`, `frames`, `checkpoints` (`name`, `frame`, `stage`, `map`,
`random_index`, `gamestate_sha1`, `gamestate_sha1_stable`, as the emulator runner records them; a checkpoint whose script step says `"image": false` has `"image": false` instead of the two hashes, and a record without `image` means true), `overlay_sequence`
(`{frame, stage, file}`), `map_sequence` (`{frame, map}`), and `inputs` (`{frame, buttons: [names]}`, the names sorted) once the script has called `port_framelog_input`. The sequences follow the emulator runner's vsync
(`{frame, stage, file}`), `map_sequence` (`{frame, map}`), and `inputs` (`{frame, buttons: [names]}`, the names sorted) once the script has called `port_framelog_input`; pad 2's
entries add `"port": 1`, and `"connected": false` (with no buttons) when it was unplugged. The sequences follow the emulator runner's vsync
listener: an entry at every change, the first frame always (`{1, 0, 0}`, map 0).

## The replay runners (`tools/replay/`)
Expand Down
6 changes: 4 additions & 2 deletions examples/hello/hello.c
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* examples/hello: a disc-free Psy-Q program on psxstack, the stack's own smoke test (tests/hello_test.py). It needs
* no game and no disc: the stack's own Psy-Q declarations (include/psxstack/psyq/), a common.h that provides the
* type names and the host hooks, and this file. The build renames main() to game_main (the runtime
* owns main). It draws two polygons that change with the frame and the pad, double-buffered as a PS1 program would,
* owns main). It draws two polygons that change with the frame and the pads (the square with pad 1, the gradient with
* pad 2 while one is connected: byte 0 of its buffer 0), double-buffered as a PS1 program would,
* initialises the sound library and sets the master volume (a key-on needs a VAB, which the example has none of). */
#include "common.h"
#include "psxstack/psyq/libetc.h"
Expand Down Expand Up @@ -37,6 +38,7 @@ int main(void) {
for (;;) {
u32 *ot = hello_ot[db];
u16 buttons = (u16)~(((u16)hello_pad0[2] << 8) | hello_pad0[3]); /* active high: a pressed button tints */
u16 buttons2 = hello_pad1[0] == 0 ? (u16)~(((u16)hello_pad1[2] << 8) | hello_pad1[3]) : 0;
POLY_F4 *q = &hello_quad[db];
POLY_G4 *g = &hello_grad[db];

Expand All @@ -48,7 +50,7 @@ int main(void) {
setPolyG4(g);
setRGB0(g, 255, 0, 0);
setRGB1(g, 0, 255, 0);
setRGB2(g, 0, 0, 255);
setRGB2(g, (u8)(buttons2 & 0xFF), (u8)(buttons2 >> 8), 255);
setRGB3(g, (u8)(frame * 5), 255, 255);
setXY4(g, 190, 50, 300, 70, 180, 180, 290, 200);
addPrim(ot + 2, g);
Expand Down
12 changes: 8 additions & 4 deletions include/psxstack/port_runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ void port_state_sha1(char full[41], char stable[41]);
/* ---- The per-frame log's events (framelog.c), from the runtime and the script */
/* A file copied into a slot (port_overlay_load): `name` is the overlay's, or NULL for a data file. */
void port_framelog_overlay_load(int tier, s32 file, const char *name, u32 word0, u32 size);
/* The pad the script holds this frame (psyq_pad_set's bits, active high): a change goes to the log and to the record's
* `inputs` ({frame, buttons: [names]}, as run.lua's apply_pad); the record has `inputs` once this has been called. */
void port_framelog_input(u16 buttons);
u16 port_framelog_last_input(void); /* the buttons last given (0 before any): the debug channel's status */
/* The pads the runtime drives: port 0 and port 1 (psyq_pad_set's ports; a multitap would add more). */
#define PORT_PADS 2
/* A port's pad this frame, as given to psyq_pad_set (`connected`, the buttons: active high; port 0 is always
* connected): a change goes to the log and to the record's `inputs` ({frame, buttons: [names]}, as run.lua's
* apply_pad; port 1's entries say "port": 1, and "connected": false while nothing is there); the record has `inputs`
* once this has been called. Port 1 starts unconnected, so a run that never connects it logs nothing for it. */
void port_framelog_input(int port, int connected, u16 buttons);
u16 port_framelog_last_input(int port); /* the buttons last given (0 before any): the debug channel's status */

/* ---- The interrupt pump (pump.c) */
extern long port_max_frames; /* --max-frames: port_wait() exits 0 after this many vsync ticks (0: no cap) */
Expand Down
12 changes: 8 additions & 4 deletions psyq/libpad.c
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* psyq/libpad.c: LIBPAD. One digital pad on port 0 (pad.c reads `buffers[port]`: byte 0 the status, 0 = ok,
* 0xFF = nothing connected; byte 1 the id, 0x41 = digital pad, 0x80 = multitap; bytes 2-3 the buttons, active
* low). The port's input script sets the buttons with psyq_pad_set; every vsync tick makes them "new data" for
* PadChkVsync.
/* psyq/libpad.c: LIBPAD. A digital pad on port 0, and one on port 1 while the runtime connects one there (pad.c reads
* `buffers[port]`: byte 0 the status, 0 = ok, 0xFF = nothing connected; byte 1 the id, 0x41 = digital pad, 0x80 =
* multitap; bytes 2-3 the buttons, active low). The runtime (the window's input, the script, the debug channel) sets
* the buttons with psyq_pad_set; every vsync tick makes them "new data" for PadChkVsync.
*
* Assumption to verify (M3, against the emulator): the id/mode numbers (PadInfoMode term 2 = the current mode id,
* 4 for a digital pad, 7 for a DualShock in analog mode; term 3 = the id table, 0 here), PadGetState 6 = stable,
Expand Down Expand Up @@ -58,6 +58,10 @@ u16 psyq_pad_get(int port) {
return (port == 0 || port == 1) ? psyq_pad_buttons[port] : 0;
}

int psyq_pad_present(int port) {
return (port == 0 || port == 1) && psyq_pad_connected[port];
}

/* The console's reset (psyq.c psyq_reset): no buffers, not started, the default pads (port 0 connected with nothing
* pressed, port 1 empty). */
void psyq_pad_reset(void) {
Expand Down
1 change: 1 addition & 0 deletions psyq/psyq.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ void psyq_cd_set_reader(int (*read)(unsigned lba, u8 *sector));
* `connected` 0 reports no controller. Default: port 0 connected with nothing pressed, port 1 empty. */
void psyq_pad_set(int port, int connected, u16 buttons);
u16 psyq_pad_get(int port); /* the buttons last set (0 when none, or the port is not 0 or 1): the crash report */
int psyq_pad_present(int port); /* 1 when a pad is connected there (port 0 or 1), else 0 */

/* The primitive stream recorder (LIBGPU): the FNV-1a hash of every primitive DrawOTag/ContinueDraw walked since
* the previous call (and the number of them in *count, if not NULL), then resets both. */
Expand Down
4 changes: 4 additions & 0 deletions runtime/crash.c
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ static void crash_write_context(const char *kind, int status) {
cw_dec(game_state_map());
cw_str("\npad: ");
cw_hex(psyq_pad_get(0), 4);
if (psyq_pad_present(1)) {
cw_str("\npad 2: ");
cw_hex(psyq_pad_get(1), 4);
}
cw_str("\nscript: ");
cw_str(port_script_active ? "yes" : "no");
cw_str("\n\nlog (last ");
Expand Down
Loading
Loading