Skip to content

feat(launcher): embed PS1 and PS2 on Windows and Linux - #86

Draft
B-Howell wants to merge 14 commits into
AnyTogether:mainfrom
B-Howell:codex/pr84-embedded-ps12
Draft

B-Howell wants to merge 14 commits into
AnyTogether:mainfrom
B-Howell:codex/pr84-embedded-ps12

Conversation

@B-Howell

@B-Howell B-Howell commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

What

This is the second slice of #84. It builds on #85 and adds in-window PS1 and PS2 play on Windows x64 and Linux x64. The shared Libretro worker owns input, options and saves. The Linux adapter uses EGL/OpenGL, Flutter's GTK pixel texture and PulseAudio. Windows uses D3D11 and waveOut.

Review the delta from #85 · Setup and verification guide

The launcher imports a user-supplied BIOS and verifies the selected core ZIP and library against hashes in this repo. Public one-click installs still need approved immutable hosting. The Windows and Linux PS3/Vulkan preview is in #87. macOS feature parity has its own Swift port as discussed in #84.

Tested

  • Windows Flutter suite passed 59 tests. Analyzer and translation validation passed. All four pinned ZIP and library pairs passed clean-directory installation checks.
  • CTest passed on Windows and Ubuntu 22.04 WSL. Both native Windows runner translation units and all four Linux runner translation units compiled against their Flutter platform headers.
  • On Linux, the exact pinned cores booted Crash Team Racing and Gran Turismo 4 through the shared worker and EGL adapter. Both rendered frames, generated non-silent PCM and shut down cleanly. The tests used offscreen Mesa llvmpipe with audio captured in memory.
  • Hosted Flutter bundle builds and macOS goldens for this layout still need CI. Full desktop texture presentation and speaker output remain unverified.

Current head verification

  • Windows focused tests at 7f8db1b. 44 passed, with 14 shell-script fixtures skipped on Windows. The tests include the upstream phone and desktop notice checks.
  • The required upstream CI run is waiting for maintainer approval. GitHub reports zero started jobs. macOS goldens for this slice remain open.

AI-assisted: yes

Co-authored-by: Codex noreply@openai.com

B-Howell and others added 3 commits November 8, 2026 11:26
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Codex <noreply@openai.com>
Use worker-owned Libretro disk control to swap playlist media while paused and keep the running session and memory card.

Co-authored-by: Codex <codex@openai.com>
B-Howell and others added 9 commits November 8, 2026 18:00
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
@utkuhalis

Copy link
Copy Markdown
Collaborator

This is impressive work, and the disc-swap handling is very thorough. We cannot take it in this shape though, mainly for licensing and architecture reasons:

  1. Licence. SwanStation and the libretro PS2 core are GPL-3.0, and this launcher is GPL-2.0-only. Loading them into the launcher process (native/retro/retro_library.cpp) makes one combined program, and those two licences do not combine. This becomes a real problem the moment we host the ZIPs, which one-click setup would need. A separate helper process with a compatible licence, talking to the launcher over IPC and a shared surface, might solve this. We would want to agree on that design before more code is written.
  2. Risk to the PS5 path. A crashing or hung core takes the whole launcher down. Every build now links the retro host (EGL, epoxy and PulseAudio on Linux, D3D11 and an 8 MB main-thread stack on Windows), and quitting now waits on prepareToQuit for every user (lib/main.dart:176). Out of process, none of this would touch people who never use the feature.
  3. Reachability. The installer only accepts ZIPs that match the pinned SHA-256 values, and the doc says the buildbot URLs change their contents, so users have no way to get those exact files. The messages that point to the setup guide are not actionable for them.
  4. Embedded runs report pid 0 (game_process.dart). Screenshots, Remote Play, the replay buffer, notice cards and window focus all look up the game window by pid, so they will quietly fail.
  5. Housekeeping: strings in all 28 locale files, a release note, goldens for the new layers, and the native CTest suite wired into CI.

If you are up for the out-of-process redesign, we would be happy to talk it through first.

@B-Howell

Copy link
Copy Markdown
Contributor Author

Thanks for the clear review. The licence point is right. A GPL-3.0 core cannot be loaded into a GPL-2.0-only process. I'd like to do the out-of-process redesign. Here is a proposal to agree on before I write code.

Shape

  • A separate anyps5-retro-host executable loads the libretro core. It has its own licence (GPL-3.0), its own source directory and its own CMake target. The launcher never links it or the core. It starts the host as a child process and talks to it over a local pipe on Windows and a Unix socket on Linux. The protocol is small and versioned: load game, input, pause and resume, save and load state, change disc, settings, quit. Events come back for progress, errors and exit.
  • Video: the host renders into a shared GPU surface that the launcher shows through the Flutter texture it uses today. That is a D3D11 shared texture with a keyed mutex on Windows, and a dmabuf handed over the socket on Linux, with a shared-memory frame as the fallback. Nothing from the host draws into the launcher's window.
  • Audio: the host plays its own audio.

