Prepare the repository for release

Clean up the development scaffolding and document the project for sharing.

Removed:
- patches/ihslib/0050-diagnostics-window-state.patch, a temporary packet-window
  tracing patch used to diagnose the stalls.
- The per-second diagnostic logging added while chasing the stutter (present
  cadence, arrival-gap histogram, decode-cost histogram). The operational lines
  stay: state, session config, video/audio start, per-5s stats, video-out mode,
  rumble/LED, and the stream-size-change notice.
- PLAN.md, an internal planning document that also carried a developer's LAN
  address. No tracked file now contains a host-specific address or path.

Kept and brought current:
- PINS.md now documents every patch in patches/ihslib/ (0015, 0016, 0018, 0019
  and 0020 were missing) and no longer claims there are only two of them.
- README.md rewritten: it still said 'Status: planning. Nothing builds yet.' It
  now leads with what works, states the console and firmware actually tested
  (CFI-2015, 13.20), and gives a per-feature verified / implemented-but-unverified
  / not-implemented breakdown rather than implying everything is proven. Build,
  install and usage instructions, the settings reference, the two host-side
  gotchas that look like client bugs (a sleeping PC display stops Steam
  capturing; Wayland hosts need 'steam -pipewire'), and the licensing position.

No functional change; verified the title still builds, deploys and streams.
This commit is contained in:
jmelovich committed 2026-10-10 11:42:19 -07:00
1 parent 3e3a60e664
commit 50d4b17a19
5 files changed
+162 -448

No files matched your search