What this fixes from your list

  1. Licence. The launcher stays GPL-2.0-only. The host plus core is a separate GPL-3.0 program that we ship with full source.
  2. PS5 path. The launcher no longer links EGL, epoxy, PulseAudio, the D3D11 retro host or the 8 MB stack. If the host crashes or hangs, the launcher sees it, kills it after a timeout and returns to the library. prepareToQuit only waits when a host is running.
  3. Reachability. The installer would take cores from a location we pin and host (with source), or a core the user selects. It would no longer expect buildbot files that change. Until hosting is decided, the setup message would say that plainly and not point to a guide users can't follow.
  4. pid 0. Embedded runs report the host's real pid. Per-process audio capture then works as it does for PS5 games. Screenshots and window focus use the launcher's own window, since that is where the game is drawn.
  5. Housekeeping. Strings in all 28 locale files, a release note, goldens for the new layers, and the host's CTest suite run in CI.

Questions before I start

  • Can the host's GPL-3.0 source live in this repository as a separate program in its own directory, or would you rather have it in its own repository?
  • Is a small JSON-over-pipe protocol acceptable for control, with video and audio kept off the pipe? Or do you have a preferred IPC library?
  • Would you rather review the host and protocol first, with the PS1/PS2 UI after, as separate PRs?

AI-assisted: yes (Claude Code)

@utkuhalis

Copy link
Copy Markdown
Collaborator

Thanks, this is the shape we were hoping for. Answers:

  1. A separate repository for the host, please (GPL-3.0 with its own CI and releases). That keeps this repository GPL-2.0-only with nothing GPL-3.0 in its tree, and the launcher only ever starts the host as a separate program. We can create it under the AnyTogether organisation once the protocol is agreed.
  2. A small versioned JSON protocol over a pipe / Unix socket is fine, with video and audio kept off it. No extra IPC library, please.
  3. Yes: host and protocol first (with the protocol written down as a short document we can review before code), then the PS1/PS2 UI as separate, smaller PRs.

One more point for the design: every launcher feature has to exist in the Mac launcher too, so please keep the protocol and the host portable (macOS on Apple silicon, Metal or MoltenVK for the shared surface), even if the first implementation is Windows and Linux only.

@B-Howell

Copy link
Copy Markdown
Contributor Author

Thanks for the answers. The protocol is written down for review. The draft repository is on our fork for now and can move under AnyTogether once you're happy with it:

https://github.com/B-Howell/anyps5-retro-host/blob/main/docs/protocol.md

In short:

  • Transport. Length-prefixed JSON (v: 1), using two anonymous pipes on Windows and a socketpair on Linux and macOS. The host's first message is a hello carrying its protocol version. No IPC library.
  • Methods. The methods are the launcher's existing anyps5/retro_core calls, forwarded unchanged, so the PS1/PS2/PS3 UI does not change.
  • Video. Video uses a shared-memory ring of three RGBA slots that neither side waits on. Windows uses a pagefile mapping, Linux memfd, and macOS shm_open. A zero-copy path (D3D11 shared NT handle, dmabuf, IOSurface) is reserved as a later ring kind.
  • Audio. The host plays its own audio, and the launcher reports the host's pid as the game's.
  • Lifetime. The host starts on the first emulator call, so PS5-only users never run it. On a crash, or a call that goes unanswered for 30 s, the launcher kills the host and returns to the library through the normal "game ended" path. On quit it waits up to 15 s for memory cards to be written. An orphaned host exits on its own.
  • macOS. The macOS section covers the POSIX handoff, Core Audio and IOSurface.

A first implementation exists on Windows and Linux to check that the design holds.

  • Host tests cover a crash, a hang, input routing and checkpoint round-trips.
  • A PS1 rollback race between a Windows host and a Linux host matched what the in-process version produced.
  • CTR boots on both OSes, and LittleBigPlanet renders through the host on Windows.
  • The launcher binary no longer imports D3D11, Vulkan, EGL, epoxy or PulseAudio, and its stack is back to the default.

I'll keep that implementation on our fork until you've reviewed the protocol, then send the host and the launcher-side client as the small PRs you described.

AI-assisted: yes (Claude Code)

@utkuhalis

Copy link
Copy Markdown
Collaborator

Thanks for writing the protocol down and for building a working host to test it against. The overall shape is what we want: a separate process, an inherited pipe / socketpair rather than a named endpoint, video and audio off the control stream, no IPC library, and tests for crash, hang and early exit. We would like to go ahead with it, with the changes below. Most are about making docs/protocol.md the actual contract.

Licence boundary

  1. The README says the launcher "carries the same protocol code", and protocol/ (including the launcher-side client, retro_host_proxy.cpp) is under the repository's GPL-3.0 licence. Copying that into the GPL-2.0-only launcher brings back the problem we are solving. Could the document and test fixtures be MIT (or CC0), with SPDX headers on every file? The launcher client would then either be written from the document, or you could license protocol/ itself as MIT or GPL-2.0-or-later.
  2. The document says the methods "are those of the existing anyps5/retro_core channel and are not redefined here". That channel is not in the launcher's main branch, and our Mac launcher is Swift, so the document needs to stand alone: each method with its argument and result shapes, plain JSON types rather than "Flutter's standard method codec", and the frame ring as a table of byte offsets, widths and endianness.
  3. Which licence is each core under, and where is the source of the modified builds? The host looks up retro_anyps5_ps3_network_policy_v1/v3 and retro_anyps5_dev9_bind_v1, which stock cores would not export. As far as we know RPCS3 is GPL-2.0-only; if your fork is too, loading it into a GPL-3.0 host has the same conflict in reverse. Licensing the host as GPL-2.0-or-later would cover both cases. Whoever hosts the binaries also needs the exact source for each build, so please name the repository and commit for each core.

Safety of the launcher
4. Frame ring: FrameRingReader::Acquire re-reads slot_bytes from shared memory on every frame, widthheight4 can wrap in 64 bits, and FrameRingBytes() can wrap at attach. A corrupted host could make the launcher read out of bounds. Please validate the geometry once at attach, keep it on the launcher side, use overflow-checked arithmetic and give the ring a maximum size in the spec. On Linux the memfd should be created with sealing and sealed against shrinking, and the launcher should check the seal, otherwise a truncate gives the launcher a SIGBUS.
5. Descriptors attached to anything other than a ring message are never closed (Message has no destructor and Dispatch ignores them). Please close them and treat unexpected descriptors as a protocol error.
6. The frame event's texture id is passed straight to frame_available. Please check it against the texture the launcher assigned.
7. ANYPS5_RETRO_HOST is honoured in every build. Please limit it to debug builds. In releases the launcher will verify the host and the core archives against our signed SHA256SUMS (the Ed25519 scheme launcher updates already use) before each start, instead of digests in the source.
8. Optional but welcome: on Windows, pass the two pipe ends with PROC_THREAD_ATTRIBUTE_HANDLE_LIST so the host does not need to open the launcher with PROCESS_DUP_HANDLE; add a kill-on-close job object; on Linux set PR_SET_PDEATHSIG in the host (--parent-pid is passed there but not read today).

Versioning and lifetime
9. Please have the launcher send its supported version range first and the host answer with the version it picked and a list of capabilities. A mismatch should have its own error, not "The emulator closed unexpectedly." Both sides should ignore unknown events and unknown fields; today the launcher kills the host on any event it does not know, so adding an event is a breaking change.
10. Timeouts: the launcher kills the host 15 s after shutdown, but the host gives itself 20 s, so a slow save could be cut off. The host's deadline should be the shorter one. A small ping would also catch a host that is stuck while no call is pending, and 30 s is a long time for the user to look at a frozen game. The shutdown wait and the pipe writes should not run on the UI or raster thread.
11. Please state the network behaviour. We would like version 1 to make no network connections at all.

macOS
12. The POSIX fallbacks need care on macOS: without MSG_NOSIGNAL the launcher gets SIGPIPE (use SO_NOSIGPIPE); without SOCK_CLOEXEC the launcher's end leaks into the host, so the host never sees end-of-stream (use POSIX_SPAWN_CLOEXEC_DEFAULT); the shm_open name is right at the 31-character limit; /proc/self/exe does not exist. A pixel-format field in the ring header would let the Mac side ask for BGRA. For the IOSurface handoff, please mark it "undecided" until there is a concrete mechanism. A macOS arm64 CI job that builds protocol/ and runs the ring and framing tests would be enough for now.

Launcher features
13. Reporting the host's pid covers per-process audio capture. The host has no window, so the contract should say explicitly that the launcher window is the capture and focus target. We are also missing a rumble event from host to launcher and volume / mute control.

Scope
The host now covers PS3, PS3 online, PS1 rollback netplay, PS2 LAN and package installs, around 30 methods. That is too much to review as a first version. Could version 1 be PS1 single-player only (start, status, pause, input, connectedPorts, changeDisc, option, stop, release), with the rest added later as capabilities? Suggested order:

  1. Spec only: the revised protocol.md plus JSON fixtures for every message.
  2. The host repository (we will create it under the organisation), PS1 only, with a fake-launcher conformance test driven by those fixtures.
  3. The launcher client, written from the spec, with a fake-host conformance test and no UI.
  4. The PS1 UI with strings and goldens.
  5. Signed distribution of the host and SwanStation.
    Then PS2, and PS3 / netplay after that.

For both conformance tests we would like negative cases too: an oversized frame, a ring with bad geometry, stray descriptors, a version mismatch and an exit in the middle of a call.

If any of this is unclear or you disagree with a point, say so here before changing code. Thanks again for the careful work.

@B-Howell

Copy link
Copy Markdown
Contributor Author

Thanks, this is clear and I agree with all of it. Stage 1 (the spec only) is ready for review:

https://github.com/B-Howell/anyps5-retro-host/tree/protocol-v1: docs/protocol.md, fixtures/v1, and tools/check_fixtures.py.

It is a fresh branch with no host code, so it can seed the organisation repository as it stands. Version 1 is single-player PS1 only, with your method list (start, status, pause, input, connectedPorts, changeDisc, option, stop, release), plus volume and a rumble event from point 13.