+26 -1
View File
@@ -7,7 +7,7 @@ commit the submodule change here together with a note below.
| Component | Where | Revision | Why this one |
| --- | --- | --- | --- |
| [ihslib](https://github.com/scaronni/ihslib) branch `auth` | `third_party/ihslib` | `f68de5f0` (2026-10-04) | Upstream [mariotaku/ihslib](https://github.com/mariotaku/ihslib) master `1881b9a0` plus two commits implementing Steam's 2026 PIN-masked X25519 pairing (`crypto: add SHA-256, X25519 and Steam's key exchange`, `client: pair with the PIN-masked key exchange`). Without them current Steam answers `AuthorizationFailed` once the PIN is typed. Switch back to upstream once they are merged. `patches/ihslib/` carries two small robustness patches on top (see below). |
| [ihslib](https://github.com/scaronni/ihslib) branch `auth` | `third_party/ihslib` | `f68de5f0` (2026-10-04) | Upstream [mariotaku/ihslib](https://github.com/mariotaku/ihslib) master `1881b9a0` plus two commits implementing Steam's 2026 PIN-masked X25519 pairing (`crypto: add SHA-256, X25519 and Steam's key exchange`, `client: pair with the PIN-masked key exchange`). Without them current Steam answers `AuthorizationFailed` once the PIN is typed. Switch back to upstream once they are merged. `patches/ihslib/` carries the patches listed below on top. |
| [protobuf-c](https://github.com/protobuf-c/protobuf-c) | `third_party/protobuf-c` | v1.5.2 | ihslib's generated sources need ≥ 1.3.0; 1.5.2 is what they were regenerated with. |
| [mbedtls](https://github.com/Mbed-TLS/mbedtls) | `third_party/mbedtls` | v3.6.4 | The revision ProsperoLight proved on the console; 4.x drops APIs ihslib uses (`mbedtls_md_hmac`). Built with `platform/ps5/mbedtls_ps5_config.h`. |
| [opus](https://github.com/xiph/opus) | `third_party/opus` | v1.5.2 | Same as ProsperoLight. Intrinsics off (the SDK's toolchain has no runtime CPU dispatch we trust). |
@@ -66,5 +66,30 @@ commit the submodule change here together with a note below.
- `0013-send-acks-redundantly-on-lossy-uplink.patch`: send each ACK three times so reliable
control handshakes (start input reports, keyframe/config delivery) survive a lossy uplink
where single ACKs are lost and the host otherwise gives up.
- `0015-control-recover-out-of-sequence-message.patch`: a control message whose encryption
counter skipped ahead was thrown away even though it had already decrypted and passed its
HMAC. Losing one such message cost either "start input reports" (the pad connects but sends
no input) or a stream-start message (a black screen), at random, on roughly 1 in 12 startups.
Resync the counter *and* deliver the message - a forward skip can never be a replay.
- `0016-control-window-hold-the-startup-burst.patch`: the control frame window held 128 slots,
too few for Steam's Big Picture startup burst while one lost reliable packet healed, so it
overflowed and disconnected before the video channel opened. Raised to 2048; the host's
resend then drains the backlog in order.
- `0018-video-keep-decoding-through-small-gap.patch`: any forward frame-sequence gap - even a
single frame - requested a keyframe and then dropped *every* frame until a full IDR arrived.
On a good link that fired about once a second, each time freezing the picture for the
keyframe round trip. Now a small gap asks for a keyframe once (coalesced, backed off from a
flat 200 ms to 500 ms) and keeps decoding, so a one-frame loss is a brief artifact the next
keyframe wipes rather than a freeze. Only a large desync still hard-waits; the decoder's own
reset path remains the backstop.
- `0019-video-soft-partial-stall.patch`: the same over-reaction on the other path - a frame
missing a fragment hard-waited for a keyframe, measured at 200-640 ms per event. Drop the
stuck partials, ask once, keep decoding.
- `0020-negotiation-advertise-display-refresh.patch`: the negotiation hard-coded 59.94 Hz as
both the available video mode's refresh and the client's maximum framerate, so a user who
chose 120 Hz in Steam's Remote Play settings was silently vetoed (the host streams at the
lower of the two) and the resulting disagreement showed up as doubled latency and artifacts.
`IHS_SessionConfig` gains `refreshRateNum/Den`; the app measures its real refresh rate and
advertises that, leaving the host's settings in charge.
All are candidates for upstream.
-276
View File
@@ -1,276 +0,0 @@
# steamlink-ps5: plan
A native Steam Remote Play client for a jailbroken PS5, as a homebrew title. Target
experience: the TV Steam Link app. Turn it on, it finds the PC running Steam, pair once
with a PIN, Big Picture Mode streams to the TV, the DualSense controllers just work.
This document records the research (October 2026), the decision, the architecture, the
build plan and the milestones. Nothing is built yet.
## 1. Decision in one paragraph
There is no open-source Steam Link client to port: Valve's client is proprietary, and its
Linux binary cannot run on the PS5 (a title may make no system call and the console is
FreeBSD-derived, not Linux). The protocol, however, has been reverse engineered:
**[ihslib](https://github.com/mariotaku/ihslib)** (C, LGPL-3.0) implements discovery,
pairing and streaming of Steam Remote Play, is actively maintained (last commit
2026-10-05, Ghidra-driven work against the real Steam client), and already powers a
Switch homebrew client ([nsteamlink](https://github.com/scaronni/nsteamlink), hardware
NVDEC) and a Raspberry Pi 5 client ([plume](https://github.com/beudbeud/plume)). On the
PS5 side, **[ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight)**
(GPL-3.0-or-later, a Moonlight client for PS5, last commit 2026-10-07) has already solved
everything hardware-specific: VideoDec2 hardware H.264/HEVC decode presented through AGC
without CPU copies, Opus audio through AudioOut, up to four DualSense controllers through
ScePad, an OpenGL launcher UI, and a Make-based build with FTP deploy built on
[ps5-native-app-boilerplate](https://github.com/blackbearreloaded/ps5-native-app-boilerplate).
**So: do not port ihsplay (the SDL2/LVGL webOS front end, stale since mid-2024). Write a
new, thin PS5 application that uses ihslib for the protocol and ProsperoLight's
platform code for the console.** The protocol is 100 % done elsewhere, the PS5 media
path is about 70 % done elsewhere, and our code is the glue: a ScePad HID provider for
ihslib, a pthread backend, a VideoDec2 video sink, an Opus-to-AudioOut sink, and the
screens.
## 2. What exists (research findings)
| Project | What it is | Verdict for us |
| --- | --- | --- |
| [ValveSoftware/steamlink-sdk](https://github.com/ValveSoftware/steamlink-sdk) | SDK for apps *on* the Steam Link box; the streaming client is a closed binary | Not usable. Confirms no official source. |
| [mariotaku/ihslib](https://github.com/mariotaku/ihslib) | Steam Remote Play protocol library: UDP discovery (port 27036), PIN authorization, streaming request, session with control/stats/data channels, AES-CBC control crypto, H.264 and HEVC frame reassembly, Opus audio, mouse/keyboard/touch input, HID gamepad channel (incl. `IHS_CONTROLLER_TYPE_PS5`), microphone, frame stats. Deps: protobuf-c runtime, mbedtls (or OpenSSL), a threads backend (SDL2 upstream). LGPL-3.0, active. | **The protocol core.** |
| [mariotaku/ihsplay](https://github.com/mariotaku/ihsplay) | SDL2 + LVGL + `ss4s` media-sink client for webOS/RPi, GPL-3.0 | Reference for UI flow and stream lifecycle only; last commit June 2024, SDL-bound. Not ported. |
| [beudbeud/ihslib `plume`](https://github.com/beudbeud/ihslib/tree/plume) | Fork: +25 fixes (retransmit deadlocks, control-channel serialization, HID bugs), **pure POSIX thread backend** (`ihs_thread_posix.c`), no SDL | Source of the pthread backend; cherry-pick what upstream lacks. |
| [scaronni/nsteamlink](https://github.com/scaronni/nsteamlink) | Switch client on ihslib (kxn fork, +72 over upstream incl. a cumulative-ACK fix that cured 20 s input stalls), FFmpeg/NVDEC, SDL2. GPL-3.0. Excellent RE docs (`docs/STEAMLINK_PROTOCOL_RE.md`, `OFFICIAL_INPUT_RE.md`, `STEAM_REMOTE_PLAY_AUTH.md`) | **The closest precedent**: console + hardware decoder + ihslib. Its docs are our protocol reference; its author now upstreams to mariotaku. |
| [blackbearreloaded/ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight) | Native Moonlight (Sunshine) client for PS5: VideoDec2 + AGC zero-copy, 1080p–2160p at 30–120 FPS, HEVC Main10 HDR, Opus → AudioOut stereo/5.1, 4 DualSense + USB keyboard/mouse, OpenGL launcher (ps5-homebrew-ui), self-update, `make deploy PS5_HOST=`. GPL-3.0-or-later, active | **The PS5 platform donor**: VideoDec2 structs and link stubs, AGC presenter, AudioOut, ScePad, sockets→sceNet adapter, mbedtls PS5 config and entropy, build/deploy tooling. |
| [blackbearreloaded/ps5-native-app-boilerplate](https://github.com/blackbearreloaded/ps5-native-app-boilerplate) | Template ProsperoLight is built on: Make + `tools/build.sh`, downloads the public ps5-payload-dev SDK v0.42 and zlib, clean-room `libc.prx`, ELF→FSELF converter, `make init/deploy/undeploy`, param.json validation | **Our project skeleton.** |
| [blackbearreloaded/ps5-hardware-video-decoding-research](https://github.com/blackbearreloaded/ps5-hardware-video-decoding-research) | Measurements: HEVC 4K60 decode 5.5 ms, 16.7 ms access-unit-to-flip; H.264 High and HEVC Main at 1080p/1440p/2160p; AV1 not available | Sets expectations for latency and bitrate. |
| [blackbearreloaded/ps5-homebrew-ui](https://github.com/blackbearreloaded/ps5-homebrew-ui) + [ps5-opengl](https://github.com/blackbearreloaded/ps5-opengl) | OpenGL UI kit (~100 widgets, themes, finished screens, PC preview) on a Mesa port; prebuilt SDK downloaded by script | Launcher UI (milestone 5). |
| `../rpcs3-PS5` (local) | RPCS3 as a PS5 title: lavavex PS5_PayloadSDK fork + PS5_Vulkan RADV link recipe, `tools/deploy.sh` (curl FTP), `ps5/ps5_pad_handler.cpp`, `ps5_audio_backend.cpp` | Deploy-script shape and the libc-gap lessons. Its Vulkan/RADV toolchain is far heavier than we need: a streaming client needs no Vulkan. |
| Thalium, [Bug hunting in Steam Remote Play](https://blog.thalium.re/posts/achieving-remote-code-execution-in-steam-remote-play/) | Protocol write-up: 32 channels, AES-CBC with IV = HMAC-MD5, codec negotiation | Background reading. |
Protocol facts that shape the design (from ihslib's headers and nsteamlink's RE docs):
- Pairing is Steam-Link-style: **the client shows a PIN and the user types it into Steam
on the PC** (Settings → Remote Play → Pair Steam Link). No keyboard needed on the console.
The client identity (`deviceId` + `secretKey` → device token) must persist, or Steam
asks to pair again.
- The host sends complete video frames; ihslib reassembles fragments and emits one
Annex-B access unit per `submit` callback (keyframe flag included, SPS/PPS in
`codecData`). That is exactly what VideoDec2 consumes, and what ProsperoLight already
feeds it from Moonlight.
- Audio is Opus 48 kHz (channel count requested by the client).
- Gamepads go through the HID channel: the client enumerates "devices" with vid/pid and
streams 48-byte input reports (6 axes, 16 buttons, gyro, accel, touch), optionally as
deltas; the host sends rumble back as HID writes. ihslib's SDL provider is the template
for a ScePad provider (~1,500 lines of SDL code, most of it enumeration plumbing).
- Timestamps on the wire are 16.16 fixed-point seconds; the session handles it.
## 3. Alternatives considered
- **Moonlight/Sunshine instead of Steam Remote Play.** ProsperoLight already exists and
streams Steam Big Picture fine *if* the PC runs Sunshine. It is the fallback if the
Steam protocol turns out to be a moving target, but it is not what was asked for:
Remote Play needs nothing installed on the PC beyond Steam.
- **Port ihsplay wholesale.** It would drag in SDL2 (no PS5 port), LVGL and the `ss4s`
sink layer, all for a UI we would replace anyway. Its `app/backend/stream/*` files are
worth reading for lifecycle ordering, nothing more.
- **Port nsteamlink.** Same SDL2 problem, plus Switch-specific NVDEC and libnx code. Its
protocol-level fixes matter; its application does not.
- **rpcs3-PS5's toolchain (lavavex SDK fork + PS5_Vulkan).** Needed there because RPCS3
renders with Vulkan. A streaming client presents decoded surfaces through AGC directly,
which the public ps5-payload-dev SDK plus ProsperoLight's link stubs already cover.
Avoiding the RADV build keeps a first build at minutes rather than tens of minutes.
## 4. Architecture
```
Steam on the PC (Remote Play enabled)
| UDP 27036 discovery / auth | UDP session: control, stats, data channels
───────────────┼─────────────────────────────────────┼──────────────────────────────────
v v
ihslib client ihslib session
(discover, PIN authorize, (crypto, reassembly, retransmit, stats)
streaming request) callbacks: video audio input/HID
| | | ^
v v v |
launcher (host list, VideoDec2 sink Opus → ScePad HID provider
PIN screen, settings) H.264/HEVC AU AudioOut (4 users, rumble,
| → GPU surface touchpad = mouse)
v | |
ps5-homebrew-ui (OpenGL) AGC presenter → VideoOut scePadReadState
or CPU text UI until M5 (zero-copy, flip on vblank) (4 ms poll thread)
```
Components and where each comes from:
| Component | Source | Our work |
| --- | --- | --- |
| Protocol (`ihslib`) | submodule, pinned to upstream `master` | A `src/platform/ihs_thread_posix.c` (pthread; 75 lines, or lifted from the `plume` fork), a small patch set under `patches/ihslib/` for anything PS5-specific, cherry-picked fixes from the nsteamlink fork that upstream lacks. Build it from a file list, not its CMake (pkg-config is a liability on the cross toolchain). |
| protobuf-c runtime | submodule, one `.c` | None. |
| mbedtls | submodule | ProsperoLight's `mbedtls_ps5_config.h` and `ps5_entropy.c` (`sysctl kern.rng_pseudo`; `/dev/urandom` is also available). ihslib needs AES-CBC/ECB, RSA-OAEP (authorization), CTR-DRBG. |
| Opus | submodule | None (decoder only). |
| Video sink | ProsperoLight `moonlight_stream.cpp` (VideoDec2 structs, create/decode/flush/reset, decoder memory, compute queue) and `native_agc_present.cpp` (1,840 lines) | Extract into `src/video/videodec2.cpp` + `src/video/agc_present.cpp` with a codec-agnostic `submit(access_unit, keyframe)` interface; keep decode and present on separate threads as ProsperoLight does. Report `IHS_SessionReportVideoFrameStage/Complete` so Steam's quality control sees decode time. |
| Audio sink | ProsperoLight's AudioOut thread; `../rpcs3-PS5` `ps5_audio_backend.cpp` (same port API) | `src/audio/opus_audioout.cpp`: Opus → S16 stereo → `sceAudioOutOutput`, bounded backlog, drop on overrun. 5.1 later via `audioChannelCount`. |
| Input | ihslib `src/hid/sdl/*` as the template; ProsperoLight `ps5_dualsense.cpp` for ScePad (users, slots, rumble, motion, touch) | `src/hid/ps5/*`: an `IHS_HIDProvider` whose devices are the signed-in users' pads, producing the 48-byte report from `ScePadData`; `write` → `scePadSetVibration`. Touchpad-as-mouse and PS+Options overlay chord via `IHS_SessionSendMouse*`. Text entry via `sceImeDialog` → `IHS_SessionSendText`. |
| Launcher UI | ps5-homebrew-ui + ps5-opengl (prebuilt SDK download) | Screens: hosts (auto-discovered, state), pairing (PIN display with countdown), settings, in-stream overlay (stats, disconnect, quit game), errors. Until M5, a CPU-rendered text screen from the boilerplate skeleton is enough to drive the stream. |
| Network | ProsperoLight `platform/ps5/ps5_sockets.c` (BSD API over `sceNet*`, epoll) | None expected. No DNS is needed (IPs only), which sidesteps the console's broken `getaddrinfo`. |
| Storage, logging | ProsperoLight `app_storage.cpp` shape; rpcs3's `/data/<app>` convention | `/data/steamlink-ps5/` (ShadowMount+ 1.7beta4+ mounts `/data` into the sandbox; the dev console has it) with `/app0/` fallback (writable nullfs of the title folder). Log file + `sceKernelDebugOutText` so klog shows it. Pairing identity, hosts and settings as a small text/JSON file. |
| Build, package, deploy | ps5-native-app-boilerplate `tools/`, `tooling/native/`, `runtime/` | Import as-is; add `tools/build-deps.sh`. |
Threads at run time: ihslib client thread, session receive thread (+ its timers), video
decode thread, present thread (vblank), audio output thread, pad poll thread (4 ms),
UI/main thread. The console gives a title 13 usable cores; the SDK reports 16, so size
nothing from `sysconf`. Give every thread an explicit stack (256 KB+); the default may
not match what the library expects (rpcs3-PS5 wraps `pthread_create` for this).
Lifecycle, following ProsperoLight: the launcher and the stream never own the display at
the same time. Launcher tears down (EGL display, its pad handle, its audio port) →
stream opens VideoOut, pads, AudioOut → stream ends → launcher is rebuilt. Returning
from HFR/HDR modes waits for the TV to settle.
## 5. Build system (deliberately plain)
One `make`, from a fresh clone, on Arch or Debian/Ubuntu/WSL:
```bash
git clone --recursive <repo>
cd steamlink-ps5
make doctor # checks clang/lld 18, python3, curl, wget, unzip
make # → dist/<TITLE_ID>/
PS5_HOST=192.168.0.192 make deploy
```
- `Makefile` and `tools/build.sh` come from the boilerplate: they fetch and verify the
pinned public ps5-payload-dev SDK and zlib into `.deps/`, compile every file under
`src/` with the SDK's clang, link with `prospero-lld` plus `--wrap`/`--defsym` bindings,
convert the ELF to a development FSELF with the repository-owned converter, generate the
clean-room `runtime/libc.prx`, and assemble `dist/<TITLE_ID>/{eboot.bin,sce_sys,sce_module}`.
- `tools/build-deps.sh` (modelled on ProsperoLight's `build-stream-deps.sh`) builds
`build/deps/lib{ihslib,protobuf-c,mbedcrypto,mbedtls,mbedx509,opus}.a` once with the
same cross compiler. ihslib and protobuf-c are compiled from explicit file lists;
mbedtls and opus through their own CMake with the SDK's `toolchain/prospero.cmake`,
with pkg-config disabled (`PKG_CONFIG_LIBDIR=/nonexistent`).
- `tools/deploy.sh` (already in this repo, adapted from rpcs3-PS5): curl FTP upload of
`dist/<TITLE_ID>` to `/data/homebrew/`, `eboot.bin` and `param.json` last, size check
afterwards. `tools/ps5sh.py` for `launch`/`kill`.
- Third-party code is git submodules pinned by commit, listed in `PINS.md` with the
reason for each pin, as rpcs3-PS5 does. Patches live in `patches/<project>/` and are
applied by `tools/build-deps.sh`, never committed into the submodule.
- No CMake at the top level, no host package discovery, nothing installed globally.
Toolchain note: the boilerplate insists on LLVM 18 (`llvm-config-18`); on Arch that is
the `llvm18`/`clang18`/`lld18` packages (to verify in M0), not the system clang.
- Optional later: `make ffpfsc` (compressed image) and a GitHub Actions build, both
already written in the boilerplate.
Proposed layout:
```
Makefile PINS.md PLAN.md README.md LICENSE
sce_sys/ param.json, icon0.png (pic0/pic1/snd0 stay out of git)
src/
main.cpp storage, log, launcher ↔ stream handover
app/ hosts registry, pairing identity, settings (plain files)
ui/ screens (text UI first, ps5-homebrew-ui from M5)
stream/ session owner: ihslib callbacks → sinks, stats, overlay chords
video/ videodec2.cpp, agc_present.cpp (from ProsperoLight)
audio/ opus_audioout.cpp
hid/ps5/ IHS_HIDProvider for ScePad (template: ihslib/src/hid/sdl)
platform/ ps5_sockets.c, ps5_entropy.c, ihs_thread_posix.c, runtime shims
patches/ ihslib/*.patch
third_party/ ihslib, protobuf-c, mbedtls, opus, ps5-homebrew-ui (submodules)
tooling/ runtime/ boilerplate's converter, CRT, libc.prx builder (imported)
tools/ build.sh, build-deps.sh, deploy.sh, ps5sh.py, doctor.sh
vendor/ps5/sdk/stubs/ link stubs for VideoDec2, VideoOut, AGC driver (from ProsperoLight)
docs/
```
## 6. Milestones
Each milestone ends with something that runs on the console; the dev console is the
PS5 at `192.168.0.192` (firmware 13.20, etaHEN, ftpsrv 2121, shsrv 2323, klogsrv 3232,
ShadowMount+ 1.7beta4). Pick a title ID now (`PPSA99350` unless homebrew.page wants
another; rpcs3 is `PPSA99300`, ProsperoLight `PPSA99002`).
**M0 — Skeleton that launches (½–1 day).** Import the boilerplate (`tools/`, `tooling/`,
`runtime/`, `Makefile`, `sce_sys/`), `make init TITLE_ID=… APP_NAME=…`, `make`, `make
deploy`, title on the home screen, a line in klog and a file under `/data/steamlink-ps5/`.
Decide the Arch LLVM 18 question. Done when the edit-deploy-launch-log loop takes under a
minute.
**M1 — Protocol on the console (2–4 days).** Cross-compile protobuf-c, mbedtls, opus,
ihslib with the pthread backend; port `ps5_sockets.c` and `ps5_entropy.c`. A headless
flow driven by the text UI: broadcast discovery finds the Steam PC, the screen shows a
PIN, the user pairs in Steam, the identity persists, a streaming request succeeds, the
session connects, and the log counts video and audio frames with their sizes and codec.
Validates the three protocol risks (broadcast in the sandbox, socket receive buffer size,
Steam version compatibility) before any media code exists.
**M2 — Video (3–5 days).** VideoDec2 sink for H.264 at 1080p60, AGC presentation,
keyframe wait on loss, `setCaptureSize` honoured, frame-stage reporting. Then HEVC
(`enableHevc`) and 1440p/2160p request sizes. Measure access-unit-to-flip with the
ProsperoLight method; expect ~17 ms.
**M3 — Audio (1–2 days).** Opus → AudioOut stereo, bounded backlog, clean start/stop with
the session. 5.1 is a setting later.
**M4 — Input (3–5 days).** ScePad HID provider for player 1 (buttons, sticks, triggers,
gyro/accel), then hot-plug and 4 users, rumble via HID writes, touchpad as mouse,
PS-button chord for the overlay, `sceImeDialog` for text. This is the milestone with the
most protocol uncertainty: nsteamlink's log shows several report-format revisions
(48-byte SDL layout vs host-requested lengths, "Generic Gamepad" announcement). Start from
upstream ihslib's SDL layout, verify against Steam's controller settings page, and adopt
nsteamlink's findings as needed. Done when a game plays end to end with two controllers.
**M5 — Launcher (3–5 days).** ps5-homebrew-ui screens: hosts list with live state,
pairing with countdown, settings (resolution cap, HEVC, audio channels, stream interface:
Big Picture / desktop / recent games), in-stream overlay (stats, disconnect, stop game),
error states and reconnect. Auto-connect to the last host on launch so the TV flow is one
button.
**M6 — Polish (ongoing).** Quality control feedback (`setTargetBitrate` etc. are host→
client in this protocol: honour them), stall recovery, HDR (Main10) if Steam offers it,
120 Hz, USB keyboard/mouse passthrough (ProsperoLight has it), microphone (ihslib has the
channel), self-update kit.
**M7 — Release.** README with a real build matrix, `PINS.md`, GitHub Actions build,
homebrew.page listing. Unlike rpcs3-PS5 (GPL-2-only RPCS3 + GPL-3 SDK), every component
here is GPL-3-compatible and the runtime shim is clean-room, so built titles can be
shared the way ProsperoLight's are.
Rough total to a playable client (M0–M4): two to three weeks of focused work; the
launcher adds one more.
## 7. Risks and unknowns
| Risk | Likelihood | Mitigation |
| --- | --- | --- |
| Steam changes the protocol; ihslib lags | Medium, ongoing | Pin ihslib; nsteamlink's author upstreams fixes; keep Moonlight (ProsperoLight) as the documented fallback for users. |
| UDP broadcast discovery blocked in the title sandbox | Low–medium | ProsperoLight does mDNS multicast from a title, so sockets are fine; verify `SO_BROADCAST` in M1; manual IP entry is a feature anyway. |
| sceNet caps `SO_RCVBUF` well below the 4 MB ihslib asks for (nsteamlink hit ~42 KB on libnx) | Medium | Measure in M1 (`ps5_sockets.c` already logs requested vs actual); if capped, drain the socket on a dedicated thread into a userspace ring. |
| Input report format mismatch (host ignores or misreads the pad) | Medium | See M4; two independent implementations to compare against (upstream SDL provider, nsteamlink's revisions). |
| VideoDec2 is not in the public SDK | None in practice | ProsperoLight's link stubs and struct definitions (GPL-3) are proven on 6.02 and 12.70; the dev console is 13.20, which runs ProsperoLight-era homebrew. |
| Decoder bitrate ceiling at 4K HEVC (~80 Mb/s measured) | Known | Steam's default quality tiers sit far below; expose a resolution cap. |
| Thread stacks and core count | Known | Explicit stack sizes; never size pools from `sysconf`. |
| `/data` absent on older ShadowMount+ | Known | `/app0` fallback; log the chosen path at startup. |
| Trademark | — | Name the app something that is not "Steam Link" (e.g. "Prospero Remote Play"); the repo name is fine. Unofficial, personal-use client. |
## 8. Day-to-day loop
```bash
make # or: make deps once, then make
tools/ps5sh.py "$PS5_HOST" kill # stop the running title
PS5_HOST=… make deploy # or tools/deploy.sh
tools/ps5sh.py "$PS5_HOST" "launch PPSA99350"
setsid nohup ~/.claude/skills/ps5-console/scripts/ps5klog.sh "$PS5_HOST" /tmp/klog.txt &
tail -F /tmp/klog.txt | grep -E "\[SLP5\]|signal:|proc name:|reason:"
curl -s "ftp://$PS5_HOST:2121/data/steamlink-ps5/log.txt"
```
Keep a Steam PC on the LAN with Remote Play enabled and a controller-friendly game; use
Steam's own `logs/streaming_log.txt` on the PC as the second half of every investigation,
as nsteamlink's docs do.
## 9. Open questions for the owner
1. Title ID and display name (`PPSA99350`, "Prospero Remote Play"?).
2. Text UI through M4 and the OpenGL kit from M5 (recommended), or the kit from M0?
3. Any interest in the Moonlight fallback being mentioned in the README, or Steam-only?
+136 -17
View File
@@ -1,26 +1,145 @@
# steamlink-ps5
# Steam Remote Play for PS5
A native Steam Remote Play ("Steam Link") client for a jailbroken PS5, built as a
homebrew title. The goal is the TV Steam Link experience: the console finds the PC
running Steam, pairs with a PIN, and streams Big Picture Mode with DualSense
controllers, hardware video decoding and low latency.
A native Steam Remote Play ("Steam Link") client for jailbroken PS5 consoles. The console
finds your PC running Steam, pairs with a PIN, and streams Big Picture Mode with hardware
video decoding and the DualSense — the TV Steam Link experience, on the PS5.
**Status: planning.** Read [PLAN.md](PLAN.md) for the research, the chosen
architecture and the milestones. Nothing builds yet.
**Status: working.** Streams 1080p60 with audio and controller input, and is playable.
Not affiliated with Valve or Sony.
## Planned workflow
| | |
| --- | --- |
| **Tested on** | PS5 CFI-2015, firmware **13.20**, etaHEN + ShadowMount+ 1.7beta4 |
| **Video** | H.264, hardware decode (VideoDec2), up to 1080p60, scaled to fit |
| **Audio** | Opus → stereo |
| **Controller** | DualSense over ScePad, seen by Steam as a real PS5 controller |
| **Build host** | Linux or WSL; the PS5 payload SDK is fetched for you |
## Features
| Feature | State |
| --- | --- |
| LAN host discovery, PIN pairing, persisted pairing key | Verified on hardware |
| H.264 hardware decode, any stream resolution scaled to fit the screen | Verified |
| Mid-stream resolution changes | Verified |
| Opus stereo audio | Verified |
| DualSense input, reported to Steam as a PS5 controller (VID 054C / PID 0CE6) | Verified |
| Options + touchpad click → Steam menu; hold 1.5 s → leave the stream | Verified |
| Auto-connect to the last PC, settings UI, smoothing (jitter) buffer | Verified |
| Rumble and lightbar | Implemented, **unverified** — Steam's Identify/LED controls don't reach a streamed pad, so it needs a game to exercise |
| Gyro / accelerometer | Implemented, unverified |
| HEVC, 5.1 audio, up to 4 controllers | Implemented, unverified (H.264 / stereo / one pad are what's been used) |
| 120 Hz output | Implemented, **untested** — no 120 Hz display available; see below |
| Keyboard, mouse, microphone, HDR | Not implemented |
| Touchpad as a trackpad | Not possible over Remote Play — the host exposes no touchpad for a streamed controller. The touchpad *click* works as a bindable button |
### About 120 Hz
The client measures its real display refresh rate and advertises it during negotiation, so
the host streams at the rate the console can actually show (nothing is hard-coded — Steam's
own Remote Play settings decide, same as for bitrate). It also asks the console for 120 Hz
output at startup, which needs `attribute3 = 0x80040` in `sce_sys/param.json` (set) plus a
120 Hz panel and the system's **Enable 120 Hz Output** setting. On a 60 Hz display the
console declines with `UNAVAILABLE_OUTPUT_MODE` and the app stays at 60 Hz — the intended
fallback. The 120 Hz path has never run on a 120 Hz display, so treat it as unproven.
## Requirements
**Console** — a jailbroken PS5 (developed on firmware 13.20) with:
- a payload loader (etaHEN or equivalent)
- `ftpsrv` on port 2121 — used to deploy
- `ShadowMount+` **1.7beta4 or newer** — it mounts `/data` into the title sandbox (the app
needs it for its settings and log) and registers homebrew on the home screen
- `shsrv` on port 2323 — optional, only for the command-line dev loop
**PC** — Steam with Remote Play enabled, on the same network.
**Build host** — Linux or WSL with `git`, `make`, `python3`, `curl`.
## Building
```bash
git clone --recursive <this repo>
make # dist/<TITLE_ID>/ (eboot.bin, sce_sys/, sce_module/)
PS5_HOST=192.168.x.x make deploy # upload over FTP (ftpsrv, port 2121)
git clone --recursive https://github.com/<you>/steamlink-ps5
cd steamlink-ps5
make doctor # checks the host for what the build needs, changes nothing
make # fetches the SDK on first run, then builds
```
Produces `dist/PPSA99350/` (`eboot.bin` + `sce_sys/`) and `dist/PPSA99350.zip`.
## Installing
```bash
PS5_HOST=192.168.x.x make deploy
```
Uploads the title to `/data/homebrew/PPSA99350/` over FTP. ShadowMount+ picks it up within
about 15 seconds and it appears on the home screen. Copying `dist/PPSA99350/` there by hand
works just as well.
For the edit-build-test loop (needs `shsrv`), `tools/relaunch.sh` stops the title, uploads,
relaunches it and prints the log:
```bash
PS5_HOST=192.168.x.x tools/relaunch.sh
```
## Using it
1. On the PC: Steam → Settings → Remote Play → enable it.
2. Launch **Steam Remote Play** on the console. It finds the PC by itself.
3. First time only: Steam shows a PIN — type it on the console.
4. It connects and streams Big Picture.
While streaming, **Options + touchpad click** opens the Steam menu (the PS button belongs to
the console, so it can't be forwarded). Holding both for 1.5 s leaves the stream.
Press Options on the launcher for settings:
| Setting | Default | Meaning |
| --- | --- | --- |
| Resolution | 1080p | cap requested of the host |
| Bitrate cap | AUTO | advertises 100 Mbit so Steam's own bandwidth setting is the limit |
| Quality | Fast | host encoder preset |
| HEVC | Off | offer HEVC instead of H.264 (unverified) |
| Auto connect | On | reconnect to the last PC without asking |
| Smoothing buffer | 2 frames | frames held before display to absorb network jitter, ~17 ms each. 0 = lowest latency, more stutter |
The console writes `/data/steamlink-ps5/log.txt` (readable over FTP) and mirrors it to the
kernel log.
## Known issues and host-side gotchas
- **Keep the PC's display awake.** When it sleeps, Steam stops capturing and the stream goes
black or hitches badly. This looks exactly like a client bug and isn't one.
- **Linux/Wayland hosts need `steam -pipewire`.** Without it Steam captures a black frame.
Start Steam with that flag (and click *Share* on the monitor picker).
- Games see the forwarded pad as an XInput device; only Steam Input sees a DualSense. That is
how Remote Play forwards controllers, not a limitation of this client.
- On a weak Wi-Fi link, packet loss shows up as brief artifacts rather than freezes — the
client deliberately keeps decoding through small losses instead of blanking.
## How it works
- **Protocol** — [ihslib](https://github.com/mariotaku/ihslib), built from a patched copy;
`patches/ihslib/` holds the changes and `PINS.md` explains every pinned revision.
- **Video** — VideoDec2 hardware decode → CPU NV12→RGBA (4 threads) → tiled blit to VideoOut,
behind a small frame-pacing buffer.
- **Audio** — Opus → `sceAudioOut`.
- **Input** — ScePad behind an ihslib HID provider that advertises the DualSense VID/PID.
`docs/` covers the native tooling and PS5 platform notes.
## Credits
[ihslib](https://github.com/mariotaku/ihslib) (protocol) and its
[`auth` branch](https://github.com/scaronni/ihslib) for current Steam pairing;
[ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight) and
[ps5-native-app-boilerplate](https://github.com/blackbearreloaded/ps5-native-app-boilerplate)
for the PS5 platform layer and build system; protobuf-c, Mbed-TLS, Opus and TLSF.
## Licence
GPL-3.0-or-later for this repository's own code. Protocol code comes from
[ihslib](https://github.com/mariotaku/ihslib) (LGPL-3.0); PS5 platform code is
adapted from [ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight)
and [ps5-native-app-boilerplate](https://github.com/blackbearreloaded/ps5-native-app-boilerplate)
(GPL-3.0-or-later). No Sony SDK, firmware, keys or Valve code are included.
Unofficial: not affiliated with Valve or Sony.
GPL-3.0-or-later for this repository's own code; see [LICENSE](LICENSE) and
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). No Sony SDK, firmware or keys, and no Valve
code, are included or required. Build it for your own console.
@@ -1,66 +0,0 @@
From: steamlink-ps5
Subject: TEMP diagnostics - log data channel window state and add-branch counts
diff -ru a/src/session/channels/ch_data.c b/src/session/channels/ch_data.c
--- a/src/session/channels/ch_data.c 2026-10-09 17:06:31.727083938 -0700
+++ b/src/session/channels/ch_data.c 2026-10-09 17:06:31.740381074 -0700
@@ -132,8 +132,21 @@
return;
}
IHS_SessionLog(channel->base.session, IHS_LogLevelInfo, "Data", "%s channel started", channelName);
+ extern unsigned long g_slp_add_accept, g_slp_add_old, g_slp_add_far, g_slp_add_overflow, g_slp_add_dup;
+ void IHS_SlpWindowDebug(const IHS_SessionPacketsWindow *, int *, int *, int *, unsigned *);
+ uint64_t slpLastLog = 0;
while (!channel->interrupted) {
IHS_MutexLock(channel->windowLock);
+ uint64_t slpNow = IHS_TimerNow();
+ if (slpNow - slpLastLog >= 1000) {
+ slpLastLog = slpNow;
+ int sz, hp, tp; unsigned tid;
+ IHS_SlpWindowDebug(channel->window, &sz, &hp, &tp, &tid);
+ IHS_SessionLog(channel->base.session, IHS_LogLevelInfo, "WIN",
+ "%s size=%d head=%d tail=%d tailid=%u add[ok=%lu old=%lu far=%lu ovf=%lu dup=%lu]",
+ channelName, sz, hp, tp, tid, g_slp_add_accept, g_slp_add_old, g_slp_add_far,
+ g_slp_add_overflow, g_slp_add_dup);
+ }
uint16_t discarded = IHS_SessionPacketsWindowDiscard(channel->window, DISCARD_DIFF);
if (discarded > 0) {
IHS_SessionLog(channel->base.session, IHS_LogLevelDebug, channelName, "Discarded %u packets", discarded);
diff -ru a/src/session/window.c b/src/session/window.c
--- a/src/session/window.c 2026-10-09 17:06:31.727198132 -0700
+++ b/src/session/window.c 2026-10-09 17:06:31.740315966 -0700
@@ -88,6 +88,15 @@
window->tail.pos = -1;
}
+unsigned long g_slp_add_accept = 0, g_slp_add_old = 0, g_slp_add_far = 0, g_slp_add_overflow = 0, g_slp_add_dup = 0;
+
+void IHS_SlpWindowDebug(const IHS_SessionPacketsWindow *window, int *size, int *headpos, int *tailpos, unsigned *tailid) {
+ *size = IHS_SessionPacketsWindowSize(window);
+ *headpos = window->head.pos;
+ *tailpos = window->tail.pos;
+ *tailid = window->tail.id;
+}
+
bool IHS_SessionPacketsWindowAdd(IHS_SessionPacketsWindow *window, IHS_SessionPacket *packet) {
/* Calculate distance of 2 items */
int tailOffset = window->tail.pos < 0 ? 1 : (int16_t) (packet->header.packetId - window->tail.id);
@@ -107,6 +116,7 @@
* asks the host for a keyframe.
*/
if (tailOffset > window->capacity) {
+ g_slp_add_far++;
return false;
}
/* Large offset means overflow, abort processing and hangup. Keep one slot free: a ring with
@@ -118,8 +128,10 @@
IHS_SessionWindowItem *tailPkt = &window->data[writePos];
/* Ignore if the slot is used */
if (FrameItemIsUsed(tailPkt)) {
+ g_slp_add_dup++;
return true;
}
+ g_slp_add_accept++;
FrameItemUsePacket(tailPkt, packet);
/* Only do incremental update */
-88
View File
@@ -275,41 +275,6 @@ void draw_stream(ps5::demo::Canvas &canvas) noexcept
if (slp::video::latest(frame) && frame.width > 0 && frame.height > 0)
{
status.have_picture = true;
// (diag) presentation cadence: at 60 Hz vsync, how many vblanks show a NEW decoded
// frame vs repeat the last one (a judder stall) vs skip past 2+ (arrival clumped).
// Smooth 60 fps = ~60 new/s, 0 repeat, 0 skip. repeats+skips/s is the judder rate.
{
static std::uint64_t d_t0 = 0, d_last = 0, d_vbl = 0, d_new = 0, d_rep = 0, d_skip = 0, d_drop = 0;
const std::uint64_t t = now_ms();
if (d_t0 == 0)
d_t0 = t;
++d_vbl;
if (d_last != 0)
{
const std::uint64_t delta = frame.serial - d_last;
if (delta == 0)
++d_rep;
else
{
++d_new;
if (delta >= 2)
{
++d_skip;
d_drop += delta - 1;
}
}
}
d_last = frame.serial;
if (t - d_t0 >= 1000)
{
slp::log("present: %llu vbl/s new=%llu repeat=%llu skip=%llu dropped=%llu",
static_cast<unsigned long long>(d_vbl), static_cast<unsigned long long>(d_new),
static_cast<unsigned long long>(d_rep), static_cast<unsigned long long>(d_skip),
static_cast<unsigned long long>(d_drop));
d_t0 = t;
d_vbl = d_new = d_rep = d_skip = d_drop = 0;
}
}
// Scale the decoded frame to fill 1920x1080, preserving aspect (letterbox).
// The host streams at its own aspect (e.g. 1280x536), so a 1:1 copy would
// leave most of the screen black; scaling makes any resolution fill.
@@ -704,38 +669,6 @@ IHS_StreamVideoSubmitResult on_video_submit(IHS_Session *session, IHS_Buffer *da
{
status.video_frames.fetch_add(1);
status.video_bytes.fetch_add(data->size);
// (diag) arrival jitter: gap between consecutive decoded-frame deliveries. At 60 fps
// the ideal is a steady ~16-17 ms. Bin per second: on-time (<=20ms), late (20-33ms, a
// missed vblank), hitch (>33ms). A steady source shows almost all on-time; many late/
// hitch = network arrival jitter, which with no buffer becomes visible judder.
{
static std::uint64_t a_t0 = 0, a_last = 0, a_on = 0, a_late = 0, a_hitch = 0, a_max = 0;
const std::uint64_t t = now_ms();
if (a_t0 == 0)
a_t0 = t;
if (a_last != 0)
{
const std::uint64_t gap = t - a_last;
if (gap > a_max)
a_max = gap;
if (gap <= 20)
++a_on;
else if (gap <= 33)
++a_late;
else
++a_hitch;
}
a_last = t;
if (t - a_t0 >= 1000)
{
slp::log("arrival: on_time=%llu late=%llu hitch=%llu maxgap=%llums",
static_cast<unsigned long long>(a_on), static_cast<unsigned long long>(a_late),
static_cast<unsigned long long>(a_hitch), static_cast<unsigned long long>(a_max));
a_t0 = t;
a_on = a_late = a_hitch = 0;
a_max = 0;
}
}
const bool keyframe = (flags & IHS_StreamVideoFrameKeyFrame) != 0;
const std::uint16_t frame_id = IHS_SessionLastSubmittedVideoFrameId(session);
const std::uint64_t started = now_ms();
@@ -750,27 +683,6 @@ IHS_StreamVideoSubmitResult on_video_submit(IHS_Session *session, IHS_Buffer *da
g_submit_finished_ms = now_ms();
if (took > 40)
g_submit_slow.fetch_add(1);
// (diag) decode+convert cost per frame: at 120 fps the whole budget is 8.3 ms, so the
// old ">40 ms = slow" counter says nothing about 120 Hz headroom. Report avg/max/s.
{
static std::uint64_t c_t0 = 0, c_sum = 0, c_max = 0, c_n = 0, c_over8 = 0;
if (c_t0 == 0)
c_t0 = started;
c_sum += took;
++c_n;
if (took > c_max)
c_max = took;
if (took > 8)
++c_over8;
if (started - c_t0 >= 1000)
{
slp::log("decode: avg=%.1fms max=%llums over8ms=%llu/%llu", c_n ? static_cast<double>(c_sum) / c_n : 0.0,
static_cast<unsigned long long>(c_max), static_cast<unsigned long long>(c_over8),
static_cast<unsigned long long>(c_n));
c_t0 = started;
c_sum = c_max = c_n = c_over8 = 0;
}
}
if (ok)
return IHS_StreamVideoSubmitOK;
// A failed decode means we wait for a keyframe. Reporting it makes ihslib reset