Point by point:

  1. Licence. The document, fixtures and checker are MIT with SPDX headers. The JSON fixtures can't hold a comment, so they're covered by REUSE.toml, and reuse lint passes. The launcher client will be written from the document; nothing is copied from the host.
  2. Standalone. Every method has its argument and result shapes in plain JSON types. The frame ring is a byte table with offsets, widths and endianness. Nothing refers to the old channel or the Flutter codec.
  3. Core licences. The host becomes GPL-2.0-or-later, as you suggested, so it can later load both GPL-3.0 cores and RPCS3. Version 1 loads only unmodified SwanStation (GPL-3.0, libretro/swanstation). Each release names the SwanStation commit it was built from. When a modified core comes in later, its patches, repository and commit are published with that host release.
  4. Ring safety.
    • The launcher checks the ring's geometry once, when the ring arrives, and keeps its own copy of the slot size.
    • Size limits: 8192×8192 frames, a 256 MiB slot and a 768 MiB ring. With those limits the 64-bit size checks cannot overflow.
    • On Linux the ring's memory is sealed against resizing (F_SEAL_SHRINK, F_SEAL_GROW, F_SEAL_SEAL), and the launcher checks the seals.
    • On macOS the launcher checks that the memory is shared memory, which macOS never lets change size.
    • Rings grow by capped doubling, so the total memory has a bound.
  5. Descriptors. Only a ring message may carry one descriptor; any other is a protocol error and is closed. There are receive rules that keep each descriptor with the message it came on.
  6. Texture id. The host no longer knows the launcher's texture at all. Each slot carries the epoch of its game, and the launcher draws only the current game's frames.
  7. Release builds. The launcher checks the host and the core against a signed SHA256SUMS before each start. ANYPS5_RETRO_HOST works in debug builds only.
  8. Process safety. All three suggestions are now required: PROC_THREAD_ATTRIBUTE_HANDLE_LIST, a kill-on-close job object, and PR_SET_PDEATHSIG (with the getppid race check, and a note that it is tied to the starting thread). On macOS the host watches the launcher with kqueue NOTE_EXIT.
  9. Versioning. The launcher sends hello first, with its version range and pixel formats. The host answers welcome, with the chosen version and its capabilities, or unsupported, which the launcher shows as an update message rather than a crash. Unknown messages and unknown fields are ignored on both sides.
  10. Timeouts.
    • Shutdown: the host has 10 s, and the launcher kills it at 15 s.
    • Liveness: the launcher pings every 2 s, and a missing reply or pong after 5 s ends the host.
    • A game that freezes (no frame for 10 s) or never starts (60 s) now ends that game only; the host keeps running.
    • The launcher never touches the control stream, or waits for the host, on its UI or raster thread.
  11. Network. Version 1 makes no network connections and opens no listening sockets.
  12. macOS. The spec now covers SO_NOSIGPIPE, POSIX_SPAWN_CLOEXEC_DEFAULT, a short random shm_open name, a locator without /proc, and a pixelFormat field in the ring with BGRA support. IOSurface is marked undecided. Stage 2 adds the macOS arm64 CI job that builds and runs the ring and framing tests, since this branch has no code to build yet.
  13. Window and audio. The launcher window is stated as the capture and focus target. A rumble event and a volume method (level and mute) are added, and the launcher zeroes rumble whenever a game ends or a controller leaves its port.

Fixtures. There are 22 message fixtures, one per message and method, and 18 sessions. The sessions include the negative cases you asked for:

  • an oversized frame;
  • bad ring geometry, a ring whose size doesn't match, and an unsealed ring;
  • stray descriptors, in both directions;
  • an oversized message, in both directions;
  • version and pixel-format mismatches;
  • an exit in the middle of a call;
  • a call that never gets a reply, and a missed pong;
  • unknown messages and fields being ignored.

Paths are placeholders (${disc} and so on) that each test fills in for its own system. The checker validates every message against the document's fields, types and ranges, and checks message direction and call/reply order.

If this looks right, the next step is stage 2: the PS1-only host with a fake-launcher conformance test driven by these fixtures, in the repository you create.

AI-assisted: yes (Claude Code)

@utkuhalis

Copy link
Copy Markdown
Collaborator

Thanks, this is a big step. The branch is clean (three docs-only commits, no host code in its history), the fixture checker passes, and most of the 13 points are covered in the text. Before we seed the organisation repository, a few things still need fixing. Most are places where two independent clients would behave differently.

Required

  1. Process id. The launcher should use the pid it got from CreateProcess or posix_spawn, not the one in welcome. A host that reports another process's pid would point audio capture, and later focus or kill, at the wrong process. A welcome pid that does not match should be a protocol error.

  2. Ring reads. The host can change the header while the launcher reads it. Please say that the launcher loads width, height and epoch of the pinned slot once into local copies, checks those and uses only those, and never trusts reading or any other header field.

  3. Ring limits on the launcher side. "A ring only grows" is only a host rule today. The launcher should treat a ring whose slotBytes is not larger than the current one as a protocol error, so a host cannot hand it hundreds of 768 MiB mappings. A failed mmap or MapViewOfFile should also be a protocol error. On Windows please map exactly bytes, check that the region is MEM_COMMIT (a SEC_RESERVE section can pass the size check and then fault), and duplicate the handle with FILE_MAP_READ | FILE_MAP_WRITE rather than the same access. Please also say that slotBytes is range-checked before 4096 + 3 x slotBytes is computed.

  4. Order errors. The protocol error list does not cover a reply whose id is not the oldest pending call, a pong for a ping that was never sent, a host message before hello, a second hello, or a call before welcome. Please add them. The checker currently accepts a session where welcome comes before hello and a pong with an unrelated id.

  5. Unknown values. The compatibility rule covers unknown types and fields but not unknown values. The checker rejects an unknown pixel format and an unknown error code (check_fixtures.py), and pixel-format-mismatch.json marks a hello offering "rgb565" as breaking a rule while expecting unsupported, which under Framing would be a protocol error. Please say that unknown pixel formats, capabilities and error codes are ignored, with an unknown error code treated as a generic failure, and make the checker and that fixture follow it.

  6. JSON details. Duplicate keys should be a protocol error, because parsers differ on which one wins. Please also say whether 1.0 or 1e0 counts as an integer. Many JSON libraries lose that distinction once parsed, so a rule based on the value is easier to implement. A lone \ud800 escape needs a rule too.

  7. Paths. "Absolute path" needs a definition per OS: UNC paths such as \nas\games (common for libraries on a NAS), \?\ prefixes, forward slashes on Windows, and .. segments. The checker rejects UNC paths today. Linux file names that are not UTF-8 and Windows names with unpaired surrogates cannot be sent, so please say what the launcher does with them. Extensions should match case-insensitively, since dumps often use .CUE. Please also state how files named inside a .cue or .m3u are resolved, whether absolute or UNC entries there are followed, and whether opening a file on an SMB share counts as a network connection under the Network rule.

  8. Method details that two hosts would answer differently:

  • connectedPorts 3 and changeDisc 200. The text says false, the error table says argument, and the checker rejects them as bad arguments.
  • An initialDisc whose path is not the playlist entry at that index.
  • start after a game ended but before release. "An ended game is released first" does not say who releases it.
  • internalResolution and options both setting swanstation_GPU_ResolutionScale.
  • The short status form reads as a launcher report, but the checker accepts it from the host. Please say whether a host may send it, for example for a game that failed before its first frame.
  • aspect needs a range, at least greater than 0.
  1. Rumble and volume. Rumble should also be zeroed on pause and when input focus is lost, otherwise a paused game can keep a motor running. Each new host starts at full volume, so the launcher should send volume after every welcome and before start. Otherwise a muted player gets full volume after a crash restart.

  2. Fixtures.

  • ring-bad-geometry.json puts the 3.3 TB size in the message's bytes field, so a launcher that only range-checks the message passes without reading the header, and no fake host can create that memory. Please use a normal-sized ring whose header slotBytes is out of range.
  • Please add negative cases for a frame from an old epoch (the main point of the epoch change), a frame inside the size limits that is larger than slotBytes, a POSIX ring without a descriptor, a ring that does not grow, a reply with an unexpected id, a body that is too short, not UTF-8, not an object or without a type, no welcome within 10 s, and a host that misses the stop and shutdown deadlines.
  • The checker requires memoryBytes to equal bytes, but macOS rounds shared memory up to whole pages (16 KiB on Apple silicon), so the 640x480 ring could not be reproduced there. Sessions also use "os": "linux", which the text does not list.

Nice to have

  • SHA256SUMS. Where the file and its signature live and in what format, how entries are matched, a minimum host version so an older signed host cannot be replayed, and hashing the opened file rather than checking a path and then starting it by path. Please also say plainly that a user-selected core does not run in release builds.
  • Linux host. Close every descriptor above 3 at start (close_range), and clear the inherited signal mask so a SIGTERM blocked in the launcher does not silence PR_SET_PDEATHSIG.
  • Windows. Create the host suspended and assign it to the job before it runs, and keep other process starts from inheriting the pipe ends while they are inheritable.
  • macOS. A kqueue registration that fails with ESRCH means the launcher is already gone. There is no MSG_CMSG_CLOEXEC, so set FD_CLOEXEC on a received descriptor straight away. A hardened, notarized host also needs a plan for loading a downloaded core under library validation.
  • Room for more than one descriptor in recvmsg, so the receiver closes extras itself instead of relying on kernel truncation, and any control message other than SCM_RIGHTS is a protocol error.
  • A machine-readable errorCode next to status.error (missing BIOS, unreadable disc, stalled, did not start), since the UI has to show it in 28 languages.
  • A CI test that the host opens no sockets, for example with strace or a network namespace.
  • A launcher write that stays blocked for more than a few seconds counts as a host failure.
  • Whether libretro duplicate frames count as frames for the 10 s stall limit.

Once these are in, the next round should only need a diff review, and then we can talk about seeding the organisation repository from this branch.

@utkuhalis

Copy link
Copy Markdown
Collaborator

The organisation repository is up: https://github.com/AnyTogether/anyps5-retro-host. It's empty on purpose. Once the review points above are in on protocol-v1, we'll seed main from that branch as it stands (its history is docs only, which is what we want), and stage 2 comes in as pull requests there.

@B-Howell

B-Howell commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, and thanks for creating the repository. All of the points are in, as one commit on protocol-v1, so it reviews as a diff: cc0935c..1d0dd29. The branch history is still docs only, and CI (fixture checker and reuse lint) is green.

Required

  1. Process id. The launcher uses the pid from CreateProcessW / posix_spawn for audio capture, focus and kill. A welcome whose pid differs is a protocol error (Processes; fixture welcome-wrong-pid).
  2. Ring reads. Reading is now numbered steps. latest is loaded once, and any value other than 0, 1, 2 or none is an error. The pinned slot's width, height and epoch are loaded once into local copies, which are checked and then used alone. reading is never read back, and no other header field is trusted after arrival.
  3. Ring limits.
    • A new ring must be larger than the current one, and at least twice it or the 256 MiB cap, whichever is smaller. A smaller ring is a protocol error, so there are at most 27 rings, and the launcher maps at most two at a time.
    • A failed DuplicateHandle, MapViewOfFile or mmap is a protocol error.
    • On Windows: duplicate with FILE_MAP_READ | FILE_MAP_WRITE, map exactly bytes, and VirtualQuery must show MEM_COMMIT (the host creates it with SEC_COMMIT).
    • slotBytes is range-checked before 4096 + 3 × slotBytes is computed.
  4. Order errors. There is a new Protocol errors section, split by which side sees what. It covers: a reply that isn't for the oldest waiting call; a pong that isn't for the oldest waiting ping (replies and pongs share one order); a host message before welcome; a second hello; a call before welcome; anything after unsupported; and a frame before any ring. The checker now rejects welcome before hello and an unrelated pong id.
  5. Unknown values. Unknown pixel formats and capabilities are ignored. An unknown error code, or status errorCode, is a failure with no known cause. Anything else out of range is still an error. pixel-format-mismatch.json is now a valid hello that gets unsupported, and unknown-pixel-format-skipped shows a host skipping rgb565 to pick bgra8.
  6. JSON. Duplicate keys at any depth are a protocol error. Integers are decided by value, so 1.0 and 1e0 are 1, and senders still write plain integers. A lone surrogate escape is a protocol error, and a number must be finite as a double.
  7. Paths. There is a new Paths section.
    • Windows accepts drive paths, UNC paths (\\nas\games\…) and both in \\?\ long form; / works in the short forms. Device paths, drive-relative paths and root-relative paths are not absolute.
    • No path may contain a ., .. or empty segment.
    • A name that can't be sent as UTF-8 isn't offered, and the player is told why.
    • Extensions match regardless of case.
    • Files named inside a .cue or .m3u are found as the core finds them: relative to that file, with absolute and UNC entries followed.
    • Reading from SMB or NFS is ordinary file access, not a network connection.
  8. Method details.
    • A wrong argument is always an argument error, whether or not a game is loaded. connectedPorts 3 is that error; changeDisc 5 (in range, but past the last disc) returns false. A wrong args is explicitly not a protocol error.
    • initialDisc is now just an index into the playlist, so there is no path to disagree with. A playlist with no entry there ends the game with errorCode: "disc".
    • start while any game is loaded, running or ended, is busy; the launcher calls release first.
    • internalResolution is gone. The resolution is swanstation_GPU_ResolutionScale in options, like any other core option.
    • A host always sends the full status, including for a game that ended before its first frame (fixture call-status-no-bios). The short form is only what the launcher's client reports after a host failure.
    • aspect is greater than 0 and at most 10; it is 4:3 until the core reports one.
  9. Rumble and volume. Rumble is zeroed when the game is paused and when input focus is lost, and isn't forwarded while either lasts. The launcher's first call after every welcome is volume; the checker enforces it, and every session starts that way.
  10. Fixtures. Now 25 messages and 50 sessions.
    • ring-bad-geometry is a normal-sized ring whose header slotBytes is out of range.
    • The new negative cases:
      • a frame from an old epoch (not drawn; launcherDraws);
      • a frame larger than its slot, and an oversized frame, both now failing at the frame that announces them;
      • a POSIX ring with no descriptor;
      • a ring that doesn't grow;
      • a reply and a pong with the wrong id;
      • bodies that are too short, not UTF-8, not JSON, not an object, have no type, repeat a key, or hold a lone surrogate, each in both directions;
      • no welcome within 10 s;
      • missed stop and shutdown deadlines.
    • Some sessions are valid ones: ring-grows, argument-error and unknown-values-from-host.
    • memoryBytes may be up to one 16 KiB page larger in a macos session, and linux and macos are now listed session systems.
    • New fixture steps (wait, launcherDraws, publish, wrongPid) are defined in the step table.
    • The checker now also checks:
      • time limits, against the seconds the steps add up to;
      • ring growth, and the ring's pixel format against welcome;
      • that frames are drawn exactly when their epoch is the current game's;
      • that a wrong argument gets an argument error;
      • paths against the session's system.

Nice to have

  • SHA256SUMS.
    • It uses the same format and the same SHA256SUMS.sig as launcher releases. Per system there are a zip and a .files manifest of the files in it, matched by exact file name.
    • It's checked on download, and again before each start: the signature, then the .files, then the host and core against it.
    • A minimum host version is built into the launcher.
    • On Windows the files are hashed from handles held open without write or delete sharing until the first start is answered. On Linux the host is started through the very descriptor that was hashed (/proc/self/fd/<n>, kept above 3). On macOS the system's code-signature checks cover the host and the library.
    • The one gap is SwanStation on Linux, which is still checked by path in launcher-owned storage. The text says so, along with the threat model: a damaged download or changed mirror, not another program run by the same user.
    • A release build never runs a core the player picks.
  • Linux. The host is spawned with an empty signal mask and SIGTERM set back to its default. At start it runs close_range(4, ~0U, 0) and unblocks every signal, then sets PR_SET_PDEATHSIG.
  • Windows.
    • The host starts suspended and is assigned to the job before it is resumed.
    • The pipe ends are inheritable only for the host's CreateProcessW. Every other process the launcher starts with inherited handles passes its own handle list, or is started under the same lock.
    • The host also waits on the launcher with SYNCHRONIZE.
  • macOS. ESRCH from the kqueue registration means the launcher has already gone. FD_CLOEXEC is set right after recvmsg. For library validation, the macOS SwanStation build is signed by the same team as the host.
  • Descriptors. recvmsg has room for 4 descriptors. More than one, or any control message other than SCM_RIGHTS, is a protocol error.
  • errorCode. Status has errorCode next to error: bios, disc, core, stalled, didNotStart, frameTooLarge or other. The client's lost-game report uses hostExited and hostHung.
  • No sockets. The spec requires the host repository's CI to check that no socket is opened while the conformance sessions run (for example with strace -f -e trace=%network). That job comes with stage 2.
  • Blocked writes. A write that hasn't finished after 5 s is a failure of the peer, on both sides.
  • Duplicate frames. A libretro duplicate frame counts as a frame for the stall limit.

Each round of these changes also went through an independent review, to look for places where two clients could read the text differently. The checker has a probe for each such case it caught.

AI-assisted: yes (Claude Code)

@B-Howell

B-Howell commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Stage 2 is ready, so it can come in as the first PR once main is seeded: branch stage2, one commit on top of protocol-v1 (diff). CI is green on Linux, macOS arm64, Windows and a ThreadSanitizer job, and reuse lint passes.

What it is. anyps5-retro-host, GPL-2.0-or-later, C++17, about 3,100 lines in src/. It implements version 1 with the ps1 capability. docs/host.md walks through it.

  • Threads. There are three:
    • the control thread, which answers calls and pings in order;
    • the game thread, which runs SwanStation through libretro and publishes frames;
    • a watch thread, which enforces the 5 s write limit, the stall and first-frame limits, and the stop and leaving limits.
  • Libraries. The only third-party code is two submodules: libretro-common (for libretro.h only) and miniaudio 0.11.26 for sound on all three systems.
  • Software rendering. The host turns down SET_HW_RENDER, so SwanStation draws in software and status.hardware is false. A GPU path (an offscreen context read back into the ring) would be its own PR; until then, a resolution scale above 1x reports resolutionError.

Tests.

  • tests/conformance.py, the fake launcher. It starts the host as a launcher would: a socketpair at descriptor 3, or two pipes with a handle list on Windows. It then plays all 17 sessions whose tests include host, reading by the framing rules, checking each ring (seals, size, MEM_COMMIT), and pinning slots with real atomics. Seven deliberately broken sessions all fail as they should.
  • tests/host_test.py covers what the fixtures don't:
    • a stuck core: the stall limit, then release replies and the host exits;
    • a core that isn't a library, and a missing disc;
    • restart-only options;
    • eight games in a row with volume, pause and stop in between.
  • No network. On Linux, CI traces every session's network calls (strace -e trace=%network) and fails on any AF_INET/AF_INET6/AF_PACKET/AF_NETLINK socket, and on any bind or listen.
  • Sanitizers. The tests also pass under ThreadSanitizer (CI) and AddressSanitizer with UndefinedBehaviorSanitizer (locally).
  • Real SwanStation. I ran an unmodified SwanStation 631f0fb build on Linux with real discs:
    • Crash Team Racing at 60 fps;
    • Final Fantasy VII from its .m3u, including a change to disc 2;
    • pause, stop, release, shutdown, and the memory card written as <disc>.srm;
    • errorCode bios for an empty system folder or an unknown BIOS-sized file;
    • disc for a .cue whose .bin is missing.
  • Not covered yet: real SwanStation on Windows and macOS. CI there runs the fake core.

Spec changes in this PR (in docs/protocol.md), which came out of building and reviewing the host:

  1. start replies before the host opens the core or the disc. A core that can't be loaded now ends the game with errorCode core, and an unreadable disc with disc, instead of a start error. The Windows launcher keeps its hashed handles open until the first game is ready or ended.
  2. Memory card 1 is <disc name without extension>.srm in saves. It's checked every 5 s between frames (also while paused) and written whenever the game ends. A card file the host can't read is never written over.
  3. restart is true for options the core marks "(Restart)", as libretro cores do, and for SwanStation's console region, 8 MB RAM and fast boot, which it applies only at boot. Until the next start, the core keeps getting the value the game started with. activeValue is the value the host gives the core.
  4. Options kept at SwanStation's default and not listed:
    • the memory card types: the host owns card 1, and SwanStation reformats card files it can't read;
    • multitap: version 1 has ports 1 and 2 only;
    • the log level: that's where SwanStation reports which BIOS it uses.
  5. release with a stuck core. If the core can't be unloaded, the host replies to release and then exits.
  6. A new reply error code, tooLarge, for a result that can't fit in 1 MiB.
  7. Fixture rules for a host under test:
    • extra frame and rumble messages are ignored;
    • only a ring's fixed header fields are compared;
    • status fields that depend on the core are anyValueFor.
  8. The CI network check is spelled out, and playing sound through the system's sound service is not a network connection.

AI-assisted: yes (Claude Code)

@utkuhalis

Copy link
Copy Markdown
Collaborator

Thanks, this round closes every required point. I checked each one against the document, read the new checker, and ran it. The cases the old checker let through (welcome before hello, a pong with an unrelated id, the short status sent by a host, and a welcome with a pixel format that was not offered) now fail as they should.

The organisation repository is seeded: main of https://github.com/AnyTogether/anyps5-retro-host is protocol-v1 at 8c6fd76, history included. main takes pull requests only, with the fixtures check required. Please open stage 2 there as a pull request from your stage2 branch.

Three small things can go in as follow-up issues there:

  1. On Windows the launcher holds its handle on the checked files until the host answers the first start, but the text also says the game loads after that reply. Please state that the host loads the core library before it replies to start, so the handle covers the load.
  2. The launcher-only error codes hostExited and hostHung are not in the errorCode table.
  3. A .cue or .m3u can name a UNC path, and the host follows it as written. On Windows that makes the machine connect to that server and sign in to it. A short known-risk note would be enough for now.

@B-Howell

Copy link
Copy Markdown
Contributor Author

Progress since stage 2. Nothing here needs a decision yet. It is ready for whenever main of AnyTogether/anyps5-retro-host is seeded.

GPU rendering for the host. Branch stage2-gpu builds on stage2 and would be its own PR after stage 2.

  • The context. The host answers SwanStation's OpenGL 3.3 core request with an offscreen context:
    • EGL on Linux, loaded at run time;
    • WGL with a hidden window on Windows;
    • CGL on macOS.
  • Frames and teardown. Frames are read back with glReadPixels into the ring. The core's context_destroy runs before retro_unload_game, as RetroArch's core_unload_game does.
  • Status. status.hardware and the internal resolution now work. A machine with no context falls back to software, and resolutionError says why. A GPU failure ends the game with other.
  • Results. On Windows at 4x, CTR runs at 2048x864 and 60 fps, and FF7 at 2560x1912.

Real-core CI. A new real-core workflow is green on Linux, macOS and Windows. It boots real SwanStation through the host, with no Sony files involved.

  • It builds OpenBIOS (with BOOT_MODE=fast) and the psyqo hello program from pcsx-redux's nugget (MIT, pinned commit).
  • tests/make_test_disc.py puts the program on a Mode 2 .cue/.bin, because start takes only disc files. Its sectors match a pressed disc byte for byte.
  • The Linux runner draws on the GPU through Mesa. The macOS and Windows runners have no GPU, so they correctly report the software fallback.

Stage 3, the launcher client is written from the spec alone. Branch stage3-retro-host-client of the fork, with docs/RETRO_HOST_CLIENT.md explaining the design.

  • Native helper. native/retro_host is a C library built by hook/build.dart. It starts the host and runs the stream's reader, writer and exit threads, so nothing on the UI isolate waits on the host. It also maps the ring after the spec's checks.
    • On Windows: a handle list and a kill-on-close job.
    • On macOS: posix_spawn with CLOEXEC_DEFAULT.
    • On Linux: a pidfd, because the Dart VM's wait() can reap any child. The client uses pidfd_spawn where glibc has it. Otherwise it opens the pidfd and checks the process's fd 3 is our socket before trusting it.
  • Dart client. lib/core/retro_host does strict JSON, message checks, ordered calls and pings, all time limits, frame pinning, and the host-failure reports.
  • The fake host. The test plays every launcher fixture session against a fake host that is started through the client's real start path. The fake host is tests/fake_host.py, on branch stage3-fake-host of the host repository. It was also written independently from the spec, and its own self-test is green on all three systems.
  • Results. Linux passes 50 of 50 (34 fixture sessions plus the client's own sessions and unit tests). Windows passes 47, plus 3 POSIX-only skips. The fork's CI runs the test on Linux and on macOS.
  • Review. Nine independent review rounds were run; the last was GO.

Not yet: the UI (stage 4) and signed distribution (stage 5).

…/PS2 slice AnyTogether#86

Resolves the conflicts with the merged AnyTogether#85 by keeping its reviewed code and
adding this PR on top: the embedded PS1 and PS2 host, disc changing and the
disc picker. The embedded start keeps its ready and timeout to its own turn,
and the disc picker test turns on the now opt-in emulator library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
B-Howell added a commit to B-Howell/AnyPS5-Desktop that referenced this pull request Nov 13, 2026
…Vulkan slice AnyTogether#87

Brings AnyTogether#87 onto current main through AnyTogether#86, keeping the merged AnyTogether#85 as reviewed.
The PS3 commits are unchanged; where AnyTogether#86 and AnyTogether#87 both changed the native host,
the combined version AnyTogether#87 already had is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@B-Howell

Copy link
Copy Markdown
Contributor Author

Thanks for seeding the repository. I've opened stage 2 there as AnyTogether/anyps5-retro-host#1.

One request before more is built on main, if you're willing. A few commits in the history you seeded were made under my personal email address by mistake, and I would like it out of the public history. I rewrote that history with only the author and committer emails changed: protocol-v1 in B-Howell/anyps5-retro-host is now at 1d0dd29. Its files are identical to your main at 8c6fd76; only the commit ids differ.

Could you reset main to that branch? For example:

git fetch https://github.com/B-Howell/anyps5-retro-host.git protocol-v1
git push --force origin FETCH_HEAD:main

The only fork so far is mine, and I'll move the stage 2 PR onto the new main straight after. If you would rather not rewrite main, that's fine; just let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants