upto 13.52 firmware and other fixes and changes

changed game detection logic, resolving retro games and homebrew, new rpc style, custom options in config.json, installer is not fixed yet

Co-Authored-By: SirHumza <204067870+SirHumza@users.noreply.github.com>
This commit is contained in:
CyberMask367andSirHumza committed 2026-10-06 03:06:28 +01:00
1 parent d8f6e541d9
commit e35c47b3f5
86 files changed
+14097 -2000

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Scripts are built and run from WSL/Linux but checked out on Windows, where
# core.autocrlf=true rewrites LF as CRLF. A CRLF shebang makes the kernel fail
# with "cannot execute: required file not found", which reads like a missing
# file rather than a line-ending problem.
*.sh text eol=lf
Makefile text eol=lf
*.mk text eol=lf
*.py text eol=lf
*.x text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.c text eol=lf
*.h text eol=lf
*.s text eol=lf
*.ini text eol=lf
+28 -18
View File
@@ -17,35 +17,45 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The release ZIP (v0.9) predates the SDK's 13.52 offsets, and the SDK's
# crt/patch.c makes _start() terminate before main() for unlisted
# firmware -- so a ZIP-built payload dies silently on 13.52. Build from
# git instead. Recipe from drakmor/nanoDNS's ps4 workflow, which ships a
# working 13.52 payload over the same elfldr path.
- name: checkout ps4-payload-sdk
uses: actions/checkout@v4
with:
repository: ps4-payload-dev/sdk
ref: 573b4a0 # Add 14.00 offsets; includes 959e4ed "Add 13.52 offsets"
path: sdk
- name: install llvm
run: sudo apt-get update -qq && sudo apt-get install -qy llvm lld
- name: fetch ps4-payload-sdk
run: sudo apt-get update -qq && sudo apt-get install -qy clang-18 lld-18 llvm-18
- name: build SDK from source
run: |
curl -sL -o /tmp/sdk.zip https://github.com/ps4-payload-dev/sdk/releases/latest/download/ps4-payload-sdk.zip
mkdir -p ~/ps4-payload-sdk && unzip -q -o /tmp/sdk.zip -d ~/ps4-payload-sdk
make -C sdk DESTDIR="$HOME/ps4-payload-sdk" clean install
- name: gate SDK firmware support
# __patch_init() errors for firmware absent from this table, and
# crt/crt.c turns that into payload_terminate() before main().
run: |
grep -q "case 0x1352:" sdk/crt/patch.c || {
echo "FAIL: SDK lacks 13.52 offsets; payload cannot boot on 13.52"; exit 1; }
echo "SDK has 13.52 offsets"
- name: build SDK daemon payload
run: |
export PS4_PAYLOAD_SDK="$HOME/ps4-payload-sdk/ps4-payload-sdk"
export PATH="/usr/lib/llvm-14/bin:$PATH"
export PS4_PAYLOAD_SDK="$HOME/ps4-payload-sdk"
export PS4_SDK_SRC="$GITHUB_WORKSPACE/sdk"
./scripts/build_sdk.sh
- name: gate daemon-world linkage only
run: |
export LLVM_READELF="$(command -v llvm-readelf || echo /usr/lib/llvm-14/bin/llvm-readelf)"
export LLVM_READELF="$(command -v llvm-readelf-18 || command -v llvm-readelf)"
NEEDED=$("$LLVM_READELF" -d build-sdk/orbisrpc_sdk.elf | grep NEEDED | sed 's/.*\[//;s/\]//')
echo "$NEEDED"
echo "$NEEDED" | grep -q libkernel_web.sprx || { echo "missing libkernel_web"; exit 1; }
echo "$NEEDED" | grep -q libSceLibcInternal.sprx || { echo "missing libSceLibcInternal"; exit 1; }
echo "$NEEDED" | grep -q libSceNet.sprx || { echo "missing libSceNet"; exit 1; }
# Foreground detection is sceSystemServiceGetAppIdOfBigApp +
# sceLncUtilGetAppTitleId. Without this module the payload cannot
# start at all, so assert it rather than trusting the link line.
echo "$NEEDED" | grep -q libSceSystemService.sprx || { echo "missing libSceSystemService (foreground detection)"; exit 1; }
if echo "$NEEDED" | grep -q 'libkernel\.so'; then echo "FORBIDDEN: app-world libkernel.so"; exit 1; fi
echo "linkage OK"
consistency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: version parity (version.h vs installer Makefile)
run: |
V_H=$(grep -oE '#define ORBISRPC_VERSION "[^"]+"' orbisrpc/version.h | cut -d'"' -f2)
V_MK=$(grep -oE '^VERSION[[:space:]]*:=.*' installer/Makefile | sed 's/.*:= *//')
echo "version.h=$V_H Makefile=$V_MK"
[ -n "$V_H" ] && [ "$V_H" = "$V_MK" ] || { echo "VERSION MISMATCH"; exit 1; }
echo "versions match"
+1
View File
@@ -26,5 +26,6 @@ installer/x64/
installer/eboot.bin
installer/pkg.gp4
installer/assets/daemon.elf
installer/assets/evict.elf
installer/sce_sys/param.sfo
tests/test_utils_asan
-86
View File
@@ -1,86 +0,0 @@
# orbisRPC Architecture
Discord Rich Presence for jailbroken PS4. No PC at runtime: the daemon
talks to the Discord gateway directly.
## Binaries
| Binary | Built by | Runs as |
|---|---|---|
| `orbisrpc.elf` | `scripts/build.sh` (OpenOrbis) | BinLoader / Payload Guest payload |
| `orbisrpc_sdk.elf` | `scripts/build_sdk.sh` (ps4-payload-sdk) | PKG `assets/daemon.elf`, copied to `/data/payloads/orbisrpc.bin` |
| Installer PKG (`ORPC00001`) | `installer/Makefile` | Setup app: SDL2 UI, token entry, file staging |
| GoldHEN plugin | frozen | not shipped; payload is the supported path |
Both payload builds share `orbisrpc/*.c`. `ORBISRPC_SDK_PAYLOAD`
selects toolchain/libc differences only — detection strategy is
identical (probe-first, see below).
## Detection cascade (ordered, first signal wins)
1. **msgbuf events** (`focus.c`): `AppFocusChanged [...]` in
kern.msgbuf. Event-driven, sees system screens (settings/browser),
resolves multi-app ambiguity. Unreadable buffer = skip.
2. **ShellCoreUtil dlopen** (`detect.c` `scu_init`): `sceShellCoreUtilIsAppLaunched`.
Firmware-independent (libkernel exports dlopen/dlsym). Fail = skip.
3. **sysctl eboot scan** with the verified kinfo table (`fw.c`):
exact offset only on firmwares in the table (currently 9.00);
everywhere else a bounded in-record scan. Never reads past recsz,
never assumes.
4. **Sandbox/save fallbacks**: `/mnt/sandbox/<TITLE>_000`, freshest
savedata / app dir / `app.pkg` atime.
New title needs 2 consecutive polls (~2s debounce). Same title
returning within 10 min resumes its timer; cross-restart resume seeds
from `session.json` (validated: 9-char id, sane epoch).
## Gateway
Explicit states, logged on transition: `DOWN -> CONNECTING ->
HELLO_WAIT -> IDENTIFYING -> READY`. Fresh IDENTIFY every connect
(no RESUME). Close 4004 (bad token) is a warning + backoff + config
reload — never an exit. Reconnect: exponential backoff with
deterministic jitter, 60s cap for 10 fails, then 10-min cadence.
Presence re-posts after every (re)connect plus a 15-min reconcile.
## No-exit policy
The daemon never exits on transient failure: no token (waits, FTP
edit lands without reboot), network drops, corrupt session file,
detection misses. Exits only on: stop request, lock held by a peer
(stands down), superseded generation, fatal `/data` unwritable.
Crash safety: consecutive unclean boots (never marked healthy after
`HEALTH_STABLE_SECS`) enter safe mode. Single-writer lock
(`/data/orbisRPC/lock`, O_EXCL + pid liveness). `daemon.gen`
generation protocol: installer bumps, older daemons exit cleanly.
## On-disk state (`/data/orbisRPC/`)
| File | What |
|---|---|
| `config.json` | token, prefs, learned titles. Atomic save, `0600` |
| `session.json` | live session for timer resume. Validated on load |
| `status.json` | heartbeat (state/title/version/ts), every 60s |
| `diag.json` | boot census: fw, kinfo-table hit, msgbuf/sandbox/shellcore reachability |
| `daemon.gen` | install generation for supersede |
| `install.log` | installer stage log (errno + sizes per step) |
| `log.txt` | daemon log, mirrored to klog |
| `playtime.log` | append-only ledger, readers total it |
## Installer
Thin layer: preflight assets, `copy_file` with FNV read-back proof
(no stat — sandbox lies), config preserved on reinstall, 3-try IME
token entry with FTP fallback, gen bump, done screen reports live
daemon state from `status.json`. Every step logs to `install.log`;
crash guard turns faults into log lines. UI is custom SDL2
(`installer/ui.c`, same `ui.h` API); font is build-time rasterized
(`scripts/genfont.py` -> `installer/font.h`).
## Compatibility
Verified baseline: **9.00**. All other firmwares: probe-and-degrade
by design, `diag.json` proves per-box signal availability. The
firmware matrix fills in from user-submitted `diag.json` files —
see `SUPPORT.md` and `docs/`.
+75
View File
@@ -0,0 +1,75 @@
# HANDOFF — OrbisRPC PS4 Rich Presence
Date: 2026-09-24. Branch: `main` (pushed). Console: PS4 9.00, GoldHEN 2.4,
`192.168.1.136` (FTP :2121 anon, klog :3232, BinLoader one-shot :9020).
## Folder layout (repo root)
- `orbisrpc/` — all daemon sources. Key files: `daemon.c` (loop/sessions),
`discord.c` (presence serialize), `detect.c` (game finding), `tmdb.c` +
`art_table.h` (Sony metadata), `art.c` (mp: proxy resolve), `tls.c` +
`ws.c` (transport), `updater.c` + `manifest.c` + `health.c` (updates),
`timesync.c` (SNTP), `lock.c`, `cfg.c`, `log.c`, `jsonlite.c`.
- `scripts/build_sdk.sh` — builds the daemon payload with ps4-payload-sdk
(`build-sdk/orbisrpc_sdk.elf`). Toolchain: `~/ps4-payload-sdk`, shim in
`~/llvmshim`. NEVER link `libkernel.so` (instant death in spawn context;
gate in CI + `tests/e2e_consumer.py`).
- `tests/` — host unit tests + e2e contracts. `make test`, `make asan`.
- `scripts/build_art_table.py` + `titles.txt` — regenerates art table/icons.
- `docs/injecting.md` — how payloads get onto the console (one-shot rule!).
- `findings/` — hardware research archive (loaders, TLS PSA, IME, linkage).
- `scripts/ps4_watch.py` — one-shot console state diff (repo copy may be
absent; logic: FTP file sizes + ports + newest screenshot).
## Existing issues (ranked, all reproduced)
1. **Cover art tile** — shows "?" instead of game art. mp: proxy renders
(proven live), uploaded asset stuck `private`, external URLs drop the
whole activity. Daemon resolves mp: per session (`art: resolved mp` in
log). Needs: propagation wait or per-title asset strategy (below).
2. **Stale title on fresh launches** — save-mtimes get bulk-touched by
cloud sync (all 28 titles shared one mtime!); app.pkg atime added as
sharper signal; eboot-set change fast-path added. Unproven live.
3. **Game crashes (CE-34878-0)** on some titles with daemon on — no
mechanism found after full audit; console has prior crash history.
Needs clean-boot test (reboot, no payload, launch).
4. **IME keyboard** never opens (0x80BC0010 through six fixes). Token is
pre-seeded via FTP; do not block on this.
5. **Rest Mode** — wake/resume coded, never tested through a real cycle.
6. **Updater drill** — staged-update path never tested live.
7. **Plugin path** — PRX can't deploy via FTP (console converts SELF→ELF);
must ship inside PKG. Untested live.
8. **`main` vs `WIP` branches** — main = proven runtime line; WIP = old
1.0 product line (installer/app/docs). Do not merge blindly.
## What could be better (next agent: pick these up)
- **Rest Mode field test** + home-screen presence proof.
- **Updater live drill** (stage → reboot → rollback check).
- **IME**: needs on-console iteration with return-code logging (already
instrumented in `pkg/app_main.c` on the WIP line).
- **Plugin live test** via PKG delivery.
- **e2e/CI**: `.github/workflows/ci.yml` exists; extend coverage for new
modules (lock, timesync, art, session persistence).
- **Playtime ledger reader** (totals per title; data already banked).
- **Media-type table**: only Netflix/YouTube mapped; add IDs as found.
## Auto icons from Sony CDN (no manual adding) — the design
Goal: never hand-add a title again. Three layers, in order:
1. **Live TMDB-over-TLS** (`tmdb_https_get()` in `tmdb.c`, already coded):
queries `tmdb.np.dl.playstation.net:443` for any title ID at resolve
time. Port 80 is blocked on-console; **443 was never proven — first
job is running `probe_tmdb443.elf` on hardware.** If green, every
current and future game resolves fully automatically.
2. **Self-growing table fallback**: daemon already logs unknown title IDs
implicitly (raw-ID sessions). Add a CI job: parse `titles.txt` +
any new IDs seen, run `build_art_table.py`, commit regenerated
`art_table.h` + icons. Table then grows itself with zero hand edits.
3. **mp: proxy at post time** (`art.c`, proven rendering): whatever URL
layers 1–2 produce gets proxied per session; cache expires per game
switch so mappings never go stale.
Rule: hands touch art ZERO times — new game installs resolve through
layer 1, or land in the table via layer 2's automation.
+278
View File
@@ -0,0 +1,278 @@
# OrbisRPC Migration Progress Log
**Started:** 2026-09-29
**Repo:** `C:\Users\user\Documents\GitHub\orbisRPC`
**SDK Reference:** `ps4-payload-sdk-reference` (local checkout, not committed)
**Old Project:** `PS4-Rich-Presence-for-Discord/goldhen-rich-presence-bin` (local, not committed)
---
## Session 1: Understanding the Codebase (2026-09-29)
### What we reviewed
- **orbisRPC** — complete self-contained Discord Rich Presence daemon running on PS4 via GoldHEN
- **Architecture:** Payload ELF → direct TLS WebSocket → Discord gateway (no PC client needed)
- **Key modules:** daemon.c (loop), detect.c (sandbox mount + eboot count), discord.c/ws.c/tls.c (gateway), art.c/tmdb.c (artwork), updater.c (self-update), cfg.c (config + self-learning titles)
- **Installer:** One-tap PKG that stages payload + evict.elf + config, prompts for Discord token
### Key differences from old project (goldhen-rich-presence-bin)
| Aspect | Old | orbisRPC |
|--------|-----|----------|
| Transport | Payload → TCP:8000 → Python PC → Discord IPC | Payload → TLS WebSocket → Discord Gateway |
| Detection | kern.msgbuf polling | /mnt/sandbox/TITLEID_000 + eboot count |
| Game names | Title ID only | Multi-source: config → app.db → SFO → TMDB |
| Artwork | None | mp: proxy via external-assets (Sony CDN) |
| Timer | PC-side | Console-side (SNTP), survives reconnects/restarts |
| Updates | Manual | Self-updating from GitHub with rollback |
| Installer | Manual ncat send | PKG (OrbisRPC-Setup.pkg) |
### Known issues (from HANDOFF.md)
1. Cover art shows "?" — mp: proxy works but asset propagation needs work
2. Stale title on fresh launches — cloud sync bulk-touches save mtimes
3. Game crashes (CE-34878-0) on some titles with daemon running
4. IME keyboard never opens (token pre-seeded via FTP)
5. Rest Mode wake/resume untested
6. Updater drill untested
7. Plugin path (PRX) untested — must ship in PKG
---
## Session 2: Removed Plugin/PRX Support (2026-09-29)
**Decision:** Keep payload-only, remove all PRX plugin code.
### Changes made
| File | Change |
|------|--------|
| `plugin/` | **Deleted entirely** |
| `orbisrpc/daemon.h` | Removed `fixed_game_name` param from `daemon_run()` |
| `orbisrpc/daemon.c` | Removed plugin path from health check targets; removed plugin-mode logic |
| `orbisrpc/main.c` | `daemon_run(NULL)` → `daemon_run()` |
| `orbisrpc/detect.h/c` | Removed "(plugin mode)" comments |
| `orbisrpc/tls.h` | Removed "and plugin processes" |
| `orbisrpc/ws.c` | Removed "(plugin_unload joins this thread)" |
| `orbisrpc/updater.h/c` | Removed plugin asset download/stage logic |
| `orbisrpc/updater_util.c` | Removed `updater_self_ok()` (SELF check for PRXs) |
| `orbisrpc/compat.c/log.c/sfo.c` | Removed "plugin" references |
---
## Session 3: Flat Binary Build (2026-09-29)
**Request:** Convert build to produce flat `.bin` for BinLoader one-shot injection (like old project).
### Change to `scripts/build_sdk.sh`
Added `objcopy -O binary` after linking:
```bash
"$CC" -o "$OUT/orbisrpc_sdk.elf" "$OUT"/*.o
"$SDK/bin/orbis-objcopy" -O binary "$OUT/orbisrpc_sdk.elf" "$OUT/orbisrpc_sdk.bin"
```
### Build command (run in WSL)
```bash
PS4_PAYLOAD_SDK=/mnt/c/Tools/ps4-payload-sdk-reference ./scripts/build_sdk.sh
```
### Fixed
- Line endings (CRLF → LF) on `scripts/build_sdk.sh` and `scripts/build_evict.sh`
### Caveats to test
1. **`.bss` handling** — flat binary drops `.bss`; large static buffers will crash
2. **SDK libc** — global constructors may not run in flat binary
3. **Relocations** — works if position-independent (`-fPIC`/`-fpie`)
---
## Correction (2026-09-29, after review)
Sessions 3 and 4 above went in the wrong direction. Fixed:
- **Wrong SDK.** `ps4-payload-sdk-reference` is Scene-Collective libPS4 (no libc, runtime-resolved functions), the SDK of the old goldhen-rich-presence-bin project. orbisRPC needs libc, BSD sockets, mbedTLS and sqlite, so it builds with the ps4-payload-dev SDK instead (`ps4-payload-sdk.zip`, see `.github/workflows/ci.yml`, job `sdk-payload`). The OpenOrbis toolchain is for PKG apps and does not provide the `orbis-clang` that `build_sdk.sh` calls.
- **Flat `.bin` reverted.** The daemon ELF is dynamically linked (CI gate requires NEEDED `libkernel_web.sprx`, `libSceLibcInternal.sprx`, `libSceNet.sprx`). `objcopy -O binary` discards the dynamic section and relocations, and BinLoader has no dynamic linker, so the flat file would most likely not run (inference, untested). The `objcopy` lines were removed from `scripts/build_sdk.sh`; it outputs `build-sdk/orbisrpc_sdk.elf` again. Line endings verified LF, `sh -n` passes.
- The ELF is meant for elfldr (port 9021 per the repo docs) or Payload Guest.
### Correct SDK install (WSL)
```bash
sudo apt update && sudo apt install -y clang lld llvm unzip curl
mkdir -p ~/ps4-payload-sdk
curl -L -o /tmp/sdk.zip https://github.com/ps4-payload-dev/sdk/releases/latest/download/ps4-payload-sdk.zip
unzip -o /tmp/sdk.zip -d ~/ps4-payload-sdk
export PS4_PAYLOAD_SDK=$HOME/ps4-payload-sdk/ps4-payload-sdk
ls $PS4_PAYLOAD_SDK/bin
cd /mnt/c/Users/user/Documents/GitHub/orbisRPC
./scripts/build_sdk.sh
```
### Open question
How to move features from the old kern.msgbuf payload (AppFocusChanged / Kill App close detection, Home/Settings screen states) into orbisRPC. To be decided after the ELF builds and runs via elfldr.
---
## Session 4: Toolchain Setup (2026-09-29)
**Goal:** Install OpenOrbis LLVM toolchain (`orbis-clang`, `orbis-objcopy`) in WSL for building the flat `.bin`.
### Attempted approaches
1. **Prebuilt release URL (404)** — `llvm-15.0.7` release not found on GitHub
2. **Build from source** — `OpenOrbis-PS4-Toolchain` repo only contains headers/stubs; `build-toolchain.sh` doesn't exist
3. **System clang + lld** — Ubuntu's clang 18 lacks FreeBSD/PS4 targets
4. **Prebuilt v0.5.4 release** — `toolchain-llvm-18.tar.gz` (LLVM 18 with FreeBSD targets) — **correct path**
### Commands to run in WSL
```bash
cd /opt
sudo wget https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/download/v0.5.4/toolchain-llvm-18.tar.gz
sudo tar -xf toolchain-llvm-18.tar.gz
sudo ln -sf /opt/toolchain-llvm-18/bin/clang /usr/local/bin/orbis-clang
sudo ln -sf /opt/toolchain-llvm-18/bin/llvm-objcopy /usr/local/bin/orbis-objcopy
sudo ln -sf /opt/toolchain-llvm-18/bin/ld.lld /usr/local/bin/orbis-ld
sudo ln -sf /opt/toolchain-llvm-18/bin/llvm-ar /usr/local/bin/orbis-ar
orbis-clang --version
```
### Environment setup
```bash
export OO_PS4_TOOLCHAIN=/opt/OpenOrbis-PS4-Toolchain
export PATH="$OO_PS4_TOOLCHAIN/bin/linux:$PATH"
```
### Then build
```bash
cd /mnt/c/Tools/ps4-payload-sdk-reference
make -C libPS4
cd /mnt/c/Users/user/Documents/GitHub/orbisRPC
PS4_PAYLOAD_SDK=/mnt/c/Tools/ps4-payload-sdk-reference ./scripts/build_sdk.sh
```
---
## Session 5: ELF vs flat bin on high firmware (2026-09-29)
- User reports ELF payloads do not work on high PS4 firmwares like 13.52 (cause not investigated). The repo docs (`docs/injecting.md`, `findings/loader.md`) say BinLoader 9020 and elfldr 9021 take the ELF, but that was only observed on 9.00 / GoldHEN 2.4.
- Context from web search: PS4 13.52 got a public WebKit jailbreak on 2026-09-19 and GoldHEN 2.4b18.11 support on 2026-09-20 (beta; other 13.xx builds planned later).
- Conclusion: a flat `.bin` (libPS4 style, like the old kern.msgbuf payload) is needed for this console.
- Extra risk: the daemon has only ever run on 9.00. `orbisrpc/detect.c` reads the process table with hard-coded offsets (name at 447, record size 479, pid at 72); these may differ on 13.52, so detection needs verifying even after a flat port.
- Approach agreed in principle: do NOT port the whole daemon at once. Grow the old flat payload into orbisRPC step by step, in a new folder, leaving the old payload and the repo daemon untouched.
### Step plan
1. Network probe: resolve `gateway.discord.gg`, plain TCP connect to :443 from a flat payload.
2. TLS handshake with mbedTLS (libc shims, /dev/urandom entropy, stack/heap size). Biggest risk.
3. WebSocket + Discord gateway (HELLO, IDENTIFY, heartbeats).
4. Presence from the old payload's detection, copied as-is (kern.msgbuf AppFocusChanged / Kill App logic, `closed_latch`, shared mmap buffer). DECIDED by user 2026-09-29: orbisRPC's `detect.c` (process-table offsets, save/atime scans) is NOT ported.
5. Names and art (SFO / pronunciation first, then TMDB over TLS, mp: proxy).
6. Persistence: config, session, timer.
Other flat-image constraints to watch: no relocations in a flat image, so static pointer tables (host allowlist, media table, protos) need rewriting or a small self-relocator; sqlite/appdb can be dropped (`appdb_title` is already commented out in `detect.c`).
### Decisions
- Port scope: connectivity (DNS/TCP/TLS), WebSocket + gateway, clock/timer first; names (config map, SFO, pronunciation) and art (mp: proxy, TMDB) second. Skip updater, manifest, health, installer, evict, lock, sqlite/appdb.
- Detection: user's kern.msgbuf method from goldhen-rich-presence-bin (see step 4). This also removes the 9.00-specific process-table risk for the port.
- Firmware detection: user's logic from goldhen-rich-presence-bin is carried over as-is (firmware string parsed from `libc.sprx` in the sandbox path, then `/system/common/lib/libc.sprx`, with the system API as fallback). Runs at startup, is logged, and is available for any firmware-specific branches. Added 2026-09-29 at user's request.
- Open concern raised: orbisRPC needs a raw Discord user token on the console and identifies as a desktop client (against Discord ToS, ban risk); suggested a throwaway account, or keeping the PC relay. Steps 1-2 need no token.
### Pending
- User go-ahead for step 1 and where the new folder goes (inside orbisRPC repo or next to the old project).
- Current state of repo: `scripts/build_sdk.sh` is ELF-only again; plugin/PRX code removed; nothing else changed.
---
## Session 6: Port step 1, network probe (2026-09-29)
User chose to build the port inside the existing project directory (`goldhen-rich-presence-bin`), not a new folder. Existing files (`Makefile`, `source/main.c`, `icon_embedded.h`) are untouched; the probe is additive.
New files:
- `source/probe_net.c`: flat-BIN probe using libPS4. Reuses the firmware detection from `main.c` unchanged. Tests (1) TCP to literal `1.1.1.1:443`, (2) DNS for `gateway.discord.gg` via libSceNet resolver (`sceNetPoolCreate` + `sceNetResolverCreate` + `sceNetResolverStartNtoa`; libPS4 has no DNS of its own), (3) TCP to the resolved IP on 443. Non-blocking connect polled up to 15 s. Output: klog, `/user/data/rpc_probe.log` (FTP :2121), and a final notification. No TLS, no token.
- `Makefile.probe`: separate build target so the working payload's Makefile is not modified. Output `Discord_RPC_Probe.bin` / `Discord_RPC_Probe.map`. Tabs and LF line endings verified.
Not compiled by Claude (user prefers to run builds). Update: user built it in WSL, build OK (no errors), now testing on console. If `-Werror` trips on a redeclared `sceNet*` symbol, paste the error.
Build and run (WSL):
```bash
cd /mnt/c/Tools/PS4-Rich-Presence-for-Discord/goldhen-rich-presence-bin
export PS4SDK=/mnt/c/Tools/ps4-payload-sdk-reference
make -f Makefile.probe clean && make -f Makefile.probe
ncat PS4_IP 9090 --send-only < Discord_RPC_Probe.bin
```
Then read `/user/data/rpc_probe.log` over FTP (:2121) or the klog.
What the result decides: if DNS fails, try other resolver setup (skip `sceNetInit`, different pool size); if TCP by IP fails, the payload's network context is the problem; if all pass, go to step 2 (mbedTLS handshake).
---
## Session 6 result: step 1 passed on firmware 13.52 (2026-09-29)
Probe output from the console:
- literal-ip: TCP 1.1.1.1:443 OK after ~100 ms
- dns: pool create rc=0x00000bc2, resolver create rc=0x0000001b (both non-zero, but the lookup worked anyway)
- dns: gateway.discord.gg -> 162.159.133.234
- gateway: TCP 162.159.133.234:443 OK after ~200 ms
Conclusion: DNS, sockets and outbound TCP all work from a flat libPS4 payload on 13.52. Step 1 done, no token involved.
---
## Session 7: step 2a, self-relocating flat BIN (2026-09-29)
Why: mbedTLS is full of static pointer tables (vtables, cipher/md info structs, function-pointer tables). A flat image has no dynamic relocations, so those point at link-time addresses and break when the loader places the image anywhere except address 0. Before compiling mbedTLS in, prove the fix on the console.
New files in `goldhen-rich-presence-bin` (additive; `Makefile`, `Makefile.probe`, `source/main.c`, `source/probe_net.c` untouched):
- `crt0_reloc.s`: entry point that walks a table appended at `__file_end` and adds the runtime load base to each listed 64-bit word, then jumps to `_main`. Only r8-r10 and rax are touched, so rdi (thread arg) is preserved.
- `linker_reloc.x`: like libPS4's `linker.x` but links at address 0, keeps `.got`, broadens `.rodata`/`.data` patterns for `-fdata-sections`, and defines `__file_end`.
- `tools/mkreloc.py`: reads the ELF (`--emit-relocs`), collects R_X86_64_64 words in loaded sections plus non-zero `.got` words, appends `u32 count + u32 offsets` to the `.bin`. Fails loudly on unsupported relocation types or a relocation inside `.bss`.
- `source/reloc_test.c`: console test with a const table of strings and function pointers plus a writable pointer to a global. Logs to `/user/data/rpc_reloc.log`, shows one notification, prints PASS or FAIL.
- `Makefile.reloc`: separate target, output `Discord_RPC_RelocTest.bin`.
Tested off-console only: a toy program at a randomized non-zero load address relocated correctly, and a negative control with the table emptied failed as expected. Not yet built with the real SDK or run on the console.
Build and send (WSL):
```bash
cd /mnt/c/Tools/PS4-Rich-Presence-for-Discord/goldhen-rich-presence-bin
export PS4SDK=/mnt/c/Tools/ps4-payload-sdk-reference
make -f Makefile.reloc clean && make -f Makefile.reloc
ncat PS4_IP 9090 --send-only < Discord_RPC_RelocTest.bin
```
Then read `/user/data/rpc_reloc.log` over FTP (:2121). Expect `relocation test PASS` and a non-zero entry count. If the build fails, paste the error. Note `mkreloc.py` needs python3 in WSL.
What the result decides: PASS means pointer tables work in a flat BIN, so go on to step 2b (mbedTLS handshake). FAIL means look at the logged base/entry count first.
### Build error and fix (2026-09-29)
First WSL build failed in `mkreloc.py`: `unsupported relocation type 11 at 0x7f in .text` (R_X86_64_32S).
Cause: `reloc_test.o` and `libPS4.a` are compiled -fPIC, so they load extern symbols with `mov reg, [rip+sym@GOTPCREL]` (R_X86_64_REX_GOTPCRELX). In a static non-PIE link the linker relaxes those into `mov reg, imm32` with an absolute 32-bit address. A 32-bit immediate can't take a 64-bit base added to it, so the self-relocator can't fix it.
Fix: add `-Wl,--no-relax` to LFLAGS in `Makefile.reloc`, so the GOT loads stay and the addresses live in `.got` as 64-bit words, which the relocator does handle. Checked in a scratch link with the same `reloc_test.o` and `libPS4.a`: 138 R_X86_64_32S relocations before, 0 after; `mkreloc.py` then reports 131 relocation words (.data: 5, .got: 126), 7,856 bytes total. Not run on the console yet.
This matters for step 2b too: mbedTLS code will hit the same thing, and the same flag covers it.
### Two more relocator bugs found in code review (2026-09-29)
Found by linking a toy program and running it at random load addresses in a scratch harness (not on the console):
1. **Table overlapped `.bss`.** `.bss` starts at `__file_end`, which is exactly where `mkreloc.py` appends the table, so zero-initialised globals started out holding table bytes (for example `rpc_log_started` began as the entry count, so the log was appended to instead of truncated). Fix: `crt0_reloc.s` now zeroes the table after applying it.
2. **Zero-valued GOT slot skipped.** With `--no-relax`, `(unsigned long)_start` is loaded through a GOT slot whose link-time value is 0, and `mkreloc.py` only relocated non-zero GOT words, so `base` read as 0 and `reloc_test` would have reported a false FAIL. Fix: `mkreloc.py` now also finds GOT slots from the GOTPCREL relocations in the code.
Scratch results: before the fixes the toy returned failure bits at every base; after, 5 of 5 random bases pass, and a negative control with an empty table fails. The real `reloc_test.o` + `libPS4.a` link now gives 132 relocation words (was 131). Files changed: `crt0_reloc.s`, `tools/mkreloc.py`. Rebuild with the same commands; expect the entry count in `rpc_reloc.log` to be 132.
---
## Next Steps (Pending User Direction)
1. **Test the flat binary** on PS4 via BinLoader (port 9020)
2. **If crashes:** Check `.bss` size in map, move large statics to runtime `mmap`
3. **Migrate features incrementally** from old project as needed:
- kern.msgbuf fallback detection?
- Different Discord auth (OAuth/Social SDK)?
- Other?
---
## Reference: Old Project Status (goldhen-rich-presence-bin)
Last session (2026-09-29) applied patches:
- `.bss` → runtime `mmap` for kern.msgbuf buffer
- False `closed` events fixed (return uncertain when log wraps)
- kern.msgbuf > 128 KB handled (dynamic size query)
- Title stays after closing app fixed (closed_latch)
Next steps noted: rebuild in WSL, send to GoldHEN, test on console, capture klog.
+42 -18
View File
@@ -5,7 +5,7 @@
# orbisRPC — Discord Rich Presence for PS4 (GoldHEN RPC)
<p align="center">
<a href="https://github.com/SirHumza/orbisRPC/releases/tag/v1.1.0"><img src="https://img.shields.io/badge/version-1.1.0-ffd800?style=flat-square" alt="version"></a>
<a href="https://github.com/SirHumza/orbisRPC/releases/tag/v1.0.0"><img src="https://img.shields.io/badge/version-1.0.0-ffd800?style=flat-square" alt="version"></a>
<img src="https://img.shields.io/badge/PS4-GoldHEN-003791?style=flat-square" alt="PS4 GoldHEN">
<img src="https://img.shields.io/badge/Discord-Rich%20Presence-5865F2?style=flat-square" alt="Discord">
<img src="https://img.shields.io/badge/tables-none-brightgreen?style=flat-square" alt="no tables">
@@ -19,23 +19,18 @@ playing to Discord: name, cover art, timer. No PC at runtime.</p>
## Install (5 minutes)
1. Grab `OrbisRPC-Setup-1.1.0.pkg` from the
[Releases page](https://github.com/SirHumza/orbisRPC/releases/tag/v1.1.0)
1. Grab `OrbisRPC-Setup-1.0.0.pkg` from the
[Releases page](https://github.com/SirHumza/orbisRPC/releases/tag/v1.0.0)
and install it with Package Installer.
2. Open **orbisRPC Setup**. It stages `orbisrpc.bin` in
`/data/payloads`, writes `/data/orbisRPC/config.json`, retires any old
2. Open **orbisRPC Setup**. It stages `orbisrpc.bin` + `evict.elf` in
`/data/payloads`, writes `/data/orbisRPC/config.json`, evicts any old
daemon, then asks for your Discord token.
3. Open **Payloads** in GoldHEN settings, go to `orbisrpc.bin`, press
**Square** to enable AutoRun, then **X** once to run it. Launch a
game, watch Discord.
After a reboot: it auto-runs on every jailbreak if you selected
AutoRun — no need to launch it again.
⚠️ Firmware note: currently tested on PS4 firmware 9.00. Other firmware
versions are supported by design (runtime capability probing, no hardcoded
offsets) but are not yet confirmed on hardware — please report results.
3. Launch **orbisrpc** from Payload Guest (GoldHEN's payload menu),
launch a game, watch Discord.
After a reboot: re-jailbreak, then enable AutoRun for `orbisrpc` in
Payload Guest once — it starts itself on every jailbreak after that.
⚠️Firmware note: Currently tested only on PS4 firmware 9.00. Other firmware versions are not yet confirmed.
## What you get
| | |
@@ -65,19 +60,48 @@ Details: [`docs/DAEMON.md`](docs/DAEMON.md) · [`docs/INSTALLER.md`](docs/INSTAL
## Config
Only `token` (your Discord user session token) is required —
`/data/orbisRPC/config.json`. Everything else ships working: presence text,
home art, poll interval, plus the self-learned `titles` map (hands off).
`/data/orbisRPC/config.json`. Everything else ships working.
| Key | Default | Meaning |
|---|---|---|
| `token` | — | Discord user session token (**required**) |
| `presence_state` | `"On PS4"` | Activity state line for games and the home screen |
| `presence_settings_text` | `"In Settings"` | Activity name while Settings is open |
| `poll_interval_s` | `12` | Detection cadence |
| `home_art` | project logo | Idle tile artwork (URL or uploaded asset key) |
| `pkgzone_enabled` | `true` | Ask pkg-zone.com for homebrew names — sends the title id to a third party; set `false` to disable |
| `retro_enabled` | `true` | Ask the retro-games indexes for PS1/PS2/PSP names and covers |
| `show_firmware` | `true` | Show the `Firmware X.YY` line |
| `show_idle` | `true` | Post a presence on the home screen, Settings and the browser |
| `show_media` | `true` | Post media apps (Netflix, YouTube, Plex…) as `Watching` |
| `show_homebrew` | `true` | Post homebrew titles. Retro classics are **not** affected |
| `debug` | `false` | Verbose per-poll logging |
A `false` visibility flag still detects the title — it just stops telling
Discord about it, so a game underneath keeps showing. The exception is the home
screen: with `show_idle` off, a game that closes is cleared rather than left up,
since nothing is running underneath there.
Plus the self-learned `titles` map, which fills itself in as titles resolve.
## Building
```bash
./scripts/build_sdk.sh # daemon payload (needs ps4-payload-sdk)
./scripts/build_sdk_from_source.sh # SDK from git (REQUIRED - see note)
PS4_SDK_SRC=~/ps4-payload-sdk-src \
PS4_PAYLOAD_SDK=~/ps4-payload-sdk ./scripts/build_sdk.sh # daemon payload
make -f installer/Makefile # Setup PKG (OpenOrbis toolchain, llvmshim)
make -C tests test # host unit tests
make -C tests asan # ASan/UBSan
python3 tests/e2e_consumer.py # contracts + linkage gate
```
⚠️ **Build the SDK from git, not the release ZIP.** The SDK's CRT refuses to
start on firmware it does not list, and terminates the payload before `main()`
with no log line. The released SDK (v0.9) lists 13.50 but not 13.52, so a
ZIP-built payload cannot boot on 13.52. See
[`findings/sdk-13.52.md`](findings/sdk-13.52.md).
## Safety
A user session token grants full account access — never share the config,
-11
View File
@@ -1,11 +0,0 @@
# orbisRPC support bundle
Paste this into your issue (find values via FTP, no klog needed):
- Firmware + jailbreak entry (e.g. 9.00 pOOBs4, GoldHEN version):
- Install method (Setup PKG / BinLoader / elfldr):
- `/data/orbisRPC/status.json` contents:
- `/data/orbisRPC/log.txt` tail (last ~30 lines):
- `/data/orbisRPC/install.log` tail (installer problems only):
- Game title ID where it fails (e.g. CUSA00001):
- What you expected vs what happened:
+118
View File
@@ -0,0 +1,118 @@
orbisRPC - BUILD SETUP
=====================
Working build command (copy/paste):
cd path to project
PS4_SDK_SRC=$HOME/ps4-payload-sdk-src \
PS4_PAYLOAD_SDK=$HOME/ps4-payload-sdk \
./scripts/build_sdk.sh
Output: build-sdk/orbisrpc_sdk.elf (~2,237,856 bytes)
Both env vars matter. PS4_PAYLOAD_SDK says where the compiler is;
PS4_SDK_SRC enables the 13.52 firmware gate described below.
ONE-TIME SDK SETUP
------------------
The ps4-payload-dev SDK must be built from git, NOT from the release ZIP.
Run this once per machine:
cd path to project
./scripts/build_sdk_from_source.sh
That clones to ~/ps4-payload-sdk-src, pins commit 573b4a0, and installs to
~/ps4-payload-sdk.
WHY NOT THE RELEASE ZIP:
The SDK's crt/patch.c holds a per-firmware table and __patch_init()
returns an error for any firmware not in it. crt/crt.c propagates that
out of _start(), which calls payload_terminate() and never reaches
main(). Silent death - no log line, nothing to diagnose.
Release v0.9 (2026-05-12) lists 13.50 but not 13.52. The 13.52 offsets
landed on master in 959e4ed (2026-06-25) and have never shipped in a
release. A payload linked against the ZIP cannot boot on 13.52.
If the SDK is missing:
./scripts/build_sdk.sh -> "FAIL: .../orbis-clang not found"
Both scripts verify the SDK source supports 13.52 before building, so a
too-old SDK fails at build time with a clear message instead of producing a
payload that dies on the console.
To move to a newer SDK commit later:
PS4_SDK_REF=<commit> ./scripts/build_sdk_from_source.sh
REQUIREMENTS
------------
WSL2 (all commands below run in WSL, not PowerShell)
git
clang-18 + lld-18 (llvm-config-18 or llvm-config must be on PATH)
Ubuntu packages:
sudo apt install git clang-18 lld llvm-18
The SDK also expects an llvm shim on PATH. build_sdk.sh adds $HOME/llvmshim
itself; if your setup differs, that line in scripts/build_sdk.sh is where to
look.
Repo location matters: scripts assume you run from the repo root, because
they reference relative paths like orbisrpc/ and third_party/.
VERIFYING THE BUILD
-------------------
ls -la build-sdk/orbisrpc_sdk.elf
A good build prints no FAIL lines and ends with the elf listing. Two lines
are normal and not errors:
"note: PS4_SDK_SRC not set, skipping the 13.52 SDK firmware gate."
(only appears if you omitted PS4_SDK_SRC - always pass it)
To rebuild from scratch:
rm -rf build-sdk && ./scripts/build_sdk.sh
HOST TESTS (optional, fast - no console needed)
make -C tests all
./tests/test_utils
Known pre-existing failure, NOT a regression:
test_appdb: Assertion `rc == SQLITE_OK' failed
The system sqlite3 library on the host differs from the bundled amalgamation
(rc=14). Tests registered before test_appdb in main() do run and pass.
SENDING IT TO THE CONSOLE
-------------------------
GoldHEN BinLoader on port 9020. Arm it in the exploit host menu, then send
the payload in ONE connection. Never probe the port first - any empty connect
burns the arm and the real payload then arrives at a dead listener:
use netcat e.g nc console_ip 9090 < payload.elf
Then confirm it ran - within ~20 seconds:
curl -s ftp://<console-ip>:2121/data/orbisRPC/log.txt
Expected first line: "orbisRPC payload boot"
No log file at all means the payload never executed. Do not debug the daemon
until you see that line. See docs/injecting.md for elfldr (port 9021) and
other methods.
+1
View File
@@ -0,0 +1 @@
[{"id":"CPCS00701","name":"DINO CRISIS 5TH ANNIVERSARY","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/CPCS00701.jpg"},{"id":"ESPM70001","name":"ICEMAN - DIGITAL PLAYSTAGE","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/ESPM70001.jpg"},{"id":"ESPM70002","name":"ROBOTS - VIDEO ALCHEMY","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/ESPM70002.jpg"},{"id":"ESPM70003","name":"PUFFY - P.S. I LOVE YOU","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/ESPM70003.jpg"},{"id":"HPS105","name":"WAI WAI TRUMP TAISEN [CONTROLLER SET]","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/HPS105.jpg"},{"id":"HPS91","name":"WAI WAI 3-NIN UCHI MAHJONG [CONTROLLER SET]","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/HPS91.jpg"},{"id":"LSP010150","name":"Lightspan - Liquid Books 1 - Lety's Favorite Stories","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010150.jpg"},{"id":"LSP010160","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - STAY & PLAY 1 - IN THE CLUBHOUSE","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010160.jpg"},{"id":"LSP010170","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - COSMIC QUEST 1 - CITY SIGHTS","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010170.jpg"},{"id":"LSP010180","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - WALKABOUT 1 - THE NATURAL HISTORY MUSEUM","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010180.jpg"},{"id":"LSP010250","name":"LIGHTSPAN - LIQUID BOOKS ADVENTURE 2 - AMRITA'S TREES AND CERDITO AND THE COYOTE","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010250.jpg"},{"id":"LSP010260","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - STAY & PLAY 2 - IN MARS' BEDROOM","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010260.jpg"},{"id":"LSP010270","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - COSMIC QUEST 2 - FAIRY TALE ISLAND","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010270.jpg"},{"id":"LSP010280","name":"Lightspan - A Mars Moose Adventure - Walkabout 2 - The Shakespeare Festival","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010280.jpg"},{"id":"LSP010350","name":"Lightspan - Liquid Books 3 - Far-Fetched Frontier Tales","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010350.jpg"},{"id":"LSP010360","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - STAY & PLAY 3 - IN LONNIE'S CLASSROOM","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010360.jpg"},{"id":"LSP010370","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - COSMIC QUEST 3 - RACE THROUGH FRANCE","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010370.jpg"},{"id":"LSP010380","name":"LIGHTSPAN - A MARS MOOSE ADVENTURE - WALKABOUT 3 - WORLD SPORTS DAY","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP010380.jpg"},{"id":"LSP020150","name":"LIGHTSPAN - THE SECRET OF GOOGOL - 1B - RESHAPING GOOGOL - THE TOWER","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020150.jpg"},{"id":"LSP020160","name":"LIGHTSPAN - THE SECRET OF GOOGOL - 1A - RESHAPING GOOGOL - THE SUBMARINE","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020160.jpg"},{"id":"LSP020170","name":"Lightspan - The Secret of Googol - Reshaping Googol 2b - Under the Ocean","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020170.jpg"},{"id":"LSP020180","name":"Lightspan - The Secret of Googol - Reshaping Googol 2a - Castle","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020180.jpg"},{"id":"LSP020260","name":"Lightspan - The Secret of Googol - The Google Counting Fair 3 - Midways","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020260.jpg"},{"id":"LSP020280","name":"Lightspan - The Secret of Googol - The Google Counting Fair 4 - Corral * Fun House","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020280.jpg"},{"id":"LSP020450","name":"Lightspan - The Secret of Googol - Googolfest 5","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020450.jpg"},{"id":"LSP020570","name":"Lightspan - The Secret of Googol - Googolfest 6","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020570.jpg"},{"id":"LSP020650","name":"Lightspan - The Secret of Googol - Eggs All Around 7","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020650.jpg"},{"id":"LSP020780","name":"Lightspan - The Secret of Googol - Googol Gulch 8","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP020780.jpg"},{"id":"LSP030101","name":"Lightspan - Str.at.e.s. 1 - Match a Batch","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP030101.jpg"},{"id":"LSP030102","name":"Lightspan - Str.at.e.s. 2 - Matchamania!","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP030102.jpg"},{"id":"LSP030103","name":"Lightspan - Str.at.e.s. 3 - Title This! Title That!","cover":"https://retro-games.cybermask.dpdns.org/images/PS1/LSP030103.jpg"},{"id":"LSP030104","name":"LiLine truncated
+1
View File
@@ -0,0 +1 @@
[{"id":"AEIA000011","name":"YOAKE MAE YORI RURIIRO NA - BRIGHTER THAN DAWNING BLUE [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/AEIA000011.jpg"},{"id":"ALCH00001","name":"KATAKAMUNA - USHINAWARETA INGARITSU [DELUXE PACK]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00001.jpg"},{"id":"ALCH00002","name":"Baldr Force EXE (Mega Box)","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00002.jpg"},{"id":"ALCH00003","name":"CHOCOLAT MAID CAFE CURIO [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00003.jpg"},{"id":"ALCH00005","name":"HARU NO ASHIOTO - STEP OF SPRING [PAKU PAKU PACK]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00005.jpg"},{"id":"ALCH00006","name":"Baldr Force EXE (Simlacrum Pack Alchemist Best)","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00006.jpg"},{"id":"ALCH00007","name":"PARFAIT - CHOCOLAT SECOND STYLE [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00007.jpg"},{"id":"ALCH00008","name":"ARIA THE NATURAL ~TOOI YUME NO MIRAGE~ [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00008.jpg"},{"id":"ALCH00009","name":"HIGURASHI NO NAKU KORO NI MATSURI [FIRST PRINT LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00009.jpg"},{"id":"ALCH00010","name":"KONO AOZORA NI YAKUSOKU WO - MELODY OF THE SUN AND SEA [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00010.jpg"},{"id":"ALCH00011","name":"PURE X CURE RE:COVERY [KOI NO KYUUKYUU SET]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00011.jpg"},{"id":"ALCH00012","name":"Baldr Bullet - Equilibrium","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00012.jpg"},{"id":"ALCH00014","name":"ARIA - THE ORIGINATION ~AOI HOSHI NO EL CIELO~ [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00014.jpg"},{"id":"ALCH00015","name":"SUGAR+SPICE! - ANOKO NO SUTEKI NA NANIMOKAMO [FIRST PRESS LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00015.jpg"},{"id":"ALCH00016","name":"KOISURU OTOME TO SHUGO NO TATE - THE SHIELD OF AIGIS [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00016.jpg"},{"id":"ALCH00017","name":"TRIGGERHEART EXELICA ENHANCED [NENDROID SUPER SET]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00017.jpg"},{"id":"ALCH00021","name":"SEKIREI - MIRAI KARA NO OKURIMONO [SPECIAL PACK]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00021.jpg"},{"id":"ALCH00027","name":"SUZUNONE SEVEN - REBIRTH KNOT [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00027.jpg"},{"id":"ALCH00028","name":"HANA TO OTOME NI SHUKUFUKU WO - HARUKAZE NO OKURIMONO [SAINT BOX]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH00028.jpg"},{"id":"ALCH0004","name":"DUEL SAVIOR DESTINY [MESSIAH BOX]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ALCH0004.jpg"},{"id":"ARP201201","name":"ARMEN NOIR [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ARP201201.jpg"},{"id":"ARZE66071","name":"Shadow Hearts - From The New World (Starter Pack)","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/ARZE66071.jpg"},{"id":"CF0015018","name":"SECRET OF EVANGELION [LIMITED EDITION AYANAMI VERSION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0015018.jpg"},{"id":"CF0015019","name":"SECRET OF EVANGELION [LIMITED EDITION ASUKA VERSION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0015019.jpg"},{"id":"CF0015022","name":"MEMORIES OFF #5 - ENCORE [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0015022.jpg"},{"id":"CF0015024","name":"12RIVEN - THE PSI-CLIMINAL OF INTEGRAL [SOUNDTRACK EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0015024.jpg"},{"id":"CF0015026","name":"PRINCESS MAKER 5 [LIMITED EDITION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0015026.jpg"},{"id":"CF0022006","name":"SHINSEIKI EVANGELION - KOUTETSU NO GIRLFRIEND SPECIAL EDITION [BEST HIT SELECTION]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0022006.jpg"},{"id":"CF0022019","name":"Infinity Plus","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CF0022019.jpg"},{"id":"CPCS01002","name":"GUN SURVIVOR 3 - DINO CRISIS [WITH GUNCON2]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CPCS01002.jpg"},{"id":"CPCS01004","name":"MARVEL VS CAPCOM 2 [MODEM PACK]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CPCS01004.jpg"},{"id":"CPCS01005","name":"GUN SURVIVOR 4 - BIOHAZARD - HEROES NEVER DIE [WITH GUNCON 2]","cover":"https://retro-games.cybermask.dpdns.org/images/PS2/CPCS01005.jpg"},{"id":"CPCS01007","name":"Hyper Street Fighter II - The Anniversary Edition (Line truncated
+1
View File
@@ -0,0 +1 @@
[{"id":"AAPP11042","name":"Are You Alice? (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/AAPP11042.jpg"},{"id":"ALCH00019","name":"Higurashi Daybreak Portable (Limited Box)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00019.jpg"},{"id":"ALCH00022","name":"Higurashi Daybreak Portable Mega Edition (Limited Box)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00022.jpg"},{"id":"ALCH00023","name":"Saki Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00023.jpg"},{"id":"ALCH00026","name":"No Fate! Only the Power of Will (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00026.jpg"},{"id":"ALCH00029","name":"Koisuru Otome to Shugo no Tate Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00029.jpg"},{"id":"ALCH00030","name":"Princess Frontier Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00030.jpg"},{"id":"ALCH00031","name":"Otome wa Oanesama ni Koi Shiteru Portable 2Jin no Elder (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00031.jpg"},{"id":"ALCH00032","name":"Saka Agari Hurricane Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00032.jpg"},{"id":"ALCH00033","name":"Dead End - Orchestral Manoeuvres in the Dead End (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00033.jpg"},{"id":"ALCH00034","name":"Kaitou Tenshi Twin Angel - Toki to Sekai no Meikyuu (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00034.jpg"},{"id":"ALCH00035","name":"Soreyuke! BurunyanMan Portable - Torimodose! Ai to Seigi to Kibou no Tsunyakan (Nyan Nyan Set)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00035.jpg"},{"id":"ALCH00039","name":"Ouka Sengoku Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00039.jpg"},{"id":"ALCH00040","name":"Renai 0 Kilometer Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00040.jpg"},{"id":"ALCH00041","name":"Onigokko! Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00041.jpg"},{"id":"ALCH00042","name":"1/2 summer+ (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00042.jpg"},{"id":"ALCH00043","name":"Gaku * Ou - The Royal Seven Stars + Meteor (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00043.jpg"},{"id":"ALCH00044","name":"Saki - Achiga-Hen Episode of Side-A Portable (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00044.jpg"},{"id":"ALCH00045","name":"Your Diary+ (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00045.jpg"},{"id":"ALCH00046","name":"Ore no Kanojo no Uraomote - Pure Sweet Heart (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ALCH00046.jpg"},{"id":"ANTW12073","name":"Amnesia Twin Pack","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ANTW12073.jpg"},{"id":"AQCS01020","name":"Higurashi no Naku Koro ni - Jan (Limited Box)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/AQCS01020.jpg"},{"id":"ATS201008","name":"Persona (Best Selection)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/ATS201008.jpg"},{"id":"BLAS42333","name":"Kamen Rider - Chou Climax Heroes","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BLAS42333.jpg"},{"id":"BLAS42334","name":"Saint Seiya Omega - Ultimate Cosmo","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BLAS42334.jpg"},{"id":"BLJM67013","name":"Final Fantasy 25TH Anniversary Ultimate Box","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BLJM67013.jpg"},{"id":"BLUE0001","name":"UnENDing BloodyCall (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BLUE0001.jpg"},{"id":"BOOST001","name":"Sekai De Ichiban NG na Koi - Full House (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BOOST001.jpg"},{"id":"BOOST002","name":"Tanteibu - The Detective Club - Tantei to Yuurei to Kaitou to (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BOOST002.jpg"},{"id":"BOOST003","name":"Tanteibu - The Detective Club - Angou to Misshitsu to Kaijin to (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BOOST003.jpg"},{"id":"BOOST004","name":"Tanteibu - The Detective Club - Haibu to Kaiga to Bakudan to (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BOOST004.jpg"},{"id":"BOOST005","name":"Tanteibu - The Detective Club - Shissou to Hangeki to Daidanen (Limited Edition)","cover":"https://retro-games.cybermask.dpdns.org/images/PSP/BOOST005.jpg"},{"id":"BOOST006","name":"Bullet Butlers - Juudan no KanatLine truncated
-61
View File
@@ -1,61 +0,0 @@
{
"CUSA00001": "THE PLAYROOM",
"CUSA00219": "Destiny",
"CUSA00568": "Destiny",
"CUSA00572": "SHAREfactory™",
"CUSA00960": "PlayStation™ Vue",
"CUSA01000": "Destiny",
"CUSA01697": "PlayStation™Now",
"CUSA01780": "Spotify",
"CUSA02012": "Media Player",
"CUSA06014": "TEKKEN™7",
"FLTZ00003": "Remote PKG installer",
"LAPY20009": "PS4-Xplorer 2.0",
"NPXS20102": "Internet Browser",
"NPXS20104": "Capture Gallery",
"NPXS20105": "Live from PlayStation",
"NPXS20106": "Music Unlimited",
"NPXS20107": "PlayStation™Video - My Videos",
"NPXS20108": "What's New",
"NPXS20109": "",
"NPXS20110": "TV & Video",
"NPXS20111": "Library",
"NPXS20112": "",
"NPXS20114": "PlayStation™Now",
"NPXS20117": "USB Music Player",
"NPXS20118": "Share Play",
"NPXS20120": "PlayStation™Video",
"NPXS20132": "",
"NPXS20133": "",
"NPXS20979": "PlayStation Store",
"NPXS21021": "PS5 Remote Play",
"NPXS39041": "Store",
"CUSA13323": "Ghost of Tsushima",
"CUSA11995": "Marvel's Spider-Man",
"CUSA20177": "Marvel's Spider-Man: Miles Morales",
"LAPY20006": "PS4 Temperature",
"CUSA01015": "YouTube",
"CUSA00411": "Grand Theft Auto V",
"CUSA26618": "Grand Theft Auto: San Andreas – The Definitive Edition",
"CHTM00777": "PS4 Cheats Manager",
"PKGI13337": "FPKGi",
"MANU90003": "FPKGi Data",
"CUSA00135": "BATMAN™: ARKHAM KNIGHT",
"CUSA08992": "DRAGON BALL FighterZ",
"CUSA03245": "Deadpool",
"CUSA06712": "Goat Simulator",
"CUSA01839": "STREET FIGHTER IV",
"CUSA14909": "SpongeBob SquarePants: Battle For Bikini Bottom",
"CUSA24899": "Stray",
"CUSA00740": "Terraria",
"CUSA20499": "Cuphead",
"CUSA00021": "WATCH_DOGS™",
"CUSA18795": "Skater XL",
"APOL00004": "Apollo Save Tool",
"CUSA04602": "Batman: Arkham City",
"CUSA01004": "Sleeping Dogs",
"CUSA04294": "WATCH_DOGS® 2",
"LAPY20015": "Avatar Changer",
"GBTX00001": "GameBaTo",
"CUSA00127": "Netflix"
}
+39 -8
View File
@@ -16,20 +16,51 @@ tile). Every 15 min the current state reposts regardless (reconciliation).
## Detection
1. `/mnt/sandbox/<ID>_000` mount = authoritative running ID.
2. eboot-process count change = launch/close right now (fast-switch).
3. Save/appdir/app.pkg-atime signals disambiguate.
The OS is asked directly: `sceSystemServiceGetAppIdOfBigApp()` names the app in
front, `sceLncUtilGetAppTitleId()` turns that into a TITLEID. One call yields
both state and identity, so they cannot disagree. No struct offsets, no
directory scans, no mtime inference — nothing here a firmware can invalidate.
`findings/proc-table-offsets.md` records what this replaced and why.
Three answers stay distinct, because collapsing any two of them clears
presence while a game is open:
| Reading | Meaning | Presence |
|---|---|---|
| `app_id == -1` | nothing in front | may clear (2 misses) |
| `app_id == 0` / `NPXS` id | system app in front | **hold** — the game is still running |
| unreadable / malformed id | no opinion | **hold** |
## Settings
ShellUI logs every scene change to `kern.msgbuf`; the newest
`OnFocusActiveSceneChanged` entry is the live scene. Settings scenes carry
`: SettingPage`.
Settings **replaces** the whole presence — its own name (`presence_settings_text`),
no artwork, no timer, no small icon — rather than the game carrying a changed
state line. The game underneath keeps its `started` epoch, so returning
restores the original elapsed time instead of restarting the clock. The buffer
is sized from the kernel's own answer and `mmap`ed, because a hardcoded size
fails with ENOMEM on any console whose ring is larger.
## Names (first hit wins, no baked tables ever)
1. `titles` map in config (manual override + self-learned).
2. System app.db, read-only SQLite: `tbl_appbrowse.titleName`, then
`tbl_appinfo` TITLE keys, then `/user/appmeta/<id>/param.json`.
3. Local pronunciation.xml / param.sfo / app.xml.
4. Sony TMDB live (`<ID>_00` + HMAC-SHA1 URL — byte-identical to the PC
tools; unreachable from most consoles, kept as fallback).
2. Local pronunciation.xml / param.sfo / app.xml.
3. Sony TMDB live (`<ID>_00` + HMAC-SHA1 URL) over TLS-443; port 80 is blocked
on a jailbroken console, so it is the last resort, not the first.
4. pkg-zone.com, **homebrew titles only** (`pkgzone_enabled`, default on).
Retail titles never reach it — TMDB owns them — and PS1/PS2 ids are excluded
as a separate deferred problem. It is a scraped third-party page with no API
contract, so a layout change degrades silently to the raw title id rather
than blocking the presence. Every hit is written back to the `titles` map.
5. Raw ID (never shown twice; retried after 5 min, never frozen).
Icon URLs from Sony's CDN still arrive as `http://` on some titles; they are
upgraded to `https://` before use, because Discord silently drops an activity
whose artwork is a non-https external URL (the tile just shows "?").
## Art
mp: proxy via external-assets → uploaded asset key → icon-pack pattern.
+16 -16
View File
@@ -3,9 +3,10 @@
## What it does
One linear flow, forward-only (every No skips ahead, nothing loops back):
confirm → copy `orbisrpc.bin` to `/data/payloads/` (mkdir -p +
confirm → evict old daemon (via `sceKernelLoadStartModule`) → copy
`evict.elf` + `orbisrpc.bin` to `/data/payloads/` (mkdir -p +
byte-count + FNV hash read-back) → pre-saved config with token
(skips entry when valid) → generation bump → done.
(skips entry when valid) → done.
Read-only status screen available via decline.
There is no WiFi check. The installer runs sandboxed, so its socket probe
@@ -14,24 +15,24 @@ reads like a verdict on Discord itself. The daemon reports real reachability
in its own log once started.
The installer never boots anything directly. Starting the daemon is
Payload Guest's `/data/payloads/` directory — pick `orbisrpc.bin`.
An older running daemon sees the installer's generation bump in
`daemon.gen` and exits cleanly on its own (no killer payload needed).
Payload Guest's `/data/payloads/` directory — pick `evict.elf` first
(removes old orbisrpc instance), then pick `orbisrpc.bin`.
No loopback ports, no injection, no boot proof to go wrong.
## Install flow
```
confirm → copy orbisrpc.bin to /data/payloads/orbisrpc.bin
confirm → sceKernelLoadStartModule(evict.elf) — kills old daemon
→ copy evict.elf to /data/payloads/evict.elf
→ copy orbisrpc.bin to /data/payloads/orbisrpc.bin
→ save config.json with Discord token (pre-populated)
→ (token entry skipped if already valid)
→ bump daemon.gen (old daemon exits cleanly)
→ done
```
The installer bumps `daemon.gen` after copying — any older running
daemon sees the new generation and exits cleanly by itself, so the
fresh payload takes over with no killer module and no sandbox fights.
The `evict.elf` is launched via `sceKernelLoadStartModule` during
install — it reads `daemon.lock`, kills the running daemon, exits.
`evict.elf` stays in `/data/payloads/` for future manual use.
## Navigation law
@@ -56,9 +57,8 @@ button on No, which inverts the whole wizard.
- Config writes are atomic (tmp+fsync+rename) with read-back proof.
- IME wait is bounded; asset key charset validated (bad keys blank the
activity).
- Old daemons are retired via the `daemon.gen` generation bump, not
signals: `kill()` is blocked by the sandbox (EPERM), so the running
daemon polls the generation file and exits cleanly when superseded.
- `evict.elf` is loaded via `sceKernelLoadStartModule` because `kill()`
is blocked by the sandbox (EPERM).
## Auto-start
@@ -69,7 +69,7 @@ shows where, it can't write the queue itself.
## Building
`make -f installer/Makefile` (`OO_PS4_TOOLCHAIN`, llvmshim). Staged assets:
`daemon.elf`, `config.json`. The PKG ships both —
the installer copies the payload, pre-saves the config token,
and bumps `daemon.gen` so an older daemon retires itself.
`daemon.elf`, `evict.elf`, `config.json`. The PKG ships all three —
the installer copies both payloads, launches evict.elf to kill the
old daemon, and pre-saves the config token.
Output: `IV0000-ORPC00001_00-ORBISRPCSETUP000.pkg`.
+3 -4
View File
@@ -25,8 +25,7 @@ libkernel.so). Running on-console at 192.168.1.136 via elfldr:9021.
gate, safe mode, signed all-or-nothing staging, boot rollback.
- `cfg.c`/`lock.c`/`timesync.c` — schema'd fallible config, sysctl-liveness
single instance, SNTP wall clock for timer ms.
- `installer/` + daemon generation protocol — installer bumps
`daemon.gen`; older daemons exit cleanly when superseded (no signals).
- `tools/evict.c` — SIGTERM-then-SIGKILL deploy rotation for the locked daemon.
## State machine
@@ -51,7 +50,7 @@ Sandbox param.sfo source, details-ID duplication, user `titles`
overrides, `home_art`, shipped logo default, app.db SQLite names,
self-learning map, presence-builder test seam, small badge, strncpy NUL
hardening, home_art validation, 4004 survival, cfg_save return,
generation-based deploy rotation, CI ASan+e2e jobs.
evict.elf deploy tool, CI ASan+e2e jobs.
## Not fixed (external / out of scope)
@@ -72,7 +71,7 @@ generation-based deploy rotation, CI ASan+e2e jobs.
## Hardware matrix (proven)
Gateway ready, TLS-ECDHE suite, game detection + ticking timer post-SNTP,
mp: art serving (phone), duplicate-ID gone, generation clean-stop rotations,
mp: art serving (phone), duplicate-ID gone, evict clean-stop rotations,
reboot recovery. Pending eyes: appdb name flip, badge render, logo tile.
## Readiness
+2 -4
View File
@@ -29,10 +29,8 @@ predates the fix — reinstall.
## Payload won't start
The Setup PKG only writes `/data/payloads/orbisrpc.bin` — it never
launches anything. Start it from **Payloads** in GoldHEN settings
(GoldHEN ≥ v2.4b18: **PayLoader → LaunchPad**; older GoldHEN: the
payload menu), or enable AutoRun for `orbisrpc` once so it boots with
every jailbreak.
launches anything. Start it from Payload Guest (GoldHEN's payload menu),
or enable AutoRun for `orbisrpc` once so it boots with every jailbreak.
Manual injection (see `injecting.md`) still exists and needs the loaders
open: ports 9021/9020 are closed whenever GoldHEN's BinLoader toggle
+7 -13
View File
@@ -1,18 +1,12 @@
# Injecting OrbisRPC into the PS4 — the complete guide
This exists because getting a payload to *execute* took longer than
writing the payload. Everything below was learned on real consoles
(9.00 primary). The flow is firmware-independent: the payload resolves
its symbols at runtime and probes firmware-specific details (kinfo
layout via a version table with a bounded-scan fallback), so the same
binary runs anywhere you can get a loader listening.
writing the payload. Everything below was learned on a real 9.00 console.
## You need first
- PS4 on a jailbreakable firmware (9.00 via pOOBs4 is the proven path;
newer firmwares need their own entry point, e.g. lapse-based hosts —
once jailbroken the steps below are identical), GoldHEN 2.4b18+
recommended (older payloader builds segfault on ELF files, see below).
- PS4 on 9.00, jailbroken with GoldHEN (2.4b18+ recommended — older
payloader builds segfault on ELF files, see below).
- Console and computer on the same network. Find the PS4 IP:
Settings → Network → View Connection Status (ours is `192.168.1.136`).
- The payload file: `build-sdk/orbisrpc_sdk.elf` (built via
@@ -20,8 +14,7 @@ binary runs anywhere you can get a loader listening.
## Method 1 — elfldr (recommended)
elfldr (ps4-payload-dev's `ps4-payload-elfldr`) is a proper ELF loader
that runs payloads as separate processes with runtime symbol resolution.
elfldr is a proper ELF loader that runs payloads as separate processes.
1. Get it listening. Either load `elfldr.elf` through GoldHEN's payloader
page once, or keep it running — it serves on **port 9021** until reboot.
@@ -93,10 +86,11 @@ the result on screen.
| Symptom | Meaning | Fix |
|---|---|---|
| `Connection refused` on 9021/9020 | No listener armed | Tap BinLoader / open payloader page, retry instantly |
| Nothing at all, any firmware 13.52+ | SDK built from the release ZIP | Rebuild via `scripts/build_sdk_from_source.sh` — the ZIP is v0.9, which predates the 13.52 CRT offsets, so `_start()` dies before `main()`. `findings/sdk-13.52.md` |
| `payload launched successfully` then silence, no log | Loader segfault (see above) | Update GoldHEN ≥ v2.4b18.5, use BinLoader server |
| `Error handling payload` | Loader rejected the bytes | Re-check file integrity (`shasum`), resend |
| Log shows `token rejected (4004)` | Token rotated/dead | Fresh token into `/data/orbisRPC/config.json` — the daemon picks it up alone, no relaunch needed |
| Multiple `Payload` processes in process list | Old instances piled up | Reinstall: the `daemon.gen` bump retires them; reboot clears stragglers |
| Log exists but `FATAL: token rejected (4004)` | Token rotated/dead | Fresh token into `/data/orbisRPC/config.json`, relaunch |
| Multiple `Payload` processes in process list | Old instances piled up | Reboot clears them; the daemon's lock prevents recurrence |
## Watching it work
BIN
View File
Binary file not shown.
+119
View File
@@ -0,0 +1,119 @@
# Foreground detection: from heuristics to asking the OS
## Superseded
The offset-hard-coded process-table approach described here is **no longer on the
detection path**. It was removed 2026-10-03. This file is kept because the
surviving users of the offset-free walk it motivated are still live, and because
the reasoning is what justifies them.
## What was removed
`scan_sandbox_mount()`, `scan_newest_save()`, `proc_has_eboot()`,
`detect_eboot_count()`, `note_proc_layout()`.
All four signals were *inferences* about what the console was doing:
- a `/mnt/sandbox/TITLEID_000` mount existing
- whichever savedata / app dir / `app.pkg` had the newest mtime
- whether any process was named `eboot.bin`
- how many were
Each is a proxy for "a game is in the foreground", and every proxy can be
wrong: a background app can hold a sandbox mount, cloud sync bulk-touches
mtimes, and the process-table leg depends on `kinfo_proc` offsets that move
between firmwares (see below).
## What replaced it
The OS answers the question directly:
```c
int32_t app_id = sceSystemServiceGetAppIdOfBigApp(); /* what is in front */
sceLncUtilGetAppTitleId((uint32_t)app_id, title_id); /* ...as a TITLEID */
```
Both live in `libSceSystemService.sprx`. No offsets, no directory scans, no
mtimes, and the TITLEID comes back in the same call that established the state,
so identity and state cannot disagree.
### The distinction that carries the design
| Reading | Meaning | Presence |
|---|---|---|
| `app_id == -1` | nothing in front | may clear |
| `app_id == 0` / `NPXS` id | system app in front | **hold** — a game is probably still running |
| any negative | unreadable | **hold** — never treat as a close |
| a 9-char `[A-Z0-9]` id | a game | post it |
Collapsing the middle two rows into "no game" is what made presence vanish
while a game was open. `detect_current_game()` therefore returns three
separate codes: `-1` closed, `-2` uncertain, `-3` system app covering a live
game. Both `-2` and `-3` suppress the clear path in `daemon.c`.
Classification lives in `orbisrpc/bigapp.c`, separate from the syscalls, because
`detect.c` cannot be host-compiled at all (`dlfcn.h`, `orbis/` headers). That
is the same split `procwalk.c` exists for.
## Settings
ShellUI logs every scene change into `kern.msgbuf`. Reading the newest
`OnFocusActiveSceneChanged` entry answers "is Settings in front" without
inference. Verified against a real console capture: the settings scenes carry
`: SettingPage` (`settings_root`, `storage_data#storage`, `id_goldhen_menu`).
`detect_settings_active()` returns 1 / 0 / **-1**, and -1 is never folded into
0 — an unavailable probe is not evidence that Settings closed. Settings overlays
a running game without closing it, so the game stays posted with its timer and
only the activity state line swaps (`presence_state_settings`, default
`"In Settings"`).
### Buffer sizing
`sysctlbyname("kern.msgbuf")` fails with **ENOMEM when the supplied buffer is
smaller than the ring**. A hardcoded 128 KB read therefore fails on *every*
poll against a larger ring, leaving the feature permanently dead while still
looking configured. The size is taken from the kernel's own answer
(`sysctlbyname(name, NULL, &len, ...)`), `mmap`ed once, 2 MB cap, and proven
with a real read before being committed to.
A sibling payload hit exactly this and needed a dynamic-size rework; it is the
reason the sizing is not a literal here.
## Still true: offsets are not self-describing
`struct kinfo_proc` field offsets shift between firmwares. That is why the
detection path no longer reads any of them. Two survivors still walk the
process table and now use `procwalk.c`, which keeps the self-describing
`ki_structsize` framing but searches inside a record instead of trusting a byte
position:
- `lock.c` — single-instance guard
- `tools/evict.c` — stop a running daemon before replacing it
Measured, old logic vs `procwalk.c`:
| Layout | old | new |
|---|---|---|
| 9.00 (name @447, record 479 B) | 1 | 1 |
| record 736 B, name @611 | **0** | 1 |
| record 520 B, name @120 | **0** | 1 |
A hit is corroboration, not proof: records are zero-padded, so both callers
reject `pid <= 0` before searching, and their failure biases are opposite
(`lock.c` assumes a peer on an unreadable table; `evict.c` refuses to kill).
## Cost of the change
`libSceSystemService.sprx` is now a hard `NEEDED` entry. If it fails to bind,
the payload dies before `main()` with no diagnostics — the same silent failure
class as the SDK firmware gate. Previously detection failing only broke
detection. `.github/workflows/ci.yml` asserts the module is present so the link
cannot silently lose it.
## Unproven
None of this has run on a console. Specifically unverified: that
`sceSystemServiceGetAppIdOfBigApp` reports the same values in a *spawned*
payload as it does in a process the user launched, and that Settings detection
survives a ring larger than the probed size.
+101
View File
@@ -0,0 +1,101 @@
# SDK firmware gate: why the payload was silent on 13.52
## Observed
- Daemon ELF does nothing on 13.52. No `/data/orbisRPC/log.txt`, no klog line,
nothing. The 9.00 console runs the same daemon.
- 13.50 is unaffected — which is the clue. 13.50 works, 13.52 does not, and the
only difference is which firmware the SDK lists.
## Cause
Not the loader, not the linker script, not `libkernel_web`. The SDK's own CRT
gates startup on a per-firmware table.
`crt/patch.c`, `__patch_init()`:
```c
case 0x1350:
...
default:
klog_printf("Unsupported firmware %x\n", fw);
```
`crt/crt.c`, `_start()`:
```c
if((err=payload_init())) {
return payload_terminate(err); // returns before main() is ever called
}
```
`payload_init()` runs `__klog_init()`, `__kernel_init()`, `__rtld_init()`,
`__patch_init()`, and returns the first non-zero. Any failure ends the payload
before `main()`, which is why there is no log line: the daemon's own first
`log_msg` never executes.
`crt/kernel.c`, `__kernel_init()` has the same shape. Unlisted firmware skips
the hard-coded offset table and falls into a `kexec_find_*` signature scan for
the kernel image base, copyin/copyout and targetid; `crt/rtld.c` then depends on
`KERNEL_ADDRESS_ROOTVNODE` / `KERNEL_ADDRESS_PRISON0` that the scan derived.
The released SDK is **v0.9, published 2026-05-12**. Its `crt/patch.c` lists
`0x1300`, `0x1302`, `0x1304`, `0x1350` — and *not* `0x1352`. 13.50 is why that
firmware works. 13.52 offsets landed on the SDK's master branch in `959e4ed`
("Add 13.52 offsets", 2026-06-25) and have **never appeared in a release**:
```diff
+ case 0x1352:
+ patch1 = 0x003B35F0;
+ patch2 = 0x003B3610;
+ patch3 = 0x001FC561;
```
`orbis-clang` links `target/lib/crt1.o` unconditionally, and `crt/Makefile`
builds `crt1.o` from `crt.o klog.o kernel.o rtld.o patch.o mdbg.o nid.o` — so
those offsets are in every payload built against the release ZIP.
## Fix
Build the SDK from git instead of downloading the release ZIP.
- `scripts/build_sdk_from_source.sh` — clones `ps4-payload-dev/sdk`, pins a ref,
refuses to proceed unless `crt/patch.c` has the required firmware cases, then
`make DESTDIR=... clean install`.
- `scripts/build_sdk.sh` — gates on `$PS4_SDK_SRC/crt/patch.c` so a stale SDK
fails the build instead of producing a payload that dies silently.
- `.github/workflows/ci.yml` — replaced the `releases/latest` ZIP download with
a git checkout plus source build, and added the firmware gate.
Recipe taken from `drakmor/nanoDNS`'s `.github/workflows/ps4.yml`, which
targets the same elfldr path (port 9021) and ships a working 13.52 payload. Its
PS4 job checks out `ps4-payload-dev/sdk` and runs `make clean install` with
clang-18/lld-18; it never downloads the release ZIP.
## Ruled out
- **Linker script.** `ldscripts/elf_x86_64.x` is byte-identical between the
release and master.
- **The `NEEDED` set.** Unchanged by the SDK update: still `libkernel_web.sprx`,
`libSceLibcInternal.sprx`, `libSceNet.sprx`, and 112 undefined symbols
resolved at load time. Same count before and after.
- **Binary offset scanning as a check.** An earlier idea — grep the linked ELF
for 13.52's patch offsets — is not sound evidence: a 4-byte pattern occurs by
chance in a 2 MB binary. The gate checks SDK *source* instead. The sound
evidence is the `v0.9` source listing above plus `crt1.o`'s md5 matching a
fresh master build (`c9011d1254f7104998f663d9bbd25416`).
- **`EchoStretch/ps4-payload-sdk`.** A mirror of Scene-Collective's libPS4 — it
has `libPS4/` and `crt0.s`, no `crt/`, no firmware table. It is the flat-`.bin`
SDK, not the ELF one.
## Not yet proven
The daemon still has not been run on 13.52 with a fixed-SDK build. If it is
still silent after this, the next suspect is the loader/spawn side, not the SDK:
`findings/elf-linkage.md` records that elfldr "SIGKILLs on the first
unresolvable import — silent by design", which would look identical from the
outside.
For reference, Scene-Collective's flat-bin SDK took the same shape of update
(`2847f1f` Add 13.50 support, `46efae9` Add 13.52 support, `b732641` Add 14.00
support), and a flat libPS4 payload already passes DNS + TCP on 13.52.
+11 -40
View File
@@ -1,23 +1,11 @@
# orbisRPC Setup installer (OpenOrbis app).
# Flow: daemon payload -> token -> done (gen bump supersedes old daemon).
# Flow: daemon payload -> evict old -> token -> done.
# Payload Guest reads /data/payloads/ on this console.
TITLE := orbisRPC Setup
VERSION := 1.1.0
VERSION := 1.0.0
TITLE_ID := ORPC00001
CONTENT_ID := IV0000-ORPC00001_00-ORBISRPCSETUP000
# Packaging knobs under experiment (see the note above param.sfo).
# PKG_ATTR=32 PKG_CATEGORY=gde AUTHINFO=1 -> Apollo-parity build
PKG_ATTR ?= 0
PKG_CATEGORY ?= gd
AUTHINFO ?= 0
AUTHINFO_BLOB := 000000000000000000000000001C004000FF000000000080000000000000000000000000000000000000008000400040000000000000008000000000000000080040FFFF000000F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
ifeq ($(AUTHINFO),1)
AUTHINFO_FLAG := --authinfo $(AUTHINFO_BLOB)
else
AUTHINFO_FLAG :=
endif
LIBS := -lc -lkernel -lc++ -lSceMsgDialog -lSceCommonDialog -lSceImeDialog -lSceSysmodule -lSceNet -lSceUserService -lSceSystemService -lScePad
ASSETS := $(wildcard installer/assets/**/*)
@@ -27,7 +15,6 @@ TOOLCHAIN ?= $(OO_PS4_TOOLCHAIN)
ifeq ($(TOOLCHAIN),)
TOOLCHAIN := /Users/mac/PS4Toolchain/OpenOrbis/PS4Toolchain
endif
export OO_PS4_TOOLCHAIN := $(TOOLCHAIN)
PROJDIR := installer
COMMONDIR := $(TOOLCHAIN)/samples/_common
INTDIR := $(PROJDIR)/x64/Debug
@@ -54,33 +41,15 @@ all: $(CONTENT_ID).pkg
$(CONTENT_ID).pkg: installer/pkg.gp4
cd installer && $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core pkg_build pkg.gp4 . && mv $(CONTENT_ID).pkg ..
installer/pkg.gp4: installer/eboot.bin installer/assets/daemon.elf installer/assets/config.json installer/sce_sys/about/right.sprx installer/sce_sys/param.sfo installer/sce_sys/icon0.png installer/sce_sys/pic1.png $(LIBMODULES) $(ASSETS)
cd installer && $(TOOLCHAIN)/bin/$(CDIR)/create-gp4 -out pkg.gp4 --content-id=$(CONTENT_ID) --files "eboot.bin assets/daemon.elf assets/config.json sce_sys/about/right.sprx sce_sys/param.sfo sce_sys/icon0.png sce_sys/pic1.png sce_module/libSceFios2.prx sce_module/libc.prx $(patsubst installer/%,%,$(ASSETS))"
installer/pkg.gp4: installer/eboot.bin installer/assets/daemon.elf installer/assets/evict.elf installer/assets/config.json installer/sce_sys/about/right.sprx installer/sce_sys/param.sfo installer/sce_sys/icon0.png installer/sce_sys/pic1.png $(LIBMODULES) $(ASSETS)
cd installer && $(TOOLCHAIN)/bin/$(CDIR)/create-gp4 -out pkg.gp4 --content-id=$(CONTENT_ID) --files "eboot.bin assets/daemon.elf assets/evict.elf assets/config.json sce_sys/about/right.sprx sce_sys/param.sfo sce_sys/icon0.png sce_sys/pic1.png sce_module/libSceFios2.prx sce_module/libc.prx $(patsubst installer/%,%,$(ASSETS))"
# PACKAGING IS AN OPEN EXPERIMENT, NOT A SETTLED ANSWER.
#
# History: v1.0.0 shipped ATTRIBUTE=0 / CATEGORY=gd and launched on real
# hardware. A "universal packaging" theory (borrowed from Apollo Save
# Tool) changed these to 32 / gde and added --authinfo; every build
# carrying those values died silently pre-logging on a real console (4/4).
# Isolation ruled out --authinfo (2 builds without it, still crashed), the
# SDL UI, and the native UI. LIBS, the object set, sce_module, and
# installer/ui.c are identical to the working v1.0.0 build, and every
# installer.c / icfg.c delta is provably safe before the first log call.
# That leaves param.sfo as the only untested variable.
#
# COUNTER-EVIDENCE (do not delete without reading): Apollo's current main
# (v02.32) ships ATTRIBUTE=32 / CATEGORY=gde / --authinfo and is a proven
# universal PS4 app. So 0/gd is very likely innocent too, and the real
# cause is still unknown. The 0/gd build is a TEST, not a conclusion.
# Whichever value survives a console test wins; switch with:
# make -f installer/Makefile PKG_ATTR=32 PKG_CATEGORY=gde AUTHINFO=1
installer/sce_sys/param.sfo: installer/Makefile
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_new $@
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ APP_TYPE --type Integer --maxsize 4 --value 1
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ APP_VER --type Utf8 --maxsize 8 --value '$(VERSION)'
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ ATTRIBUTE --type Integer --maxsize 4 --value $(PKG_ATTR)
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ CATEGORY --type Utf8 --maxsize 4 --value '$(PKG_CATEGORY)'
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ ATTRIBUTE --type Integer --maxsize 4 --value 0
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ CATEGORY --type Utf8 --maxsize 4 --value 'gd'
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ CONTENT_ID --type Utf8 --maxsize 48 --value '$(CONTENT_ID)'
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ DOWNLOAD_DATA_SIZE --type Integer --maxsize 4 --value 0
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ SYSTEM_VER --type Integer --maxsize 4 --value 0
@@ -90,7 +59,7 @@ installer/sce_sys/param.sfo: installer/Makefile
installer/eboot.bin: $(OBJS)
$(LD) $(INTDIR)/*.o -o $(INTDIR)/$(PROJDIR).elf $(LDFLAGS)
$(TOOLCHAIN)/bin/$(CDIR)/create-fself-macos -in=$(INTDIR)/$(PROJDIR).elf -out=$(INTDIR)/$(PROJDIR).oelf --eboot "installer/eboot.bin" --paid 0x3800000000000011 $(AUTHINFO_FLAG)
$(TOOLCHAIN)/bin/$(CDIR)/create-fself-macos -in=$(INTDIR)/$(PROJDIR).elf -out=$(INTDIR)/$(PROJDIR).oelf --eboot "installer/eboot.bin" --paid 0x3800000000000011
$(INTDIR)/%.o: $(PROJDIR)/%.c
$(CC) $(CFLAGS) -o $@ $<
@@ -98,11 +67,13 @@ $(INTDIR)/%.o: $(PROJDIR)/%.c
$(INTDIR)/jsonlite.o: orbisrpc/jsonlite.c
$(CC) $(CFLAGS) -o $@ $<
# Stage the daemon payload + config into the app image.
# Stage the daemon + evict payloads + config into the app image.
# config.json carries the SET_ME placeholder; a pre-seeded valid token
# (kept out of the repo) makes the installer skip token entry.
installer/assets/daemon.elf: build-sdk/orbisrpc_sdk.elf
cp $< $@
installer/assets/evict.elf: build-sdk/evict.elf
cp $< $@
installer/assets/config.json: installer/config.json
cp $< $@
@@ -110,4 +81,4 @@ $(INTDIR)/%.o: $(PROJDIR)/%.cpp
$(CCX) $(CXXFLAGS) -o $@ $<
clean:
rm -rf $(INTDIR) installer/eboot.bin installer/pkg.gp4 $(CONTENT_ID).pkg installer/sce_sys/param.sfo installer/assets/daemon.elf installer/assets/config.json
rm -rf $(INTDIR) installer/eboot.bin installer/pkg.gp4 $(CONTENT_ID).pkg installer/sce_sys/param.sfo installer/assets/daemon.elf installer/assets/evict.elf installer/assets/config.json
-54
View File
@@ -4,7 +4,6 @@
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <unistd.h>
#define ICFG_MAX (64u*1024u)
@@ -61,8 +60,6 @@ static int icfg_write(const char *path, jl_val_t *r){
if(!s) return -1;
f = fopen(tmp, "wb");
if(!f){ free(s); return -1; }
/* token lives in this file: owner-only before bytes hit disk */
{ int fd0 = fileno(f); if(fd0 >= 0) fchmod(fd0, 0600); }
if(fputs(s, f) < 0) ok = 0;
if(ok){
int fd = fileno(f);
@@ -194,54 +191,3 @@ int icfg_get_int(const char *path, const char *key, long *out){
jl_free(r);
return -1;
}
/* Daemon liveness from status.json (the daemon's alive tick writes
* {"version","state","title","ts"}). now = time(NULL) at the caller,
* max_age_s = freshness bound (120 covers the 60s tick plus slack).
* Returns 1 fresh (state_out gets "state", "" when absent),
* 0 missing/corrupt/stale/unreadable. Never crashes on garbage. */
int icfg_daemon_state(const char *path, long now, long max_age_s,
char *state_out, size_t state_cap){
FILE *f;
long sz;
char buf[1024];
size_t n;
const char *p, *q;
long ts;
if(state_out && state_cap) state_out[0] = 0;
if(!path || max_age_s < 0) return 0;
f = fopen(path, "rb");
if(!f) return 0;
if(fseek(f, 0, SEEK_END) != 0){ fclose(f); return 0; }
sz = ftell(f);
if(sz <= 0 || sz >= (long)sizeof buf){ fclose(f); return 0; }
if(fseek(f, 0, SEEK_SET) != 0){ fclose(f); return 0; }
n = fread(buf, 1, (size_t)sz, f);
fclose(f);
if(n != (size_t)sz) return 0;
buf[n] = 0;
p = strstr(buf, "\"ts\"");
if(!p) return 0;
p = strchr(p + 4, ':');
if(!p) return 0;
ts = strtol(p + 1, NULL, 10);
if(ts <= 0 || ts > now || now - ts > max_age_s) return 0;
p = strstr(buf, "\"state\"");
if(p){
p = strchr(p + 7, ':');
if(p){
p = strchr(p + 1, '"');
if(p){
p++;
q = strchr(p, '"');
if(q && state_out && state_cap){
size_t len = (size_t)(q - p);
if(len >= state_cap) len = state_cap - 1;
memcpy(state_out, p, len);
state_out[len] = 0;
}
}
}
}
return 1;
}
-4
View File
@@ -23,9 +23,5 @@ int icfg_get_str(const char *path, const char *key, char *out, size_t cap);
int icfg_get_int(const char *path, const char *key, long *out);
/* Count entries in the "titles" map (learned + manual). Fail-soft 0. */
int icfg_titles_count(const char *path);
/* Daemon liveness from status.json: 1 fresh (state_out gets "state"),
* 0 missing/corrupt/stale. now = time(NULL), max_age_s ~ 120. */
int icfg_daemon_state(const char *path, long now, long max_age_s,
char *state_out, size_t state_cap);
#endif
+37 -105
View File
@@ -17,43 +17,19 @@
#include "icfg.h"
#ifndef SETUP_VERSION
#define SETUP_VERSION "1.1.0"
#define SETUP_VERSION "1.0.0"
#endif
#define DAEMON_ELF "/app0/assets/daemon.elf"
#define GEN_PATH "/data/orbisRPC/daemon.gen"
#define EVICT_ELF "/app0/assets/evict.elf"
#define INST_DIR "/data/orbisRPC"
#define INST_LOG "/data/orbisRPC/install.log"
#define PAYLOAD_BIN "/data/payloads/orbisrpc.bin"
#define STATUS_PATH "/data/orbisRPC/status.json"
#define EVICT_BIN "/data/payloads/evict.elf"
/* Stage log: every copy step records errno + sizes to a file we can read
* back over FTP. The dialog alone can't say WHICH stage failed. */
static const char *g_stage = "-";
/* --- pre-main boot marker ------------------------------------------
* The wizard died before main() on every packaging experiment, leaving
* install.log untouched and the crash handler (installed inside main)
* never armed. A constructor runs before main, so it separates the two
* failure classes for good:
* no BOOT line at all -> the loader/SELF never got control (packaging,
* authinfo, SIGSYS) or a pre-main fault
* BOOT but no stage -> main started, died before the first ilog()
* Async-signal-safe only: raw open/write, no stdio, no malloc. */
__attribute__((constructor))
static void boot_marker(void){
static const char msg[] = "BOOT installer entered pre-main\n";
const char *paths[2];
size_t i;
paths[0] = "/data/install.log";
paths[1] = INST_LOG;
for(i = 0; i < 2; i++){
int fd = open(paths[i], O_WRONLY | O_APPEND | O_CREAT, 0666);
if(fd < 0) continue;
{ ssize_t w = write(fd, msg, sizeof msg - 1); (void)w; }
close(fd);
}
}
static void ilog(const char *tag, int err, long expect, long got){
FILE *f;
g_stage = tag;
@@ -241,25 +217,33 @@ static int mkdirs(const char *path){
static int step_files(void){
char report[512];
int ok = -1;
int evict_ok = -1;
mkdir(INST_DIR, 0777);
mkdirs("/data/payloads");
ui_progress_open("Installing orbisRPC");
/* Copy daemon payload. */
ui_progress_msg("Copying payload");
ok = copy_file(DAEMON_ELF, PAYLOAD_BIN);
ilogv("daemon-copy", ok, 0);
ui_progress_set(60);
/* Pre-save config from PKG asset on FRESH installs only. A reinstall
* must never clobber the existing config: it holds the token and
* every learned title. step_token() below handles token entry. */
ui_progress_msg("Saving config");
/* Evict any running orbisRPC daemon before copying new payload.
* kill() is blocked by the sandbox (EPERM), so use
* sceKernelLoadStartModule to launch evict.elf as a module.
* evict.elf reads daemon.lock, kills the daemon, exits. */
{
if(exists(ICFG_PATH)){
ilogv("cfg-keep", 0, 0);
} else if(exists("/app0/assets/config.json")){
evict_ok = copy_file(EVICT_ELF, EVICT_BIN);
ilog("evict-copy", evict_ok, 0, 0);
if(evict_ok == 0){
uint32_t rv = sceKernelLoadStartModule(EVICT_BIN, 0, NULL, 0, NULL, NULL);
ilog("evict-launch", rv, 0, 0);
sceKernelUsleep(500000);
}
}
/* Copy daemon payload. */
ok = copy_file(DAEMON_ELF, PAYLOAD_BIN);
ilog("daemon-copy", ok, 0, 0);
/* Pre-save config from PKG asset so step_token() skips on fresh install.
* Copy config.json from /app0/assets/config.json to /data/orbisRPC/config.json. */
{
FILE *src = fopen("/app0/assets/config.json", "rb");
if(src){
fclose(src);
int rc = copy_file("/app0/assets/config.json", ICFG_PATH);
ilogv("cfg-copy", rc, 0);
if(rc == 0) chmod(ICFG_PATH, 0600);
ilog("cfg-copy", rc == 0 ? 0 : (errno ? errno : -1), 0, 0);
} else {
ilog("cfg-noasset", errno, 0, 0);
/* Fallback: write template with pre-set token. */
@@ -267,16 +251,16 @@ static int step_files(void){
if(f){
fputs("{\"schema_version\":1,\"token\":\"SET_ME\",\"presence_state\":\"On PS4\"}", f);
fclose(f);
chmod(ICFG_PATH, 0600);
}
}
}
snprintf(report, sizeof report,
"%s : %s%s%s%s\n"
"%s : %s%s%s%s",
PAYLOAD_BIN, ok == 0 ? "OK" : "denied",
ok == 0 ? "" : " [stage ", ok == 0 ? "" : g_stage, ok == 0 ? "" : "]");
ui_progress_set(80);
ui_progress_close();
ok == 0 ? "" : " [stage ", ok == 0 ? "" : g_stage, ok == 0 ? "" : "]",
EVICT_BIN, evict_ok == 0 ? "OK" : "denied",
evict_ok == 0 ? "" : " [stage ", evict_ok == 0 ? "" : g_stage, evict_ok == 0 ? "" : "]");
ui_ok(report);
if(ok != 0){
ui_ok("Install failed: could not write the payload.\n\nStopping here.");
@@ -295,7 +279,6 @@ static int step_files(void){
if(f){
fputs("{\"schema_version\":1,\"token\":\"SET_ME\",\"presence_state\":\"On PS4\"}", f);
fclose(f);
chmod(ICFG_PATH, 0600);
}
} else {
fclose(f);
@@ -303,26 +286,6 @@ static int step_files(void){
}
}
ilogv("cfg-done", 0, 0);
/* Generation bump: any older running daemon sees the new generation
* and exits cleanly so the fresh payload takes over (no evict). */
{
long cur = 0;
FILE *gf = fopen(GEN_PATH, "rb");
if(gf){
char gb[32];
size_t n = fread(gb, 1, sizeof gb - 1, gf);
fclose(gf);
if(n > 0){ gb[n] = 0; cur = atol(gb); }
}
gf = fopen(GEN_PATH, "wb");
if(gf){
fprintf(gf, "%ld\n", cur + 1);
fclose(gf);
ilogv("gen-bump", cur + 1, 0);
} else {
ilog("gen-bump", errno ? errno : -1, 0, 0);
}
}
return 0;
}
@@ -340,28 +303,11 @@ static void step_token(void){
}
for(tries = 0; tries < 3; tries++){
r = ui_input("Discord token", "paste token, Done to save", tok, sizeof tok);
if(r < 0){
/* IME failure is transient (system dialog busy, OOM): retry
* instead of surrendering, and say so. Last try keeps the
* FTP fallback message. */
ilog("token-imefail", tries, 0, 0);
if(tries < 2){
ui_ok("Text input glitched. Try again.");
continue;
}
ui_ok("Text input failed. You can paste the token into "
"/data/orbisRPC/config.json over FTP instead.");
return;
}
if(r < 0){ ui_ok("Text input failed. Skipping."); return; }
if(r == 0) return;
if(token_valid(tok)){
if(icfg_token_save(ICFG_PATH, tok) == 0){
ilogv("token-saved", 0, 0);
ui_ok("Config saved.");
} else {
ilog("token-save", errno, 0, 0);
ui_ok("Could not save the token.\n\nPaste it into /data/orbisRPC/config.json over FTP instead.");
}
r = icfg_token_save(ICFG_PATH, tok);
ui_ok("Config saved.");
return;
}
ui_ok("That doesn't look like a token.\nCheck it and try again, or cancel to skip.");
@@ -378,22 +324,6 @@ static void finish(int code){
_exit(code);
}
/* Final screen reports what is actually true: daemon liveness comes
* from status.json freshness, not from the copy steps succeeding. */
static void step_done(void){
char st[32] = "";
char msg[256];
if(icfg_daemon_state(STATUS_PATH, (long)time(NULL), 120, st, sizeof st)){
snprintf(msg, sizeof msg,
"Done.\n\nDaemon is alive (%s).\nLaunch orbisrpc from the payload launcher after a reboot.", st);
} else {
snprintf(msg, sizeof msg,
"Done.\n\nNo live daemon seen — normal on first install.\nLaunch orbisrpc from the payload launcher.");
}
ilogv("done-shown", 0, 0);
ui_ok(msg);
}
int main(void){
/* Hide the PS4 splash first — dialogs opened while the splash
* is visible get auto-dismissed (half-second flash) or never
@@ -401,13 +331,15 @@ int main(void){
* run with the splash already gone, so no visible lag. */
sceSystemServiceHideSplashScreen();
crash_guard();
if(ui_init() != 0){ ilogv("ui-init-fail", 0, 0); finish(1); }
if(ui_init() != 0) finish(1);
ui_ok("orbisRPC");
if(step_assets() != 0) finish(1);
ilogv("assets-ok", 0, 0);
if(step_files() != 0) finish(1);
step_token();
ilogv("post-token", 0, 0);
step_done();
ui_ok("Done.\n\n"
"Payloads in /data/payloads.\n"
"Launch orbisrpc from the payload launcher.");
finish(0);
}
+43
View File
@@ -0,0 +1,43 @@
/* nettest.c - connect-only probes. A closed port and a filtered host
* both read as unreachable; that honesty is the point (Sony TMDB from
* the console genuinely fails while Discord succeeds). */
#include "nettest.h"
#include "send.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/time.h>
#include <sys/select.h>
/* Non-blocking connect with a hard deadline lives in send.c (shared);
* blocking connect ignores SNDTIMEO on filtered hosts (75 s hole). */
int net_probe(const char *host, int port, int timeout_s){
struct addrinfo hints, *res = NULL, *rp;
char svc[8];
int ok = 0;
if(!host || !host[0] || port <= 0 || port > 65535) return 0;
if(net_init() != 0) return 0; /* stack dead: everything unreachable */
if(timeout_s < 1) timeout_s = 1;
if(timeout_s > 10) timeout_s = 10;
snprintf(svc, sizeof svc, "%d", port);
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET; /* v4 only: deterministic on console stacks */
hints.ai_socktype = SOCK_STREAM;
if(getaddrinfo(host, svc, &hints, &res) != 0 || !res) return 0;
for(rp = res; rp; rp = rp->ai_next){
int fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if(fd < 0) continue;
if(sock_connect_deadline(fd, rp->ai_addr, rp->ai_addrlen, timeout_s) == 0) ok = 1;
close(fd);
if(ok) break;
}
freeaddrinfo(res);
return ok;
}
+8
View File
@@ -0,0 +1,8 @@
/* nettest.h - honest TCP reachability probes (no traffic beyond connect). */
#ifndef INSTALLER_NETTEST_H
#define INSTALLER_NETTEST_H
/* 1 reachable, 0 not. timeout_s clamped 1..10. Host-testable. */
int net_probe(const char *host, int port, int timeout_s);
#endif
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
/* send.c - loopback payload injection: file bytes over TCP to the
* console's own loaders. Chunked send (no whole-file malloc). */
#include "send.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/time.h>
#include <sys/select.h>
#ifdef INSTALLER_PS4
#include <orbis/Net.h>
#include <orbis/Sysmodule.h>
#endif
#define SEND_CHUNK (64u*1024u)
static const int SEND_PORTS[] = { 9090, 9021, 9020 };
int net_init(void){
#ifdef INSTALLER_PS4
static int done = 0;
int probe;
if(done) return 0;
/* Internal NET module (Payload Guest boot pattern) + best-effort
* stack init. Ground truth is a probe socket, not return codes. */
(void)sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_NET);
(void)sceNetInit();
(void)sceNetPoolCreate("orbisrpc", 64*1024, 0);
probe = socket(AF_INET, SOCK_STREAM, 0);
if(probe < 0) return -1;
close(probe);
done = 1;
#endif
return 0;
}
int sock_connect_deadline(int fd, const struct sockaddr *sa, socklen_t len, int timeout_s){
int flags, rc, err = 0;
socklen_t elen = sizeof err;
fd_set wf;
struct timeval tv;
if(timeout_s < 1) timeout_s = 1;
if(timeout_s > 15) timeout_s = 15;
flags = fcntl(fd, F_GETFL, 0);
if(flags < 0) return -1;
if(fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return -1;
rc = connect(fd, sa, len);
if(rc == 0){
fcntl(fd, F_SETFL, flags);
return 0;
}
if(errno != EINPROGRESS){
fcntl(fd, F_SETFL, flags);
return -1;
}
FD_ZERO(&wf);
FD_SET(fd, &wf);
tv.tv_sec = timeout_s;
tv.tv_usec = 0;
rc = select(fd + 1, NULL, &wf, NULL, &tv);
fcntl(fd, F_SETFL, flags);
if(rc <= 0) return -1;
if(getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen) != 0) return -1;
return err == 0 ? 0 : -1;
}
static int send_one(int fd, const char *path, void (*progress)(unsigned)){
FILE *f = fopen(path, "rb");
/* Static, not stack: 64 KB is a real fraction of an app thread stack. */
static unsigned char buf[SEND_CHUNK];
long total = 0, sent = 0;
size_t n;
if(!f) return -1;
if(fseek(f, 0, SEEK_END) != 0){ fclose(f); return -1; }
total = ftell(f);
if(total <= 0 || total > 16*1024*1024){ fclose(f); return -1; }
if(fseek(f, 0, SEEK_SET) != 0){ fclose(f); return -1; }
if(progress) progress(0);
while((n = fread(buf, 1, sizeof buf, f)) > 0){
size_t off = 0;
int stalls = 0;
while(off < n){
ssize_t w = send(fd, buf + off, n - off, 0);
if(w <= 0){
/* Blocking socket + SNDTIMEO: EAGAIN means the loader
* stopped reading. Retry briefly, then fail instead of
* spinning forever. */
if((errno == EAGAIN || errno == EWOULDBLOCK) && ++stalls < 4)
continue;
fclose(f);
return -1;
}
stalls = 0;
off += (size_t)w;
}
sent += (long)n;
if(progress) progress((unsigned)(sent * 100 / total));
}
fclose(f);
if(progress) progress(100);
return 0;
}
int send_file_loopback(const char *path, int *port_used, void (*progress)(unsigned)){
unsigned i;
if(!path || !path[0]) return -1;
if(net_init() != 0) return -1; /* no stack, no sockets: fail, don't crash */
for(i = 0; i < sizeof SEND_PORTS/sizeof SEND_PORTS[0]; i++){
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in sa;
struct timeval tv = { 20, 0 };
if(fd < 0) continue;
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons((uint16_t)SEND_PORTS[i]);
sa.sin_addr.s_addr = inet_addr("127.0.0.1");
if(sock_connect_deadline(fd, (struct sockaddr *)&sa, sizeof sa, 5) < 0){
close(fd);
continue;
}
/* Back to blocking for the bulk send, WITH a send timeout so a
* stalled loader surfaces EAGAIN (capped retries below) instead
* of wedging forever. */
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if(send_one(fd, path, progress) == 0){
close(fd);
if(port_used) *port_used = SEND_PORTS[i];
return 0;
}
close(fd);
}
return -1;
}
+19
View File
@@ -0,0 +1,19 @@
/* send.h - loopback payload injection (clean-room, standard sockets).
* Tries native GoldHEN BinLoader 9090, then elfldr 9021, then 9020.
* 9020 is one-shot: accepted bytes count as sent. */
#ifndef INSTALLER_SEND_H
#define INSTALLER_SEND_H
#include <sys/socket.h>
/* progress(pct) may be NULL. Returns 0 + port_used set on success. */
int send_file_loopback(const char *path, int *port_used, void (*progress)(unsigned pct));
/* Non-blocking connect with a hard deadline (filtered hosts can't stall
* past timeout_s). 0 connected, -1 failed. Leaves fd blocking. */
int sock_connect_deadline(int fd, const struct sockaddr *sa, socklen_t len, int timeout_s);
/* One-time network stack init (sceNetInit + pool). 0 ready, -1 dead.
* No-op returning 0 on host test builds. Safe to call repeatedly. */
int net_init(void);
#endif
+6 -125
View File
@@ -1,6 +1,11 @@
/* art.c - external-asset resolution (host-testable parsing + PS4 HTTPS).
* Pure parsing lives in art_parse_mp() (unit-tested); the POST itself
* mirrors updater.c's bounded HTTPS client. */
* mirrors updater.c's bounded HTTPS client.
*
* mp: mappings are cached per boot only. Persisting them was artwork_cache.json,
* which is gone: the cover URL a title resolves to now lives in
* games_cache.json alongside its name, so there is one cache, one TTL, and no
* chance of replaying an mp: path whose source URL has since changed. */
#include "art.h"
#include "jsonlite.h"
#include "log.h"
@@ -8,7 +13,6 @@
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
/* Extract external_asset_path for url from an external-assets response.
@@ -72,120 +76,6 @@ void art_cache_clear(void){
s_last_mp[0] = 0;
}
/* Disk cache: url -> mp path, 7-day TTL. Survives reboots so titles
* resolved once never pay the external-assets round trip again.
* Best-effort only: any I/O failure degrades to memory-only caching. */
#define ART_DISK_MAX 32
#define ART_DISK_TTL (7*24*3600)
static struct { char url[512]; char mp[512]; int64_t at; } s_disk[ART_DISK_MAX];
static int s_disk_n = -1; /* -1 = not loaded yet */
static void art_disk_load(void){
if(s_disk_n >= 0) return;
s_disk_n = 0;
FILE *f = fopen("/data/orbisRPC/artwork_cache.json", "rb");
if(!f) return;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
int64_t now = (int64_t)time(NULL);
if(sz > 0 && sz < 65536){
char *buf = (char*)malloc((size_t)sz + 1);
if(buf && fread(buf, 1, (size_t)sz, f) == (size_t)sz){
buf[sz] = 0;
jl_val_t *r = jl_parse(buf, (size_t)sz);
if(r && r->type == JL_ARRAY){
for(size_t i = 0; ; i++){
const jl_val_t *e = jl_arr_at(r, i);
if(!e) break;
if(s_disk_n >= ART_DISK_MAX) break;
if(e->type != JL_OBJECT) continue;
const jl_val_t *u = jl_obj_get(e, "url");
const jl_val_t *m = jl_obj_get(e, "mp");
const jl_val_t *a = jl_obj_get(e, "at");
if(!u || u->type != JL_STRING || !u->str) continue;
if(!m || m->type != JL_STRING || !m->str) continue;
if(!a || a->type != JL_NUMBER) continue;
if(now - (int64_t)a->num > ART_DISK_TTL) continue; /* prune */
snprintf(s_disk[s_disk_n].url, sizeof s_disk[s_disk_n].url, "%s", u->str);
snprintf(s_disk[s_disk_n].mp, sizeof s_disk[s_disk_n].mp, "%s", m->str);
s_disk[s_disk_n].at = (int64_t)a->num;
s_disk_n++;
}
}
if(r) jl_free(r);
}
free(buf);
}
fclose(f);
}
static void art_disk_save(void){
char tmp[256];
snprintf(tmp, sizeof tmp, "%s.new", "/data/orbisRPC/artwork_cache.json");
FILE *f = fopen(tmp, "wb");
if(!f) return;
int ok = 1;
if(fputs("[", f) < 0) ok = 0;
for(int i = 0; ok && i < s_disk_n; i++){
if(i > 0 && fputs(",", f) < 0){ ok = 0; break; }
/* URLs/mp paths are response-derived; escape defensively. */
char eu[1024], em[1024];
size_t a = 0, b = 0;
for(const char *p = s_disk[i].url; *p && a + 2 < sizeof eu; p++){
if(*p == '"' || *p == '\\') eu[a++] = '\\';
eu[a++] = *p;
}
eu[a] = 0;
for(const char *p = s_disk[i].mp; *p && b + 2 < sizeof em; p++){
if(*p == '"' || *p == '\\') em[b++] = '\\';
em[b++] = *p;
}
em[b] = 0;
if(fprintf(f, "{\"url\":\"%s\",\"mp\":\"%s\",\"at\":%lld}",
eu, em, (long long)s_disk[i].at) < 0) ok = 0;
}
if(ok && fputs("]", f) < 0) ok = 0;
if(ok && fflush(f) != 0) ok = 0;
if(ok){ int fd = fileno(f); if(fd >= 0 && fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
if(ok) rename(tmp, "/data/orbisRPC/artwork_cache.json");
else remove(tmp);
}
static int art_disk_get(const char *url, char *out_mp, size_t cap){
art_disk_load();
int64_t now = (int64_t)time(NULL);
for(int i = 0; i < s_disk_n; i++){
if(!strcmp(s_disk[i].url, url)){
if(now - s_disk[i].at > ART_DISK_TTL) return 0;
strncpy(out_mp, s_disk[i].mp, cap - 1);
out_mp[cap - 1] = 0;
return 1;
}
}
return 0;
}
static void art_disk_put(const char *url, const char *mp){
art_disk_load();
for(int i = 0; i < s_disk_n; i++){
if(!strcmp(s_disk[i].url, url)){
snprintf(s_disk[i].mp, sizeof s_disk[i].mp, "%s", mp);
s_disk[i].at = (int64_t)time(NULL);
art_disk_save();
return;
}
}
if(s_disk_n < ART_DISK_MAX){
snprintf(s_disk[s_disk_n].url, sizeof s_disk[s_disk_n].url, "%s", url);
snprintf(s_disk[s_disk_n].mp, sizeof s_disk[s_disk_n].mp, "%s", mp);
s_disk[s_disk_n].at = (int64_t)time(NULL);
s_disk_n++;
art_disk_save();
}
}
static int art_post(const char *app_id, const char *token, const char *url,
char *out_body, size_t body_cap, size_t *out_len){
#ifdef ORBISRPC_SDK_PAYLOAD
@@ -286,14 +176,6 @@ int art_resolve_mp(const char *app_id, const char *token, const char *url,
out_mp[cap - 1] = 0;
return 1;
}
/* Disk cache (7-day TTL): titles resolved on earlier boots skip the
* round trip entirely. */
if(art_disk_get(url, out_mp, cap)){
snprintf(s_last_url, sizeof s_last_url, "%s", url);
snprintf(s_last_mp, sizeof s_last_mp, "%s", out_mp);
log_msg("art: mp from disk cache");
return 1;
}
static char resp[ART_RESP_MAX];
size_t rlen = 0;
if(art_post(app_id, token, url, resp, sizeof resp, &rlen) != 0){
@@ -306,7 +188,6 @@ int art_resolve_mp(const char *app_id, const char *token, const char *url,
}
snprintf(s_last_url, sizeof s_last_url, "%s", url);
snprintf(s_last_mp, sizeof s_last_mp, "%s", out_mp);
art_disk_put(url, out_mp);
log_msg("art: resolved mp (%zuB)", strlen(out_mp));
return 1;
}
+67
View File
@@ -0,0 +1,67 @@
/* bigapp.c - see bigapp.h. */
#include "bigapp.h"
#include "log.h"
#include "retro.h"
#include <string.h>
int is_homebrew_id(const char *tid){
if(!tid || !tid[0]) return 0;
size_t n = strlen(tid);
if(n < 4) return 0;
if(!strncmp(tid, "CUSA", 4)) return 0; /* retail -> TMDB */
if(!strncmp(tid, "NPXS", 4)) return 0; /* system -> never posted */
if(retro_wants(tid)) return 0; /* retro -> deliberately untouched */
return 1;
}
int bigapp_titleid_valid(const char *s){
if(!s) return 0;
size_t n = strlen(s);
if(n != BIGAPP_TITLEID_LEN) return 0;
for(size_t i = 0; i < n; i++){
char c = s[i];
if((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) continue;
return 0;
}
return 1;
}
int bigapp_classify(int32_t app_id, int title_rc, const char *title_id,
char *out, size_t cap){
if(out && cap) out[0] = 0;
/* The OS reports "no big app" and "System UI in front" differently, and
* the difference matters: the first means a game may have closed, the
* second means one is probably still running underneath. */
if(app_id == -1) return BIGAPP_NONE;
if(app_id == 0) return BIGAPP_SYSTEM;
if(app_id < 0){
log_msg("big-app query returned 0x%08x", (uint32_t)app_id);
return BIGAPP_UNKNOWN;
}
const char *t = title_id ? title_id : "";
if(title_rc != 0){
log_msg("title id for app 0x%08x rc=%d", (uint32_t)app_id, title_rc);
return BIGAPP_UNKNOWN;
}
if(!bigapp_titleid_valid(t)){
log_msg("title id for app 0x%08x is not %d chars of [A-Z0-9]: '%s'",
(uint32_t)app_id, BIGAPP_TITLEID_LEN, t);
return BIGAPP_UNKNOWN;
}
/* NPXS is the system namespace: SceShellUI (NPXS20001), the store
* shells (NPXS21002, NPXS210xx) and similar. Verified on console
* 2026-10-03 from the klog -- NPXS20001 backs SceShellUI,
* SecureUIProcess.self and SecureWebProcess.self, and every
* AppFocusChanged event starts there. Never post it.
*
* App and media containers use other namespaces (ItemzFlow is
* ITEM00001) and are resolved like any other title id. */
if(strncmp(t, "NPXS", 4) == 0) return BIGAPP_SYSTEM;
if(!out || cap <= BIGAPP_TITLEID_LEN) return BIGAPP_UNKNOWN;
memcpy(out, t, BIGAPP_TITLEID_LEN);
out[BIGAPP_TITLEID_LEN] = 0;
return BIGAPP_GAME;
}
+58
View File
@@ -0,0 +1,58 @@
/* bigapp.h - classify what the console reports as the foreground app.
*
* Split out of detect.c so it can be unit-tested off-console: detect.c pulls
* in dlfcn.h and orbis/ headers and cannot be host-compiled at all. Only the
* decision logic lives here -- the two syscalls stay in detect.c.
*
* The whole point of this module is the distinction between "nothing is
* running", "something else is in front" and "we could not tell". Collapsing
* any of those into a single "no game" answer is what made presence vanish
* while a game was still running. */
#ifndef ORBISRPC_BIGAPP_H
#define ORBISRPC_BIGAPP_H
#include <stddef.h>
#include <stdint.h>
/* A TITLEID is exactly 9 characters of [A-Z0-9] (CUSA00740, PPSA01234, ...). */
#define BIGAPP_TITLEID_LEN 9
/* Classify a foreground-app reading.
*
* app_id what sceSystemServiceGetAppIdOfBigApp() returned
* title_rc what sceLncUtilGetAppTitleId() returned (ignored unless app_id > 0)
* title_id the buffer that call wrote
* out receives the validated TITLEID when the result is BIGAPP_GAME
* cap size of out
*
* Returns BIGAPP_GAME (1) with out filled, BIGAPP_NONE (0) for the home
* screen, BIGAPP_SYSTEM (2) for a system app in front, or BIGAPP_UNKNOWN (-1)
* when the reading is unusable. On anything but BIGAPP_GAME, out is cleared. */
#define BIGAPP_GAME 1
#define BIGAPP_NONE 0
#define BIGAPP_SYSTEM 2
#define BIGAPP_UNKNOWN (-1)
int bigapp_classify(int32_t app_id, int title_rc, const char *title_id,
char *out, size_t cap);
/* 1 when `s` is a well-formed TITLEID. Exposed for tests. */
int bigapp_titleid_valid(const char *s);
/* 1 when the id is homebrew as opposed to retail or retro.
*
* "Homebrew" here means the pkg-zone namespace: not a retail CUSA id, not a
* system NPXS id, and not one of the retro prefixes. Retro is excluded on
* purpose -- those are public classics, and show_homebrew is not meant to
* silence them.
*
* App containers (ITEM00001 and friends) land here too. That is intended: they
* are not retail games either, and the flag governs what you advertise rather
* than how the name was resolved.
*
* Lives here rather than in detect.c because it is a namespace rule sitting
* next to the other ones, and because detect.c cannot be host-compiled, so the
* tests could not reach it there. */
int is_homebrew_id(const char *tid);
#endif /* ORBISRPC_BIGAPP_H */
+47 -1
View File
@@ -1,5 +1,6 @@
/* ca_bundle_pem.h - curated trust anchors (generated, do not edit).
* 22 roots for GitHub/Discord/Sony. Regenerate with scripts/make_ca_bundle.py.
* 24 roots for GitHub/Discord/Sony/pkg-zone. Regenerate with
* scripts/make_ca_bundle.py.
*/
#ifndef ORBISRPC_CA_BUNDLE_PEM_H
#define ORBISRPC_CA_BUNDLE_PEM_H
@@ -496,5 +497,50 @@ static const char ORBISRPC_CA_BUNDLE_PEM[] =
"E/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmm\n"
"J1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAYQqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL\n"
"-----END CERTIFICATE-----\n"
"-----BEGIN CERTIFICATE-----\n"
"MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBN\n"
"MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMT\n"
"HERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN\n"
"NDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs\n"
"IEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwggIi\n"
"MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS87IE+\n"
"ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG0\n"
"2C+JFvuUAT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgp\n"
"wgscONyfMXdcvyej/Cestyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZM\n"
"pG2T6T867jp8nVid9E6P/DsjyG244gXazOvswzH016cpVIDPRFtMbzCe88zdH5RD\n"
"nU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnVDdXifBBiqmvwPXbzP6Po\n"
"sMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9qTXeXAaDx\n"
"Zre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cd\n"
"Lvvyz6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvX\n"
"KyY//SovcfXWJL5/MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNe\n"
"XoVPzthwiHvOAbWWl9fNff2C+MIkwcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPL\n"
"tgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4EFgQUUTMc7TZArxfTJc1paPKv\n"
"TiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcN\n"
"AQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw\n"
"GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7H\n"
"PNtQOa27PShNlnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLF\n"
"O4uJ+DQtpBflF+aZfTCIITfNMBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQ\n"
"REtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/u4cnYiWB39yhL/btp/96j1EuMPik\n"
"AdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9GOUrYU9DzLjtxpdRv\n"
"/PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh47a+\n"
"p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilw\n"
"MUc/dNAUFvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WF\n"
"qUITVuwhd4GTWgzqltlJyqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCK\n"
"ovfepEWFJqgejF0pW8hL2JpqA15w8oVPbEtoL8pU9ozaMv7Da4M/OMZ+\n"
"-----END CERTIFICATE-----\n"
"-----BEGIN CERTIFICATE-----\n"
"MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n"
"CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n"
"R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n"
"MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n"
"ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n"
"EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n"
"+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n"
"ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n"
"AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n"
"zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n"
"tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n"
"/q4AaOeMSQ+2b1tbFfLn\n"
"-----END CERTIFICATE-----\n"
;
#endif
+64 -8
View File
@@ -5,7 +5,6 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
cfg_t g_cfg;
@@ -15,14 +14,45 @@ void cfg_defaults(cfg_t *c) {
memset(c, 0, sizeof(*c));
c->schema_version = CFG_SCHEMA_VERSION;
c->enabled = 1;
c->auto_update = 0; /* dormant: network self-update removed; updates via reinstall */
/* On by default, but it sends the console's title id to a third-party
* site, so it is one boolean away from being off for good. */
c->pkgzone_enabled = 1;
c->retro_enabled = 1;
c->show_firmware = 1;
c->show_idle = 1;
c->show_media = 1;
c->show_homebrew = 1;
c->auto_update = 1;
c->poll_interval_s = 12;
strncpy(c->token, "SET_ME", sizeof(c->token)-1);
strncpy(c->presence_state, "On PS4", sizeof(c->presence_state)-1);
/* Idle tile ships working: project-hosted orbisRPC logo, resolved
* through the mp: proxy like every other art URL. Override with any
* http(s) URL or uploaded asset key. */
strncpy(c->home_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/main/config/icons/logo.png",
/* Presence shows three lines: name, details, state (+ timer). The details
* line says what you are doing; the state line carries the firmware. */
strncpy(c->presence_details_game, "Playing on PlayStation 4",
sizeof(c->presence_details_game)-1);
strncpy(c->presence_details_home, "Idling on Home Menu",
sizeof(c->presence_details_home)-1);
strncpy(c->presence_settings_text, "In Settings",
sizeof(c->presence_settings_text)-1);
/* Uploaded Discord asset keys. Referencing a key needs no
* external-assets POST, so these work with no network at all.
* Empty by default: the URLs below are what actually get used, and
* raw https in large_image only renders because discord.c proxies it
* through mp:. Set either to a key to prefer that instead. */
c->asset_idle[0] = 0;
c->asset_playing[0] = 0;
/* URLs for the two images, resolved through Discord's mp: external-assets
* proxy at post time (a raw https in large_image renders "?" or drops the
* activity). large_art falls back to home_art, small_art to large_art. */
strncpy(c->large_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png",
sizeof(c->large_art)-1);
strncpy(c->small_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-blue.png",
sizeof(c->small_art)-1);
strncpy(c->browser_art, "https://retro-games.cybermask.dpdns.org/images/web_browser.png",
sizeof(c->browser_art)-1);
/* Idle tile: same logo as large_art, kept as the legacy fallback for
* configs written before large_art existed. */
strncpy(c->home_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png",
sizeof(c->home_art)-1);
c->n_titles = 0;
/* Default art backend: our own Sony-CDN icon pack, resolved through
@@ -77,6 +107,14 @@ int cfg_load(const char *path, cfg_t *c) {
STR("art_base_url", art_base_url);
STR("home_art", home_art);
STR("presence_state", presence_state);
STR("presence_details_game", presence_details_game);
STR("presence_details_home", presence_details_home);
STR("presence_settings_text", presence_settings_text);
STR("asset_idle", asset_idle);
STR("asset_playing", asset_playing);
STR("large_art", large_art);
STR("small_art", small_art);
STR("browser_art", browser_art);
#undef STR
/* User-local title overrides: {"CUSA11995": "Marvel's Spider-Man"}.
* Defensive: wrong types, overlong keys/names, and overflow past
@@ -104,6 +142,12 @@ int cfg_load(const char *path, cfg_t *c) {
}
}
o = jl_obj_get(root, "enabled"); if (o && o->type == JL_BOOL) c->enabled = (int)o->num;
o = jl_obj_get(root, "pkgzone_enabled"); if (o && o->type == JL_BOOL) c->pkgzone_enabled = (int)o->num;
o = jl_obj_get(root, "retro_enabled"); if (o && o->type == JL_BOOL) c->retro_enabled = (int)o->num;
o = jl_obj_get(root, "show_firmware"); if (o && o->type == JL_BOOL) c->show_firmware = (int)o->num;
o = jl_obj_get(root, "show_idle"); if (o && o->type == JL_BOOL) c->show_idle = (int)o->num;
o = jl_obj_get(root, "show_media"); if (o && o->type == JL_BOOL) c->show_media = (int)o->num;
o = jl_obj_get(root, "show_homebrew"); if (o && o->type == JL_BOOL) c->show_homebrew = (int)o->num;
o = jl_obj_get(root, "auto_update"); if (o && o->type == JL_BOOL) c->auto_update = (int)o->num;
o = jl_obj_get(root, "debug"); if (o && o->type == JL_BOOL) c->debug = (int)o->num;
o = jl_obj_get(root, "poll_interval_s"); if (o && o->type == JL_NUMBER) c->poll_interval_s = (int)o->num;
@@ -171,10 +215,24 @@ int cfg_save(const char *path, const cfg_t *c) {
jl_obj_set(r, "application_id", jl_new_string(c->application_id));
jl_obj_set(r, "art_base_url", jl_new_string(c->art_base_url));
jl_obj_set(r, "enabled", jl_new_bool(c->enabled));
jl_obj_set(r, "pkgzone_enabled", jl_new_bool(c->pkgzone_enabled));
jl_obj_set(r, "retro_enabled", jl_new_bool(c->retro_enabled));
jl_obj_set(r, "show_firmware", jl_new_bool(c->show_firmware));
jl_obj_set(r, "show_idle", jl_new_bool(c->show_idle));
jl_obj_set(r, "show_media", jl_new_bool(c->show_media));
jl_obj_set(r, "show_homebrew", jl_new_bool(c->show_homebrew));
jl_obj_set(r, "auto_update", jl_new_bool(c->auto_update));
jl_obj_set(r, "debug", jl_new_bool(c->debug));
jl_obj_set(r, "poll_interval_s", jl_new_number((double)c->poll_interval_s));
jl_obj_set(r, "presence_state", jl_new_string(c->presence_state));
jl_obj_set(r, "presence_details_game", jl_new_string(c->presence_details_game));
jl_obj_set(r, "presence_details_home", jl_new_string(c->presence_details_home));
jl_obj_set(r, "presence_settings_text", jl_new_string(c->presence_settings_text));
jl_obj_set(r, "asset_idle", jl_new_string(c->asset_idle));
jl_obj_set(r, "asset_playing", jl_new_string(c->asset_playing));
jl_obj_set(r, "large_art", jl_new_string(c->large_art));
jl_obj_set(r, "small_art", jl_new_string(c->small_art));
jl_obj_set(r, "browser_art", jl_new_string(c->browser_art));
jl_obj_set(r, "home_art", jl_new_string(c->home_art));
if(c->n_titles > 0){
jl_val_t *t = jl_new_object();
@@ -192,8 +250,6 @@ int cfg_save(const char *path, const cfg_t *c) {
FILE *f = fopen(tmp, "wb");
int ok = 0;
if (f) {
/* token lives in this file: owner-only before bytes hit disk */
{ int fd0 = fileno(f); if(fd0 >= 0) fchmod(fd0, 0600); }
ok = (fputs(s, f) >= 0);
if(fflush(f) != 0) ok = 0;
/* force bytes to disk before rename */
+26 -2
View File
@@ -31,10 +31,34 @@ typedef struct {
char title_ids[CFG_MAX_TITLES][CFG_TITLEID_LEN];
char title_names[CFG_MAX_TITLES][CFG_TITLENAME_LEN];
int enabled;
int auto_update; /* DORMANT (parsed for old configs, never acted on) */
int pkgzone_enabled; /* ask pkg-zone.com for homebrew names (3rd party) */
int retro_enabled; /* ask the retro-games indexes for retro/homebrew */
/* Visibility gates. Each suppresses one category of presence; the
* daemon still detects it, it just does not tell Discord about it.
* All default to 1, so a config that predates them behaves as before. */
int show_firmware; /* 0: omit the "Firmware X.YY" state line */
int show_idle; /* 0: no presence on home / Settings / browser */
int show_media; /* 0: media apps are not posted at all */
int show_homebrew; /* 0: pkg-zone homebrew not posted; retro is
* deliberately unaffected (see is_homebrew_id) */
int auto_update; /* check GitHub releases once per boot, stage newer */
int debug; /* verbose debug logging (per-poll detail, no secrets) */
int poll_interval_s; /* game-check cadence */
char presence_state[128]; /* activity "state" line, e.g. "On PS4" */
char presence_state[128]; /* legacy: "On PS4". If changed from the default it
* overrides presence_details_game, so existing
* customisations keep working. */
char presence_details_game[128]; /* line 2 while a game is posted */
char presence_details_home[128]; /* line 2 on the home screen */
char presence_settings_text[128]; /* line 2 while Settings is in front */
char asset_idle[64]; /* large image on home + settings */
char asset_playing[64]; /* small badge while playing/watching */
/* Large image (home + settings) and the game badge, as URLs.
* Both go through Discord's mp: external-assets proxy: raw https
* in large_image renders "?" or drops the whole activity.
* Fall back: large_art -> home_art, small_art -> large_art. */
char large_art[256];
char small_art[256];
char browser_art[256]; /* large image while the web browser is up */
} cfg_t;
extern cfg_t g_cfg;
int cfg_load(const char *path, cfg_t *c);
+1 -1
View File
@@ -1,6 +1,6 @@
/* compat.c - tiny libc shims for the PS4 link environment.
* SceLibcInternal lacks getentropy/__errno_location/gmtime_r, and
* mbedTLS wants a platform entropy poll. Compiled into payload + plugin. */
* mbedTLS wants a platform entropy poll. Compiled into payload. */
#include <stddef.h>
#include <fcntl.h>
#include <unistd.h>
+271 -205
View File
@@ -1,13 +1,10 @@
/* daemon.c - orbisRPC core loop, usable from the ELF payload (main.c) or from a
* GoldHEN plugin. When `fixed_game_name` is non-NULL the daemon posts presence
* for that game unconditionally (the plugin runs inside the game process and
* already knows the title); otherwise it detects the foreground game.
/* daemon.c - orbisRPC core loop (payload ELF only).
*
* Auth model: a Discord USER SESSION token pasted into config.json ("token").
* There is no OAuth flow anymore — OAuth2 access tokens are rejected by the
* gateway (close 4004), which was why v1 never worked.
*
* A `stop` flag is polled so plugin_unload() can shut the loop down cleanly. */
* A `stop` flag is polled so a signal handler can shut the loop down cleanly. */
#include "cfg.h"
#include "clock.h"
#include "lock.h"
@@ -17,15 +14,13 @@
#include "ws.h"
#include "discord.h"
#include "detect.h"
#include "focus.h"
#include "fw.h"
#include "notify.h"
#include "updater.h"
#include "version.h"
#include "jsonlite.h"
#include "art.h"
#include <sys/stat.h>
#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
@@ -37,7 +32,7 @@ extern cfg_t g_cfg;
static volatile sig_atomic_t s_stop = 0;
/* Tells a running daemon_run() to shut down (called from plugin_unload). */
/* Tells a running daemon_run() to shut down. */
void daemon_request_stop(void){ s_stop = 1; }
void daemon_clear_stop(void){ s_stop = 0; }
int daemon_stop_requested(void){ return s_stop; }
@@ -50,8 +45,8 @@ static void daemon_on_signal(int sig){
s_stop = 1;
}
/* Sleep up to `secs` but wake within a second when the plugin asks us to
* stop, so plugin_unload() never hangs on a long backoff. Returns 1 stopped. */
/* Sleep up to `secs` but wake within a second when stop is requested,
* so we never hang on a long backoff. Returns 1 stopped. */
static int sleep_stop(int secs){
for(int i = 0; i < secs; i++){
if(s_stop) return 1;
@@ -60,6 +55,74 @@ static int sleep_stop(int secs){
return s_stop;
}
/* Last known Settings-screen state: 1 in Settings, 0 elsewhere, -1 never
* determined. Settings overlays a running game without closing it, so while it
* is up the presence is REPLACED by a dedicated "In Settings" one rather than
* the game carrying a changed state line. -1 is sticky: an unknown probe
* result must not overwrite a real reading. */
static int s_in_settings = -1;
/* 1 once the Settings presence is on the wire, so a poll storm does not
* re-post it; cleared when Settings closes so returning re-posts the game. */
static int s_settings_posted = 0;
/* Same for the web browser. */
static int s_in_browser = -1;
static int s_browser_posted = 0;
/* "Firmware 13.52" for the Settings presence. Reads through the same helper the
* rest of the daemon uses, so an unavailable firmware string degrades to
* "unknown" instead of stalling the post. */
static void firmware_state_line(char *out, size_t cap){
if(!g_cfg.show_firmware){ out[0] = 0; return; } /* "" omits `state` */
char fw[16] = "unknown";
fw_version(fw, sizeof fw);
snprintf(out, cap, "Firmware %s", fw);
}
/* The second visible line, per context. Beside the firmware helper so all
* three lines of a presence are chosen in one place. */
static const char *details_for_home(void){
return g_cfg.presence_details_home[0] ? g_cfg.presence_details_home
: "Idling on Home Menu";
}
static const char *details_for_game(const char *title_id){
/* Media apps get their own phrasing: Discord draws a "Watching" context
* of its own for type 3, so this line says where, not what. */
if(title_id && title_id[0] && detect_media_type(title_id) == 3)
return "Watching on PlayStation 4";
/* presence_state is legacy ("On PS4"). If the operator has changed it from
* that default it wins, so an existing customisation is not silently lost
* now that presence_details_game exists. */
if(g_cfg.presence_state[0] && strcmp(g_cfg.presence_state, "On PS4") != 0)
return g_cfg.presence_state;
return g_cfg.presence_details_game[0] ? g_cfg.presence_details_game
: "Playing on PlayStation 4";
}
static const char *details_for_settings(void){
return g_cfg.presence_settings_text[0] ? g_cfg.presence_settings_text
: "In Settings";
}
/* Effective artwork URLs, most specific first.
* large: large_art -> home_art. small: small_art -> large.
* Returns NULL when nothing is configured, which makes discord.c
* omit the assets rather than send a dangling key. */
static const char *large_art_url(void){
if(g_cfg.large_art[0]) return g_cfg.large_art;
if(g_cfg.home_art[0]) return g_cfg.home_art;
return NULL;
}
static const char *small_art_url(void){
if(g_cfg.small_art[0]) return g_cfg.small_art;
return large_art_url();
}
/* Browser image: its own key, falling back to the idle logo so a console
* without a browser.png still renders something sensible. */
static const char *browser_art_url(void){
if(g_cfg.browser_art[0]) return g_cfg.browser_art;
return large_art_url();
}
static int have_token(const cfg_t *c){
if(!c || !c->token[0]) return 0;
if(strcmp(c->token,"SET_ME")==0) return 0;
@@ -97,49 +160,6 @@ static void sess_save(const char *tid, const char *name, int64_t started){ jl
free(s);
}
/* status.json heartbeat: machine-readable liveness for users and the
* installer (FTP-readable proof the daemon is alive, no klog needed).
* Written on state changes + every alive tick. Never fatal. */
static void status_write(const char *state, const char *title,
const char *gw){
jl_val_t *r = jl_new_object();
if(!r) return;
jl_obj_set(r, "version", jl_new_string(ORBISRPC_VERSION));
jl_obj_set(r, "state", jl_new_string(state ? state : "?"));
jl_obj_set(r, "title", jl_new_string(title ? title : ""));
jl_obj_set(r, "gw", jl_new_string(gw ? gw : "down"));
jl_obj_set(r, "ts", jl_new_number((double)time(NULL)));
char *s = jl_stringify(r);
jl_free(r);
if(!s) return;
FILE *f = fopen("/data/orbisRPC/status.json.new", "wb");
if(f){
int ok = (fputs(s, f) >= 0) && (fflush(f) == 0);
if(ok){ int fd = fileno(f); if(fd < 0 || fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
if(ok) rename("/data/orbisRPC/status.json.new",
"/data/orbisRPC/status.json");
else remove("/data/orbisRPC/status.json.new");
}
free(s);
}
/* Generation protocol (replaces evict.elf): the installer bumps
* daemon.gen on every install; an older running daemon that sees a newer
* generation exits cleanly so the fresh payload takes over. No signals,
* no module loading, no sandbox fights. Missing file = generation 0. */
static long gen_read(void){
FILE *f = fopen("/data/orbisRPC/daemon.gen", "rb");
if(!f) return 0;
char b[32];
size_t n = fread(b, 1, sizeof b - 1, f);
fclose(f);
if(n == 0) return 0;
b[n] = 0;
long v = atol(b);
return v < 0 ? 0 : v;
}
/* Playtime ledger: append-only "<title_id> <name> <seconds>" per finished
* session. Totals are computed by readers (app/docs); the daemon only
* appends, so a corrupt ledger can never break the runtime. */
@@ -185,55 +205,8 @@ static void pres_set(pres_state_t *cur, pres_state_t next){
log_msg("STATE: presence %s -> %s", pres_name(*cur), pres_name(next));
*cur = next;
}
/* diag.json: one boot-time signal census for the firmware matrix.
* Users paste this single file instead of "it doesn't work": daemon
* version, firmware, and which of the four focus signals this box
* actually has. Read-only probes, never fatal. */
extern void *dlopen(const char *filename, int flags);
extern int dlclose(void *handle);
static void diag_write(void){
char fw[16] = "";
int noff = 0, mrec = 0;
fw_version(fw, sizeof fw);
int kinfo_known = (fw_kinfo(&noff, &mrec) == 0);
int msgbuf = focus_msgbuf_ok();
int sandbox = 0;
{
DIR *d = opendir("/mnt/sandbox");
if(d){ sandbox = 1; closedir(d); }
}
int scu = 0;
{
void *h = dlopen("libSceShellCoreUtil.sprx", 0);
if(h){ scu = 1; dlclose(h); }
}
jl_val_t *r = jl_new_object();
if(!r) return;
jl_obj_set(r, "version", jl_new_string(ORBISRPC_VERSION));
jl_obj_set(r, "fw", jl_new_string(fw));
jl_obj_set(r, "kinfo_table", jl_new_number((double)kinfo_known));
jl_obj_set(r, "msgbuf", jl_new_number((double)msgbuf));
jl_obj_set(r, "sandbox", jl_new_number((double)sandbox));
jl_obj_set(r, "shellcore", jl_new_number((double)scu));
jl_obj_set(r, "ts", jl_new_number((double)time(NULL)));
char *s = jl_stringify(r);
jl_free(r);
if(!s) return;
FILE *f = fopen("/data/orbisRPC/diag.json.new", "wb");
if(f){
int ok = (fputs(s, f) >= 0) && (fflush(f) == 0);
if(ok){ int fd = fileno(f); if(fd < 0 || fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
if(ok) rename("/data/orbisRPC/diag.json.new",
"/data/orbisRPC/diag.json");
else remove("/data/orbisRPC/diag.json.new");
}
free(s);
}
/* fixed_game_name != NULL -> post presence for that game only, no detection.
* NULL -> poll the foreground app like the payload daemon does.
* Returns 0 normal stop, 1 config error, 2 auth-fatal (bad token). */
int daemon_run(const char *fixed_game_name){
/* Returns 0 normal stop, 1 config error, 2 auth-fatal (bad token). */
int daemon_run(void){
s_stop = 0;
#ifdef SIGTERM
signal(SIGTERM, daemon_on_signal);
@@ -253,7 +226,6 @@ int daemon_run(const char *fixed_game_name){
}
log_init(LOG_PATH);
log_msg("orbisRPC daemon start — build %s %s", __DATE__, __TIME__);
diag_write();
/* Single writer: a second launch (or a stale pileup from repeated
* injections) stands down instead of fighting over the gateway. */
{
@@ -272,7 +244,6 @@ int daemon_run(const char *fixed_game_name){
{
static const char *targets[] = {
"/data/payloads/orbisrpc.bin",
"/data/GoldHEN/plugins/orbisrpc_plugin.prx",
};
for(unsigned ti = 0; ti < sizeof targets/sizeof targets[0]; ti++){
int vr = health_verify_or_rollback(targets[ti]);
@@ -293,38 +264,51 @@ int daemon_run(const char *fixed_game_name){
log_msg("created template %s; edit \"token\" then reboot", CFG_PATH);
} else fclose(probe);
log_msg("config load failed; running on defaults until valid config appears");
notify_once_boot("cfgfail", "config.json unreadable - using defaults");
}
if(!g_cfg.enabled){ log_msg("disabled in config; exiting"); health_mark_clean(); log_close(); return 0; }
log_set_debug(g_cfg.debug);
if(g_cfg.debug) log_dbg("debug logging on (config)");
/* Boot census. MUST come after cfg_load(): an earlier revision printed this
* before the load and so reported the zero-initialised global, which made a
* correctly enabled pkgzone_enabled read as "off". */
{
char fw[16] = "unknown";
fw_version(fw, sizeof fw);
int st = detect_settings_active();
log_msg("env: firmware=%s pkgzone=%s settings_probe=%s debug=%d",
fw, g_cfg.pkgzone_enabled ? "on" : "off",
st == 1 ? "in-settings" : (st == 0 ? "ok" : "unavailable"),
g_cfg.debug);
}
/* LAN kill switch: lets a fresh payload be injected without rebooting the
* console. Non-fatal if the bind fails. */
/* Wall-clock correction for Discord timestamps (PSN time sync is
* typically blocked on jailbroken consoles). Boot sweep is bounded;
* hourly ticks are single-host attempts (never stall heartbeats). */
time_sync_all();
if(!have_token(&g_cfg)){
/* Waiting for configuration is a HEALTHY boot, not a crash:
* mark clean so token-less boots never trip safe mode, then
* wait for a token to appear (FTP edit, no reboot, no exit). */
* mark clean so token-less boots never trip safe mode. */
health_mark_healthy();
log_msg("no token in %s; waiting (edit \"token\" over FTP)", CFG_PATH);
status_write("waiting_token", "", "down");
for(;;){
if(s_stop){ log_close(); return 0; }
if(sleep_stop(15)) { log_close(); return 0; }
cfg_t next = g_cfg;
if(cfg_load(CFG_PATH, &next) == 0) g_cfg = next;
if(have_token(&g_cfg)) break;
}
log_msg("token appeared; continuing boot");
log_msg("FATAL: put your Discord user token in %s as \"token\":\"...\"", CFG_PATH);
notify_once_boot("notoken", "No Discord token - edit config.json");
log_close();
return 1;
}
/* No network self-update: new versions arrive via reinstall PKG,
* which bumps daemon.gen so this process supersedes cleanly. */
log_msg("boot: local %s (updates via reinstall)", ORBISRPC_VERSION);
/* Self-update once per boot, before first connect. Never fatal:
* staged artifacts take effect on next launch/injection. */
if(g_cfg.auto_update && !safe_mode){
int ur = updater_check_and_stage();
log_msg("updater: %s (local %s)",
ur > 0 ? "staged newer build" : ur == 0 ? "already current" : "check failed",
ORBISRPC_VERSION);
} else if(safe_mode){
log_msg("updater: skipped (safe mode)");
}
discord_t dc;
memset(&dc, 0, sizeof dc); /* ws_close guards on connected; zero = safe */
long boot_gen = gen_read();
int base_poll = g_cfg.poll_interval_s;
if(base_poll < 5) base_poll = 5;
if(base_poll > 60) base_poll = 60;
@@ -340,18 +324,6 @@ int daemon_run(const char *fixed_game_name){
int64_t last_health = 0;
for(;;){ /* outer: connect cycles with backoff on failure */
if(s_stop) break;
/* Superseded by a newer install: exit cleanly (presence cleared
* below when connected) so the fresh payload takes over. */
{
long cur = gen_read();
if(cur > boot_gen){
log_msg("superseded by generation %ld; exiting cleanly", cur);
status_write("superseded", "", "down");
if(dc.connected) discord_clear_presence(&dc);
ws_close(&dc.ws);
break;
}
}
/* re-read config every cycle so token edits land without a reboot.
* On parse failure keep last-good config instead of stale defaults. */
{
@@ -366,6 +338,7 @@ int daemon_run(const char *fixed_game_name){
}
if(!have_token(&g_cfg)){
log_msg("no token in config; waiting %ds", backoff);
notify_once_boot("notoken", "No Discord token - edit config.json");
if(sleep_stop(backoff)) break;
continue;
}
@@ -382,6 +355,9 @@ int daemon_run(const char *fixed_game_name){
}
if(rc != 0){
int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
/* Throttled: the backoff already grows, so an unthrottled
* notification here fires every 13s and then every minute. */
notify_throttled("connect", "Cannot reach Discord - retrying", 300);
log_msg("gateway connect failed (attempt %d); retry in %ds",
conn_fails, wait);
if(sleep_stop(wait)) break;
@@ -471,38 +447,19 @@ int daemon_run(const char *fixed_game_name){
static int cand_hits = 0, miss_hits = 0;
int64_t last_poll = 0;
int64_t last_alive = 0;
int64_t last_wall = 0;
static int healthy_marked = 0;
/* re-post after every (re)connect so Discord never sticks on stale */
int need_post = active && last[0];
while(!s_stop){ /* inner: live session, serviced every second */
/* Poll every tick, not every detect poll: STOP must land within a
* second even while a title is being resolved. */
int64_t now = orbis_mono_s();
if(now - last_tsync >= 3600){ last_tsync = now; time_sync(); }
if(now != last_poll){
/* Rest Mode / resume detection: the wall clock jumping
* forward while the monotonic clock barely moved means we
* slept through a suspend. Force a time re-sync and a
* state reconcile so timers and presence heal instead of
* posting stale epochs. */
{
int64_t wall = (int64_t)time(NULL);
if(last_wall > 0 && wall - last_wall > 900 &&
now - last_poll <= 2){
log_msg("wall jumped +%llds (resumed?); re-syncing",
(long long)(wall - last_wall));
last_tsync = 0;
if(active && last[0]) need_post = 1;
else home_posted = 0;
}
last_wall = wall;
}
last_poll = now;
if(now - last_alive >= 60){
last_alive = now;
log_msg("alive: %s", active ? last : "idle");
status_write(active ? "playing" : "idle",
active ? last : "",
discord_state_name(&dc));
}
/* State reconciliation: re-post current presence every
* 15 min so a silently desynced tile (dropped update,
@@ -536,42 +493,118 @@ int daemon_run(const char *fixed_game_name){
}
char name[128] = "";
int scan_unknown = 0;
#ifdef ORBISRPC_SDK_PAYLOAD
/* Launch/close shortcut: the eboot set changing means the
* foreground game changed RIGHT NOW. Reset debounce so the
* switch commits within ~2 polls. Deliberately NOT instant:
* the title scan needs one poll for fresh atime data, and
* committing a stale scan instantly would lock the wrong
* title with a full session. */
{
static int last_eboots = -1;
int nboots = detect_eboot_count();
if(nboots >= 0 && nboots != last_eboots){
if(last_eboots >= 0)
log_msg("eboot set %d -> %d; fast-switching",
last_eboots, nboots);
last_eboots = nboots;
cand_title[0] = 0;
cand_hits = 0;
miss_hits = 0;
{
int dr = detect_current_game(name, sizeof name, NULL, 0);
/* -2 uncertain and -3 system app covering a live game both
* mean hold. They are logged apart because only one says
* the daemon has lost its view of the console. Both must
* suppress the clear path below: neither is a close. */
scan_unknown = (dr == -2 || dr == -3);
if(dr == -2) log_dbg("detect read failed; holding");
else if(dr == -3) log_dbg("system UI in front; holding %s", last);
if(scan_unknown) name[0] = 0;
}
/* Settings screen. Only a definite answer moves the state:
* a probe that cannot see kern.msgbuf leaves the last known
* value alone instead of flapping the presence off and on. */
{
int sstate = detect_settings_active();
if(sstate >= 0 && sstate != s_in_settings){
s_in_settings = sstate;
log_msg("settings screen: %s", sstate ? "in" : "out");
/* Leaving Settings always hands control back to the
* normal path below: re-arm the game post, and re-arm
* the home post so returning to the home screen is
* allowed to speak again. Previously this only set
* need_post, which the home-screen branch below never
* looks at, so leaving Settings wedged the daemon. */
if(!sstate){
s_settings_posted = 0;
need_post = active && last[0];
home_posted = 0;
}
}
}
#endif
if(fixed_game_name){
strncpy(name, fixed_game_name, sizeof name-1);
name[sizeof name-1] = 0;
}else{
/* detect_current_game already checks foreground-active
* internally; don't double-call it. -2 means the scan
* itself failed: hold position, touch nothing. */
scan_unknown = (detect_current_game(name, sizeof name, NULL, 0) == -2);
if(scan_unknown){
log_dbg("detect scan failed; holding");
name[0] = 0;
}
}
if(name[0]){
/* Browser: same shape as Settings. Probed only when
* Settings is not in front, so Settings keeps priority
* when both would match. */
int bstate = (s_in_settings == 1) ? -1 : detect_browser_active();
if(bstate >= 0 && bstate != s_in_browser){
s_in_browser = bstate;
log_msg("browser screen: %s", bstate ? "in" : "out");
if(!bstate){
s_browser_posted = 0;
need_post = active && last[0];
home_posted = 0;
}
}
/* While Settings is up it replaces the whole presence: its
* own name, no artwork, no timer, no small icon. The game
* underneath keeps `started`, so returning restores the
* original elapsed time rather than restarting the clock.
* It also counts as "hold" below -- Settings must never
* look like a game closing. */
int in_settings = (s_in_settings == 1);
int in_browser = (s_in_settings != 1 && s_in_browser == 1);
/* Neither may look like a game closing. */
if(in_settings || in_browser) scan_unknown = 1;
/* Visibility gates. Detection always runs; this only
* decides whether Discord is told about it.
*
* "Don't send" is safe wherever something could be
* running underneath -- Settings, the browser, a media app,
* homebrew. Home is the exception: nothing is underneath
* there, so a closed game must be cleared rather than held,
* otherwise the tile would linger forever. */
const char *cur_tid_now = detect_last_titleid();
int gate_idle = g_cfg.show_idle == 0;
int gate_media = (g_cfg.show_media == 0) &&
cur_tid_now && detect_media_type(cur_tid_now) == 3;
int gate_homebrew = (g_cfg.show_homebrew == 0) &&
is_homebrew_id(cur_tid_now);
if(in_settings && gate_idle) log_dbg("presence: settings suppressed");
else if(in_settings && !s_settings_posted){
char st[64];
firmware_state_line(st, sizeof st);
int pr = discord_set_presence_ex(&dc, st,
"PlayStation 4", details_for_settings(),
g_cfg.asset_idle, NULL,
"settings", g_cfg.application_id, NULL,
large_art_url(), NULL, 0);
if(pr == 0){
log_msg("presence: settings");
s_settings_posted = 1;
n_posts++;
} else {
log_msg("settings presence send failed; will retry");
}
} else if(in_browser && gate_idle) log_dbg("presence: browser suppressed");
else if(in_browser && !s_browser_posted){
/* Standalone presence: its own name, line and artwork,
* no timer and no badge. A game may be running
* underneath; it comes back on its own when the
* browser closes. */
char st[64];
firmware_state_line(st, sizeof st);
int pr = discord_set_presence_ex(&dc, st,
"PlayStation 4", "Using Web Browser",
NULL, NULL,
"browser", g_cfg.application_id, NULL,
browser_art_url(), NULL, 0);
if(pr == 0){
log_msg("presence: browser");
s_browser_posted = 1;
n_posts++;
} else {
log_msg("browser presence send failed; will retry");
}
}
/* Settings or the browser has already spoken for this poll:
* skip the whole game/home/clear decision below rather than
* letting a stale name or a pending miss counter fight it. */
else if(name[0]){
const char *cur_tid = detect_last_titleid();
if(!cur_tid) cur_tid = "";
if(!strcmp(cur_tid, cand_title)){
@@ -582,7 +615,7 @@ int daemon_run(const char *fixed_game_name){
cand_hits = 1;
}
miss_hits = 0;
if(fixed_game_name || cand_hits >= 2){
if(cand_hits >= 2){
if(!active || strcmp(cur_tid,sess_tid)!=0){
/* Switching away from a live session: bank its time.
* (Fresh starts and resumes have nothing to bank;
@@ -607,28 +640,41 @@ int daemon_run(const char *fixed_game_name){
log_msg("GAME_DETECTED title=%s name=%s", cur_tid[0]?cur_tid:"?", name);
art_cache_clear();
sess_save(cur_tid, name, started);
/* Self-learning map: persist authoritatively resolved
* names so later boots resolve instantly, even when
* every live source is unreachable. Raw-ID fallbacks
* (ok=0) are never learned. */
if(cur_tid[0] && detect_last_ok() && cfg_learn(&g_cfg, cur_tid, name)){
if(cfg_save(CFG_PATH, &g_cfg) != 0)
log_msg("config: learn save failed for %s", cur_tid);
else
log_msg("config: learned %s", cur_tid);
}
/* No metadata is written to config.json any more.
* detect.c persists id+name+cover to games_cache.json
* when a title resolves; that file is the only place
* the daemon learns anything. config.json keeps only
* operator settings and hand-edited overrides. */
} else if(strcmp(name,last)!=0){
strncpy(last, name, sizeof last-1);
last[sizeof last-1] = 0;
need_post = 1;
}
/* A media app or homebrew title that is switched off is
* never announced. Sending nothing leaves whatever was
* last posted standing, so a game underneath keeps
* showing and an empty console simply stays quiet. */
int gate_this_title = gate_media || gate_homebrew;
if(need_post && gate_this_title){
log_dbg("presence: %s suppressed", last);
need_post = 0;
}
if(need_post){
const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL;
/* name / details / state: what is playing, where it
* is, and the firmware. */
char gst[64];
firmware_state_line(gst, sizeof gst);
const char *state = gst[0] ? gst : NULL;
const char *details = details_for_game(sess_tid);
const char *tart = detect_last_art();
/* Playback queue of one: only clear need_post after
* the bytes actually go out. A failed send stays
* queued and rides the next tick/reconnect. */
int pr = discord_set_presence_ex(&dc, state, last, sess_tid[0]?sess_tid:NULL, g_cfg.application_id, g_cfg.art_base_url, tart, g_cfg.home_art[0]?g_cfg.home_art:NULL, started);
int pr = discord_set_presence_ex(&dc, state, last, details,
NULL, g_cfg.asset_playing,
sess_tid[0]?sess_tid:NULL, g_cfg.application_id,
g_cfg.art_base_url, tart,
small_art_url(), started);
if(pr == 0){
log_msg("presence: %s", last);
pres_set(&pres, PS_GAME);
@@ -654,14 +700,34 @@ int daemon_run(const char *fixed_game_name){
last[0]=0; sess_tid[0]=0; active=0; started=0;
home_posted = 0;
}
} else if(gate_idle && active){
/* Home with idle switched off. Clear rather than leave the
* previous game up: nothing is running underneath on the
* home screen, so holding would strand a closed game's
* tile for good. This is the one place where "don't send"
* would be wrong. */
discord_clear_presence(&dc);
log_msg("presence cleared (home, idle suppressed)");
pres_set(&pres, PS_NONE);
ledger_append(sess_tid, last, started, time_fixed());
strncpy(prev_tid, sess_tid, sizeof prev_tid-1);
prev_started = started;
prev_end = now;
remove("/data/orbisRPC/session.json");
last[0]=0; sess_tid[0]=0; active=0; started=0;
home_posted = 0;
} else if(!home_posted && !scan_unknown){
/* Home screen support: no game running. Post a timerless
* home presence once (instead of bare online), clear it
* the moment a game commits. */
const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL;
if(discord_set_presence_ex(&dc, state, "PlayStation 4", "home",
g_cfg.application_id, g_cfg.art_base_url,
g_cfg.home_art[0] ? g_cfg.home_art : NULL, NULL, 0) == 0){
char hst[64];
firmware_state_line(hst, sizeof hst);
const char *state = hst[0] ? hst : NULL;
if(discord_set_presence_ex(&dc, state, "PlayStation 4",
details_for_home(),
g_cfg.asset_idle, NULL, "home",
g_cfg.application_id, g_cfg.art_base_url,
large_art_url(), NULL, 0) == 0){
log_msg("presence: home");
pres_set(&pres, PS_HOME);
n_posts++;
+4 -6
View File
@@ -1,12 +1,10 @@
/* daemon.h - shared orbisRPC daemon loop (payload ELF and GoldHEN plugin). */
/* daemon.h - orbisRPC daemon loop (payload ELF only). */
#ifndef DAEMON_H
#define DAEMON_H
/* fixed_game_name != NULL: post presence for that game only (plugin mode).
* NULL: poll the foreground app (payload mode).
* Returns 0 on clean stop, 1 on missing/invalid configuration, or 2 when
/* Returns 0 on clean stop, 1 on missing/invalid configuration, or 2 when
* Discord rejects the token with close code 4004. */
int daemon_run(const char *fixed_game_name);
int daemon_run(void);
void daemon_request_stop(void);
void daemon_clear_stop(void);
int daemon_stop_requested(void); /* 1 when plugin_unload() wants us to exit */
int daemon_stop_requested(void);
#endif
+335 -346
View File
@@ -19,12 +19,14 @@
* foreground) is reliable via ShellCoreUtil.
*/
#include "detect.h"
#include "fw.h"
#include "cfg.h"
#include "log.h"
#include "sfo.h"
#include "tmdb.h"
#include "clock.h"
#include "procwalk.h"
#include "bigapp.h"
#include "pkgzone.h"
#include "gamecache.h"
#ifdef ORBISRPC_SDK_PAYLOAD
/* Payload-SDK build: dlopen/dlsym come from the SDK libc (dlfcn);
* there is no UserService here — user_init() below degrades. */
@@ -46,212 +48,203 @@
#ifdef ORBISRPC_SDK_PAYLOAD
#include <sys/types.h>
#include <sys/sysctl.h>
#include <sys/mman.h>
#ifndef ORBISRPC_SDK_PAYLOAD
static int is_title_prefix(const char *n);
#endif
/* --- SDK-build foreground/title signals (no ShellCoreUtil/UserService) ---
* A launched game shows up as an "eboot.bin" process; its identity comes
* from the freshest savedata dir (gameplay writes saves continuously).
* Both facts verified live via probes before wiring them in. */
/* Count eboot.bin processes etc. live below (shared sysctl fallback,
* outside the SDK-only ifdef so both builds probe identically). */
/* --- payload-build foreground, identity and screen --------------------
* Everything below asks the OS a direct question. None of it reads a struct
* offset, scans a directory for the "newest" title, or infers activity from
* file mtimes -- so none of it can be invalidated by a firmware that moves
* things around, which is what made the previous heuristics unusable past
* 9.00.
*
* Foreground + title id: sceSystemServiceGetAppIdOfBigApp() names the app in
* front, sceLncUtilGetAppTitleId() turns that app id into a TITLEID. Both come
* from libSceSystemService.sprx, so the payload now links it.
*
* Screen: the kernel logs every ShellUI scene change into kern.msgbuf, so
* "am I looking at Settings right now" is answerable without inference.
*
* Prototypes are local because this file also builds for the OpenOrbis app,
* where these symbols arrive via <orbis/...> headers. */
int32_t sceSystemServiceGetAppIdOfBigApp(void);
int sceLncUtilGetAppTitleId(uint32_t app_id, char *title_id);
/* Sandbox mounts: /mnt/sandbox/<TITLE>_000 exists exactly while that
* title's game process lives. This is authoritative foreground identity
* straight from the OS — no heuristics, no races, no sync pollution.
* Verified live: only the running game's mount is listed. */
static int scan_sandbox_mount(char *out, size_t cap){
if(!out || cap < 10) return -1;
DIR *d = opendir("/mnt/sandbox");
if(!d) return -1;
struct dirent *e;
int found = 0;
char best[16] = "";
while((e = readdir(d))){
const char *n = e->d_name;
if(strlen(n) != 13) continue; /* TITLEID_000 */
if(n[9] != '_' || n[10] != '0' || n[11] != '0' || n[12] != '0') continue;
char tid[16];
memcpy(tid, n, 9);
tid[9] = 0;
if(!is_title_prefix(tid)) continue;
if(!found){
strncpy(best, tid, sizeof best - 1);
found = 1;
}
#define TITLEID_LEN 9
/* Buffer handed to the OS for the title id. Sony writes a fixed-width field,
* so size it wider than the 9 valid characters and terminate before use. */
#define TITLEID_BUF 16
/* Classify what is in the foreground.
* 1 a game: TITLEID written to out
* 0 no big app at all (home screen)
* 2 a system app is in front (app id 0, or an NPXS id)
* -1 the read failed; the caller must treat this as "unknown"
*
* The 0/2/-1 split is the load-bearing part. A system app in front is not a
* closed game, and a failed read is not a closed game either; collapsing
* either into "gone" is how presence got cleared while a game was running. */
static int read_big_app(char *out, size_t cap){
if(!out || cap < TITLEID_LEN + 1) return -1;
out[0] = 0;
int32_t app_id = sceSystemServiceGetAppIdOfBigApp();
/* The decision lives in bigapp.c so it can be unit-tested off-console. */
char raw[TITLEID_BUF];
memset(raw, 0, sizeof raw);
int rc = 0;
if(app_id > 0) rc = sceLncUtilGetAppTitleId((uint32_t)app_id, raw);
raw[TITLEID_BUF - 1] = 0;
return bigapp_classify(app_id, rc, raw, out, cap);
}
/* --- Settings screen, read from the kernel's own scene log ------------
* ShellUI logs a line per scene change; the newest one is the current scene.
* Scenes that matter carry ": SettingPage" (verified against a real console
* capture: settings_root, storage_data#storage, id_goldhen_menu). */
/* kern.msgbuf is a fixed ring and sysctlbyname() fails with ENOMEM when the
* supplied buffer is smaller than it, so the size is taken from the kernel's
* own answer rather than hardcoded. A fixed 128 KB read fails on every poll
* against a larger ring, which leaves the feature permanently dead while
* still looking configured. */
#define MSGBUF_MIN (64u * 1024u)
#define MSGBUF_MAX (2u * 1024u * 1024u)
#define SCENE_MAX 192
static char *g_msgbuf = NULL;
static size_t g_msgbuf_sz = 0;
static int g_msgbuf_state = 0; /* 0 untried, 1 ready, -1 unavailable */
static int g_msgbuf_logged = 0;
static void settings_msgbuf_failed(const char *why){
if(g_msgbuf_logged) return;
g_msgbuf_logged = 1;
log_msg("settings probe unavailable: %s (screen state text disabled)", why);
}
static int settings_msgbuf_acquire(void){
if(g_msgbuf_state) return g_msgbuf_state > 0 ? 0 : -1;
size_t need = 0;
if(sysctlbyname("kern.msgbuf", NULL, &need, NULL, 0) != 0 || need == 0){
settings_msgbuf_failed("kern.msgbuf size query failed");
g_msgbuf_state = -1;
return -1;
}
closedir(d);
if(!found) return -1;
strncpy(out, best, cap - 1);
out[cap - 1] = 0;
if(need < MSGBUF_MIN) need = MSGBUF_MIN;
if(need > MSGBUF_MAX){
settings_msgbuf_failed("kern.msgbuf exceeds the 2 MB cap");
g_msgbuf_state = -1;
return -1;
}
/* Round up: mmap does not require the exact length to be page sized. */
size_t page = 4096;
size_t rounded = (need + page - 1) & ~(page - 1);
void *p = mmap(NULL, rounded, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANON, -1, 0);
if(p == MAP_FAILED){
settings_msgbuf_failed("mmap failed");
g_msgbuf_state = -1;
return -1;
}
/* Prove the buffer really is large enough before committing to it. */
size_t probe = rounded;
if(sysctlbyname("kern.msgbuf", p, &probe, NULL, 0) != 0){
settings_msgbuf_failed("kern.msgbuf read failed at the queried size");
munmap(p, rounded);
g_msgbuf_state = -1;
return -1;
}
g_msgbuf = (char *)p;
g_msgbuf_sz = rounded;
g_msgbuf_state = 1;
log_msg("settings probe: kern.msgbuf %zu bytes", g_msgbuf_sz);
return 0;
}
static long scan_newest_save(char *out, size_t cap){ static const char *users[] = { "1898cd02", "1898cd03", NULL };
/* user dirs vary per console; probe the known ones plus a scan of
* /user/home for anything looking like a user id dir. */
char udirs[8][32];
int ndirs = 0;
DIR *hd = opendir("/user/home");
if(hd){
struct dirent *e;
while((e = readdir(hd)) && ndirs < 8){
if(e->d_name[0] == '.') continue;
strncpy(udirs[ndirs], e->d_name, 31);
udirs[ndirs][31] = 0;
ndirs++;
}
closedir(hd);
}
for(int i = 0; users[i] && ndirs < 8; i++){
int dup = 0;
for(int k = 0; k < ndirs; k++) if(!strcmp(udirs[k], users[i])) dup = 1;
if(!dup){ strncpy(udirs[ndirs], users[i], 31); udirs[ndirs][31] = 0; ndirs++; }
}
long best = -1;
/* Newest focused scene out of the kernel ring: 1 + fills out, 0 when the ring
* holds no scene change yet, -1 when the probe is unavailable.
*
* Shared by the settings and browser matchers so both always agree on which
* event is current. If they disagreed, one would post its presence while the
* other believed it owned the screen. */
static int last_focused_scene(char *out, size_t cap){
if(!out || cap == 0) return -1;
out[0] = 0;
const char *best_src = "none";
/* Per-title activity = max(savedata writes, app.pkg access time).
* app.pkg atime is the sharper signal: the running game streams its
* own package continuously, while save mtimes get bulk-touched by
* cloud sync (proven: all 28 titles sharing one mtime). atime wins
* ties because only gameplay advances it. */
for(int u = 0; u < ndirs; u++){
char spath[96];
snprintf(spath, sizeof spath, "/user/home/%s/savedata", udirs[u]);
DIR *sd = opendir(spath);
if(!sd) continue;
struct dirent *e;
while((e = readdir(sd))){
if(e->d_name[0] == '.') continue;
if(strlen(e->d_name) != 9 || !is_title_prefix(e->d_name)) continue;
char tp[160];
snprintf(tp, sizeof tp, "%s/%s", spath, e->d_name);
/* newest write inside the title dir wins */
DIR *td = opendir(tp);
long tb = -1;
if(td){
struct dirent *f;
while((f = readdir(td))){
if(f->d_name[0] == '.') continue;
/* d_name can be up to 255 chars; tp already holds up
* to ~150 — skip overlong names instead of overflowing. */
if(strlen(f->d_name) > 64) continue;
char fp[256];
int wn = snprintf(fp, sizeof fp, "%s/%s", tp, f->d_name);
if(wn <= 0 || (size_t)wn >= sizeof fp) continue;
struct stat fs;
if(stat(fp, &fs) == 0 && fs.st_mtime > tb) tb = fs.st_mtime;
}
closedir(td);
}
if(tb < 0){
struct stat ds;
if(stat(tp, &ds) == 0) tb = ds.st_mtime;
}
if(tb > best){ best = tb; strncpy(out, e->d_name, cap-1); out[cap-1] = 0; best_src = "save"; }
}
closedir(sd);
#ifdef ORBISRPC_SDK_PAYLOAD
if(settings_msgbuf_acquire() != 0) return -1;
size_t len = g_msgbuf_sz;
if(sysctlbyname("kern.msgbuf", g_msgbuf, &len, NULL, 0) != 0){
settings_msgbuf_failed("kern.msgbuf read failed");
return -1;
}
/* Second signal: /user/app/<TITLE> dir mtime. Launches touch the app
* dir even when the game hasn't saved yet (the exact hole that showed
* a stale title for a freshly launched game), and it covers titles
* with no savedata at all. */
{
DIR *ad = opendir("/user/app");
if(ad){
struct dirent *e;
while((e = readdir(ad))){
if(strlen(e->d_name) != 9 || !is_title_prefix(e->d_name)) continue;
char ap[64];
snprintf(ap, sizeof ap, "/user/app/%s", e->d_name);
struct stat st;
if(stat(ap, &st) == 0 && st.st_mtime > best){
best = st.st_mtime;
strncpy(out, e->d_name, cap-1);
out[cap-1] = 0;
best_src = "appdir";
}
}
closedir(ad);
}
if(len >= g_msgbuf_sz) len = g_msgbuf_sz - 1;
g_msgbuf[len] = 0;
/* The ring is chronological, so the newest entry is the live scene. */
const char *cur = g_msgbuf;
const char *last = NULL;
while((cur = strstr(cur, "OnFocusActiveSceneChanged")) != NULL){
last = cur;
cur += sizeof("OnFocusActiveSceneChanged") - 1;
}
/* Third signal (strongest): app.pkg ACCESS time. The running game
* streams its own package, so its atime is the freshest on the box;
* cloud sync touches mtimes, never atimes. Verified live: the
* foreground title's atime beats every idle title by days. */
{
DIR *ad = opendir("/user/app");
if(ad){
struct dirent *e;
while((e = readdir(ad))){
if(strlen(e->d_name) != 9 || !is_title_prefix(e->d_name)) continue;
char pp[96];
int wn = snprintf(pp, sizeof pp, "/user/app/%s/app.pkg",
e->d_name);
if(wn <= 0 || (size_t)wn >= sizeof pp) continue;
struct stat st;
if(stat(pp, &st) == 0 && (long)st.st_atime > best){
best = (long)st.st_atime;
strncpy(out, e->d_name, cap-1);
out[cap-1] = 0;
best_src = "pkg-atime";
}
}
closedir(ad);
}
}
if(out[0]) log_dbg("title scan: %s via %s", out, best_src);
return out[0] ? 0 : -1;
if(!last) return 0;
const char *target = strstr(last, "-> [");
if(!target) return 0;
const char *end = strchr(target + 4, ']');
if(!end) return 0;
size_t n = (size_t)(end - (target + 4));
if(n >= cap) return 0; /* implausible; stay honest */
memcpy(out, target + 4, n);
out[n] = 0;
return 1;
#else
return -1; /* app build: no kern.msgbuf story */
#endif
}
#endif
/* --- shared sysctl fallback (payload-SDK builds only) ------------------ */
/* OpenOrbis SDK ships no <sys/sysctl.h>, so this probe lives in SDK builds;
* OpenOrbis builds fall back to the UserService foreground user instead.
* Both builds probe ShellCoreUtil first (see detect_foreground_active). */
#ifdef ORBISRPC_SDK_PAYLOAD
/* Defined outside the payload block so the app build links too; it has no
* kern.msgbuf story there and reports unknown rather than guessing.
*
* 1 = Settings in front, 0 = a different scene, -1 = unknown.
*
* -1 means "no opinion" and must never be folded into 0: the probe being
* unavailable is not evidence that Settings closed. It also covers "the ring
* holds no scene change yet", which resolves on its own once the user moves
* around the UI. */
int detect_settings_active(void){
#ifndef ORBISRPC_SDK_PAYLOAD
return -1; /* not wired for the app build */
#else
char scene[SCENE_MAX];
if(last_focused_scene(scene, sizeof scene) != 1) return -1;
return strstr(scene, "SettingPage") != NULL ? 1 : 0;
/* Count eboot.bin processes. A change means launch/close: callers drop
* stale state immediately instead of waiting out debounce windows. */
int detect_eboot_count(void){
int mib[4] = { 1, 14, 8, 0 };
size_t sz = 0;
int n = 0;
if(sysctl(mib, 4, NULL, &sz, NULL, 0) != 0) return -1;
static unsigned char buf[256*1024];
if(sz > sizeof buf) return -1;
if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return -1;
size_t off = 0;
while(off + 4 <= sz){
int recsz = *(int *)(buf + off);
if(recsz <= 0 || off + (size_t)recsz > sz) break;
if(fw_match_eboot(buf + off, recsz))
n++;
off += (size_t)recsz;
}
return n;
#endif
}
static int proc_has_eboot(void){
int mib[4] = { 1, 14, 8, 0 };
size_t sz = 0;
if(sysctl(mib, 4, NULL, &sz, NULL, 0) != 0) return -1;
static unsigned char buf[256*1024];
if(sz > sizeof buf) return -1;
if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return -1;
size_t off = 0;
while(off + 4 <= sz){
int recsz = *(int *)(buf + off);
if(recsz <= 0 || off + (size_t)recsz > sz) return -1;
if(fw_match_eboot(buf + off, recsz))
return 1;
off += (size_t)recsz;
}
/* Web browser. Scene names verified on console 2026-10-04 from a live klog:
* [] -> [WebBrowserScene : WebBrowserScene] browser opens
* [SettingsScene] -> [WebViewDialog : ...] login popup over the page
* [WebViewDialog] -> [BrowserMain : MainScene] page loaded
* [BrowserMain] -> [ContentAreaScene] browser closed
* "WebBrowserPlugin" is a plugin load, not a focus target, so it never reaches
* this matcher; including "WebBrowser" costs nothing either way. */
int detect_browser_active(void){
#ifndef ORBISRPC_SDK_PAYLOAD
return -1; /* not wired for the app build */
#else
char scene[SCENE_MAX];
if(last_focused_scene(scene, sizeof scene) != 1) return -1;
if(strstr(scene, "WebBrowser") || strstr(scene, "BrowserMain") ||
strstr(scene, "WebViewDialog")) return 1;
return 0;
#endif
}
#endif /* ORBISRPC_SDK_PAYLOAD (sysctl fallback) */
#ifndef ORBISRPC_SDK_PAYLOAD
static int s_user_inited = 0;
static int s_user_ok = 0;
static int user_init(void){
@@ -285,27 +278,25 @@ static int scu_init(void){
if(s_scu_tried) return s_is_app_launched != NULL;
s_scu_tried = 1;
void *h = dlopen("libSceShellCoreUtil.sprx", 0);
if(!h){ log_msg("ShellCoreUtil unavailable (dlopen fail); using sysctl eboot scan"); return 0; }
if(!h){ log_msg("ShellCoreUtil unavailable (dlopen fail); using foreground-user fallback"); return 0; }
s_is_app_launched = (shellcore_isapplaunched_fn)(uintptr_t)dlsym(h, "sceShellCoreUtilIsAppLaunched");
log_msg("ShellCoreUtil IsAppLaunched %s", s_is_app_launched ? "resolved" : "unavailable (dlsym fail)");
return s_is_app_launched != NULL;
}
#endif
/* --- foreground-active: the core "a game is running" signal ---------- */
int detect_foreground_active(void){
/* ShellCoreUtil dlopen works on all firmwares (libkernel exports
* dlopen/dlsym directly). Firmware-independent — preferred over the
* fragile sysctl kinfo_proc scan which assumes a hardcoded process-table
* layout (offset 447, recsz>=479) that differs across PS4 firmware versions. */
if(scu_init() && s_is_app_launched){
return s_is_app_launched() ? 1 : 0; /* 0 when sitting on the home screen */
}
/* Fallback depends on toolchain capability: SDK builds scan the
* process table; OpenOrbis builds ask UserService for the
* foreground user. Both fail closed (inactive) on error. */
#ifdef ORBISRPC_SDK_PAYLOAD
return proc_has_eboot();
char tid[16];
return read_big_app(tid, sizeof tid);
#else
if(scu_init() && s_is_app_launched){
int on = s_is_app_launched();
return (on != 0) ? 1 : 0; /* 0 when sitting on the home screen */
}
/* fallback: foreground user exists. If UserService itself failed,
* report inactive instead of guessing "playing". */
if(user_init() != 0) return 0;
int32_t fg = -1;
int32_t rc = sceUserServiceGetForegroundUser(&fg);
@@ -315,12 +306,14 @@ int detect_foreground_active(void){
}
/* --- title naming ---------------------------------------------------- */
#ifndef ORBISRPC_SDK_PAYLOAD
static int is_title_prefix(const char *n){
/* CUSA (PS4) + PPSA (PS5-backport) + EU/JP/indie variants */
return strncmp(n,"CUSA",4)==0 || strncmp(n,"PPSA",4)==0 ||
strncmp(n,"PCSE",4)==0 || strncmp(n,"PCSB",4)==0 ||
strncmp(n,"PCSG",4)==0 || strncmp(n,"EPSA",4)==0;
}
#endif
/* last resolved titleId (for Discord asset key); valid after a successful
* detect_current_game / detect_name_for_title. */
static char s_last_titleid[16] = "";
@@ -336,9 +329,21 @@ static char s_rs_art[256] = "";
* resolves reuse indefinitely; raw fallbacks re-resolve after 5 min. */
static int s_rs_ok = 0;
static int64_t s_rs_at = 0;
static int resolve_reuse(const char *ti, char *out_name, size_t cap){
static int resolve_reuse(const char *ti, char *out_name, size_t cap, int *was_resolved){
if(was_resolved) *was_resolved = 0;
if(!ti || !ti[0] || strcmp(ti, s_rs_tid) != 0 || !s_rs_name[0]) return 0;
if(!s_rs_ok && orbis_mono_s() - s_rs_at > 300) return 0;
if(!s_rs_ok){
/* Inside the grace window the raw id is the best thing we have, so
* show it -- but do not claim it was a resolve. */
if(orbis_mono_s() - s_rs_at <= 300){
strncpy(out_name, s_rs_name, cap - 1);
out_name[cap - 1] = 0;
return 1;
}
log_dbg("name: cached raw id for %s expired; re-resolving", ti);
return 0; /* expired: the network sources get another turn */
}
if(was_resolved) *was_resolved = 1;
strncpy(out_name, s_rs_name, cap - 1);
out_name[cap - 1] = 0;
strncpy(s_last_art, s_rs_art, sizeof s_last_art - 1);
@@ -380,6 +385,7 @@ static void remember_titleid(const char *ti){
strncpy(s_last_titleid, ti, sizeof s_last_titleid - 1);
s_last_titleid[sizeof s_last_titleid - 1] = 0;
}
#ifndef ORBISRPC_SDK_PAYLOAD
static long scan_one_appdir(const char *base, char *out, size_t cap, long best){
if(!base || !out || cap < 2) return best;
DIR *d = opendir(base);
@@ -401,6 +407,7 @@ static long scan_one_appdir(const char *base, char *out, size_t cap, long best){
closedir(d);
return best;
}
#endif
#ifndef ORBISRPC_SDK_PAYLOAD
static long scan_recent_titleid(char *out, size_t cap){
out[0]=0;
@@ -410,137 +417,39 @@ static long scan_recent_titleid(char *out, size_t cap){
}
#endif /* scan_recent_titleid unused in SDK builds */
/* Best name on the box: /user/appmeta/<id>/pronunciation.xml holds the
* display title in its first <text> element (works for every game that
* ships speech data, e.g. Terraria). Small file, single read. */
static int pronunc_title(const char *titleId, char *out, size_t cap){
char path[256]; snprintf(path,sizeof path,"/user/appmeta/%s/pronunciation.xml",titleId);
int fd=open(path,O_RDONLY);
if(fd<0){ log_msg("appmeta open fail %s err=%d", path, errno); return -1; }
char buf[2048]; ssize_t n=read(fd,buf,sizeof buf-1); close(fd);
if(n<=0) return -1; buf[n]=0;
const char *p=strstr(buf,"<text>");
if(!p) return -1;
p+=6;
while(*p==' '||*p=='\t'||*p=='\r'||*p=='\n') p++;
size_t i=0;
while(*p && *p!='<' && *p!='\n' && *p!='\r' && i<cap-1){ out[i++]=*p++; }
out[i]=0;
while(i>0 && (out[i-1]==' '||out[i-1]=='\t')) out[--i]=0;
return (i>1)?0:-1;
}
/* Game-process-safe TITLE read: the running game's own param.sfo via the
* app0 mount plus on-disc sce_sys copies. Small single read, works on ANY
* console with zero setup. Returns 0 on success. */
static int sfo_file_title(const char *titleId, char *out, size_t cap){
char path[256];
/* Running game's own sandbox mount first: the payload process can
* already list /mnt/sandbox (that is where the title ID comes from),
* and the live mount carries the game's own sce_sys/param.sfo with
* its TITLE field. Fails soft when untraversable. */
if(titleId && titleId[0]){
snprintf(path, sizeof path, "/mnt/sandbox/%s_000/sce_sys/param.sfo", titleId);
int sfd = open(path, O_RDONLY);
if(sfd >= 0){
unsigned char buf[4096];
ssize_t n = read(sfd, buf, sizeof buf);
close(sfd);
if(n > 0 && sfo_title(buf, (size_t)n, out, cap) == 0) return 0;
}
}
const char *fixed[] = { "app0/sce_sys/param.sfo", "/app0/sce_sys/param.sfo", NULL };
for(int i = 0; fixed[i]; i++){
int fd = open(fixed[i], O_RDONLY);
if(fd < 0) continue;
unsigned char buf[4096];
ssize_t n = read(fd, buf, sizeof buf);
close(fd);
if(n > 0 && sfo_title(buf, (size_t)n, out, cap) == 0) return 0;
}
if(titleId && titleId[0]){
const char *bases[] = { "/user/app", "/data/app", NULL };
for(int b = 0; bases[b]; b++){
snprintf(path, sizeof path, "%s/%s/sce_sys/param.sfo", bases[b], titleId);
int fd = open(path, O_RDONLY);
if(fd < 0) continue;
unsigned char buf[4096];
ssize_t n = read(fd, buf, sizeof buf);
close(fd);
if(n > 0 && sfo_title(buf, (size_t)n, out, cap) == 0) return 0;
}
}
return -1;
}
static int appxml_title(const char *titleId, char *out, size_t cap){
const char *bases[] = { "/user/app", "/data/app", NULL };
for(int b=0; bases[b]; b++){
char path[256]; snprintf(path,sizeof path,"%s/%s/app.xml",bases[b],titleId);
int fd=open(path,O_RDONLY); if(fd<0) continue;
char buf[640]; ssize_t n=read(fd,buf,sizeof buf-1); close(fd);
if(n<=0) continue; buf[n]=0;
char *p=strstr(buf,"<title>");
if(!p) p=strstr(buf,"titleName");
if(!p) continue;
p = strchr(p, '>');
if(!p) continue;
p++;
while(*p==' '||*p=='\t'||*p=='\r'||*p=='\n') p++; /* trim leading ws */
size_t i=0;
while(*p && *p!='<' && *p!='\n' && *p!='\r' && i<cap-1){ out[i++]=*p++; }
out[i]=0;
while(i>0 && (out[i-1]==' '||out[i-1]=='\t')) out[--i]=0; /* trim trailing */
if(i>0) return 0;
}
return -1;
}
/* NOTE: an earlier revision byte-scanned app.db for titles. Removed:
* the packed record layout makes the title/contentId boundary ambiguous
* and the scanner returned wrong names (worse than raw IDs). Exact
* sources above plus Sony TMDB cover every case instead. */
int detect_current_game(char *out_name, size_t cap, char *out_path, size_t p_cap){
if(!out_name || cap==0) return -1;
out_name[0] = 0;
if(out_path && p_cap) out_path[0] = 0;
{
/* Tri-state foreground: 1 game, 0 none, -1 scan failed (unknown).
* Unknown must NOT count as disappearance — it means "keep whatever
* we had", never a transition. */
int fg = detect_foreground_active();
if(fg < 0) return -2;
/* Event-driven focus (kern.msgbuf AppFocusChanged): authoritative
* when readable — settles multi-app ambiguity and surfaces system
* screens. Log-only for now; snapshot probes still gate transitions
* until msgbuf readability is confirmed per firmware. */
{
char scr_tid[16] = "";
int scr = detect_system_screen(scr_tid, sizeof scr_tid);
static char last_scr[16] = "";
if(scr != FOCUS_UNKNOWN && strcmp(scr_tid, last_scr) != 0){
strncpy(last_scr, scr_tid, sizeof last_scr - 1);
log_msg("focus: %s (%s)", scr_tid,
scr == FOCUS_GAME ? "game" : "system");
}
}
if(!fg) return -1;
}
char titleId[16]=""; int named=0, have_tid=0;
#ifdef ORBISRPC_SDK_PAYLOAD
/* Sandbox mount first: authoritative OS-level identity. Save-scan
* stays as fallback (its mtimes get bulk-touched by cloud sync). */
if(scan_sandbox_mount(titleId, sizeof titleId) == 0){
have_tid = 1;
remember_titleid(titleId);
s_last_art[0] = 0;
} else if(scan_newest_save(titleId,sizeof titleId)==0){
have_tid = 1;
remember_titleid(titleId);
s_last_art[0] = 0;
} else return -1;
/* One query yields both state and identity, so nothing can disagree with
* itself.
*
* -1 uncertain -> -2: hold position, never clear
* 2 system app in front -> -3: the game has not closed, just covered
* 0 nothing in front -> -1: genuinely gone
* 1 a game -> carry on with the TITLEID the read gave us */
int fg = read_big_app(titleId, sizeof titleId);
if(fg < 0){ log_dbg("detect: big-app read uncertain"); return -2; }
if(fg == 2){
/* Name the system app when we can: a media app id landing here is
* the explanation for an unexpected "not playing" presence. */
const char *last = detect_last_titleid();
log_dbg("detect: system app in front (%s)", last ? last : "unresolved");
return -3;
}
if(fg == 0) return -1;
have_tid = 1;
remember_titleid(titleId);
s_last_art[0] = 0;
#else
{
int fg = detect_foreground_active();
if(fg < 0) return -2;
if(fg == 2) return -3;
if(!fg) return -1;
}
have_tid = (scan_recent_titleid(titleId,sizeof titleId)==0);
if(have_tid){
remember_titleid(titleId);
@@ -550,27 +459,88 @@ int detect_current_game(char *out_name, size_t cap, char *out_path, size_t p_cap
if(have_tid){
remember_titleid(titleId);
s_last_art[0] = 0;
if(resolve_reuse(titleId, out_name, cap)){
/* A cache hit only counts when it WAS a resolve. A cached raw id
* still pre-fills the name so the poll is cheap, but the network
* sources below must get their turn -- claiming success here is what
* pinned a title to its raw id for the whole session. */
int cached_ok = 0;
if(resolve_reuse(titleId, out_name, cap, &cached_ok) && cached_ok){
log_dbg("name: %s via cache", out_name);
named = 1;
}
/* cheap, game-process-safe sources first; Sony TMDB (network)
* resolves anything local sources miss, on any console. */
/* games_cache.json persists BOTH the name and the cover URL, so
* it is consulted before any network call. A title resolved on an
* earlier boot arrives with its art attached; without this, art is
* RAM-only and the presence falls through to a pack URL that may
* not exist, which is exactly the missing-icon symptom. */
char cc_art[256] = "";
if(!named && gamecache_get(titleId, out_name, cap, cc_art, sizeof cc_art)){
named = 1;
if(cc_art[0]){
strncpy(s_last_art, cc_art, sizeof s_last_art-1);
s_last_art[sizeof s_last_art-1] = 0;
}
log_msg("name: %s via games_cache%s", out_name,
cc_art[0] ? " (+art)" : " (no art)");
}
/* config.json titles are still READ: hand-edited overrides win, and
* names learned before games_cache existed keep resolving. The daemon
* no longer writes here -- that is games_cache.json now. */
if(!named && cfg_title(&g_cfg, titleId, out_name, cap)==0){ named=1; log_msg("name: %s via config", out_name); }
/* System app.db: the authoritative on-box title registry (SQLite,
* read-only). Covers disc + digital where per-file sources miss. */
/* appdb_title(titleId, out_name, cap); */
if(!named && pronunc_title(titleId, out_name, cap)==0){ named=1; log_msg("name: %s via appmeta", out_name); }
if(!named){ if(sfo_file_title(titleId, out_name, cap)==0){ named=1; log_msg("name: %s via sfo", out_name); } }
if(!named){ if(appxml_title(titleId, out_name, cap)==0){ named=1; log_msg("name: %s via appxml", out_name); } }
if(!named){
/* Three mutually exclusive sources, one per class of title id:
* CUSA* -> Sony TMDB
* retro -> retro-games (ps1.json, then ps2.json, then psp.json)
* else -> pkg-zone (homebrew)
* Never chained: each class has exactly one index that knows it, so
* asking a second one costs a full TLS handshake for a guaranteed
* miss. This is what let SCUS97399 hammer pkg-zone at 1 Hz. */
int want_retro = (!named && g_cfg.retro_enabled && retro_wants(titleId));
int try_pkgzone = (!named && !want_retro && g_cfg.pkgzone_enabled
&& pkgzone_wants(titleId));
if(!named && !try_pkgzone && !want_retro){
char art[256] = "";
if(tmdb_resolve(titleId, out_name, cap, art, sizeof art)==0){
named = 1;
strncpy(s_last_art, art, sizeof s_last_art-1);
} else s_last_art[0] = 0;
}
if(!named){ strncpy(out_name, titleId, cap-1); out_name[cap-1]=0; }
/* Homebrew: no on-box source and no TMDB entry, so this is the only
* chance at a real name. Failure is silent by design: the raw title id
* below is a perfectly good fallback. */
if(try_pkgzone){
char art[256] = "";
if(pkgzone_resolve(titleId, out_name, cap, art, sizeof art)==0){
named = 1;
strncpy(s_last_art, art, sizeof s_last_art-1);
s_last_art[sizeof s_last_art-1] = 0;
}
}
/* Retro: fetches each platform index in turn (ps1 -> ps2 -> psp) and
* stops at the first hit. An index miss is normal, not an error, so
* only a hard failure is logged. */
if(want_retro){
char art[RETRO_MAX_URL] = "";
if(retro_resolve(titleId, out_name, cap, art, sizeof art)==0){
named = 1;
if(art[0]){
strncpy(s_last_art, art, sizeof s_last_art-1);
s_last_art[sizeof s_last_art-1] = 0;
}
}
}
/* Only a genuine resolve is worth keeping. detect_last_ok() is 0 for
* a raw-id fallback, so a network miss never poisons the cache. */
if(named && detect_last_ok())
gamecache_put(titleId, out_name, s_last_art,
want_retro ? "retro" : try_pkgzone ? "pkgzone" : "tmdb");
if(!named){
strncpy(out_name, titleId, cap-1); out_name[cap-1]=0;
/* Every source missed. Without this line the presence just shows
* a bare title id and there is no way to tell a network failure
* from a genuinely unknown title. */
log_msg("name: %s UNRESOLVED (tried: config, %s)",
titleId, want_retro ? "retro" : try_pkgzone ? "pkgzone" : "tmdb");
}
resolve_remember(titleId, out_name, s_last_art, named);
}else{
remember_titleid("");
@@ -588,18 +558,12 @@ int detect_name_for_title(const char *titleId, char *out_name, size_t cap){
if(!titleId || !titleId[0] || !out_name || cap==0) return -1;
remember_titleid(titleId);
s_last_art[0] = 0;
if(resolve_reuse(titleId, out_name, cap)){
int cached_ok = 0;
if(resolve_reuse(titleId, out_name, cap, &cached_ok) && cached_ok){
log_dbg("name: %s via cache", out_name);
return 0;
}
/* Game-process-safe only: small reads plus one bounded network
* lookup; no multi-megabyte scans anywhere in this codebase. */
if(cfg_title(&g_cfg, titleId, out_name, cap)==0){ log_msg("name: %s via config", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
/* appdb_title removed: no baked table (README "tables-none"). */
if(pronunc_title(titleId, out_name, cap)==0){ log_msg("name: %s via appmeta", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
else log_msg("name: appmeta miss for %s", titleId);
if(sfo_file_title(titleId, out_name, cap)==0){ log_msg("name: %s via sfo", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
if(appxml_title(titleId, out_name, cap)==0){ log_msg("name: %s via appxml", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
{
char art[256] = "";
if(tmdb_resolve(titleId, out_name, cap, art, sizeof art)==0){
@@ -609,6 +573,31 @@ int detect_name_for_title(const char *titleId, char *out_name, size_t cap){
}
s_last_art[0] = 0;
}
/* Same three-way split as detect_name_for_app(): CUSA -> TMDB, retro ->
* retro-games, everything else -> pkg-zone. */
if(g_cfg.retro_enabled && retro_wants(titleId)){
char art[RETRO_MAX_URL] = "";
if(retro_resolve(titleId, out_name, cap, art, sizeof art)==0){
if(art[0]){
strncpy(s_last_art, art, sizeof s_last_art-1);
s_last_art[sizeof s_last_art-1] = 0;
}
resolve_remember(titleId, out_name, art, 1);
return 0;
}
/* Every platform index missed. Fall through to the raw id. */
log_msg("retro: %s not found in any index", titleId);
} else if(g_cfg.pkgzone_enabled && pkgzone_wants(titleId)){
char art[256] = "";
if(pkgzone_resolve(titleId, out_name, cap, art, sizeof art)==0){
if(art[0]){
strncpy(s_last_art, art, sizeof s_last_art-1);
s_last_art[sizeof s_last_art-1] = 0;
}
resolve_remember(titleId, out_name, art, 1);
return 0;
}
}
strncpy(out_name, titleId, cap-1); out_name[cap-1]=0;
resolve_remember(titleId, out_name, "", 0);
return 0;
+26 -10
View File
@@ -2,21 +2,37 @@
#ifndef DETECT_H
#define DETECT_H
#include <stddef.h>
#include "focus.h"
#include "retro.h"
/* Returns 0 and writes a display name when a foreground game is found.
* Returns -1 when no game is active or the arguments are invalid.
* Returns -1 when no game is active, -2 when the read failed and the caller
* should hold its current state, and -3 when a system app (Settings, browser,
* store) is covering a game that is still running -- also hold, but it means
* the game did not close.
* out_path is optional and receives the per-title cache path when provided. */
int detect_current_game(char *out_name, size_t cap, char *out_path, size_t p_cap);
/* Returns 1 if a foreground user app is running, 0 if definitely none,
* -1 if the scan itself failed (unknown: keep previous state). */
/* 1 game in front, 0 nothing in front, 2 system app in front, -1 unknown. */
int detect_foreground_active(void);
/* Number of eboot.bin processes (launch/close churn detector). -1 unknown.
* Payload-SDK builds only (needs raw sysctl visibility). */
#ifdef ORBISRPC_SDK_PAYLOAD
int detect_eboot_count(void);
#endif
/* Resolve a display name for a KNOWN title id (plugin mode). */
/* 1 = Settings in front, 0 = a different scene, -1 = unknown. Unknown must
* never be treated as 0: an unavailable probe is not evidence that Settings
* closed. */
int detect_settings_active(void);
/* 1 when the id is in the pkg-zone homebrew namespace: not retail (CUSA), not
* system (NPXS), and not a retro prefix. Retro is excluded on purpose so
* show_homebrew never silences the classic titles. Declared in bigapp.h with
* the other namespace rules. */
int is_homebrew_id(const char *tid);
/* 1 = web browser in front, 0 = another scene, -1 = unknown. Unknown is
* never folded into 0, for the same reason as detect_settings_active. */
int detect_browser_active(void);
/* Resolve a display name for a KNOWN title id. */
int detect_name_for_title(const char *titleId, char *out_name, size_t cap);
/* Last resolved titleId (e.g. "CUSA00740") or NULL if none yet.
* Used as the Discord asset key so icons follow automatically. */
const char *detect_last_titleid(void);
+48 -23
View File
@@ -12,6 +12,7 @@
#include "clock.h"
#include "b64.h"
#include "log.h"
#include "notify.h"
#include "art.h"
#include "jsonlite.h"
#include "detect.h"
@@ -150,28 +151,15 @@ static int send_identify(discord_t *d, const char *token){
return rc < 0 ? -1 : 0;
}
const char *discord_state_name(const discord_t *d){
if(!d || !d->connected) return "down";
switch(d->state){
case GW_CONNECTING: return "connecting";
case GW_HELLO_WAIT: return "hello_wait";
case GW_IDENTIFYING: return "identifying";
case GW_READY: return "ready";
default: return "down";
}
}
int discord_connect(discord_t *d, const char *token){
if(!d || !token || !token[0]) return -1;
memset(d,0,sizeof(*d));
d->state = GW_CONNECTING;
strncpy(d->token, token, sizeof d->token-1);
d->token[sizeof d->token-1] = 0;
char key[64]=""; make_key(key);
int rc=ws_connect(&d->ws, GW_HOST, GW_PORT, GW_PATH, key);
if(rc){ log_msg("ws connect fail %d",rc); return -1; }
d->connected=1;
d->state = GW_HELLO_WAIT;
int64_t now=orbis_mono_s();
d->last_heartbeat=now; d->last_ack=now;
/* HELLO (text frame carrying {"op":10,...}) */
@@ -203,7 +191,6 @@ int discord_connect(discord_t *d, const char *token){
ws_close(&d->ws); d->connected=0;
return -1;
}
d->state = GW_IDENTIFYING;
log_msg("discord: identify sent, hb=%llds",(long long)(d->hb_interval_ms/1000));
/* READY confirms the token was accepted. `op` is the WebSocket frame
* type; the gateway event is JSON inside — parse it, don't switch on it. */
@@ -226,8 +213,10 @@ int discord_connect(discord_t *d, const char *token){
if(go==11){ d->last_ack=orbis_mono_s(); continue; }
if(go==0 && is_ready(buf, (size_t)nr)){
gw_seq(d, buf, (size_t)nr);
d->state = GW_READY;
log_msg("discord: gateway ready");
/* Fires on every successful READY, including recovery after an
* outage -- that is the notification you actually want. */
notify_show("Connected to Discord");
return 0;
}
/* other pre-READY events: ignore */
@@ -238,7 +227,9 @@ int discord_connect(discord_t *d, const char *token){
int discord_set_presence(discord_t *d, const char *state, const char *name,
const char *application_id, int64_t started_epoch){
return discord_set_presence_ex(d, state, name, NULL, application_id, NULL, NULL, NULL, started_epoch);
return discord_set_presence_ex(d, state, name, NULL, NULL, NULL,
NULL, application_id, NULL, NULL,
NULL, started_epoch);
}
/* Lowercase titleId into an asset-key buffer. Returns key length. */
@@ -257,6 +248,8 @@ static size_t asset_key(const char *title_id, char *key, size_t cap){
/* Test seam: pure activity-JSON builder (no sockets). token may be ""
* to skip the mp: proxy (deterministic offline tests). NULL on OOM. */
jl_val_t *discord_build_activity(const char *state, const char *name,
const char *details,
const char *asset_idle, const char *asset_playing,
const char *title_id, const char *application_id,
const char *art_base_url, const char *art_url,
const char *small_art_url,
@@ -274,6 +267,11 @@ jl_val_t *discord_build_activity(const char *state, const char *name,
}
jl_obj_set(act,"type",jl_new_int(atype)); /* 0 Playing */
}
/* Second visible line. Never the raw title ID: that renders as noise
* under the name, and twice when the name itself fell back to the ID.
* Callers pass a short human phrase (what you are doing); the
* firmware version belongs in `state`, alongside the timer. */
if(details&&details[0]) jl_obj_set(act,"details",jl_new_string(details));
if(state&&state[0]) jl_obj_set(act,"state",jl_new_string(state));
/* details intentionally mirrors the human-readable name, never the raw
* title ID: sending the ID here renders it as a second line under the
@@ -299,7 +297,13 @@ jl_val_t *discord_build_activity(const char *state, const char *name,
* dangling keys drop the whole activity. Two working forms, tried in
* order: (1) mp: proxy resolved via external-assets for the URL we
* have (Sony CDN or art_base_url pack); (2) uploaded asset key. */
if(title_id&&!strcmp(title_id,"home")&&application_id&&application_id[0]){
/* "settings" is idle artwork too: Settings shows the same large
* image as home. Without this the branch fell through and the
* activity was sent with no assets at all. */
int idle_art = title_id && title_id[0] &&
(!strcmp(title_id,"home") || !strcmp(title_id,"settings") ||
!strcmp(title_id,"browser"));
if(idle_art && application_id && application_id[0]){
/* Idle tile: PlayStation logo. home_art (arrives as art_url):
* http(s) URL -> mp: proxy; bare value -> uploaded asset key
* used as-is; empty -> <art_base_url>home.png when the pack
@@ -323,13 +327,21 @@ jl_val_t *discord_build_activity(const char *state, const char *name,
if(n>0 && (size_t)n<sizeof home_pack) hsrc = home_pack;
}
char hmp[512] = "";
/* Prefer the configured idle asset key: already uploaded to the
* Discord app, so it costs no external-assets round trip and
* works with no network. art_url stays the fallback so an
* operator who prefers a URL is not overridden. */
const char *himg = NULL;
if(asset_idle && asset_idle[0]){
himg = asset_idle;
} else {
if(hsrc && !strncmp(hsrc,"http",4)){
if(token && token[0] && art_resolve_mp(application_id, token, hsrc, hmp, sizeof hmp))
himg = hmp;
} else if(hsrc){
himg = hsrc; /* operator-supplied uploaded key: trusted */
}
}
if(himg){
jl_val_t *as=jl_new_object();
if(as){
@@ -375,7 +387,21 @@ jl_val_t *discord_build_activity(const char *state, const char *name,
* PlayStation logo next to game art. mp: URLs resolve through the
* disk-cached proxy; anything unresolvable is omitted, never sent
* raw (bad small images drop the whole activity). */
if(small_art_url && small_art_url[0]){
int playing_art = title_id && title_id[0] &&
strcmp(title_id,"home") != 0 &&
strcmp(title_id,"settings") != 0 &&
strcmp(title_id,"browser") != 0;
if(asset_playing && asset_playing[0] && playing_art){
jl_val_t *as = jl_obj_get(act, "assets");
if(!as || as->type != JL_OBJECT){
as = jl_new_object();
if(as) jl_obj_set(act, "assets", as);
}
if(as && as->type == JL_OBJECT){
jl_obj_set(as, "small_image", jl_new_string(asset_playing));
jl_obj_set(as, "small_text", jl_new_string("PlayStation 4"));
}
} else if(small_art_url && small_art_url[0]){
char smp[512] = "";
const char *simg = NULL;
if(!strncmp(small_art_url, "mp:", 3)){
@@ -396,12 +422,15 @@ jl_val_t *discord_build_activity(const char *state, const char *name,
}
int discord_set_presence_ex(discord_t *d, const char *state, const char *name,
const char *details,
const char *asset_idle, const char *asset_playing,
const char *title_id, const char *application_id,
const char *art_base_url, const char *art_url,
const char *small_art_url,
int64_t started_epoch){
if(!d || !d->connected || !name) return -1;
jl_val_t *act = discord_build_activity(state, name, title_id,
jl_val_t *act = discord_build_activity(state, name, details,
asset_idle, asset_playing, title_id,
application_id, art_base_url, art_url, small_art_url, started_epoch, d->token);
if(!act) return -1;
jl_val_t *dd=jl_new_object();
@@ -441,10 +470,6 @@ int discord_clear_presence(discord_t *d){
int discord_tick(discord_t *d){
if(!d || !d->connected) return -1;
/* Liveness lives in connected; state records the highest phase
* reached. A drop leaves state at its phase with connected=0 —
* observers must read through discord_state_name(), which reports
* any !connected session as down. */
int64_t now=orbis_mono_s();
long hb_s=(long)(d->hb_interval_ms/1000); if(hb_s<5)hb_s=5;
/* gateway must ack heartbeats; 2 missed intervals means it's gone */
+7 -14
View File
@@ -10,21 +10,9 @@ typedef struct {
int64_t last_ack; /* when the gateway last acked (op 11) */
int64_t hb_interval_ms;
int seq; /* last dispatch sequence (heartbeat payload) */
int connected; /* 1 iff state == GW_READY (derived mirror) */
int connected;
int sent_hb; /* at least one heartbeat sent */
int state; /* gw_state_t: explicit session state */
} discord_t;
/* Explicit gateway state machine. GW_DOWN is 0 so a zeroed struct starts
* DOWN; connected==1 exactly when state==GW_READY. Deliberately no
* RESUME state: we always fresh-IDENTIFY (simpler, stateless recovery). */
typedef enum {
GW_DOWN = 0, /* no usable session */
GW_CONNECTING, /* TCP+TLS+WebSocket handshake in flight */
GW_HELLO_WAIT, /* waiting for op 10 HELLO */
GW_IDENTIFYING, /* IDENTIFY sent, waiting for READY */
GW_READY /* live session (connected == 1) */
} gw_state_t;
const char *discord_state_name(const discord_t *d);
/* Returns 0 on READY, -1 net/proto error, -2 auth-fatal (close 4004: don't
* retry — the token is wrong and Discord bans IPs that hammer it). */
int discord_connect(discord_t *d, const char *token);
@@ -34,14 +22,19 @@ int discord_set_presence(discord_t *d, const char *state, const char *name,
* assets { large_image: "<lower titleId>", large_text: "<name>" } so the
* game icon shows once uploaded under that name in the Discord app.
* When art_base_url is set instead, large_image becomes
* "<base><lower titleId>.png" (external URL assets, no uploads needed). */
* "<base><lower titleId>.png" (external URL assets, no uploads needed).
* `details` is the second visible line; NULL or "" omits it. */
int discord_set_presence_ex(discord_t *d, const char *state, const char *name,
const char *details,
const char *asset_idle, const char *asset_playing,
const char *title_id, const char *application_id,
const char *art_base_url, const char *art_url,
const char *small_art_url,
int64_t started_epoch); /* op 3 */
/* Test seam: pure activity-JSON builder (no sockets). token "" skips mp:. */
jl_val_t *discord_build_activity(const char *state, const char *name,
const char *details,
const char *asset_idle, const char *asset_playing,
const char *title_id, const char *application_id,
const char *art_base_url, const char *art_url,
const char *small_art_url,
-143
View File
@@ -1,143 +0,0 @@
/* focus.c - kern.msgbuf AppFocusChanged focus tracking.
* Probe-first: tries each candidate msgbuf path, remembers the one that
* works (sticky), parses only the LAST event (current focus), classifies by
* title-id prefix. Any failure (no device, no events, short read) returns
* FOCUS_UNKNOWN so callers keep previous state — never a transition. */
#include "focus.h"
#include "log.h"
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
static const char kEv[] = "AppFocusChanged [";
static const void *mem_find(const void *h, size_t hl,
const void *n, size_t nl){
if(!h || !n || nl == 0 || hl < nl) return NULL;
const unsigned char *p = h;
for(size_t i = 0; i + nl <= hl; i++){
if(!memcmp(p + i, n, nl)) return p + i;
}
return NULL;
}
int focus_parse_appfocus(const char *buf, size_t len,
char *out_tid, size_t cap){
if(!buf || !out_tid || cap == 0) return -1;
out_tid[0] = 0;
/* Last event wins: earlier entries are stale focus history. */
const char *last = NULL;
const char *p = buf;
size_t rem = len;
while(rem >= sizeof kEv - 1){
const char *f = mem_find(p, rem, kEv, sizeof kEv - 1);
if(!f) break;
last = f;
size_t adv = (size_t)(f - p) + 1;
p = f + 1;
rem -= adv;
}
if(!last) return -1;
/* Collect bracketed tokens after the marker; the focus target is the
* last one ("AppFocusChanged [FROM] -> [TO]"). The first token's
* opening bracket is already consumed by the marker itself. */
const char *q = last + sizeof kEv - 1;
const char *end = buf + len;
char best[16] = "";
const char *qs = q;
while(q < end && *q != ']' && *q != '[' &&
(size_t)(q - qs) < sizeof best - 1) q++;
if(q < end && *q == ']' && q > qs){
size_t n = (size_t)(q - qs);
memcpy(best, qs, n);
best[n] = 0;
q++;
}
while(q < end){
if(*q != '['){ q++; continue; }
q++;
qs = q;
while(q < end && *q != ']' && (size_t)(q - qs) < sizeof best - 1) q++;
if(q < end && *q == ']' && q > qs){
size_t n = (size_t)(q - qs);
if(n < sizeof best){ memcpy(best, qs, n); best[n] = 0; }
}
if(q < end) q++;
}
if(!best[0]) return -1;
strncpy(out_tid, best, cap - 1);
out_tid[cap - 1] = 0;
return 0;
}
int focus_classify(const char *tid){
if(!tid || !tid[0]) return FOCUS_UNKNOWN;
if(!strncmp(tid, "NPXS", 4)) return FOCUS_SYSTEM;
if(!strncmp(tid, "CUSA", 4) || !strncmp(tid, "PPSA", 4) ||
!strncmp(tid, "PCSE", 4) || !strncmp(tid, "PCSB", 4) ||
!strncmp(tid, "PCSG", 4) || !strncmp(tid, "EPSA", 4))
return FOCUS_GAME;
return FOCUS_UNKNOWN;
}
/* Candidate kernel message-buffer paths, in probe order. The exact device
* name varies, so we try and remember — never assume. */
static const char *kPaths[] = {
"/dev/kern.msgbuf",
"/dev/msgbuf",
"kern.msgbuf",
NULL,
};
int detect_system_screen(char *out_tid, size_t cap){
if(out_tid && cap) out_tid[0] = 0;
static int known = -1; /* sticky index into kPaths */
static int logged_no_dev = 0;
static unsigned char buf[256 * 1024];
for(int pass = 0; pass < 2 && known != -2; pass++){
int start = (known >= 0) ? known : 0;
for(int i = start; kPaths[i]; i++){
if(known >= 0 && i != known) continue;
int fd = open(kPaths[i], O_RDONLY);
if(fd < 0){
if(known == i) known = -1; /* device vanished, re-probe */
continue;
}
ssize_t n = read(fd, buf, sizeof buf - 1);
close(fd);
if(n <= 0){
if(known == i) known = -1;
continue;
}
known = i;
buf[n] = 0;
char tid[16] = "";
if(focus_parse_appfocus((const char *)buf, (size_t)n,
tid, sizeof tid) != 0)
return FOCUS_UNKNOWN; /* buffer readable, no events yet */
if(out_tid && cap){
strncpy(out_tid, tid, cap - 1);
out_tid[cap - 1] = 0;
}
return focus_classify(tid);
}
if(known >= 0) break;
known = -1;
if(pass == 0) continue;
}
if(!logged_no_dev){
logged_no_dev = 1;
log_msg("msgbuf unreadable from payload; focus via scu/sysctl");
}
return FOCUS_UNKNOWN;
}
int focus_msgbuf_ok(void){
for(int i = 0; kPaths[i]; i++){
int fd = open(kPaths[i], O_RDONLY);
if(fd >= 0){ close(fd); return 1; }
}
return 0;
}
-28
View File
@@ -1,28 +0,0 @@
/* focus.h - kernel message-buffer focus tracking (event-driven).
* The kernel logs "AppFocusChanged [...]" on every focus switch, which beats
* polling: multi-app ambiguity disappears and system screens (settings,
* browser) become visible instead of "no game". Verified approach: a
* third-party 18KB payload tracks focus exactly this way off kern.msgbuf.
* Everything here fails soft to "unknown" — snapshot probes stay fallback. */
#ifndef ORBISRPC_FOCUS_H
#define ORBISRPC_FOCUS_H
#include <stddef.h>
#define FOCUS_UNKNOWN 0 /* no information (keep previous state) */
#define FOCUS_GAME 1 /* game/app title id (CUSA, PPSA, ...) */
#define FOCUS_SYSTEM 2 /* system app (NPXS...) — settings, browser, etc. */
/* Parse the LAST AppFocusChanged event in a raw msgbuf window.
* Returns 0 and writes the focus target title id, -1 when no event found. */
int focus_parse_appfocus(const char *buf, size_t len,
char *out_tid, size_t cap);
/* 1 game, 2 system (NPXS), 0 unknown prefix. */
int focus_classify(const char *tid);
/* Probe kern.msgbuf candidates, parse last focus event, classify it.
* Returns FOCUS_* class, writes tid when parsed. Never crashes, never
* blocks: unreadable buffer just means FOCUS_UNKNOWN. */
int detect_system_screen(char *out_tid, size_t cap);
/* Reachability probe for the census: 1 if any candidate msgbuf path
* opens, 0 otherwise. No parsing, no logging, no sticky state. */
int focus_msgbuf_ok(void);
#endif
+92 -92
View File
@@ -1,107 +1,107 @@
/* fw.c - firmware detection + per-FW kinfo_proc layout table. See fw.h.
* Version source is uname(2) (libc, no libs, no syscalls): PS4 reports the
* system version in the release field. The payload SDK libc has no uname,
* so SDK builds report unknown and use the bounded scan (still correct,
* just never the exact-offset fast path). Only FWs verified live on
* hardware go in the table; everything else uses the bounded scan. */
/* fw.c - see fw.h. */
#include "fw.h"
#include "log.h"
#include <string.h>
#include <stdio.h>
#ifndef ORBISRPC_SDK_PAYLOAD
#include <sys/utsname.h>
#endif
#include <stdint.h>
void fw_version(char *out, size_t cap){
if(!out || cap == 0) return;
/* Default before any parsing so every exit path is defined. */
snprintf(out, cap, "?.??");
#ifdef ORBISRPC_SDK_PAYLOAD
/* No uname in the payload SDK libc: unknown FW. Callers fall back
* to the bounded scan, which needs no version. */
return;
#else
struct utsname u;
if(uname(&u) != 0) return;
/* Accept "9.00", "9.0", "13.52", or FreeBSD-style "12.0-RELEASE":
* take leading digits.digits and normalize to MM.mm. */
int maj = -1, min = -1;
if(sscanf(u.release, "%d.%d", &maj, &min) != 2) return;
if(maj < 0 || maj > 99 || min < 0 || min > 99) return;
snprintf(out, cap, "%d.%02d", maj, min);
#endif
}
/* Firmware layout as reported by sceKernelGetSystemSwVersion(). The string is
* authoritative; the packed value is only a fallback for builds where the
* string comes back empty. */
typedef struct fw_info {
uint64_t reserved;
char version_string[0x1c];
uint32_t version;
} fw_info_t;
/* Verified live on hardware. DO NOT extend from theory: an entry here is
* a promise that offset 447 holds the process name on that FW. */
static const struct { const char *ver; int name_off; int min_rec; } kKnown[] = {
{ "9.00", 447, 479 },
};
int sceKernelGetSystemSwVersion(fw_info_t *);
int fw_kinfo_for(const char *ver, int *name_off, int *min_rec){
if(!ver) return -1;
for(unsigned i = 0; i < sizeof kKnown / sizeof kKnown[0]; i++){
if(!strcmp(ver, kKnown[i].ver)){
if(name_off) *name_off = kKnown[i].name_off;
if(min_rec) *min_rec = kKnown[i].min_rec;
return 0;
}
/* Sony returns a three-part version, e.g. 13.520.001. Only the first two
* parts are wanted, so trailing components are accepted and trimmed
* rather than treated as garbage -- a strict MAJOR.MINOR check reported
* unknown on a perfectly readable 13.52 console. */
static int digits_ok(const char *s){
if(!s || !s[0]) return 0;
int dots = 0, n = 0;
for(const char *p = s; *p; p++){
if(*p == '.'){ dots++; continue; }
if(*p < '0' || *p > '9') return 0;
n++;
}
return -1;
return dots >= 1 && n >= 2;
}
int fw_kinfo(int *name_off, int *min_rec){
char ver[16];
fw_version(ver, sizeof ver);
return fw_kinfo_for(ver, name_off, min_rec);
/* Normalise Sony's packed version to "MAJOR.MINOR".
*
* The minor field is three digits with a trailing sub-patch zero, so
* "13.520.001" means 13.52, not 13.520:
*
* 13.520.001 -> 13.52 9.000 -> 9.00 8.500 -> 8.50
*
* Cutting only at the second dot (an earlier revision) left the zero attached
* and rendered "Firmware 13.520" on the presence card.
*/
void fw_normalize(char *v){
if(!v) return;
/* Sony pads the string with a leading space on some firmware -- 9.00
* returns " 9.008.031" (verified on console 2026-10-05) while 13.52
* returns "13.520" clean. Skipped here rather than in digits_ok() so
* the caller hands out a trimmed string either way: without this the
* space survived the dot logic and the presence read "Firmware 9.00".
*
* The bytes are shifted down, not just walked past: this edits the
* caller's buffer in place, so a bare pointer bump would leave the
* space sitting at v[0] for them to print. */
char *start = v;
while(*start == ' ' || *start == '\t') start++;
if(start != v) memmove(v, start, strlen(start) + 1);
/* trailing padding would survive the dot cut the same way */
size_t n = strlen(v);
while(n && (v[n-1] == ' ' || v[n-1] == '\t')) v[--n] = 0;
char *first = strchr(v, '.');
if(!first) return;
char *second = strchr(first + 1, '.');
if(second) *second = 0;
/* minor is at most 2 significant digits; drop a third if present */
if(first[1] && first[2] && first[3]) first[3] = 0;
}
/* Bounded needle search inside one record. recsz caps the search so a
* corrupt/short record can never over-read. */
static int rec_find(const unsigned char *rec, int recsz,
const char *needle, int *at){
int nlen = (int)strlen(needle);
if(!rec || recsz <= 0 || nlen <= 0 || nlen > recsz) return 0;
for(int i = 0; i + nlen <= recsz; i++){
if(!memcmp(rec + i, needle, (size_t)nlen)){
if(at) *at = i;
return 1;
}
int fw_version(char *out, size_t cap){
if(!out || cap == 0) return -1;
static const char unknown[] = "unknown";
snprintf(out, cap, "%s", unknown);
fw_info_t info;
memset(&info, 0, sizeof info);
if(sceKernelGetSystemSwVersion(&info) != 0){
log_msg("fw: system sw version call failed");
return -1;
}
return 0;
}
int fw_match_eboot(const unsigned char *rec, int recsz){
static const char want[] = "eboot.bin";
int off = 0, minrec = 0;
if(fw_kinfo(&off, &minrec) == 0){
/* Known FW: exact offset, exact cost. */
if(recsz >= minrec && off + 10 <= recsz &&
!memcmp(rec + off, want, 10))
return 1;
info.version_string[sizeof(info.version_string) - 1] = 0;
/* Normalise BEFORE validating: fw_normalize() strips the padding some
* firmware adds, and digits_ok() rejects any byte that is not a digit or
* a dot. Validating first would report a padded-but-perfectly-readable
* " 9.008.031" as unrecognised. */
char s[sizeof(info.version_string)];
snprintf(s, sizeof(s), "%s", info.version_string);
fw_normalize(s);
if(digits_ok(s)){
snprintf(out, cap, "%s", s);
return 0;
}
/* Unknown FW: bounded scan for the name anywhere in the record.
* Require the trailing NUL so "eboot.binX" can't false-positive. */
int at = -1;
if(!rec_find(rec, recsz, want, &at)) return 0;
if(at + 9 >= recsz || rec[at + 9] != 0) return 0;
return 1;
}
int fw_match_payload_pid(const unsigned char *rec, int recsz, int pid){
if(pid <= 0) return 0;
int off = 0, minrec = 0;
if(fw_kinfo(&off, &minrec) == 0){
if(recsz < minrec || off + 8 > recsz) return 0;
if(*(const int *)(rec + 72) != pid) return 0;
return !memcmp(rec + off, "Payload", 8) && rec[off + 8] == 0;
/* Fall back to the packed BCD-ish value: major<<40 | minor<<32.
* Shift on a 64-bit copy: shifting a uint32_t by 40 would be undefined. */
uint64_t v = (uint64_t)info.version;
uint8_t major = (uint8_t)(v >> 40);
uint8_t minor = (uint8_t)(v >> 32);
if((major & 0x0f) <= 9 && (major >> 4) <= 9 &&
(minor & 0x0f) <= 9 && (minor >> 4) <= 9 && major){
snprintf(out, cap, "%x.%02x", major, minor);
return 0;
}
/* Unknown FW: pid field offset is unverified, so only the name part
* is probed; pid match is skipped rather than guessed. A missed
* reclaim is a minor stall, a wrong kill would be data loss. */
int at = -1;
if(!rec_find(rec, recsz, "Payload", &at)) return 0;
if(at + 7 >= recsz || rec[at + 7] != 0) return 0;
(void)pid;
return 1;
}
/* Bracketed so a stray leading/trailing byte is visible instead of
* blending into the quotes as padding. */
log_msg("fw: unrecognised version string [%s]", info.version_string);
return -1;
}
+18 -27
View File
@@ -1,34 +1,25 @@
/* fw.h - firmware detection + per-FW kinfo_proc layout table.
* Ported pattern from OSM-Made/PS4-Kernel-SDK (detect FW, resolve per-FW
* data at runtime) adapted to usermode: we never touch the kernel, we
* only need the sysctl KERN_PROC record layout, which moves between
* firmwares. Unknown firmwares fall back to a bounded in-record scan
* instead of a hardcoded offset, so a new FW degrades, never crashes. */
/* fw.h - console firmware version string, for display only.
*
* Not the same thing as the SDK's firmware gating: the CRT refuses to start
* on unlisted firmware (see findings/sdk-13.52.md), while this just wants to
* print something like "13.52" next to the presence.
*
* Unlike upstream's fw.c this does NOT carry a per-firmware kinfo_proc offset
* table. That job belongs to procwalk.c, which needs no offsets at all. */
#ifndef ORBISRPC_FW_H
#define ORBISRPC_FW_H
#include <stddef.h>
/* Firmware version as "MAJOR.MINOR" (e.g. "9.00", "13.52"). Never fails:
* unknown/unparseable platforms yield "?.??". */
void fw_version(char *out, size_t cap);
/* Writes the firmware as "MAJOR.MINOR" (e.g. "13.52") into out.
* Never fails loudly: on any error it writes "unknown" and returns -1, so a
* missing firmware string can never stop a presence from being posted. */
int fw_version(char *out, size_t cap);
/* Pure table lookup for a given version string (testable).
* Returns 0 known, -1 unknown. */
int fw_kinfo_for(const char *ver, int *name_off, int *min_rec);
/* Normalise a raw Sony version string to "MAJOR.MINOR".
* Pure, so the host tests can pin the exact shape: "13.520.001" ->
* "13.52", "9.000" -> "9.00". Exposed because a wrong trim here shows
* up verbatim on the presence card. */
void fw_normalize(char *v);
/* Fill name_off and min_rec with the kinfo_proc offsets for this box.
* Returns 0 when the FW is in the verified table, -1 when unknown
* (caller must use the bounded scan instead of assuming). */
int fw_kinfo(int *name_off, int *min_rec);
/* Match an "eboot.bin" process name inside one sysctl record.
* Tries the table offset first, then a bounded scan of the record.
* Returns 1 match, 0 no match. Never reads past rec+recsz. */
int fw_match_eboot(const unsigned char *rec, int recsz);
/* Match a ("Payload", pid) pair for lock-holder liveness.
* pid_off is verified per-FW the same way. Returns 1 live peer. */
int fw_match_payload_pid(const unsigned char *rec, int recsz, int pid);
#endif
#endif /* ORBISRPC_FW_H */
+230
View File
@@ -0,0 +1,230 @@
/* gamecache.c - see gamecache.h. */
#include "gamecache.h"
#include "jsonlite.h"
#include "log.h"
#include "clock.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
static gamecache_entry_t s_ent[GAMECACHE_MAX_ENTRIES];
static int s_n = -1; /* -1 = not loaded yet */
static int s_loaded_from_file = 0;
int gamecache_id_valid(const char *id){
if(!id) return 0;
size_t n = strlen(id);
if(n == 0 || n >= GAMECACHE_ID_LEN) return 0;
for(size_t i = 0; i < n; i++){
char c = id[i];
if(!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'))
return 0;
}
return 1;
}
/* Copy with a hard cap and guaranteed NUL. Returns 0 when src is unusable. */
static int copy_field(char *dst, size_t cap, const char *src){
if(!dst || cap == 0) return 0;
dst[0] = 0;
if(!src || !src[0]) return 0;
size_t n = strlen(src);
if(n >= cap) n = cap - 1;
memcpy(dst, src, n);
dst[n] = 0;
return 1;
}
int gamecache_parse(const char *json, size_t len, gamecache_entry_t *out,
int max, int64_t now){
if(!json || !out || max <= 0) return -1;
if(max > GAMECACHE_MAX_ENTRIES) max = GAMECACHE_MAX_ENTRIES;
memset(out, 0, sizeof(gamecache_entry_t) * (size_t)max);
jl_val_t *r = jl_parse(json, len);
if(!r || r->type != JL_OBJECT){ if(r) jl_free(r); return -1; }
const jl_val_t *games = jl_obj_get(r, "games");
if(!games || games->type != JL_ARRAY){
/* No usable games array: an empty store, not a failure. A hand-edited
* or truncated file must not stop the daemon resolving anything. */
jl_free(r);
return 0;
}
int n = 0;
for(size_t i = 0; i < (size_t)max; i++){
const jl_val_t *e = jl_arr_at(games, i);
if(!e || e->type != JL_OBJECT) continue;
const jl_val_t *id = jl_obj_get(e, "id");
const jl_val_t *nm = jl_obj_get(e, "name");
const jl_val_t *art = jl_obj_get(e, "art");
const jl_val_t *at = jl_obj_get(e, "at");
if(!id || id->type != JL_STRING || !id->str) continue;
if(!gamecache_id_valid(id->str)) continue;
if(!nm || nm->type != JL_STRING || !nm->str || !nm->str[0]) continue;
/* at is optional: a missing timestamp is treated as stale so a
* hand-written row is re-resolved rather than trusted blindly. */
int64_t when = (at && at->type == JL_NUMBER) ? (int64_t)at->num : 0;
if(now - when > GAMECACHE_TTL_SECS) continue;
memcpy(out[n].id, id->str, strlen(id->str) + 1);
copy_field(out[n].name, sizeof out[n].name, nm->str);
copy_field(out[n].art, sizeof out[n].art,
(art && art->type == JL_STRING) ? art->str : NULL);
out[n].at = when;
n++;
}
jl_free(r);
return n;
}
char *gamecache_serialize(const gamecache_entry_t *e, int n){
if(!e || n <= 0) return NULL;
size_t cap = (size_t)n * (GAMECACHE_ID_LEN + GAMECACHE_NAME_LEN +
GAMECACHE_ART_LEN + 64) + 64;
char *out = (char *)malloc(cap);
if(!out) return NULL;
size_t o = 0;
int wrote = snprintf(out + o, cap - o, "{\"schema\":1,\"games\":[");
if(wrote < 0 || (size_t)wrote >= cap - o){ free(out); return NULL; }
o += (size_t)wrote;
for(int i = 0; i < n; i++){
if(i > 0 && o + 2 < cap){ out[o++] = ','; }
char id[GAMECACHE_ID_LEN*2], nm[GAMECACHE_NAME_LEN*2],
ar[GAMECACHE_ART_LEN*2];
/* Escape defensively: names and URLs are network-derived. */
size_t a = 0;
for(const char *p = e[i].id; *p && a + 2 < sizeof id; p++){
if(*p == '"' || *p == '\\') id[a++] = '\\';
id[a++] = *p;
}
id[a] = 0;
a = 0;
for(const char *p = e[i].name; *p && a + 2 < sizeof nm; p++){
if(*p == '"' || *p == '\\') nm[a++] = '\\';
nm[a++] = *p;
}
nm[a] = 0;
a = 0;
for(const char *p = e[i].art; *p && a + 2 < sizeof ar; p++){
if(*p == '"' || *p == '\\') ar[a++] = '\\';
ar[a++] = *p;
}
ar[a] = 0;
int w = snprintf(out + o, cap - o,
"{\"id\":\"%s\",\"name\":\"%s\",\"art\":\"%s\",\"at\":%lld}",
id, nm, ar, (long long)e[i].at);
if(w < 0 || (size_t)w >= cap - o){ free(out); return NULL; }
o += (size_t)w;
}
if(o + 3 >= cap){ free(out); return NULL; }
out[o++] = ']';
out[o++] = '}';
out[o] = 0;
return out;
}
void gamecache_load(void){
if(s_n >= 0) return;
s_n = 0;
FILE *f = fopen(GAMECACHE_PATH, "rb");
if(!f) return;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
/* Bounded: a corrupt or hand-edited file must not be able to allocate
* unbounded memory on a payload. */
if(sz > 0 && sz < 1024*1024){
char *buf = (char *)malloc((size_t)sz + 1);
if(buf && fread(buf, 1, (size_t)sz, f) == (size_t)sz){
buf[sz] = 0;
int n = gamecache_parse(buf, (size_t)sz, s_ent, GAMECACHE_MAX_ENTRIES,
(int64_t)time(NULL));
if(n >= 0){ s_n = n; s_loaded_from_file = 1; }
else log_msg("games_cache: unreadable, starting empty");
}
free(buf);
} else if(sz >= 1024*1024){
log_msg("games_cache: %ld bytes is over the cap; ignoring", sz);
}
fclose(f);
log_msg("games_cache: %d entr%s from disk", s_n, s_n == 1 ? "y" : "ies");
}
int gamecache_get(const char *id, char *name, size_t name_cap,
char *art, size_t art_cap){
if(name && name_cap) name[0] = 0;
if(art && art_cap) art[0] = 0;
gamecache_load();
if(!gamecache_id_valid(id)) return 0;
for(int i = 0; i < s_n; i++){
if(strcmp(s_ent[i].id, id) != 0) continue;
copy_field(name, name_cap, s_ent[i].name);
copy_field(art, art_cap, s_ent[i].art);
return name && name[0] ? 1 : 0;
}
return 0;
}
void gamecache_save(void){
gamecache_load();
if(s_n <= 0) return;
char *json = gamecache_serialize(s_ent, s_n);
if(!json){ log_msg("games_cache: serialise failed"); return; }
char tmp[128];
snprintf(tmp, sizeof tmp, "%s.new", GAMECACHE_PATH);
FILE *f = fopen(tmp, "wb");
if(!f){ log_msg("games_cache: cannot write %s", tmp); free(json); return; }
int ok = 1;
size_t n = strlen(json);
if(fwrite(json, 1, n, f) != n) ok = 0;
if(ok && fflush(f) != 0) ok = 0;
if(ok){ int fd = fileno(f); if(fd >= 0 && fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
free(json);
/* Atomic: a half-written cache is worse than a missing one. */
if(ok) rename(tmp, GAMECACHE_PATH);
else { remove(tmp); log_msg("games_cache: save failed, kept the old file"); }
}
int gamecache_put(const char *id, const char *name, const char *art,
const char *src){
if(!gamecache_id_valid(id) || !name || !name[0]) return 0;
if(strcmp(id, name) == 0) return 0; /* an unresolved echo, not a name */
gamecache_load();
int64_t now = (int64_t)time(NULL);
for(int i = 0; i < s_n; i++){
if(strcmp(s_ent[i].id, id) != 0) continue;
int same = !strcmp(s_ent[i].name, name) &&
!strcmp(s_ent[i].art, art ? art : "");
if(same) return 0;
copy_field(s_ent[i].name, sizeof s_ent[i].name, name);
copy_field(s_ent[i].art, sizeof s_ent[i].art, art);
s_ent[i].at = now;
gamecache_save();
log_msg("games_cache: updated %s (%s)", id, src ? src : "?");
return 1;
}
if(s_n >= GAMECACHE_MAX_ENTRIES){
log_msg("games_cache: full (%d); not storing %s", s_n, id);
return 0;
}
memcpy(s_ent[s_n].id, id, strlen(id) + 1);
copy_field(s_ent[s_n].name, sizeof s_ent[s_n].name, name);
copy_field(s_ent[s_n].art, sizeof s_ent[s_n].art, art);
s_ent[s_n].at = now;
s_n++;
gamecache_save();
log_msg("games_cache: stored %s (%s)", id, src ? src : "?");
return 1;
}
void gamecache_reset(void){
memset(s_ent, 0, sizeof s_ent);
s_n = 0;
s_loaded_from_file = 0;
}
+76
View File
@@ -0,0 +1,76 @@
/* gamecache.h - persistent title id -> {name, cover URL} store.
*
* Replaces the two caches this used to have:
* - config.json's "titles" map (id -> name), written by cfg_learn()
* - artwork_cache.json (url -> mp: path), the art disk cache
*
* One file, one job: remember what a title id resolved to, so a boot with no
* network still shows the right name and the right cover. Name AND art live
* here because art was never persisted anywhere -- s_last_art is RAM-only and
* is cleared on every title change, so cover art was re-fetched every session.
*
* Pure parsing/serialisation is separated from the file I/O so the host tests
* can drive it without a console, the same split as procwalk.c and bigapp.c.
*/
#ifndef ORBISRPC_GAMECACHE_H
#define ORBISRPC_GAMECACHE_H
#include <stddef.h>
#include <stdint.h>
#define GAMECACHE_PATH "/data/orbisRPC/games_cache.json"
#define GAMECACHE_MAX_ENTRIES 512
#define GAMECACHE_ID_LEN 16
#define GAMECACHE_NAME_LEN 128
#define GAMECACHE_ART_LEN 512
/* Entries older than this are re-resolved rather than trusted forever: a
* homebrew title can be renamed upstream and a stale name would stick. */
#define GAMECACHE_TTL_SECS (30LL*24*3600)
/* Where a cached entry came from, for diagnostics only. */
typedef struct {
char id[GAMECACHE_ID_LEN];
char name[GAMECACHE_NAME_LEN];
char art[GAMECACHE_ART_LEN];
int64_t at;
} gamecache_entry_t;
/* --- pure helpers (host-testable) ----------------------------------- */
/* Parse a games_cache.json document. Returns the number of entries loaded, or
* -1 when the document is unusable. Entries that are malformed are skipped
* rather than failing the whole load. `max` caps how many are stored.
* `now` is compared against each entry's "at" to drop stale rows. */
int gamecache_parse(const char *json, size_t len, gamecache_entry_t *out,
int max, int64_t now);
/* Serialise entries to a fresh malloc'd NUL-terminated buffer. Caller frees.
* Returns NULL on OOM. */
char *gamecache_serialize(const gamecache_entry_t *e, int n);
/* A title id is only worth storing if it looks like one and is not an echo of
* its own name (which is what a failed resolve produces). */
int gamecache_id_valid(const char *id);
/* --- live store ----------------------------------------------------- */
/* Load once, lazily. Safe to call repeatedly. */
void gamecache_load(void);
/* 1 + filled name (and art when the entry has one) for a fresh entry.
* 0 when absent or stale, so the caller falls through to the network. */
int gamecache_get(const char *id, char *name, size_t name_cap,
char *art, size_t art_cap);
/* Insert or update. `src` is informational and stored for diagnostics only.
* Returns 1 if the store changed (caller may log), 0 if unchanged/full. */
int gamecache_put(const char *id, const char *name, const char *art,
const char *src);
/* Flush to disk. Called after puts; failures are logged, never fatal. */
void gamecache_save(void);
/* Forget everything (used by tests and by a manual reset). */
void gamecache_reset(void);
#endif /* ORBISRPC_GAMECACHE_H */
File diff suppressed because it is too large. Load diff
+20 -14
View File
@@ -2,7 +2,6 @@
* so use atomic create (O_CREAT|O_EXCL) + process-table liveness
* (sysctl, no signals needed in spawned context). */
#include "lock.h"
#include "fw.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
@@ -11,6 +10,19 @@
#ifdef ORBISRPC_SDK_PAYLOAD
#include <sys/types.h>
#include <sys/sysctl.h>
#include "procwalk.h"
/* Is `pid` still a live spawned payload?
*
* Field offsets inside a kinfo_proc record are not self-describing and shift
* between firmwares. The previous name-at-+447 / pid-at-+72 lookup therefore
* silently failed on anything newer than 9.00: it reported live peers as dead,
* so a second daemon would reclaim the lock while the first was still running
* and both would fight over the gateway.
*
* Narrow to the record whose name is exactly "Payload" (unambiguous), then
* look for the pid anywhere inside it. Spelled-out bias: a false positive only
* costs an unnecessary stand-down, a false negative costs two daemons, so
* err toward matching. */
static int pid_live(int pid){
if(pid <= 0 || pid == (int)getpid()) return 0;
int mib[4] = { 1, 14, 8, 0 };
@@ -19,19 +31,13 @@ static int pid_live(int pid){
static unsigned char buf[256*1024];
if(sz > sizeof buf) return 1; /* huge table: assume live, fail closed */
if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return 0;
size_t off = 0;
while(off + 4 <= sz){
int recsz = *(int *)(buf + off);
if(recsz <= 0 || off + (size_t)recsz > sz) break;
/* A recycled PID owned by a system daemon must not block us:
* only a live payload process counts as a peer. Spawned
* payloads (elfldr/GoldHEN) show up as "Payload". Offsets are
* per-FW (see fw.h); unknown FWs fail closed. */
if(fw_match_payload_pid(buf + off, recsz, pid))
return 1;
off += (size_t)recsz;
}
return 0;
const unsigned char *rec = NULL;
size_t recsz = 0;
/* rc -1 = unreadable table: fail closed, assume a peer holds the lock */
int rc = procwalk_find_comm(buf, sz, "Payload", &rec, &recsz);
if(rc == -1) return 1;
if(rc != 0) return 0; /* no Payload process at all */
return procwalk_record_has_i32(rec, recsz, pid);
}
#else
#include <signal.h>
+81 -35
View File
@@ -1,6 +1,6 @@
/* log.c - file logger: rotating (512 KB cap), timestamped, falls back to
* stderr. Tries several paths because mount visibility differs between app,
* plugin, and payload processes. Every line is mirrored to /dev/klog when
* stderr. Tries several paths because mount visibility differs between app
* and payload processes. Every line is mirrored to /dev/klog when
* possible so payloads remain debuggable even with no writable mount. */
#include "log.h"
#include <stdarg.h>
@@ -31,38 +31,84 @@ static const char *const LOG_FALLBACKS[] = {
"/orbisrpc.log",
};
/* mkdir the directory part of `path`, ignoring EEXIST.
*
* Without this, fopen("/data/orbisRPC/log.txt") fails on a console where
* nothing has created /data/orbisRPC yet -- which is exactly a fresh install
* with no PKG installer. main() writes its first line before daemon_run() gets
* around to creating that directory, so the payload could run perfectly and
* write nothing anywhere: silent, and indistinguishable from not starting.
*
* mkdir() is not recursive, so every missing component is created in turn. */
static void ensure_parent_dir(const char *path){
char dir[256];
size_t n = path ? strlen(path) : 0;
if(n == 0 || n >= sizeof dir) return;
memcpy(dir, path, n + 1);
char *slash = strrchr(dir, '/');
if(!slash || slash == dir) return; /* no directory component, or root */
*slash = 0;
for(char *p = dir + 1; *p; p++){
if(*p != '/') continue;
*p = 0;
(void)mkdir(dir, 0777); /* EEXIST is success, ignore */
*p = '/';
}
(void)mkdir(dir, 0777);
}
/* Try `want` first, then the fallback list. Returns the open stream or NULL.
* Does not touch g_log itself so callers can retry later. */
static FILE *open_log(const char *want){
const char *cand[sizeof LOG_FALLBACKS/sizeof LOG_FALLBACKS[0] + 1];
size_t n = 0;
if(want && want[0]) cand[n++] = want;
for(unsigned i = 0; i < sizeof LOG_FALLBACKS/sizeof LOG_FALLBACKS[0]; i++){
const char *p = LOG_FALLBACKS[i];
if(p && (!want || strcmp(p, want) != 0)) cand[n++] = p;
}
for(size_t i = 0; i < n; i++){
ensure_parent_dir(cand[i]);
FILE *f = fopen(cand[i], "ab");
if(!f) continue;
/* cap check AFTER open to avoid stat->remove TOCTOU */
struct stat st;
if(fstat(fileno(f), &st) == 0 && st.st_size > LOG_CAP){
fclose(f);
remove(cand[i]);
f = fopen(cand[i], "ab");
if(!f) continue;
}
setvbuf(f, NULL, _IONBF, 0);
return f;
}
return NULL;
}
/* Lazy re-open cadence: mounts can appear after startup and the data directory
* is only created later in the run, so keep retrying instead of giving up. */
static unsigned s_retry = 0;
void log_init(const char *path) {
if (g_log) return;
if(path) {
g_log = fopen(path, "ab");
if(g_log){
/* cap check AFTER open to avoid stat->remove TOCTOU */
struct stat st;
if(fstat(fileno(g_log), &st)==0 && st.st_size > LOG_CAP){
fclose(g_log); g_log = NULL;
remove(path);
g_log = fopen(path, "ab");
}
}
}
if (!g_log) {
for (unsigned i = 0; i < sizeof(LOG_FALLBACKS)/sizeof(LOG_FALLBACKS[0]); i++) {
const char *p = LOG_FALLBACKS[i];
if (!p || !path || strcmp(p, path) == 0) continue;
g_log = fopen(p, "ab");
if (g_log) break;
}
}
if (g_log) setvbuf(g_log, NULL, _IONBF, 0);
if(g_log) return;
g_log = open_log(path);
/* klog mirror: survives even when every mount is invisible */
g_klog = open("/dev/klog", O_WRONLY);
if(g_klog < 0) g_klog = -1;
}
/* Resolve an output stream. Deliberately does NOT latch stderr into g_log:
* doing so pinned logging to an unreachable stream permanently, even after the
* data directory became writable. */
static FILE *log_out(void){
if(!g_log && (s_retry++ & 0x1fu) == 0) g_log = open_log(NULL);
return g_log ? g_log : stderr;
}
void log_msg(const char *fmt, ...) {
if (!fmt) return;
log_lock();
if (!g_log) { g_log = stderr; }
FILE *out = log_out();
va_list ap, ap2;
va_start(ap, fmt);
va_copy(ap2, ap); /* copy BEFORE vfprintf consumes ap */
@@ -74,10 +120,10 @@ void log_msg(const char *fmt, ...) {
char ts[20];
if(tm) strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm);
else { strncpy(ts, "1970-01-01 00:00:00", sizeof ts); ts[sizeof ts-1]=0; }
fprintf(g_log, "[%s] ", ts);
vfprintf(g_log, fmt, ap);
fprintf(g_log, "\n");
fflush(g_log);
fprintf(out, "[%s] ", ts);
vfprintf(out, fmt, ap);
fprintf(out, "\n");
fflush(out);
va_end(ap);
if (g_klog >= 0) {
char line[512];
@@ -99,7 +145,7 @@ void log_close(void) {
void log_dbg(const char *fmt, ...) {
if (!g_debug || !fmt) return;
log_lock();
if (!g_log) { g_log = stderr; }
FILE *out = log_out();
va_list ap;
va_start(ap, fmt);
time_t t = time(NULL);
@@ -109,10 +155,10 @@ void log_dbg(const char *fmt, ...) {
char ts[20];
if(tm) strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm);
else { strncpy(ts, "1970-01-01 00:00:00", sizeof ts); ts[sizeof ts-1]=0; }
fprintf(g_log, "[%s] DBG ", ts);
vfprintf(g_log, fmt, ap);
fprintf(g_log, "\n");
fflush(g_log);
fprintf(out, "[%s] DBG ", ts);
vfprintf(out, fmt, ap);
fprintf(out, "\n");
fflush(out);
va_end(ap);
log_unlock();
}
}
+3 -1
View File
@@ -2,11 +2,13 @@
* Runs as a GoldHEN payload (BinLoader push) or standalone ELF. */
#include "daemon.h"
#include "log.h"
#include "notify.h"
#include <stddef.h>
int main(int argc, char **argv){
(void)argc;(void)argv;
log_init("/data/orbisRPC/log.txt");
log_msg("orbisRPC payload boot");
return daemon_run(NULL);
notify_once_boot("boot", "orbisRPC loaded");
return daemon_run();
}
+164
View File
@@ -0,0 +1,164 @@
/* notify.c - see notify.h. */
#include "notify.h"
#include "log.h"
#include "clock.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#ifdef ORBISRPC_SDK_PAYLOAD
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <sys/stat.h>
#include "icon_embedded.h"
#pragma pack(push, 1)
/* Layout verified against a working payload: the size is not derivable from
* the fields, so it is asserted rather than assumed. */
typedef struct notify_request {
char reserved[16];
int32_t target_id;
char reserved_after_target[24];
uint8_t use_icon_image_uri;
char message[1024];
char icon_uri[2048];
char padding[3];
} notify_request_t;
#pragma pack(pop)
_Static_assert(sizeof(notify_request_t) == 0xc30,
"notify_request layout changed");
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
/* /user/data/orbisRPC, not /data/orbisRPC -- same mount, but this is the path
* the notification service is expected to resolve. */
#define NOTIFY_ICON_PATH "/user/data/orbisRPC/icon.png"
#define NOTIFY_ICON_DIR "/user/data/orbisRPC"
/* Once per boot, keyed by a short tag so callers can name what fired. */
#define ONCE_MAX 8
static char s_once_keys[ONCE_MAX][24];
static int s_once_n = 0;
/* Rate limiters, same keying. */
#define THROTTLE_MAX 8
static char s_throttle_keys[THROTTLE_MAX][24];
static int64_t s_throttle_at[THROTTLE_MAX];
static int s_throttle_n = 0;
/* Drop the icon once. A failure is fine: the notification still posts, just
* with the system icon, which is better than posting nothing. */
static int ensure_icon(void){
static int tried = 0;
static int have = 0;
if(tried) return have;
tried = 1;
struct stat st;
if(stat(NOTIFY_ICON_DIR, &st) != 0){
(void)mkdir(NOTIFY_ICON_DIR, 0777);
}
/* Already the right file? Skip the write, same as the reference which
* sets icon_written once. */
if(stat(NOTIFY_ICON_PATH, &st) == 0 &&
(size_t)st.st_size == (size_t)ORBISRPC_NOTIFY_ICON_SIZE){
have = 1;
return 1;
}
int fd = open(NOTIFY_ICON_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if(fd < 0){
log_msg("notify: cannot write icon (%s) errno=%d", NOTIFY_ICON_PATH, errno);
return 0;
}
size_t off = 0;
int ok = 1;
while(off < (size_t)ORBISRPC_NOTIFY_ICON_SIZE){
ssize_t n = write(fd, orbisrpc_notify_icon_png + off,
(size_t)ORBISRPC_NOTIFY_ICON_SIZE - off);
if(n <= 0){ ok = 0; break; }
off += (size_t)n;
}
close(fd);
if(!ok){
log_msg("notify: icon write failed");
(void)remove(NOTIFY_ICON_PATH);
return 0;
}
have = 1;
return 1;
}
void notify_show(const char *message){
if(!message || !message[0]) return;
notify_request_t req;
memset(&req, 0, sizeof req);
snprintf(req.message, sizeof req.message, "%s", message);
int have_icon = ensure_icon();
if(have_icon){
/* target_id must be -1 for a custom icon to be honoured. Left at 0
* it names the system app and the notification renders with the
* default icon regardless of use_icon_image_uri. */
req.target_id = -1;
req.use_icon_image_uri = 1;
snprintf(req.icon_uri, sizeof req.icon_uri, "%s", NOTIFY_ICON_PATH);
}
int rc = sceKernelSendNotificationRequest(0, &req, sizeof req, 0);
/* A rejected notification is otherwise invisible: the message still pops
* with the default icon and nothing says why. Log both the return code
* and whether an icon was attached. */
if(rc != 0){
log_msg("notify: send failed rc=0x%08x msg=\"%s\" icon=%s",
(unsigned)rc, message, have_icon ? NOTIFY_ICON_PATH : "none");
} else if(!have_icon){
log_msg("notify: sent without icon: \"%s\"", message);
}
}
/* A tiny key->seen map. Keys are compile-time literals at every call site, so
* linear search over 8 slots is free and avoids allocating. */
static int once_seen(const char *key){
for(int i = 0; i < s_once_n; i++)
if(!strcmp(s_once_keys[i], key)) return 1;
if(s_once_n < ONCE_MAX){
snprintf(s_once_keys[s_once_n], sizeof s_once_keys[0], "%s", key);
s_once_n++;
}
return 0;
}
void notify_once_boot(const char *key, const char *message){
if(!key || once_seen(key)) return;
notify_show(message);
}
void notify_throttled(const char *key, const char *message, int interval_s){
if(!key || !message) return;
if(interval_s < 1) interval_s = 1;
int64_t now = orbis_mono_s();
for(int i = 0; i < s_throttle_n; i++){
if(strcmp(s_throttle_keys[i], key)) continue;
if(now - s_throttle_at[i] < interval_s) return;
s_throttle_at[i] = now;
notify_show(message);
return;
}
if(s_throttle_n < THROTTLE_MAX){
snprintf(s_throttle_keys[s_throttle_n], sizeof s_throttle_keys[0],
"%s", key);
s_throttle_at[s_throttle_n] = now;
s_throttle_n++;
}
notify_show(message);
}
#else /* app build: no notification API wired here */
void notify_show(const char *message){ (void)message; }
void notify_once_boot(const char *key, const char *message){ (void)key; (void)message; }
void notify_throttled(const char *key, const char *message, int interval_s){
(void)key; (void)message; (void)interval_s;
}
#endif
+23
View File
@@ -0,0 +1,23 @@
/* notify.h - on-screen notifications from the payload.
*
* sceKernelSendNotificationRequest takes a PATH for the icon, not a memory
* blob, so the icon is dropped on disk once at first boot from an embedded
* copy (icon_embedded.h) and referenced by path afterwards.
*
* Every call is best-effort: a notification that fails must never affect the
* daemon, so nothing here returns an error the caller has to handle.
*/
#ifndef ORBISRPC_NOTIFY_H
#define ORBISRPC_NOTIFY_H
/* Show a message with the orbisRPC icon. Never fails loudly. */
void notify_show(const char *message);
/* Once-only variants: safe to call every retry loop without spamming. */
void notify_once_boot(const char *key, const char *message);
/* Rate-limited variant for a condition that persists (Discord unreachable).
* Fires on the first call, then at most once per `interval_s`. */
void notify_throttled(const char *key, const char *message, int interval_s);
#endif /* ORBISRPC_NOTIFY_H */
+232
View File
@@ -0,0 +1,232 @@
/* pkgzone.c - see pkgzone.h. */
#include "pkgzone.h"
#include "log.h"
#include "retro.h"
#include <string.h>
#include <stdio.h>
#include <ctype.h>
#ifdef ORBISRPC_SDK_PAYLOAD
#include "tls.h"
#include "updater_http.h"
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#endif
#define PKGZONE_BOILERPLATE "Install HB-Store on your Playstation"
int pkgzone_wants(const char *title_id){
if(!title_id) return 0;
size_t n = strlen(title_id);
if(n != 9) return 0;
/* Shape first: this only makes sense for a real TITLEID-shaped id. */
for(size_t i = 0; i < n; i++){
char c = title_id[i];
if(!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) return 0;
}
/* Retail titles are served by Sony TMDB; never ask a third party about
* them, and never pay the latency. */
if(!strncmp(title_id, "CUSA", 4)) return 0;
/* Retro ids belong to retro-games, which indexes them. Ask the function
* that owns the prefix tables so this cannot drift from them: the
* hand-written list that used to live here was missing SCUS, so
* SCUS97399 was scraped here forever (302/500 every poll) while
* retro-games -- which has it -- was never consulted. */
if(retro_wants(title_id)) return 0;
/* Homebrew that targets a retro platform (self-hosted, custom-built).
* retro.c does not list these and neither index carries them -- checked
* all three: HARC/ELUA/ELUS/ELPM are 0 entries everywhere. So they stay
* out of retro-games but still belong here rather than falling through
* to a guaranteed miss. */
if(!strncmp(title_id, "HARC", 4) || !strncmp(title_id, "ELUA", 4) ||
!strncmp(title_id, "ELUS", 4) || !strncmp(title_id, "ELPM", 4))
return 0;
return 1;
}
/* --- pure scrape ----------------------------------------------------- */
/* strcasestr is glibc-only and the PS4 libc does not have it. A short
* case-insensitive find over a bounded haystack is all this needs. */
static const char *find_ci(const char *hay, size_t hlen, const char *needle){
size_t n = strlen(needle);
if(n == 0 || hlen < n) return NULL;
for(size_t i = 0; i + n <= hlen; i++){
size_t k = 0;
while(k < n && tolower((unsigned char)hay[i + k]) == tolower((unsigned char)needle[k])) k++;
if(k == n) return hay + i;
}
return NULL;
}
/* Collapse HTML text to a single trimmed line: tags out, entities resolved
* for the handful that actually show up in a title. */
static void squeeze(char *dst, size_t cap, const char *src, size_t len){
size_t o = 0;
int in_tag = 0;
for(size_t i = 0; i < len && o + 1 < cap; i++){
char c = src[i];
if(in_tag){ if(c == '>') in_tag = 0; continue; }
if(c == '<'){ in_tag = 1; continue; }
if(c == '&'){
/* &amp; &quot; &#39; &nbsp; &amp; — enough for a heading */
if(i + 4 < len && !strncmp(src + i, "&amp;", 5)){ dst[o++] = '&'; i += 4; continue; }
if(i + 5 < len && !strncmp(src + i, "&quot;", 6)){ dst[o++] = '"'; i += 5; continue; }
if(i + 4 < len && !strncmp(src + i, "&nbsp;", 6)){ dst[o++] = ' '; i += 5; continue; }
if(i + 4 < len && !strncmp(src + i, "&#39;", 5)){ dst[o++] = '\''; i += 4; continue; }
}
/* headings are single-line; collapse any whitespace run */
if(isspace((unsigned char)c)){
if(o && dst[o - 1] != ' ') dst[o++] = ' ';
continue;
}
dst[o++] = c;
}
while(o && dst[o - 1] == ' ') dst[--o] = 0;
dst[o] = 0;
}
int pkgzone_parse(const char *html, size_t len, const char *title_id,
char *out, size_t cap, char *url, size_t url_cap){
if(!html || !out || cap == 0) return -1;
out[0] = 0;
if(url && url_cap) url[0] = 0;
if(!title_id || strlen(title_id) != 9) return -1;
/* First <h1> that is not the site boilerplate is the title. */
char raw[256];
const char *cur = html;
const char *end = html + len;
while(cur && cur < end){
const char *open = find_ci(cur, (size_t)(end - cur), "<h1");
if(!open || open >= end) break;
/* Require a tag boundary so "<h1foo" cannot match. */
if(open + 3 < end && !isspace((unsigned char)open[3]) && open[3] != '>'){
cur = open + 3;
continue;
}
const char *gt = memchr(open, '>', (size_t)(end - open));
if(!gt) break;
const char *close = find_ci(gt, (size_t)(end - gt), "</h1>");
if(!close) break;
size_t n = (size_t)(close - (gt + 1));
if(n >= sizeof raw) n = sizeof raw - 1;
memcpy(raw, gt + 1, n);
raw[n] = 0;
char line[256];
squeeze(line, sizeof line, raw, n);
if(line[0] && strcmp(line, PKGZONE_BOILERPLATE) != 0){
snprintf(out, cap, "%s", line);
/* The cover path is deterministic; parsing the page for it would
* just be one more thing to break when the layout changes. */
if(url && url_cap)
snprintf(url, url_cap,
"https://" PKGZONE_HOST "/images/%s/cover.png", title_id);
return 0;
}
cur = close + 5;
}
return -1;
}
/* --- network half ---------------------------------------------------- */
#ifdef ORBISRPC_SDK_PAYLOAD
int pkgzone_resolve(const char *title_id, char *name, size_t name_cap,
char *url, size_t url_cap){
if(name && name_cap) name[0] = 0;
if(url && url_cap) url[0] = 0;
if(!pkgzone_wants(title_id)){
log_msg("pkgzone: skipping %s (retail, retro or malformed)", title_id);
return -1;
}
log_msg("pkgzone: resolving %s", title_id);
char host[128], path[64];
snprintf(host, sizeof host, "%s", PKGZONE_HOST);
snprintf(path, sizeof path, "/details/%s", title_id);
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if(getaddrinfo(host, "443", &hints, &res) != 0 || !res){
log_msg("pkgzone: dns fail for %s", host);
if(res) freeaddrinfo(res);
return -1;
}
int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if(fd < 0){ freeaddrinfo(res); log_msg("pkgzone: socket fail errno=%d", errno); return -1; }
if(connect(fd, res->ai_addr, (unsigned)res->ai_addrlen) != 0){
int e = errno;
freeaddrinfo(res);
close(fd);
log_msg("pkgzone: connect %s:443 fail errno=%d", host, e);
return -1;
}
freeaddrinfo(res);
tls_ctx_t *t = tls_start(fd, host);
if(!t){
close(fd);
log_msg("pkgzone: tls handshake failed (see tls: lines above)");
return -1;
}
char req[256];
int rl = snprintf(req, sizeof req,
"GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: Mozilla/5.0\r\n"
"Accept: text/html\r\nConnection: close\r\n\r\n", path, host);
int rc = -1;
if(rl <= 0 || tls_write(t, req, (size_t)rl) != rl){
log_msg("pkgzone: request write fail");
goto done;
}
/* Read to EOF. Bounded: a hostile or broken page must not eat the heap. */
static char raw[PKGZONE_MAX_HTML + 4096];
size_t total = 0;
for(;;){
int n = tls_read(t, raw + total, sizeof raw - total - 1);
if(n < 0) break;
if(n == 0) break;
total += (size_t)n;
if(total >= sizeof raw - 1) break;
}
raw[total] = 0;
int status = 0, blen = 0;
char loc[256];
char *body = upd_parse_response(raw, total, PKGZONE_MAX_HTML, &status,
(size_t *)&blen, loc, sizeof loc);
if(!body){ log_msg("pkgzone: unparseable HTTP response (%zu bytes)", total); goto done; }
if(status != 200){
log_msg("pkgzone: status %d", status);
free(body);
rc = -1;
goto done;
}
if(pkgzone_parse(body, (size_t)blen, title_id, name, name_cap,
url, url_cap) == 0){
log_msg("pkgzone: %s -> %s", title_id, name);
rc = 0;
} else {
log_msg("pkgzone: no heading for %s", title_id);
}
free(body);
done:
tls_free(t);
close(fd);
return rc;
}
#else
int pkgzone_resolve(const char *title_id, char *name, size_t name_cap,
char *url, size_t url_cap){
(void)title_id; (void)name; (void)name_cap; (void)url; (void)url_cap;
return -1;
}
#endif
+41
View File
@@ -0,0 +1,41 @@
/* pkgzone.h - name + cover lookup for homebrew titles on pkg-zone.com.
*
* Why this exists: Sony's TMDB endpoint only serves retail CUSA titles, so
* homebrew (LAPY/HT0/AUR0/...) has no on-box source and no CDN entry. TMDB
* cannot be asked about it at all -- the lookup is 404 by construction.
*
* Scope and caveats, deliberately narrow:
* - only for titles that are NOT CUSA*, so retail games never touch this
* - disabled by nothing: on by default, killed by cfg pkgzone_enabled=0
* - pkg-zone.com is a third-party page with no API contract. It is scraped,
* not queried, so a layout change breaks it silently. Every failure falls
* back to the raw title id rather than blocking the presence.
*
* TLS: pkg-zone.com chains to ISRG Root X1, which is already in the curated
* bundle (scripts/make_ca_bundle.py), so no trust anchor is added. The cover
* URL is derived from the title id rather than parsed out of the page. */
#ifndef ORBISRPC_PKGZONE_H
#define ORBISRPC_PKGZONE_H
#include <stddef.h>
#define PKGZONE_HOST "pkg-zone.com"
#define PKGZONE_MAX_HTML (192 * 1024)
/* True when this title id is worth asking pkg-zone.com about: anything that is
* not a retail CUSA id. PS1/PS2 ids are excluded on purpose -- they are handled
* separately and PKG-Zone's answers for them are less trustworthy. */
int pkgzone_wants(const char *title_id);
/* Pure HTML scrape: copy the first meaningful <h1> text into out and build the
* cover URL for title_id. Returns 0 on success, -1 when the page has no usable
* heading. No PS4 dependencies, so the host tests can drive it. */
int pkgzone_parse(const char *html, size_t len, const char *title_id,
char *out, size_t cap, char *url, size_t url_cap);
/* Full lookup (socket + TLS + scrape). Returns 0 on a hit. Any failure is
* non-fatal: the caller keeps whatever name it already had. */
int pkgzone_resolve(const char *title_id, char *name, size_t name_cap,
char *url, size_t url_cap);
#endif /* ORBISRPC_PKGZONE_H */
+97
View File
@@ -0,0 +1,97 @@
/* procwalk.c - see procwalk.h. */
#include "procwalk.h"
#include <string.h>
/* Read the leading int without unaligned-load or aliasing undefined
* behaviour. Records are 4-byte aligned in practice, but memcpy costs
* nothing here and stays correct if that ever stops being true. */
static int rd_int(const unsigned char *p){
int v;
memcpy(&v, p, sizeof v);
return v;
}
/* Does this record carry a NUL-terminated `needle` anywhere inside it?
*
* Requiring the terminator to sit within the record matters: a bare memmem
* would happily match a name that runs off the end of the record into the
* next one. ki_comm is a fixed MAXCOMLEN+1 char array, so a genuine hit is
* always NUL-terminated inside its own record. */
static int rec_has_comm(const unsigned char *rec, size_t recsz, const char *needle){
if(!rec || !needle) return 0;
size_t n = strlen(needle);
if(n == 0 || recsz < n + 1) return 0;
for(size_t i = 0; i + n < recsz; i++){
if(rec[i + n] != '\0') continue;
if(memcmp(rec + i, needle, n) == 0) return 1;
}
return 0;
}
int procwalk_count_comm(const unsigned char *buf, size_t sz, const char *needle){
if(!buf || !needle || !needle[0]) return -1;
size_t off = 0;
int n = 0;
while(off + sizeof(int) <= sz){
int recsz = rd_int(buf + off);
/* A non-positive size means the framing is broken. Report unknown
* rather than 0: callers read 0 as "no game running", which would
* blank an active presence instead of leaving it alone. */
if(recsz <= 0) return -1;
size_t rsz = (size_t)recsz;
/* A record claiming more bytes than the buffer holds is corruption we
* cannot tell apart from a truncated final record. Either way the
* count would be a guess, and a wrong guess here costs presence, so
* report unknown. */
if(off + rsz > sz) return -1;
if(rec_has_comm(buf + off, rsz, needle)) n++;
off += rsz;
}
return n;
}
int procwalk_has_comm(const unsigned char *buf, size_t sz, const char *needle){
int n = procwalk_count_comm(buf, sz, needle);
if(n < 0) return -1;
return n > 0 ? 1 : 0;
}
size_t procwalk_first_structsize(const unsigned char *buf, size_t sz){
if(!buf || sz < sizeof(int)) return 0;
int recsz = rd_int(buf);
if(recsz <= 0 || (size_t)recsz > sz) return 0;
return (size_t)recsz;
}
int procwalk_find_comm(const unsigned char *buf, size_t sz, const char *needle,
const unsigned char **rec, size_t *recsz){
if(!rec || !recsz) return -1;
*rec = NULL;
*recsz = 0;
if(!buf || !needle || !needle[0]) return -1;
size_t off = 0;
while(off + sizeof(int) <= sz){
int rsz_i = rd_int(buf + off);
if(rsz_i <= 0) return -1;
size_t rsz = (size_t)rsz_i;
if(off + rsz > sz) return -1;
if(rec_has_comm(buf + off, rsz, needle)){
*rec = buf + off;
*recsz = rsz;
return 0;
}
off += rsz;
}
return -2;
}
int procwalk_record_has_i32(const unsigned char *rec, size_t recsz, int want){
if(!rec || recsz < sizeof(int32_t)) return 0;
int32_t v = (int32_t)want;
for(size_t i = 0; i + sizeof v <= recsz; i++){
int32_t got;
memcpy(&got, rec + i, sizeof got);
if(got == v) return 1;
}
return 0;
}
+73
View File
@@ -0,0 +1,73 @@
/* procwalk.h - firmware-proof process-table record scanning.
*
* sysctl KERN_PROC_ALL (mib {1,14,8,0}) hands back a buffer of
* variable-length struct kinfo_proc records. Each record leads with
* ki_structsize, its own byte length, so the *framing* is self-describing
* and survives firmware changes. The field offsets *inside* a record are not
* self-describing and do drift between firmware versions: orbisRPC used to
* hard-code "eboot.bin" at byte 447 and reject any record under 479 bytes,
* which was 9.00-specific. On any firmware where that layout differs the scan
* reported "no game running" forever and the daemon posted nothing.
*
* So: keep ki_structsize framing, but locate the process name by searching
* inside the record instead of trusting an offset.
*
* Pure C99 over <string.h> only -- no PS4 headers, no sysctl, no libc beyond
* memcpy/memcmp/strlen. detect.c itself cannot be host-compiled (it pulls in
* dlfcn.h and orbis/), so the parsing lives here where the unit tests can
* reach it.
*/
#ifndef PROCWALK_H
#define PROCWALK_H
#include <stddef.h>
#include <stdint.h>
/* The comm we care about: a launched game runs as eboot.bin. */
#define PROCWALK_EBOOT "eboot.bin"
/* Count records whose NUL-terminated name field equals `needle`.
* Returns the count, or -1 when the buffer is malformed: a record with a
* non-positive size, or one claiming more bytes than the buffer holds. A
* truncated final record is indistinguishable from corruption, so it also
* reports -1 rather than a partial count — callers read 0 as "no game
* running" and would blank an active presence, so a guess is worse than
* "unknown". */
int procwalk_count_comm(const unsigned char *buf, size_t sz, const char *needle);
/* 1 if any record matches, 0 if none, -1 if malformed. */
int procwalk_has_comm(const unsigned char *buf, size_t sz, const char *needle);
/* ki_structsize of the first record, or 0 when the buffer is empty or the
* first record is malformed. Lets the daemon log what the running firmware
* actually reports instead of guessing and staying silent. */
size_t procwalk_first_structsize(const unsigned char *buf, size_t sz);
/* Locate the first record whose NUL-terminated name equals `needle`.
* Returns 0 and sets rec/recsz to that record's extent, -2 when no record
* matches, -1 when the buffer is malformed. Both outputs are cleared first.
* Splitting "which record" out of "does it match" is what lets callers narrow
* a record by its unambiguous process name before looking for fields whose
* offset is not knowable. */
int procwalk_find_comm(const unsigned char *buf, size_t sz, const char *needle,
const unsigned char **rec, size_t *recsz);
/* Does this record contain `want` as a 32-bit integer anywhere inside it?
*
* For fields like the pid there is no self-describing marker, so the only
* firmware-proof option is to search the record. Always narrow the record
* with procwalk_find_comm() first: a hit inside a record already known to be
* the right process is meaningful, whereas a hit anywhere in the table would
* be noise.
*
* A hit is corroboration, not proof. Records are zero-padded and a process
* name ends in a NUL, so a record routinely contains the int32 0 in its
* padding -- callers MUST reject pid <= 0 before asking.
*
* Bias note: callers should treat a miss as "probably stale" rather than
* certain. For the single-instance lock a false positive merely causes an
* unnecessary stand-down, while a false negative lets a second daemon run
* alongside the first, so erring toward matching is the safe direction. */
int procwalk_record_has_i32(const unsigned char *rec, size_t recsz, int want);
#endif /* PROCWALK_H */
+307
View File
@@ -0,0 +1,307 @@
/* retro.c - see retro.h. */
#include "retro.h"
#include "log.h"
#include <errno.h>
#ifdef ORBISRPC_SDK_PAYLOAD
#include "tls.h"
#include <sys/socket.h>
#include <netdb.h>
#include <sys/time.h>
#include <unistd.h>
#endif
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#define RETRO_HOST "retro-games.cybermask.dpdns.org"
#define RETRO_DEADLINE_S 25
#define RETRO_HDR_MAX 8192
/* Working window. Independent of the 1.5 MB file: a chunk plus enough tail to
* hold any single entry, so a match across a chunk boundary survives. */
#define RETRO_CHUNK 4096
#define RETRO_TAIL 768
/* Verified on console 2026-10-05: ps1.json 1.31 MB / 9,930 entries,
* ps2.json 1.50 MB / 11,207, psp.json 0.94 MB / 6,959. Compact, BOM-free,
* bare array of flat entries. Some prefixes belong to more than one platform
* (SLPM, PBPX), so candidates are tried in this order and the first hit wins. */
#define RETRO_PLAT_N 3
static const char *const s_plat_file[RETRO_PLAT_N] = {
"ps1.json", "ps2.json", "psp.json",
};
static const char *const retro_prefixes_ps1[] = {
"SCUS","SLUS","SPUS","SCES","SLES","SCED","SLED","SCPS","SLPS","SLPM",
"SIPS","SCZS","SCKA","SLKA","ESPM","PAPX","PBPX","PCPD","PCPX","PDPX",
"PEPX","PUPX","LSD","SND",
};
static const char *const retro_prefixes_ps2[] = {
"SCUS","SLUS","SCES","SLES","SCED","SLED","TCES","TLES","SCPS","SLPS",
"SLPM","KOEI","SCAJ","SLAJ","SCKA","SLKA","SCCH","SLCH","PBPX","PTPX",
"PICS","LDTL",
};
static const char *const retro_prefixes_psp[] = {
"UCUS","ULUS","UCES","ULES","UCJS","ULJS","ULJM","UCAS","ULAS","UCKS",
"ULKS","ULUX","UMDT","NPUG","NPUH","NPUJ","NPUM","NPEG","NPEH","NPEZ",
"NPJG","NPJH","NPJJ","NPHG","NPHH","NPHZ",
};
#define NELEM(a) (sizeof(a)/sizeof *(a))
/* Prefix lengths vary: most are 4 characters, but the list carries 3-char
* ones (LSD, SND), so compare each entry at its own length rather than
* assuming four. */
static int in_list(const char *const *list, size_t n, const char *pfx){
for(size_t i = 0; i < n; i++){
size_t len = strlen(list[i]);
if(len == 0 || len > 4) continue;
if(!strncmp(list[i], pfx, len)) return 1;
}
return 0;
}
static int retro_candidates(const char *pfx, unsigned char *out){
int n = 0;
if(in_list(retro_prefixes_ps1, NELEM(retro_prefixes_ps1), pfx)) out[n++] = 0;
if(in_list(retro_prefixes_ps2, NELEM(retro_prefixes_ps2), pfx)) out[n++] = 1;
if(in_list(retro_prefixes_psp, NELEM(retro_prefixes_psp), pfx)) out[n++] = 2;
return n;
}
int retro_candidates_for(const char *title_id, unsigned char *out){
if(!title_id || !out) return 0;
size_t n = strlen(title_id);
if(n < 4 || n >= RETRO_MAX_ID) return 0;
return retro_candidates(title_id, out);
}
int retro_wants(const char *title_id){
if(!title_id) return 0;
size_t n = strlen(title_id);
if(n < 4 || n >= RETRO_MAX_ID) return 0;
unsigned char c[RETRO_PLAT_N];
return retro_candidates(title_id, c) > 0;
}
/* --- JSON strings -------------------------------------------------------
* Names carry quotes and backslashes (verified), so raw bytes cannot be
* copied straight out. */
static size_t unescape(const char *src, size_t len, char *dst, size_t cap){
size_t o = 0;
for(size_t i = 0; i < len && o + 1 < cap; i++){
if(src[i] != '\\'){ dst[o++] = src[i]; continue; }
if(i + 1 >= len) break;
char c = src[++i];
switch(c){
case 'n': dst[o++] = '\n'; break;
case 't': dst[o++] = '\t'; break;
case 'r': dst[o++] = '\r'; break;
case 'b': dst[o++] = '\b'; break;
case 'f': dst[o++] = '\f'; break;
case 'u': {
if(i + 4 >= len){ i = len; break; }
unsigned cp = 0;
for(int k = 1; k <= 4; k++){
char h = src[i + k];
cp <<= 4;
if(h >= '0' && h <= '9') cp |= (unsigned)(h - '0');
else if(h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10);
else if(h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10);
}
i += 4;
if(cp < 0x80) dst[o++] = (char)cp;
else if(cp < 0x800 && o + 2 < cap){
dst[o++] = (char)(0xC0 | (cp >> 6));
dst[o++] = (char)(0x80 | (cp & 0x3F));
} else if(cp < 0x10000 && o + 3 < cap){
dst[o++] = (char)(0xE0 | (cp >> 12));
dst[o++] = (char)(0x80 | ((cp >> 6) & 0x3F));
dst[o++] = (char)(0x80 | (cp & 0x3F));
}
break;
}
default: dst[o++] = c; break; /* covers \" \\ \/ */
}
}
dst[o] = 0;
return o;
}
/* Copy the JSON string starting at p (which must point at '"'), unescaped. */
static int grab_string(const char *p, const char *end, char *dst, size_t cap){
if(!p || p >= end || *p != '"') return 0;
p++;
const char *q = p;
while(q < end){
if(*q == '\\'){ if(q + 1 >= end) return 0; q += 2; continue; }
if(*q == '"') break;
q++;
}
if(q >= end) return 0;
unescape(p, (size_t)(q - p), dst, cap);
return 1;
}
/* Value of the key whose opening quote is at `key`. Skips to the colon rather
* than a fixed offset: the quote sits at +7 after "name":" but +8 after
* "cover":", so a hardcoded skip silently drops one of the two. */
static int grab_value(const char *key, const char *end, char *dst, size_t cap){
if(!key || key >= end) return 0;
const char *c = key;
while(c < end && *c != ':') c++;
if(c >= end) return 0;
return grab_string(c + 1, end, dst, cap);
}
/* Pull the entry for `pat` ("id":"XXXX") out of a body region. */
int retro_extract(const char *p, size_t len, const char *pat,
char *name, size_t name_cap,
char *url, size_t url_cap){
/* Clear first: on a miss the caller's buffers must not keep whatever the
* previous title put there, or a stale name could be posted. */
if(name && name_cap) name[0] = 0;
if(url && url_cap) url[0] = 0;
if(!p || !pat || len < strlen(pat)) return 0;
const char *hit = NULL;
for(const char *q = p; q + strlen(pat) <= p + len; q++)
if(!memcmp(q, pat, strlen(pat))){ hit = q; break; }
if(!hit) return 0;
const char *end = p + len;
const char *nm = strstr(hit, "\"name\"");
const char *cv = strstr(hit, "\"cover\"");
if(nm && nm < end) grab_value(nm, end, name, name_cap);
if(cv && cv < end) grab_value(cv, end, url, url_cap);
return name[0] ? 1 : 0;
}
/* --- streaming fetch ----------------------------------------------------
* The index is 1.3-1.5 MB and one entry is wanted, so the body is never held
* whole. A fixed window is scanned after each chunk and the front is dropped;
* a tail of RETRO_TAIL bytes is kept so a match spanning a chunk boundary is
* still found. */
#ifdef ORBISRPC_SDK_PAYLOAD
static int retro_fetch_scan(const char *path, const char *pat,
char *name, size_t name_cap,
char *url, size_t url_cap){
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if(getaddrinfo(RETRO_HOST, "443", &hints, &res) != 0 || !res){
if(res) freeaddrinfo(res);
return -1;
}
int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if(fd < 0){ freeaddrinfo(res); return -1; }
struct timeval tv = { .tv_sec = RETRO_DEADLINE_S, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if(connect(fd, res->ai_addr, res->ai_addrlen) != 0){
freeaddrinfo(res); close(fd); return -1;
}
freeaddrinfo(res);
tls_ctx_t *t = tls_start(fd, RETRO_HOST);
if(!t){ close(fd); return -1; }
char req[512];
int rl = snprintf(req, sizeof req,
"GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: Mozilla/5.0\r\n"
"Accept: application/json\r\nConnection: close\r\n\r\n",
path, RETRO_HOST);
if(rl <= 0 || rl >= (int)sizeof req || tls_write(t, req, (size_t)rl) < 0){
tls_free(t); close(fd); return -1;
}
static char win[RETRO_HDR_MAX + RETRO_CHUNK + RETRO_TAIL];
size_t have = 0;
size_t body_at = 0;
int rc = 0;
/* --- headers --- */
while(have < RETRO_HDR_MAX){
int r = tls_read(t, win + have, sizeof win - have);
if(r <= 0) break;
have += (size_t)r;
char *e = strstr(win, "\r\n\r\n");
if(e){
size_t body = (size_t)(e - win) + 4;
if(strncmp(win, "HTTP/1.1 200", 12) != 0 &&
strncmp(win, "HTTP/2 200", 11) != 0){
log_msg("retro: %s -> HTTP %d", path,
atoi(win + 9) ? atoi(win + 9) : 0);
rc = -1;
goto done;
}
/* keep the body start plus room to read into */
memmove(win, win + body, have - body);
have -= body;
body_at = body;
break;
}
}
if(!body_at){ rc = -1; goto done; }
/* --- body, streamed --- */
for(;;){
if(retro_extract(win, have, pat, name, name_cap, url, url_cap)){
rc = 1;
goto done;
}
/* drop everything except a tail that can still contain a whole entry */
if(have > RETRO_TAIL) memmove(win, win + have - RETRO_TAIL, RETRO_TAIL), have = RETRO_TAIL;
int r = tls_read(t, win + have, sizeof win - have - 1);
if(r <= 0) break; /* EOF or deadline: a miss, not an error */
have += (size_t)r;
}
rc = 0; /* scanned to the end, no match */
done:
tls_free(t);
close(fd);
return rc;
}
#else /* host test build: no sockets, so the lookup always misses. The pure
* parts above (prefix matching, unescaping, entry extraction) stay
* compiled and are unit-tested. */
static int retro_fetch_scan(const char *path, const char *pat,
char *name, size_t name_cap,
char *url, size_t url_cap){
(void)path; (void)pat; (void)name_cap; (void)url_cap;
if(name && name_cap) name[0] = 0;
if(url && url_cap) url[0] = 0;
return -1;
}
#endif
int retro_resolve(const char *title_id, char *name, size_t name_cap,
char *url, size_t url_cap){
if(!title_id || !name || !name_cap) return -1;
name[0] = 0;
if(url && url_cap) url[0] = 0;
char pat[RETRO_MAX_ID + 16];
int pl = snprintf(pat, sizeof pat, "\"id\":\"%s\"", title_id);
if(pl <= 0 || pl >= (int)sizeof pat) return -1;
unsigned char cands[RETRO_PLAT_N];
int n = retro_candidates(title_id, cands);
if(!n) return -1;
for(int k = 0; k < n; k++){
char path[64];
snprintf(path, sizeof path, "/%s", s_plat_file[cands[k]]);
name[0] = 0;
if(url && url_cap) url[0] = 0;
int rc = retro_fetch_scan(path, pat, name, name_cap, url, url_cap);
if(rc == 1){
log_msg("retro: %s -> %s (%s)", title_id, name, path);
return 0;
}
if(rc < 0)
log_msg("retro: %s fetch failed (%s)", path, strerror(errno));
}
return -1;
}
+51
View File
@@ -0,0 +1,51 @@
/* retro.h - homebrew/retro title names and covers from retro-games.cybermask.
*
* Three JSON indexes, one per platform, checked in order. This exists because
* Sony's TMDB endpoint only knows retail CUSA titles, so a retro or homebrew
* title id has no on-box source and no pkg-zone entry.
*
* Fetched, not parsed: each index is ~1-1.5 MB and jsonlite would allocate a
* node per value, costing megabytes of heap for a single title lookup. The
* entries are flat and uniform --
*
* {"id":"CPCS00701","name":"DINO CRISIS 5TH ANNESSARY","cover":"https://..."}
*
* -- so the stream is scanned for the wanted id and the fields beside it are
* copied out. The response is never held whole.
*/
#ifndef ORBISRPC_RETRO_H
#define ORBISRPC_RETRO_H
#include <stddef.h>
/* Longest name in the three indexes is 164 bytes; the extra room keeps the
* widest title intact instead of slicing it. */
#define RETRO_MAX_NAME 176
#define RETRO_MAX_URL 320
/* Ids run to 20 characters -- far past the PS4's 9 -- so match on the 4-char
* prefix and carry the whole id through. */
#define RETRO_MAX_ID 24
/* 1 when this id's 4-character prefix belongs to any retro platform. */
int retro_wants(const char *title_id);
/* Pure entry extractor: pull the entry whose "id" matches `pat` ("id":"XXXX")
* out of a body region. Test seam — no sockets, so the host suite drives this
* directly instead of the fetch. Returns 1 when a name was recovered. */
int retro_extract(const char *p, size_t len, const char *pat,
char *name, size_t name_cap,
char *url, size_t url_cap);
/* Which platform indexes this prefix could be in, in check order. Returns the
* count and fills `out` with indices into the PS1/PS2/PSP list. */
int retro_candidates_for(const char *title_id, unsigned char *out);
/* Resolve a title id to its name and cover URL.
* Returns 0 on a hit (name always filled; url empty when the entry has none),
* -1 on a miss or any fetch/parse failure. Never partially resolves: a title
* either gets a real name or none at all, so the caller never posts a name
* with no cover behind a stale cached one. */
int retro_resolve(const char *title_id, char *name, size_t name_cap,
char *url, size_t url_cap);
#endif /* ORBISRPC_RETRO_H */
+2 -2
View File
@@ -1,7 +1,7 @@
/* sfo.c - PARAM.SFO TITLE extractor (bounds-checked, no libc beyond string).
* Every PS4 game carries sce_sys/param.sfo with a TITLE field. Inside the
* game process it is reachable via the app0 mount; the plugin tries those
* paths first because they work on ANY console with zero setup. */
* game process it is reachable via the app0 mount; these paths work on ANY
* console with zero setup. */
#include "sfo.h"
#include <string.h>
#include <stdint.h>
+11 -2
View File
@@ -137,7 +137,9 @@ tls_ctx_t *tls_start(int fd, const char *host){
if((rc = mbedtls_x509_crt_parse(&t->ca,
(const unsigned char *)ORBISRPC_CA_BUNDLE_PEM,
sizeof ORBISRPC_CA_BUNDLE_PEM)) < 0){
log_msg("tls: CA bundle parse fail %d", rc);
/* Name the host on a parse failure: it is almost always a malformed or
* truncated header, and it is invisible without this. */
log_msg("tls[%s]: CA bundle parse fail %d", host, rc);
goto fail;
}
mbedtls_ssl_conf_ca_chain(&t->conf, &t->ca, NULL);
@@ -187,7 +189,14 @@ tls_ctx_t *tls_start(int fd, const char *host){
{
uint32_t vf = mbedtls_ssl_get_verify_result(&t->ssl);
if(vf != 0){
log_msg("tls: cert verify fail flags=0x%08x", vf);
log_msg("tls[%s]: CERT VERIFY FAILED flags=0x%08x", host, vf);
/* mbedTLS flag bits, spelled out. BADCERT_NOT_TRUSTED (0x08) is
* the one that bites when a CA rotated out of the bundle. */
if(vf & 0x08) log_msg("tls: -> chain does not reach a bundled root (root missing from ca_bundle_pem.h?)");
if(vf & 0x01) log_msg("tls: -> certificate not yet valid (console clock)");
if(vf & 0x02) log_msg("tls: -> certificate expired");
if(vf & 0x04) log_msg("tls: -> certificate revoked");
if(vf & 0x10) log_msg("tls: -> hostname does not match");
goto fail;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* tls.h - self-contained TLS client (mbedTLS, statically linked).
* No PS4 TLS-module dependency: works in payload and plugin processes.
* No PS4 TLS-module dependency: works in payload processes.
* Certificate verification is REQUIRED against the curated bundle;
* see ca_bundle_pem.h. */
#ifndef TLS_H
+11 -5
View File
@@ -208,15 +208,15 @@ static int https_get_tmdb(const char *path, char *out, size_t cap, int *out_stat
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if(getaddrinfo(TMDB_HOST, "443", &hints, &res) != 0 || !res){
log_msg("tmdb: https dns fail");
log_msg("tmdb: dns fail for %s", TMDB_HOST);
return -1;
}
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0){ freeaddrinfo(res); return -1; }
if(fd < 0){ log_msg("tmdb: socket fail errno=%d", errno); freeaddrinfo(res); return -1; }
struct timeval tv = { .tv_sec = TMDB_HTTPS_DEADLINE_S, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if(connect(fd, res->ai_addr, res->ai_addrlen) < 0){
log_msg("tmdb: https connect fail");
log_msg("tmdb: connect %s:443 fail errno=%d", TMDB_HOST, errno);
freeaddrinfo(res); close(fd); return -1;
}
freeaddrinfo(res);
@@ -226,6 +226,7 @@ static int https_get_tmdb(const char *path, char *out, size_t cap, int *out_stat
}
tls_ctx_t *t = tls_start(fd, TMDB_HOST);
if(!t){
log_msg("tmdb: tls handshake failed for %s (see tls: lines above)", path);
close(fd);
return -1;
}
@@ -254,10 +255,11 @@ static int https_get_tmdb(const char *path, char *out, size_t cap, int *out_stat
}
tls_free(t);
close(fd);
if(bl == 0) return -1;
if(bl == 0){ log_msg("tmdb: empty body for %s", path); return -1; }
int st = 0;
size_t olen = 0;
char *body = upd_parse_response(raw, bl, cap, &st, &olen, NULL, 0);
log_msg("tmdb: %s -> HTTP %d, %d body bytes", path, st, (int)olen);
if(out_status) *out_status = st;
if(!body || st != 200){ free(body); return -1; }
if(olen >= cap){ free(body); return -1; }
@@ -285,7 +287,11 @@ int tmdb_resolve(const char *titleId, char *name, size_t name_cap,
* consoles — http_get below is last-resort only). No baked tables:
* CUSA code + Sony CDN is the source of truth. */
char path[128];
if(tmdb_path(titleId, path, sizeof path) != 0) return -1;
if(tmdb_path(titleId, path, sizeof path) != 0){
log_msg("tmdb: %s is not a valid title id for this lookup", titleId);
return -1;
}
log_msg("tmdb: resolving %s -> https://%s%s", titleId, TMDB_HOST, path);
static char body[TMDB_BODY_MAX];
int status = 0;
int n = https_get_tmdb(path, body, sizeof body, &status);
+16 -2
View File
@@ -124,9 +124,23 @@ int tmdb_parse(const char *body, size_t len,
const jl_val_t *i0 = (icons && icons->type == JL_ARRAY) ? jl_arr_at(icons, 0) : NULL;
const jl_val_t *iu = i0 ? jl_obj_get(i0, "icon") : NULL;
if(iu && iu->type == JL_STRING && iu->str[0]){
/* Sony's icon CDN still answers in http on some titles.
* Discord silently drops an activity whose artwork is a
* non-https external URL -- the tile just shows "?" -- so
* upgrade the scheme rather than dropping the asset. */
const char *src = iu->str;
char up[256];
if(!strncmp(src, "http://", 7)){
size_t n = strlen(src + 7);
if(n + 8 <= sizeof up){
memcpy(up, "https://", 8);
memcpy(up + 8, src + 7, n + 1);
src = up;
}
}
/* only http(s) URLs are usable as external assets */
if(!strncmp(iu->str, "http://", 7) || !strncmp(iu->str, "https://", 8)){
strncpy(icon, iu->str, icon_cap-1); icon[icon_cap-1] = 0;
if(!strncmp(src, "http://", 7) || !strncmp(src, "https://", 8)){
strncpy(icon, src, icon_cap-1); icon[icon_cap-1] = 0;
}
}
}
+451
View File
@@ -0,0 +1,451 @@
/* updater.c - self-updater. Pure logic (host-tested) plus PS4 HTTPS.
*
* Flow, once per daemon boot when enabled:
* GET https://api.github.com/repos/<repo>/releases/latest
* -> tag_name + asset browser_download_urls
* if tag newer than ORBISRPC_VERSION:
* download each known asset (cap 4MB), validate ELF magic,
* write <target>.new, rename over target.
* Payload .bin takes effect on next injection. Never deletes, never writes unvalidated bytes.
*/
#include "updater.h"
#include "updater_http.h"
#include "version.h"
#include "clock.h"
#include "jsonlite.h"
#include "health.h"
#include "manifest.h"
#include "release_pubkey.h"
#include "log.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
/* ---- PS4 HTTPS transport (mirrors ws.c/tmdb.c bring-up) ---- */
#include "tls.h"
#include <mbedtls/sha256.h>
#ifdef ORBISRPC_SDK_PAYLOAD
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <fcntl.h>
#else
#include <orbis/Net.h>
#include <orbis/Sysmodule.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#endif
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define UPD_HOST "api.github.com"
#define UPD_DEADLINE_S 15
#define UPD_BODY_MAX (4u*1024u*1024u)
#define UPD_MAX_REDIRECTS 3
/* Release-channel host allowlist: only GitHub API + release infra.
* Redirects anywhere else are refused (fail closed). */
static int upd_host_allowed(const char *host){
static const char *ok[] = {
"api.github.com",
"github.com",
"uploads.github.com",
"objects.githubusercontent.com",
"codeload.github.com",
"raw.githubusercontent.com",
NULL,
};
if(!host || !host[0]) return 0;
for(int i = 0; ok[i]; i++) if(!strcmp(host, ok[i])) return 1;
return 0;
}
#ifndef SO_NBIO
#define SO_NBIO 0x2000 /* same local define as ws.c; absent from SDK headers */
#endif
static int32_t s_upool = -1;
static int upd_net(void){
static int ready = 0;
if(ready) return 0;
#ifdef ORBISRPC_SDK_PAYLOAD
ready = 1;
return 0;
#else
uint32_t ur = sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_NET);
if((int)ur < 0) return -1;
if(sceNetInit() < 0) return -1;
s_upool = (int32_t)sceNetPoolCreate("upd", 128*1024, 0);
if(s_upool < 0) return -1;
ready = 1;
return 0;
#endif
}
static int upd_connect(const char *host, int port){
if(upd_net() < 0) return -1;
#ifdef ORBISRPC_SDK_PAYLOAD
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
{
char portbuf[16];
snprintf(portbuf, sizeof portbuf, "%d", port);
if(getaddrinfo(host, portbuf, &hints, &res) != 0 || !res){
log_msg("updater: dns fail");
return -1;
}
}
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0){ freeaddrinfo(res); return -1; }
struct timeval tv = { .tv_sec = UPD_DEADLINE_S, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if(connect(fd, res->ai_addr, res->ai_addrlen) < 0){
close(fd); freeaddrinfo(res); return -1;
}
freeaddrinfo(res);
{
int fl = fcntl(fd, F_GETFL, 0);
if(fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
return fd;
#else
OrbisNetInAddr in;
memset(&in, 0, sizeof in);
int32_t rid = sceNetResolverCreate("updR", (uint32_t)s_upool, 0);
if(rid < 0) return -1;
int32_t rr = sceNetResolverStartNtoa(rid, host, &in, 8000000, 3, 0);
sceNetResolverDestroy(rid);
if(rr < 0){ log_msg("updater: dns fail"); return -1; }
int fd = sceNetSocket("upd", ORBIS_NET_AF_INET, ORBIS_NET_SOCK_STREAM, 0);
if(fd < 0) return -1;
struct timeval tv = { .tv_sec = UPD_DEADLINE_S, .tv_usec = 0 };
sceNetSetsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
OrbisNetSockaddr sa;
memset(&sa, 0, sizeof sa);
sa.sa_family = 2;
*(uint16_t*)sa.sa_data = sceNetHtons((uint16_t)port);
memcpy(sa.sa_data+2, &in.s_addr, 4);
if(sceNetConnect(fd, &sa, sizeof sa) < 0){
sceNetSocketClose(fd); return -1;
}
int nb = 1;
sceNetSetsockopt(fd, SOL_SOCKET, SO_NBIO, &nb, sizeof nb);
return fd;
#endif
}
/* HTTPS GET with real HTTP semantics (bounded):
* status line + headers (cap UPD_HDR_MAX) + Content-Length or
* Transfer-Encoding: chunked decoding + redirect capture.
* Returns heap body (caller frees) with out_len; out_status always set;
* out_location gets the redirect target ("" when none). Only the body
* counts against cap; headers are separately bounded. */
static char *https_get_once(const char *host, const char *path,
size_t cap, int *out_status, size_t *out_len,
char *out_location, size_t loc_cap){
if(out_status) *out_status = 0;
if(out_len) *out_len = 0;
if(out_location && loc_cap) out_location[0] = 0;
if(!upd_host_allowed(host)){ log_msg("updater: host refused (%s)", host); return NULL; }
int fd = upd_connect(host, 443);
if(fd < 0) return NULL;
tls_ctx_t *t = tls_start(fd, host);
if(!t){
#ifdef ORBISRPC_SDK_PAYLOAD
close(fd);
#else
sceNetSocketClose(fd);
#endif
return NULL;
}
char req[512];
int rl = snprintf(req, sizeof req,
"GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: orbisRPC/%s\r\nConnection: close\r\n\r\n",
path, host, ORBISRPC_VERSION);
if(rl <= 0 || rl >= (int)sizeof req){ tls_free(t); return NULL; }
if(tls_write(t, req, (size_t)rl) < 0){ tls_free(t); return NULL; }
/* Read raw response (headers + body) up to header cap + body cap. */
size_t rawcap = UPD_HDR_MAX + cap;
char *raw = (char*)malloc(rawcap + 1); /* +1: NUL pad when a read fills cap exactly */
if(!raw){ tls_free(t); return NULL; }
size_t rl2 = 0;
int64_t dl = orbis_mono_s() + UPD_DEADLINE_S + 20;
for(;;){
char tmp[2048];
int r = tls_read(t, tmp, sizeof tmp);
if(r < 0) break;
if(r == 0){
if(orbis_mono_s() > dl) break;
usleep(20000);
continue;
}
size_t room = rawcap > rl2 ? rawcap - rl2 : 0;
size_t cp = (size_t)r < room ? (size_t)r : room;
if(cp) memcpy(raw + rl2, tmp, cp);
rl2 += cp;
if(rl2 >= rawcap - 1 || orbis_mono_s() > dl) break;
}
tls_free(t);
if(rl2 == 0){ free(raw); return NULL; }
raw[rl2] = 0;
char *out = upd_parse_response(raw, rl2, cap, out_status, out_len,
out_location, loc_cap);
free(raw);
return out;
}
/* HTTPS GET following absolute-URL https redirects within the allowlist
* (GitHub release assets redirect to object storage). Relative Location
* values stay on the same host. Refuses non-https, off-allowlist, and
* redirect loops. */
static char *https_get(const char *host, const char *path,
size_t cap, int *out_status, size_t *out_len){
char hbuf[128], pbuf[512], loc[512];
snprintf(hbuf, sizeof hbuf, "%s", host);
snprintf(pbuf, sizeof pbuf, "%s", path);
for(int i = 0; i <= UPD_MAX_REDIRECTS; i++){
int status = 0;
size_t len = 0;
char *body = https_get_once(hbuf, pbuf, cap, &status, &len,
loc, sizeof loc);
if(!body){
if(out_status) *out_status = status;
if(out_len) *out_len = 0;
return NULL;
}
if((status == 301 || status == 302 || status == 303 ||
status == 307 || status == 308) && loc[0]){
free(body);
if(!strncmp(loc, "https://", 8)){
const char *h0 = loc + 8;
const char *p0 = strchr(h0, '/');
if(!p0 || (size_t)(p0 - h0) >= sizeof hbuf){
if(out_status) *out_status = status;
if(out_len) *out_len = 0;
return NULL;
}
memcpy(hbuf, h0, (size_t)(p0 - h0));
hbuf[p0 - h0] = 0;
snprintf(pbuf, sizeof pbuf, "%s", p0);
} else if(loc[0] == '/'){
snprintf(pbuf, sizeof pbuf, "%s", loc);
} else {
if(out_status) *out_status = status;
if(out_len) *out_len = 0;
return NULL;
}
if(!upd_host_allowed(hbuf)){
log_msg("updater: redirect refused (off-allowlist %s)", hbuf);
if(out_status) *out_status = status;
if(out_len) *out_len = 0;
return NULL;
}
log_msg("updater: redirect -> %s%s", hbuf, pbuf);
continue;
}
if(out_status) *out_status = status;
if(out_len) *out_len = len;
if(status != 200 || len == 0){ free(body); return NULL; }
return body;
}
if(out_status) *out_status = 0;
if(out_len) *out_len = 0;
return NULL;
}
static int stage_file(const char *target, const unsigned char *data, size_t n){
char tmp[192];
snprintf(tmp, sizeof tmp, "%s.new", target);
if(!updater_image_ok(data, n)){ log_msg("updater: staged bytes failed validation"); return -1; }
FILE *f = fopen(tmp, "wb");
if(!f){ log_msg("updater: cannot write %s", tmp); return -1; }
int ok = (fwrite(data, 1, n, f) == n);
if(fflush(f) != 0) ok = 0;
if(ok){ int fd = fileno(f); if(fd >= 0 && fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
if(!ok){ remove(tmp); return -1; }
/* Atomic verified activation: validates <target>.new, backs up live
* to .bak, activates, re-verifies, auto-restores .bak on failure.
* A refused stage leaves the live target untouched (ORX-UPDATE-003). */
if(health_stage_activate(target) != 0){
log_msg("updater: stage activate failed for %s; live kept", target);
return -1;
}
return 0;
}
/* Download a release asset by exact name. Returns heap body or NULL. */
static char *fetch_asset(const jl_val_t *assets, const char *want_name,
size_t cap, int *status, size_t *out_len){
for(size_t i = 0; ; i++){
const jl_val_t *a = jl_arr_at(assets, i);
if(!a) break;
const jl_val_t *nm = jl_obj_get(a, "name");
const jl_val_t *dl = jl_obj_get(a, "browser_download_url");
if(!nm || nm->type != JL_STRING || !dl || dl->type != JL_STRING) continue;
if(strcmp(nm->str, want_name) != 0) continue;
const char *url = dl->str;
if(strncmp(url, "https://", 8) != 0) return NULL;
const char *h0 = url + 8;
const char *p0 = strchr(h0, '/');
if(!p0 || (size_t)(p0 - h0) >= 128) return NULL;
char host[128];
memcpy(host, h0, (size_t)(p0 - h0)); host[p0 - h0] = 0;
return https_get(host, p0, cap, status, out_len);
}
return NULL;
}
/* Decode manifest.sig: raw 64 bytes, or 128 hex chars. 0 ok. */
static int decode_sig(const char *body, size_t len, unsigned char out[64]){
if(len == 64){ memcpy(out, body, 64); return 0; }
/* strip whitespace for hex form; exactly 128 hex chars required —
* trailing garbage after the 128 fails closed */
char hex[129];
size_t hn = 0, total = 0;
for(size_t i = 0; i < len; i++){
char c = body[i];
if(c == ' ' || c == '\t' || c == '\r' || c == '\n') continue;
total++;
if(hn < 128) hex[hn++] = c;
}
if(hn != 128 || total != 128) return -1;
for(int i = 0; i < 64; i++){
unsigned v = 0;
for(int k = 0; k < 2; k++){
char c = hex[2 * i + k];
v <<= 4;
if(c >= '0' && c <= '9') v |= (unsigned)(c - '0');
else if(c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10);
else if(c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10);
else return -1;
}
out[i] = (unsigned char)v;
}
return 0;
}
int updater_check_and_stage(void){
char path[160];
snprintf(path, sizeof path, "/repos/%s/releases/latest", ORBISRPC_REPO);
size_t rl = 0;
int status = 0;
char *js = https_get(UPD_HOST, path, 65536, &status, &rl);
if(!js){ log_msg("updater: release check failed (status=%d)", status); return -1; }
jl_val_t *r = jl_parse(js, rl);
free(js);
if(!r){ log_msg("updater: release parse failed"); return -1; }
const jl_val_t *tag = jl_obj_get(r, "tag_name");
const jl_val_t *assets = jl_obj_get(r, "assets");
int updated = 0;
if(tag && tag->type == JL_STRING && tag->str[0] &&
updater_cmp(tag->str, ORBISRPC_VERSION) > 0){
log_msg("updater: %s available (local %s)", tag->str, ORBISRPC_VERSION);
if(assets && assets->type == JL_ARRAY){
/* Preferred: signed manifest (manifest.json + manifest.sig).
* Verified with the embedded release pubkey; every binary must
* match its listed SHA256. A bad signature refuses the whole
* update (ORX-UPDATE-002). */
manifest_t mf;
int have_manifest = 0;
size_t ml = 0;
char *mbody = fetch_asset(assets, "manifest.json", 65536, &status, &ml);
if(mbody){
size_t sl = 0;
char *sbody = fetch_asset(assets, "manifest.sig", 4096, &status, &sl);
if(sbody && manifest_parse(mbody, ml, &mf) == 0 &&
manifest_gate(&mf) && manifest_is_newer(&mf, ORBISRPC_VERSION)){
unsigned char sig[64];
if(decode_sig(sbody, sl, sig) == 0 &&
manifest_verify_sig((unsigned char*)mbody, ml, sig,
ORBISRPC_RELEASE_PUBKEY) == 0){
have_manifest = 1;
log_msg("updater: manifest %s verified (key %s)",
mf.version, ORBISRPC_RELEASE_KEY_ID);
} else {
log_msg("updater: WARN ORX-UPDATE-002: manifest signature invalid; refusing update");
}
} else if(sbody){
log_msg("updater: WARN ORX-UPDATE-002: manifest invalid/gated; refusing update");
}
free(sbody);
if(!have_manifest){ free(mbody); mbody = NULL; }
}
/* Refuse unsigned updates outright. SHA256SUMS comes from the
* same release as the binaries, so it pins nothing against
* release-asset compromise — exactly what the signed manifest
* defends against (ORX-UPDATE-002). */
if(!have_manifest){
log_msg("updater: no valid signed manifest; refusing update (ORX-UPDATE-002)");
jl_free(r);
return 0;
}
/* Two-phase commit: download + verify the asset first, then
* activate it. A failure stages nothing — rollback restores the runtime. */
struct { const char *target; char *bin; size_t len; } pend[1];
memset(pend, 0, sizeof pend);
int pend_n = 0, pend_fail = 0;
for(size_t i = 0; ; i++){
const jl_val_t *a = jl_arr_at(assets, i);
if(!a) break;
const jl_val_t *nm = jl_obj_get(a, "name");
const jl_val_t *dl = jl_obj_get(a, "browser_download_url");
if(!nm || nm->type != JL_STRING || !dl || dl->type != JL_STRING) continue;
const char *target = NULL;
if(!strcmp(nm->str, "orbisrpc.bin")) target = "/data/payloads/orbisrpc.bin";
else continue;
/* download URLs must be https (fail closed on http/other). */
const char *url = dl->str;
if(strncmp(url, "https://", 8) != 0){ pend_fail = 1; break; }
const char *h0 = url + 8;
const char *p0 = strchr(h0, '/');
if(!p0 || (size_t)(p0-h0) >= 128){ pend_fail = 1; break; }
char host[128];
memcpy(host, h0, (size_t)(p0-h0)); host[p0-h0] = 0;
size_t al = 0;
char *bin = https_get(host, p0, UPD_BODY_MAX, &status, &al);
if(!bin || !updater_image_ok((unsigned char*)bin, al)){
log_msg("updater: asset %s failed validation", nm->str);
free(bin);
pend_fail = 1;
break;
}
if(manifest_check(&mf, nm->str, (unsigned char*)bin, al) != 0){
log_msg("updater: asset %s not in manifest or hash mismatch; refusing (ORX-UPDATE-002)", nm->str);
free(bin);
pend_fail = 1;
break;
}
if(pend_n < 2){
pend[pend_n].target = target;
pend[pend_n].bin = bin;
pend[pend_n].len = al;
pend_n++;
} else {
free(bin); /* more binaries than we stage; ignore extras */
}
}
if(!pend_fail && pend_n > 0){
for(int pi = 0; pi < pend_n; pi++){
if(stage_file(pend[pi].target,
(unsigned char*)pend[pi].bin, pend[pi].len) == 0){
log_msg("updater: staged %s (%zu bytes)",
pend[pi].target, pend[pi].len);
updated = 1;
}
}
} else if(pend_fail){
log_msg("updater: incomplete set; staged nothing (versions stay matched)");
}
for(int pi = 0; pi < pend_n; pi++) free(pend[pi].bin);
free(mbody);
}
}
jl_free(r);
return updated ? 1 : 0;
}
+5 -5
View File
@@ -1,7 +1,4 @@
/* updater.h - version compare + staged-image validation helpers.
* Network self-update was REMOVED (updates ship via reinstall PKG);
* the remaining routines are pure/host-testable and back local
* integrity checks (health rollback) and the installer. */
/* updater.h - self-updater: version compare, staged install. */
#ifndef UPDATER_H
#define UPDATER_H
#include <stddef.h>
@@ -10,6 +7,9 @@
int updater_cmp(const char *a, const char *b);
/* Validate a downloaded payload: ELF magic, 64-bit, x86-64, sane size. */
int updater_elf_ok(const unsigned char *buf, size_t n);
/* Accepts raw ELF (payload .bin) or signed SELF (plugin .prx). */
int updater_image_ok(const unsigned char *buf, size_t n);
/* Check latest GitHub release; download + atomically stage newer
* artifacts the daemon actually runs from. Returns 1 updated,
* 0 already current, -1 failed/checked-off. Never fatal. */
int updater_check_and_stage(void);
#endif
+1 -10
View File
@@ -29,15 +29,6 @@ int updater_elf_ok(const unsigned char *buf, size_t n){
return 1;
}
/* SELF (signed container) check: plugin PRXs ship as SELF, not raw ELF. */
static int updater_self_ok(const unsigned char *buf, size_t n){
if(!buf || n < 64) return 0;
if(buf[0] != 0x4f || buf[1] != 0x15 || buf[2] != 0x3d || buf[3] != 0x1d)
return 0;
if(n > 8u*1024u*1024u) return 0;
return 1;
}
int updater_image_ok(const unsigned char *buf, size_t n){
return updater_elf_ok(buf, n) || updater_self_ok(buf, n);
return updater_elf_ok(buf, n);
}
+3 -3
View File
@@ -1,8 +1,8 @@
/* version.h - single source of truth for the release version.
* Bump on every GitHub release; status.json/diag.json report this
* so support can tell which build a log came from. */
* Bump on every GitHub release; the on-console updater compares this
* against the latest release tag. */
#ifndef ORBISRPC_VERSION_H
#define ORBISRPC_VERSION_H
#define ORBISRPC_VERSION "1.1.0"
#define ORBISRPC_VERSION "1.0.0"
#define ORBISRPC_REPO "SirHumza/orbisRPC"
#endif
+1 -1
View File
@@ -189,7 +189,7 @@ int ws_connect(ws_t *w, const char *host, int port, const char *resource, const
(unsigned char)sa.sa_data[2], (unsigned char)sa.sa_data[3],
(unsigned char)sa.sa_data[4], (unsigned char)sa.sa_data[5], port);
/* Bound the blocking connect: 10s send timeout so a dead route can't
* hang the daemon thread forever (plugin_unload joins this thread). */
* hang the daemon thread forever. */
{
struct timeval tv = { .tv_sec = 10, .tv_usec = 0 };
sceNetSetsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
-88
View File
@@ -1,88 +0,0 @@
# orbisRPC GoldHEN plugin build (plugin/).
# Builds plugin.prx — a GoldHEN plugin that runs the orbisRPC daemon inside the
# game process, so presence auto-starts at every boot with no PC involvement.
DEBUG_FLAGS = -D__FINAL__=1
TYPE = final
OUTPUT_PRX := orbisrpc_plugin
BUILD_DIR := build
TARGET := $(BUILD_DIR)/$(OUTPUT_PRX).prx
TARGET_ELF := $(BUILD_DIR)/$(OUTPUT_PRX).elf
# Root vars
TOOLCHAIN := $(or $(OO_PS4_TOOLCHAIN),/Users/mac/PS4Toolchain/OpenOrbis/PS4Toolchain)
GH_SDK := $(or $(GOLDHEN_SDK),/Users/mac/PS4Toolchain/GoldHEN_Plugins_SDK)
ROOT := ..
SRCDIR := .
ORBISRPC := $(ROOT)/orbisrpc
INTDIR := $(BUILD_DIR)
# Our daemon sources (payload main.c is NOT part of the plugin; plugin.c is the entry)
DAEMON_SRC := $(ORBISRPC)/log.c $(ORBISRPC)/cfg.c $(ORBISRPC)/jsonlite.c $(ORBISRPC)/b64.c \
$(ORBISRPC)/sfo.c $(ORBISRPC)/tmdb_crypto.c $(ORBISRPC)/tmdb.c \
$(ORBISRPC)/updater.c $(ORBISRPC)/updater_http.c $(ORBISRPC)/updater_util.c \
$(ORBISRPC)/tls.c $(ORBISRPC)/ws.c $(ORBISRPC)/discord.c $(ORBISRPC)/detect.c \
$(ORBISRPC)/compat.c $(ORBISRPC)/lock.c $(ORBISRPC)/timesync.c \
$(ORBISRPC)/art.c $(ORBISRPC)/health.c $(ORBISRPC)/manifest.c \
$(ORBISRPC)/daemon.c
PLUGIN_SRC := $(SRCDIR)/plugin.c
CFILES := $(PLUGIN_SRC) $(DAEMON_SRC)
OBJS := $(patsubst %.c,$(INTDIR)/%.o,$(notdir $(CFILES)))
MBED_SRC := $(shell find $(ROOT)/third_party/mbedtls/library -name '*.c' ! -name 'net_sockets.c' ! -name 'timing.c' ! -name 'entropy_poll.c' | sort)
MBED_OBJS := $(patsubst $(ROOT)/third_party/mbedtls/library/%.c,$(INTDIR)/mbed/%.o,$(MBED_SRC))
LIBS := -lSceLibcInternal -lSceNet -lSceNetCtl \
-lSceSysmodule -lSceUserService -lSceAppInstUtil -lSceAppContent \
-lGoldHEN_Hook -lkernel
EXTRAFLAGS := $(DEBUG_FLAGS) -std=gnu11 -Wall -Wno-unused \
-Wno-int-conversion -Wno-incompatible-pointer-types \
-Wno-address-of-packed-member \
-DMBEDTLS_NO_PLATFORM_ENTROPY
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -c $(EXTRAFLAGS) \
-isysroot $(TOOLCHAIN) -isystem $(TOOLCHAIN)/include \
-I$(GH_SDK)/include -I$(GH_SDK) -I$(ORBISRPC) -I$(SRCDIR) \
-I$(ROOT)/third_party/mbedtls/include
LDFLAGS := -m elf_x86_64 -pie --script $(TOOLCHAIN)/link.x -e _init --eh-frame-hdr \
-L$(TOOLCHAIN)/lib -L$(GH_SDK) $(LIBS)
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
CC := $(or $(LLVM_CC),/usr/local/opt/llvm/bin/clang)
LD := $(or $(LLD),/Users/mac/lldbuild/build/bin/ld.lld)
CDIR := macos
FSUFFIX := -macos
else
CC := clang
LD := ld.lld
CDIR := linux
FSUFFIX :=
endif
_unused := $(shell mkdir -p $(INTDIR))
$(INTDIR)/%.o: $(ORBISRPC)/%.c
$(CC) $(CFLAGS) -o $@ $<
$(INTDIR)/plugin.o: $(SRCDIR)/plugin.c
$(CC) $(CFLAGS) -o $@ $<
$(INTDIR)/mbed/%.o: $(ROOT)/third_party/mbedtls/library/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -o $@ $<
$(TARGET): $(OBJS) $(MBED_OBJS)
$(LD) $(GH_SDK)/build/crtprx.o $(OBJS) $(MBED_OBJS) -o $(TARGET_ELF) $(LDFLAGS)
OO_PS4_TOOLCHAIN=$(TOOLCHAIN) $(TOOLCHAIN)/bin/$(CDIR)/create-fself$(FSUFFIX) -in=$(TARGET_ELF) -out=$(TARGET_ELF).oelf \
--lib=$(TARGET) --paid 0x3800000000000011
.PHONY: clean
.DEFAULT_GOAL := all
all: $(TARGET)
@echo "plugin -> $(TARGET)"
clean:
rm -rf $(BUILD_DIR)
-119
View File
@@ -1,119 +0,0 @@
/* plugin.c - orbisRPC as a GoldHEN plugin (.prx loaded into the game process).
*
* GoldHEN loads plugins per game title via /data/GoldHEN/plugins.ini. A
* plugin runs inside the game's process, so:
* - we KNOW which game is running: sys_sdk_proc_info() -> procInfo.titleid
* - the game has full network access (this is the whole point)
* - it auto-starts at every boot/jailbreak with zero PC involvement
*
* plugin_load() resolves the titleid to a display name and starts the shared
* daemon thread (daemon.c) which handles token refresh, the Discord gateway
* and presence. plugin_unload() stops it cleanly.
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <orbis/libkernel.h>
#include "Common.h"
#include "daemon.h"
#include "detect.h"
/* Only post presence for real game titles (CUSA/PPSA/etc). System apps
* (NPXS..., shell) launching would otherwise post bogus "playing" state. */
static int is_game_titleid(const char *t){
static const char *pfx[] = { "CUSA", "PPSA", "PCSE", "PCJS", "EPSA", "PCSB", NULL };
for(int i=0; pfx[i]; i++) if(strncmp(t, pfx[i], 4)==0) return 1;
return 0;
}
#define attr_public __attribute__((visibility("default")))
#define attr_module_hidden __attribute__((weak)) __attribute__((visibility("hidden")))
attr_public const char *g_pluginName = "orbisrpc";
attr_public const char *g_pluginDesc = "Discord Rich Presence for PS4";
attr_public const char *g_pluginAuth = "orbisRPC";
attr_public uint32_t g_pluginVersion = 0x00000100; /* 1.00 */
struct proc_info procInfo;
static OrbisPthread s_daemon_thread;
static int s_daemon_started = 0;
static void *daemon_thread(void *args){
/* args = display name of the game we are loaded into */
char name[128];
strncpy(name, (const char *)args, sizeof name - 1);
name[sizeof name - 1] = 0;
FILE *m = fopen("/data/orbisRPC/plugin_boot.log", "a");
if(m){ fprintf(m, "daemon thread up game=%s\n", name); fclose(m); }
daemon_clear_stop();
daemon_run(name);
return NULL;
}
int32_t attr_public plugin_load(int32_t argc, const char *argv[]){
(void)argc; (void)argv;
klog("[orbisrpc] <%s\\Ver.0x%08x> %s\n", g_pluginName, g_pluginVersion, __func__);
klog("[orbisrpc] Plugin Author(s): %s\n", g_pluginAuth);
{ FILE *m = fopen("/data/orbisRPC/plugin_boot.log", "a");
if(m){ fprintf(m, "plugin_load enter\n"); fclose(m); } }
if(sys_sdk_proc_info(&procInfo) != 0){
klog("[orbisrpc] sys_sdk_proc_info failed; not starting\n");
return 0;
}
/* Validate SDK struct: must be NUL-terminated ASCII, else version mismatch */
procInfo.titleid[sizeof procInfo.titleid - 1] = 0;
procInfo.name[sizeof procInfo.name - 1] = 0;
size_t tlen = strnlen(procInfo.titleid, sizeof procInfo.titleid);
if(tlen != 9 || !is_game_titleid(procInfo.titleid)){
klog("[orbisrpc] not a game title (system app?); not starting daemon\n");
return 0;
}
klog("[orbisrpc] loaded into process: pid=%d name=%s titleid=%s\n",
procInfo.pid, procInfo.name, procInfo.titleid);
/* Resolve a display name for the title we are loaded into. */
static char game_name[128];
detect_name_for_title(procInfo.titleid, game_name, sizeof game_name);
klog("[orbisrpc] game: %s\n", game_name);
/* explicit fat stack: TLS handshakes + JSON buffers overflow the
* small default plugin-thread stacks. 256KB is mandatory — falling
* back to 64KB crashes inside games, so refuse to start instead. */
OrbisPthreadAttr attr;
memset(&attr, 0, sizeof attr);
if(scePthreadAttrInit(&attr) != 0 ||
scePthreadAttrSetstacksize(&attr, 256*1024) != 0){
klog("[orbisrpc] attr/stack setup failed; NOT starting (no fallback)\n");
return 0;
}
if(scePthreadCreate(&s_daemon_thread, &attr, daemon_thread, game_name, "orbisrpc_daemon") == 0)
s_daemon_started = 1;
else
klog("[orbisrpc] daemon thread creation failed\\n");
return 0;
}
int32_t attr_public plugin_unload(int32_t argc, const char *argv[]){
(void)argc; (void)argv;
klog("[orbisrpc] <%s\\Ver.0x%08x> %s\n", g_pluginName, g_pluginVersion, __func__);
daemon_request_stop();
if(s_daemon_started){
int rc = scePthreadJoin(s_daemon_thread, NULL);
klog("[orbisrpc] daemon thread joined rc=%d\\n", rc);
s_daemon_started = 0;
}
return 0;
}
int32_t attr_module_hidden module_start(int64_t argc, const void *args){
(void)argc; (void)args;
return 0;
}
int32_t attr_module_hidden module_stop(int64_t argc, const void *args){
(void)argc; (void)args;
return 0;
}
-25
View File
@@ -1,25 +0,0 @@
; GoldHEN plugins.ini — GTA V mod menu + orbisRPC Discord presence
;
; [default] loads plugins for ANY game title, so orbisRPC auto-starts
; whenever a game launches (no PC needed).
;
[default]
/data/GoldHEN/plugins/orbisrpc_plugin.prx=true
; Lotus SPRX (primary mod menu — requires GTA V 1.48 update)
; Press R1 + Square in-game to open
;
[CUSA00411]
/data/GoldHEN/plugins/gtaPayload_148.sprx=true
[CUSA00419]
/data/GoldHEN/plugins/gtaPayload_148.sprx=true
; Scorpion Menu v1.2B (alternate mod menu — backup option)
; Uncomment these lines if you prefer Scorpion over Lotus:
;
;[CUSA00411]
;/data/GoldHEN/plugins/ScorpionMenu-v1.2b-148.prx=true
;
;[CUSA00419]
;/data/GoldHEN/plugins/ScorpionMenu-v1.2b-148.prx=true
+5 -7
View File
@@ -41,13 +41,13 @@ CFLAGS="--target=$TARGET -fPIC -std=gnu11 -Wall -Wno-unused \
-Wno-int-conversion -Wno-incompatible-pointer-types \
-DMBEDTLS_NO_PLATFORM_ENTROPY \
-isystem $SDK/include -Ithird_party/mbedtls/include"
LIBS="-lc -lkernel -lSceNet -lSceSysmodule \
-lSceUserService"
LIBS="-lc -lkernel -lSceNet -lSceNetCtl -lSceSysmodule \
-lSceUserService -lSceAppInstUtil -lSceAppContent"
LDFLAGS="-m elf_x86_64 -pie --eh-frame-hdr -L$SDK/lib $LIBS $SDK/lib/crt1.o --script $SDK/link.x"
export OO_PS4_TOOLCHAIN="$SDK"
OUT="$ROOT/build"; mkdir -p "$OUT"
echo "=== compiling (CC=$CC LD=$LD SDK=$SDK) ==="
for f in log cfg jsonlite b64 sfo tmdb_crypto tmdb updater_http updater_util tls ws detect focus fw discord daemon compat lock timesync art health manifest main; do
for f in log cfg jsonlite b64 sfo procwalk bigapp fw pkgzone gamecache notify retro tmdb_crypto tmdb updater updater_http updater_util tls ws detect discord daemon compat lock timesync art health manifest main; do
"$CC" $CFLAGS -c -o "$OUT/$f.o" "orbisrpc/$f.c" || fail "compile $f"
done
echo "=== mbedtls (skip net_sockets/timing: POSIX-only) ==="
@@ -61,10 +61,8 @@ echo "ELF -> $OUT/orbisrpc.elf ($(stat -f%z "$OUT/orbisrpc.elf" 2>/dev/null || s
MODE="${1:-all}"
if [ "$MODE" = "fself" ] || [ "$MODE" = "all" ]; then
"$SDK/bin/macos/create-fself-macos" -in="$OUT/orbisrpc.elf" \
-out="$OUT/orbisrpc.fself" --eboot "$OUT/orbisrpc-eboot.bin" --paid 0x3800000000000011 --authinfo 000000000000000000000000001C004000FF000000000080000000000000000000000000000000000000008000400040000000000000008000000000000000080040FFFF000000F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 || fail "fself creation"
-out="$OUT/orbisrpc.fself" --eboot "$OUT/orbisrpc-eboot.bin" --paid 0x3800000000000011 || fail "fself creation"
echo "FSELF -> $OUT/orbisrpc.fself"
echo " (deploy $OUT/orbisrpc.elf -> /data/payloads/orbisrpc.bin for Payload Guest)"
echo " note: GoldHEN's OWN payload slot is /data/payload.bin (a different, single-purpose slot)"; \
echo " run orbisRPC from Payload Guest, not as a GoldHEN payload.bin"
echo " (GoldHEN payload to deploy is $OUT/orbisrpc.elf -> /data/GoldHEN/payloads/orbisrpc.bin)"
fi
echo "done."
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# build_evict.sh - evict.elf via ps4-payload-sdk (daemon-world linkage only).
# Usage: PS4_PAYLOAD_SDK=/path ./scripts/build_evict.sh
set -e
SDK="${PS4_PAYLOAD_SDK:-$HOME/ps4-payload-sdk/ps4-payload-sdk}"
CC="$SDK/bin/orbis-clang"
export PS4_PAYLOAD_SDK="$SDK"
export PATH="$HOME/llvmshim:$PATH"
OUT="build-sdk"
mkdir -p "$OUT"
if [ ! -x "$CC" ]; then
echo "FAIL: $CC not found. Build the SDK from git first:"
echo " ./scripts/build_sdk_from_source.sh"
exit 1
fi
echo "=== evict (SDK) ==="
# evict.c shares orbisrpc/procwalk.c with the daemon so the kinfo_proc record
# walk stays offset-free in one place. A private second copy in evict.c is
# exactly how the 9.00-only +447/+72 offsets survived there in the first place.
"$CC" -O2 -Wall -DORBISRPC_SDK_PAYLOAD -Iorbisrpc -c -o "$OUT/procwalk_evict.o" orbisrpc/procwalk.c \
|| { echo "FAIL: procwalk"; exit 1; }
"$CC" -O2 -Wall -DORBISRPC_SDK_PAYLOAD -Iorbisrpc -o "$OUT/evict.elf" \
tools/evict.c "$OUT/procwalk_evict.o" || { echo "FAIL: evict"; exit 1; }
ls -la "$OUT/evict.elf"
+46 -7
View File
@@ -1,14 +1,49 @@
#!/bin/sh
# build_sdk.sh - OrbisRPC daemon payload via ps4-payload-sdk (daemon-world
# linkage: no libkernel.so, BSD sockets, SDK libc). Test vehicle: elfldr.
# Usage: PS4_PAYLOAD_SDK=/path ./scripts/build_sdk.sh
#
# Usage:
# PS4_PAYLOAD_SDK=/path ./scripts/build_sdk.sh
#
# The SDK must be built from git, not downloaded from the release ZIP: see
# scripts/build_sdk_from_source.sh for why. Set PS4_SDK_SRC to that checkout to
# have the firmware gate below verify the SDK actually supports 13.52 instead of
# trusting it.
set -e
SDK="${PS4_PAYLOAD_SDK:-$HOME/ps4-payload-sdk/ps4-payload-sdk}"
[ -x "$SDK/toolchain/../../ps4-payload-sdk/bin/orbis-clang" ] || {
# resolve relative to SDK root regardless of layout
true
}
CC="$SDK/bin/orbis-clang"
if [ ! -x "$CC" ]; then
echo "FAIL: $CC not found."
echo " Build the SDK from git first: ./scripts/build_sdk_from_source.sh"
echo " (the release ZIP is v0.9, which predates the 13.52 offsets)"
exit 1
fi
# Gate: the SDK's crt/patch.c lists supported firmware and __patch_init() errors
# out for anything absent, which makes _start() call payload_terminate() before
# main() -- silent death, no log line. v0.9 lists 13.50 but not 13.52.
# Checked against source, not the linked ELF: a 4-byte offset pattern occurs by
# chance in a 2 MB binary, so a byte scan would prove nothing.
if [ -n "${PS4_SDK_SRC:-}" ] && [ -f "$PS4_SDK_SRC/crt/patch.c" ]; then
echo "=== SDK firmware gate ==="
ok=1
for fw in 1350 1352; do
if grep -q "case 0x${fw}:" "$PS4_SDK_SRC/crt/patch.c"; then
echo " ok crt/patch.c case 0x${fw}"
else
echo " FAIL crt/patch.c missing case 0x${fw}"
ok=0
fi
done
[ "$ok" -eq 1 ] || {
echo "FAIL: SDK at $PS4_SDK_SRC cannot boot 13.52; payload would die before main()."
exit 1
}
else
echo "note: PS4_SDK_SRC not set, skipping the 13.52 SDK firmware gate."
echo " If the payload is silent on 13.52, the SDK is the first suspect."
fi
export PS4_PAYLOAD_SDK="$SDK"
export PATH="$HOME/llvmshim:$PATH"
OUT="build-sdk"
@@ -16,7 +51,7 @@ mkdir -p "$OUT"
CFLAGS="-O2 -Wall -DORBISRPC_SDK_PAYLOAD -Iorbisrpc -Ithird_party/mbedtls/include -Ithird_party/sqlite/sqlite-amalgamation-3510100"
SQLITE_DIR="third_party/sqlite/sqlite-amalgamation-3510100"
echo "=== daemon sources (SDK) ==="
for f in log cfg jsonlite b64 sfo tmdb_crypto tmdb updater_http updater_util tls ws detect focus fw discord daemon compat lock timesync art health manifest appdb main; do
for f in log cfg jsonlite b64 sfo procwalk bigapp fw pkgzone gamecache notify retro tmdb_crypto tmdb updater updater_http updater_util tls ws detect discord daemon compat lock timesync art health manifest appdb main; do
# clock has no .c (header-only helper lives in compat.c); skip if missing
[ -f "orbisrpc/$f.c" ] || continue
"$CC" $CFLAGS -c -o "$OUT/$f.o" "orbisrpc/$f.c" || { echo "FAIL: $f"; exit 1; }
@@ -29,5 +64,9 @@ for f in $(find third_party/mbedtls/library -name '*.c' ! -name 'net_sockets.c'
"$CC" $CFLAGS -Wno-unused-parameter -c -o "$o" "$f" || { echo "FAIL: mbedtls $f"; exit 1; }
done
echo "=== link ==="
"$CC" -o "$OUT/orbisrpc_sdk.elf" "$OUT"/*.o || { echo "FAIL: link"; exit 1; }
# libSceSystemService supplies sceSystemServiceGetAppIdOfBigApp and
# sceLncUtilGetAppTitleId, which are the daemon's entire foreground signal.
# Dropping it makes the payload fail at load time, which is why the CI gate
# asserts it is present rather than trusting this line.
"$CC" -o "$OUT/orbisrpc_sdk.elf" "$OUT"/*.o -lSceSystemService || { echo "FAIL: link"; exit 1; }
ls -la "$OUT/orbisrpc_sdk.elf"
+94
View File
@@ -0,0 +1,94 @@
#!/bin/sh
# build_sdk_from_source.sh - build the ps4-payload-dev SDK from git and install
# it to a DESTDIR, instead of downloading the release ZIP.
#
# WHY THIS EXISTS
#
# The SDK's crt/patch.c holds a per-firmware table, and __patch_init() returns
# an error for any firmware not listed in it:
#
# default:
# klog_printf("Unsupported firmware %x\n", fw);
#
# crt/crt.c propagates that error out of _start():
#
# if((err=payload_init())) {
# return payload_terminate(err); // never reaches main(), no log line
# }
#
# crt/kernel.c has the same shape: unlisted firmware falls through to a
# kexec_find_* signature scan for the kernel image base, copyin/copyout and
# targetid, and rtld.c then depends on addresses __kernel_init() derived.
#
# The released SDK (v0.9, 2026-05-12) lists 13.50 (0x1350) but NOT 13.52
# (0x1352), so a payload linked against the release ZIP cannot boot on 13.52 --
# it dies before main() with no diagnostics. 13.52 offsets landed on the SDK's
# master branch in 959e4ed (2026-06-25) and have never been in a release.
#
# This script therefore builds from git. Recipe taken from drakmor/nanoDNS's
# .github/workflows/ps4.yml, which targets the same elfldr path and ships a
# working 13.52 payload.
#
# Usage:
# PS4_SDK_REF=573b4a0 ./scripts/build_sdk_from_source.sh
# PS4_SDK_REF=573b4a0 PS4_SDK_DEST=~/ps4-payload-sdk ./scripts/build_sdk_from_source.sh
#
# Then build the daemon against it:
# PS4_PAYLOAD_SDK=~/ps4-payload-sdk ./scripts/build_sdk.sh
set -e
# Pinned to the SDK commit that carries 13.52 and 14.00 offsets. Bump when the
# SDK lands a release that includes them, then this script can be retired.
REF="${PS4_SDK_REF:-573b4a0}"
DEST="${PS4_SDK_DEST:-$HOME/ps4-payload-sdk}"
SRC="${PS4_SDK_SRC:-$HOME/ps4-payload-sdk-src}"
command -v git >/dev/null 2>&1 || { echo "FAIL: git not found"; exit 1; }
command -v llvm-config-18 >/dev/null 2>&1 || command -v llvm-config >/dev/null 2>&1 || {
echo "FAIL: llvm-config not found. Install clang-18 + lld-18."; exit 1; }
mkdir -p "$SRC"
if [ -d "$SRC/.git" ]; then
echo "=== fetching SDK $REF ==="
git -C "$SRC" fetch -q origin
else
echo "=== cloning SDK ==="
git clone -q https://github.com/ps4-payload-dev/sdk.git "$SRC"
fi
git -C "$SRC" checkout -q "$REF"
echo "SDK at $(git -C "$SRC" rev-parse --short HEAD) ($(git -C "$SRC" log -1 --format=%s))"
# Fail loudly and early rather than shipping a payload that cannot boot. This
# is a source-level check on purpose: the installed SDK is a bare tree with no
# crt/ sources, and byte-scanning the linked ELF for a firmware offset is not
# reliable (a 4-byte pattern occurs by chance in a 2 MB binary).
echo "=== verifying firmware support in SDK source ==="
missing=""
for fw in 1350 1352; do
if grep -q "case 0x${fw}:" "$SRC/crt/patch.c" 2>/dev/null; then
echo " crt/patch.c has case 0x${fw}"
else
echo " crt/patch.c MISSING case 0x${fw}"
missing="$missing patch:0x$fw"
fi
done
if [ -n "$missing" ]; then
echo "FAIL: SDK $REF lacks 13.52 offsets ($missing)."
echo " Payloads built against it die before main() with no log."
echo " Bump PS4_SDK_REF to a newer SDK commit."
exit 1
fi
echo "=== building SDK -> $DEST ==="
make -C "$SRC" DESTDIR="$DEST" clean >/dev/null 2>&1 || true
make -C "$SRC" DESTDIR="$DEST" install >/dev/null
for f in bin/orbis-clang target/lib/crt1.o toolchain/orbis.mk; do
[ -e "$DEST/$f" ] || { echo "FAIL: $DEST/$f missing after install"; exit 1; }
done
echo
echo "SDK installed at $DEST"
echo "Next:"
echo " PS4_PAYLOAD_SDK=$DEST ./scripts/build_sdk.sh"
echo " PS4_SDK_SRC=$SRC ./scripts/build_sdk.sh # enables the firmware gate"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""One-off downscale of a notification icon to 512x512.
Notification icons are capped around 512x512; anything larger is rejected and
the notification falls back to the default system icon. This is a tool for
preparing a source icon, NOT a build step -- gen_icon_header.py embeds whatever
PNG it is pointed at and only warns if that PNG is oversized, so the operator
owns the resolution from here on.
python3 scripts/fit_icon.py config/icons/logo.png [out.png] [size]
"""
import sys, os
try:
from PIL import Image
except ImportError:
sys.exit('Pillow is required for this one-off: pip install Pillow')
SRC = sys.argv[1] if len(sys.argv) > 1 else 'config/icons/logo.png'
OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.splitext(SRC)[0] + '.notify.png'
SIZE = int(sys.argv[3]) if len(sys.argv) > 3 else 512
im = Image.open(SRC)
before = im.size
# Fit inside SIZE x SIZE without cropping, preserving aspect. Transparent
# logos stay RGBA; opaque ones become palette PNGs, which are far smaller.
if im.mode in ('RGBA', 'LA'):
im = im.convert('RGBA')
else:
im = im.convert('RGB')
im.thumbnail((SIZE, SIZE), Image.LANCZOS)
if im.mode == 'RGBA':
im.save(OUT, optimize=True)
else:
im.convert('P', palette=Image.ADAPTIVE, colors=256).save(OUT, optimize=True)
after = Image.open(OUT).size
print('%s %dx%d %d bytes' % (SRC, before[0], before[1], os.path.getsize(SRC)))
print('%s %dx%d %d bytes' % (OUT, after[0], after[1], os.path.getsize(OUT)))
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Generate orbisrpc/icon_embedded.h from a PNG, for the notification icon.
python3 scripts/gen_icon_header.py [png] [out]
sceKernelSendNotificationRequest's icon_uri points at a file on disk, not at
payload memory, so the bytes have to be written out at runtime. That makes an
embedded copy the only way to show a custom icon without shipping the PNG
separately.
The PNG is embedded exactly as given -- no resizing here. Notification icons
are capped around 512x512 and the service silently falls back to the default
icon when given something larger, so this only warns. Use scripts/fit_icon.py
to prepare a source icon.
"""
import sys, os, struct
SRC = sys.argv[1] if len(sys.argv) > 1 else 'config/icons/logo.notify.png'
OUT = sys.argv[2] if len(sys.argv) > 2 else 'orbisrpc/icon_embedded.h'
MAX_ICON = 512
data = open(SRC, 'rb').read()
if data[:8] != b'\x89PNG\r\n\x1a\n':
sys.exit('%s is not a PNG (bad magic)' % SRC)
w, h = struct.unpack('>II', data[16:24])
if w > MAX_ICON or h > MAX_ICON:
print('WARNING: %s is %dx%d; notifications reject anything over %d '
'and will show the default icon.' % (SRC, w, h, MAX_ICON))
print(' Run scripts/fit_icon.py %s to produce a fitted copy.'
% os.path.basename(SRC))
else:
print('%s is %dx%d (within the %d limit)' % (SRC, w, h, MAX_ICON))
L = []
L.append('/* icon_embedded.h - GENERATED by scripts/gen_icon_header.py. Do not edit.\n'
' *\n'
' * Source: %s (%d bytes)\n'
' *\n'
' * Embedded so the payload can drop the notification icon on disk at\n'
' * first boot; sceKernelSendNotificationRequest takes a path, not a\n'
' * memory blob.\n'
' */\n' % (SRC, len(data)))
L.append('#ifndef ORBISRPC_ICON_EMBEDDED_H\n#define ORBISRPC_ICON_EMBEDDED_H\n')
L.append('#include <stddef.h>\n')
L.append('static const unsigned char orbisrpc_notify_icon_png[] = {\n')
for i in range(0, len(data), 16):
L.append(' ' + ''.join('0x%02x,' % b for b in data[i:i+16]) + '\n')
L.append('};\n')
L.append('#define ORBISRPC_NOTIFY_ICON_SIZE %d\n' % len(data))
L.append('#endif /* ORBISRPC_ICON_EMBEDDED_H */\n')
open(OUT, 'w').write(''.join(L))
print('%s -> %s (%d bytes of PNG)' % (SRC, OUT, len(data)))
+14
View File
@@ -15,6 +15,14 @@ WANT = [
"Sectigo Public Server Authentication Root R46",
"USERTrust ECC Certification Authority",
"USERTrust RSA Certification Authority",
# Sony TMDB serves through a DigiCert TLS RSA4096 chain. Verified against
# the live host 2026-10-03:
# tmdb.np.dl.playstation.net -> DigiCert G5 TLS RSA4096 SHA384 2021 CA1
# -> DigiCert TLS RSA4096 Root G5
# "DigiCert Trusted Root G4" does NOT cover this: G5 is a separate root
# that cross-signs to GlobalSign, so a bundle holding only G4 fails the
# handshake with ORX-TLS-003 and TMDB silently never resolves a name.
"DigiCert TLS RSA4096 Root G5",
"DigiCert Global Root CA",
"DigiCert Global Root G2",
"DigiCert Global Root G3",
@@ -29,6 +37,12 @@ WANT = [
"Baltimore CyberTrust Root",
"Starfield Services Root Certificate Authority - G2",
"GlobalSign Root CA",
# pkg-zone.com (homebrew metadata) chains through Let's Encrypt's new
# generation. Verified against the live host 2026-10-03:
# pkg-zone.com -> Let's Encrypt YE1 -> ISRG Root YE -> ISRG Root X2
# The bundle already had ISRG Root X1, which covers the *previous* LE
# chain but not this one -- another silent ORX-TLS-003.
"ISRG Root X2",
]
-72
View File
@@ -1,72 +0,0 @@
#!/bin/sh
# ps4-check.sh - one command to answer "what is the console actually running?".
# Pulls the evidence files over FTP, lists staged PKGs, and diffs local
# artifacts against what the console has, so support questions resolve
# without a multi-command FTP dance.
#
# Usage: ./scripts/ps4-check.sh [tail_lines] (default 25)
# Env: PS4_HOST (default 192.168.1.136) PS4_PORT (2121)
set -eu
cd "$(dirname "$0")/.."
HOST="${PS4_HOST:-192.168.1.136}"
PORT="${PS4_PORT:-2121}"
TAIL="${1:-25}"
BASE="ftp://$HOST:$PORT"
FTP="curl -sS --connect-timeout 5 --max-time 20"
echo "=== console $HOST:$PORT ==="
# Probe with a file we know exists; fall back to a directory listing, since
# some FTP builds refuse listings but serve files fine.
if $FTP "$BASE/user/data/orbisRPC/install.log" >/dev/null 2>&1 \
|| $FTP "$BASE/user/data/orbisRPC/log.txt" >/dev/null 2>&1 \
|| $FTP "$BASE/user/data/pkg/" >/dev/null 2>&1; then
:
else
echo "UNREACHABLE (jailbreak must be active + console awake)"
exit 1
fi
# --- evidence: newest run of the installer -------------------------
echo
echo "--- install.log (last $TAIL) ---"
if $FTP "$BASE/user/data/orbisRPC/install.log" 2>/dev/null | tail -n "$TAIL"; then :; else
echo "(missing)"
fi
# --- evidence: daemon log ------------------------------------------
echo
echo "--- log.txt (last $TAIL) ---"
$FTP "$BASE/user/data/orbisRPC/log.txt" 2>/dev/null | tail -n "$TAIL" || echo "(missing)"
# --- liveness / diagnostics (remaster v1.1.0+) ---------------------
for f in status.json diag.json daemon.gen; do
echo
echo "--- $f ---"
$FTP "$BASE/user/data/orbisRPC/$f" 2>/dev/null || echo "(missing = pre-remaster build)"
done
# --- staged PKGs: which one is the current test build? -------------
echo
echo "--- /user/data/pkg/ ---"
$FTP "$BASE/user/data/pkg/" 2>/dev/null | sed -n '1,40p' || echo "(missing)"
# --- local vs remote payload: is the console running OUR build? ---
echo
echo "--- payload: local vs console ---"
LOCAL=""
for c in build/orbisrpc.elf build-sdk/orbisrpc_sdk.elf; do
[ -f "$c" ] && { LOCAL="$c"; break; }
done
if [ -z "$LOCAL" ]; then
echo "no local build yet (run ./scripts/build.sh elf)"
else
lsz=$(wc -c <"$LOCAL" | tr -d ' ')
rsz=$($FTP "$BASE/user/data/payloads/orbisrpc.bin" 2>/dev/null | wc -c | tr -d ' ')
echo "local $LOCAL $lsz"
echo "remote /data/payloads/orbisrpc.bin $rsz"
[ "$lsz" = "$rsz" ] && echo "MATCH (console runs the current payload)" \
|| echo "DIFFERS (payload staged but not launched, or console is older)"
fi
echo
echo "done."
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# release.sh - one-command release: SDK payload -> staged assets -> Setup PKG.
# Usage: ./scripts/release.sh (needs OO_PS4_TOOLCHAIN + PS4_PAYLOAD_SDK)
# Output: OrbisRPC-Setup-<version>.pkg at repo root.
set -eu
cd "$(dirname "$0")/.."
VER="$(sed -n 's/^#define ORBISRPC_VERSION "\(.*\)"/\1/p' orbisrpc/version.h)"
[ -n "$VER" ] || { echo "FAIL: version.h unreadable"; exit 1; }
echo "=== release $VER ==="
./scripts/build_sdk.sh || { echo "FAIL: sdk payload"; exit 1; }
make -f installer/Makefile || { echo "FAIL: pkg"; exit 1; }
PKG="IV0000-ORPC00001_00-ORBISRPCSETUP000.pkg"
[ -f "$PKG" ] || { echo "FAIL: $PKG missing"; exit 1; }
OUT="OrbisRPC-Setup-$VER.pkg"
mv "$PKG" "$OUT"
ls -la "$OUT"
echo "done: $OUT"
+1 -1
View File
@@ -10,7 +10,7 @@ MBEDTLS_DIR := ../third_party/mbedtls/library
# Same exclusion set as scripts/build.sh (POSIX-only modules).
MBEDTLS_SRCS := $(filter-out $(MBEDTLS_DIR)/net_sockets.c $(MBEDTLS_DIR)/timing.c $(MBEDTLS_DIR)/entropy_poll.c,$(wildcard $(MBEDTLS_DIR)/*.c))
ORBIS_SRCS := ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c ../orbisrpc/health.c ../orbisrpc/manifest.c ../orbisrpc/compat.c ../orbisrpc/cfg.c ../orbisrpc/appdb.c ../orbisrpc/discord.c ../orbisrpc/focus.c ../orbisrpc/fw.c
ORBIS_SRCS := ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c ../orbisrpc/health.c ../orbisrpc/manifest.c ../orbisrpc/compat.c ../orbisrpc/cfg.c ../orbisrpc/appdb.c ../orbisrpc/discord.c ../orbisrpc/procwalk.c ../orbisrpc/bigapp.c ../orbisrpc/pkgzone.c ../orbisrpc/gamecache.c ../orbisrpc/fw.c ../orbisrpc/notify.c ../orbisrpc/retro.c
INST_SRCS := ../installer/icfg.c
# SQLite amalgamation: -O0 for host iteration speed (payload uses -O2).
SQLITE_DIR := ../third_party/sqlite/sqlite-amalgamation-3510100
+781 -110
View File
@@ -10,8 +10,14 @@
#include "../orbisrpc/appdb.h"
#include "../orbisrpc/discord.h"
#include "../orbisrpc/detect.h"
#include "../orbisrpc/focus.h"
#include "../orbisrpc/procwalk.h"
#include "../orbisrpc/bigapp.h"
#include "../orbisrpc/retro.h"
#include "../orbisrpc/pkgzone.h"
#include "../orbisrpc/gamecache.h"
#include "../orbisrpc/fw.h"
#include "../orbisrpc/gamecache.h"
#include "../orbisrpc/pkgzone.h"
#include "../installer/icfg.h"
#include "sqlite3.h"
#include <string.h>
@@ -183,13 +189,24 @@ static void test_tmdb(void) {
assert(tmdb_path("junk!", path, sizeof path) != 0);
assert(tmdb_path("CUSA00740", path, 10) != 0);
}
/* response parse: name + icon URL */
/* response parse: name + icon URL.
* Sony's icon CDN still answers in http on some titles; Discord silently
* drops an activity whose artwork is a non-https external URL, so the
* scheme is upgraded rather than the asset being discarded. */
{
const char body[] = "{\"names\":[{\"name\":\"Terraria\"}],\"icons\":[{\"icon\":\"http://x/y/icon0.png\",\"type\":\"512x512\"}]}";
char name[64], icon[128];
assert(tmdb_parse(body, sizeof(body)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(strcmp(name, "Terraria") == 0);
assert(strcmp(icon, "http://x/y/icon0.png") == 0);
assert(strcmp(icon, "https://x/y/icon0.png") == 0);
/* already https: untouched */
const char body3[] = "{\"names\":[{\"name\":\"T\"}],\"icons\":[{\"icon\":\"https://x/y/i.png\"}]}";
assert(tmdb_parse(body3, sizeof(body3)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(strcmp(icon, "https://x/y/i.png") == 0);
/* gs2 CDN form from the reference client */
const char body4[] = "{\"names\":[{\"name\":\"T\"}],\"icons\":[{\"icon\":\"http://gs2-sec.ww.prod.dl.playstation.net/a/b.png\"}]}";
assert(tmdb_parse(body4, sizeof(body4)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(strcmp(icon, "https://gs2-sec.ww.prod.dl.playstation.net/a/b.png") == 0);
/* non-URL icon rejected, name still wins */
const char body2[] = "{\"names\":[{\"name\":\"X\"}],\"icons\":[{\"icon\":\"not a url\"}]}";
assert(tmdb_parse(body2, sizeof(body2)-1, name, sizeof name, icon, sizeof icon) == 0);
@@ -447,7 +464,12 @@ static void test_cfg_titles(void) {
fclose(f);
assert(cfg_load(path, &c) == 0);
assert(c.n_titles == 0);
assert(!strcmp(c.home_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/main/config/icons/logo.png"));
/* home_art is the legacy fallback; large_art is what actually gets used.
* Both default to the operator-hosted idle logo. */
assert(!strcmp(c.home_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png"));
assert(!strcmp(c.large_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png"));
assert(!strcmp(c.small_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-blue.png"));
assert(!strcmp(c.browser_art, "https://retro-games.cybermask.dpdns.org/images/web_browser.png"));
}
static void test_appdb(void) {
@@ -503,13 +525,15 @@ static void test_appdb(void) {
static void test_discord_builder(void) {
/* game presence: name shown, raw ID nowhere visible, hover has the ID */
jl_val_t *a = discord_build_activity("On PS4", "Marvel's Spider-Man",
jl_val_t *a = discord_build_activity("On PS4", "Marvel's Spider-Man",NULL,"ps_logo_blue","Playing on PlayStation 4",
"CUSA11995", "1536977374795538532", "https://x/icons/", NULL, NULL,
1700000000LL, "");
assert(a);
const jl_val_t *v = jl_obj_get(a, "name");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Marvel's Spider-Man"));
assert(jl_obj_get(a, "details") == NULL); /* never the raw ID */
v = jl_obj_get(a, "details");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Playing on PlayStation 4"));
assert(!strstr(v->str, "CUSA")); /* never the raw ID */
v = jl_obj_get(a, "state");
assert(v && v->type == JL_STRING && !strcmp(v->str, "On PS4"));
v = jl_obj_get(a, "type");
@@ -527,7 +551,7 @@ static void test_discord_builder(void) {
assert(jl_obj_get(as, "small_image") == NULL); /* no badge requested */
jl_free(a);
/* system badge: mp: URL used as-is with platform hover text */
a = discord_build_activity("On PS4", "Marvel's Spider-Man",
a = discord_build_activity("On PS4", "Marvel's Spider-Man",NULL,"ps_logo_blue","Playing on PlayStation 4",
"CUSA11995", "1536977374795538532", NULL, NULL, "mp:1/2/logo",
1700000000LL, "");
assert(a);
@@ -539,20 +563,20 @@ static void test_discord_builder(void) {
assert(v && v->type == JL_STRING && !strcmp(v->str, "PlayStation 4"));
jl_free(a);
/* media type mapping survives */
a = discord_build_activity(NULL, "YouTube", "CUSA01015", "app",
a = discord_build_activity(NULL, "YouTube",NULL,"ps_logo_blue","Watching on PlayStation 4", "CUSA01015", "app",
NULL, NULL, NULL, 0, "");
assert(a);
v = jl_obj_get(a, "type");
assert(v && (int)v->num == 0); /* stub maps only CUSA00127 */
jl_free(a);
a = discord_build_activity(NULL, "Netflix", "CUSA00127", "app",
a = discord_build_activity(NULL, "Netflix",NULL,"ps_logo_blue","Watching on PlayStation 4", "CUSA00127", "app",
NULL, NULL, NULL, 0, "");
assert(a);
v = jl_obj_get(a, "type");
assert(v && (int)v->num == 3);
jl_free(a);
/* home + uploaded key: trusted key used as-is */
a = discord_build_activity("On PS4", "PlayStation 4", "home", "app",
a = discord_build_activity("On PS4", "PlayStation 4","ps_logo_full",NULL,"Idling on Home Menu", "home", "app",
NULL, "pslogo", NULL, 0, "");
assert(a);
as = jl_obj_get(a, "assets");
@@ -563,13 +587,13 @@ static void test_discord_builder(void) {
assert(v && !strcmp(v->str, "PlayStation 4"));
jl_free(a);
/* home with nothing: no assets block at all (never dangling) */
a = discord_build_activity("On PS4", "PlayStation 4", "home", "app",
a = discord_build_activity("On PS4", "PlayStation 4","ps_logo_full",NULL,"Idling on Home Menu", "home", "app",
NULL, NULL, NULL, 0, "");
assert(a);
assert(jl_obj_get(a, "assets") == NULL);
jl_free(a);
/* NULL name rejected */
assert(discord_build_activity(NULL, NULL, "home", "app", NULL, NULL, NULL, 0, "") == NULL);
assert(discord_build_activity(NULL, NULL,"ps_logo_full",NULL,"Idling on Home Menu", "home", "app", NULL, NULL, NULL, 0, "") == NULL);
}
static void test_cfg_learn(void) {
@@ -620,108 +644,758 @@ static void test_installer_cfg(void) {
assert(icfg_get_str("/nonexistent/x.json", "k", st, sizeof st) != 0);
}
static void test_daemon_state(void) {
char dir[64], path[96], st[32];
FILE *f;
assert(make_tmpdir(dir, sizeof dir) == 0);
snprintf(path, sizeof path, "%s/status.json", dir);
/* missing file: not running */
assert(icfg_daemon_state(path, 1000000, 120, st, sizeof st) == 0);
/* fresh heartbeat: running, state copied */
f = fopen(path, "wb"); assert(f);
fputs("{\"version\":\"1.0.0\",\"state\":\"playing\",\"title\":\"Game\",\"ts\":999990}", f);
fclose(f);
assert(icfg_daemon_state(path, 1000000, 120, st, sizeof st) == 1);
assert(!strcmp(st, "playing"));
/* stale heartbeat: not running */
assert(icfg_daemon_state(path, 1000200, 120, st, sizeof st) == 0);
/* corrupt file: not running, no crash */
f = fopen(path, "wb"); assert(f); fputs("not json{{{", f); fclose(f);
assert(icfg_daemon_state(path, 1000000, 120, st, sizeof st) == 0);
/* future ts (clock skew): stale-safe */
f = fopen(path, "wb"); assert(f); fputs("{\"ts\":2000000}", f); fclose(f);
assert(icfg_daemon_state(path, 1000000, 120, st, sizeof st) == 0);
/* ts present, state absent: fresh with "" */
f = fopen(path, "wb"); assert(f); fputs("{\"ts\":999999}", f); fclose(f);
assert(icfg_daemon_state(path, 1000000, 120, st, sizeof st) == 1);
assert(!strcmp(st, ""));
/* guards */
assert(icfg_daemon_state(NULL, 1000000, 120, st, sizeof st) == 0);
assert(icfg_daemon_state(path, 1000000, -1, st, sizeof st) == 0);
/* --- procwalk: process-table record parsing ---------------------------
* detect.c used to hard-code "eboot.bin" at byte 447 and reject records
* under 479 bytes. Those are 9.00 numbers; on a firmware that shifts
* kinfo_proc the scan reported "no game running" forever and the daemon
* posted nothing. procwalk keeps the self-describing ki_structsize framing
* but searches for the name instead of trusting an offset, so these tests
* pin that down: the 9.00 layout must keep working, and shifted layouts
* must now be found. */
/* Write a synthetic kinfo_proc record: leading ki_structsize, then `comm`
* (NUL-terminated) at comm_off, zero padding out to recsz. Sizes are native
* endian ints -- PS4 and the host runner are both little-endian. The comm is
* only written when it fits entirely inside the record, which is how we build
* the deliberately-truncated-name case. */
static size_t put_rec(unsigned char *buf, size_t at, int recsz,
size_t comm_off, const char *comm) {
memset(buf + at, 0, (size_t)recsz);
memcpy(buf + at, &recsz, sizeof recsz);
if (comm && comm_off + strlen(comm) + 1 <= (size_t)recsz)
memcpy(buf + at + comm_off, comm, strlen(comm) + 1);
return at + (size_t)recsz;
}
static void test_focus(void) {
char tid[16];
/* last event wins; target is the TO side of -> */
const char *b1 = "boot\nAppFocusChanged [CUSA00001] -> [CUSA00740]\n"
"noise\nAppFocusChanged [CUSA00740] -> [NPXS20001]\n";
assert(focus_parse_appfocus(b1, strlen(b1), tid, sizeof tid) == 0);
assert(!strcmp(tid, "NPXS20001"));
/* single event without arrow: the bracketed id itself */
const char *b2 = "xx AppFocusChanged [PPSA12345] yy";
assert(focus_parse_appfocus(b2, strlen(b2), tid, sizeof tid) == 0);
assert(!strcmp(tid, "PPSA12345"));
/* no event */
assert(focus_parse_appfocus("nothing here", 12, tid, sizeof tid) == -1);
/* guards */
assert(focus_parse_appfocus(NULL, 10, tid, sizeof tid) == -1);
assert(focus_parse_appfocus(b2, strlen(b2), NULL, sizeof tid) == -1);
assert(focus_parse_appfocus(b2, strlen(b2), tid, 0) == -1);
/* classify */
assert(focus_classify("CUSA00740") == FOCUS_GAME);
assert(focus_classify("PPSA12345") == FOCUS_GAME);
assert(focus_classify("NPXS20001") == FOCUS_SYSTEM);
assert(focus_classify("XXXX00000") == FOCUS_UNKNOWN);
assert(focus_classify(NULL) == FOCUS_UNKNOWN);
/* no msgbuf device on the host: reachability must report 0, no crash */
assert(focus_msgbuf_ok() == 0);
static void put_i32(unsigned char *p, int v) { memcpy(p, &v, sizeof v); }
static void test_procwalk(void) {
unsigned char buf[4096];
size_t at;
/* The 9.00 layout this code was written against. Regression guard. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_has_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_first_structsize(buf, at) == 479);
/* A firmware that grew the record and moved the name. The old 447/479
* check missed every one of these and reported "no game running". */
at = put_rec(buf, 0, 512, 300, "SystemUI");
at = put_rec(buf, at, 736, 611, "eboot.bin");
at = put_rec(buf, at, 640, 200, "eapServer");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_has_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_first_structsize(buf, at) == 512);
/* Name shoved right up against the end of the record, terminator in the
* very last byte, still found. */
at = put_rec(buf, 0, 22, 12, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 1);
/* Two games up -> count 2. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
at = put_rec(buf, at, 479, 447, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 2);
/* No match at all is 0 (an error), not -1 (unknown). */
at = put_rec(buf, 0, 479, 447, "SystemUI");
at = put_rec(buf, at, 600, 500, "webShell");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 0);
assert(procwalk_has_comm(buf, at, PROCWALK_EBOOT) == 0);
/* Empty buffer. */
assert(procwalk_count_comm(buf, 0, PROCWALK_EBOOT) == 0);
assert(procwalk_has_comm(buf, 0, PROCWALK_EBOOT) == 0);
assert(procwalk_first_structsize(buf, 0) == 0);
/* Broken framing must report "unknown" (-1), never 0. Callers read 0 as
* "no game running" and would blank an active presence. */
memset(buf, 0, 16);
assert(procwalk_count_comm(buf, 16, PROCWALK_EBOOT) == -1);
assert(procwalk_has_comm(buf, 16, PROCWALK_EBOOT) == -1);
put_i32(buf, 479);
put_i32(buf + 479, -8);
at = put_rec(buf, 0, 479, 447, "eboot.bin");
put_i32(buf + at, -8);
assert(procwalk_count_comm(buf, at + 4, PROCWALK_EBOOT) == -1);
memset(buf, 0, 16);
put_i32(buf, 0);
assert(procwalk_count_comm(buf, 16, PROCWALK_EBOOT) == -1);
/* A final record cut short by the buffer is reported as unknown, never as
* the count of the records before it and never read past the end. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
at = put_rec(buf, at, 479, 447, "eboot.bin");
size_t second = at - 479;
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 2);
assert(procwalk_count_comm(buf, second + 300, PROCWALK_EBOOT) == -1);
assert(procwalk_count_comm(buf, second + 4, PROCWALK_EBOOT) == -1);
/* A name too long for its own record is not a match: it must not alias
* into whatever record follows it. */
at = put_rec(buf, 0, 16, 12, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 0);
/* Prefix of the needle is not a match ("eboot" != "eboot.bin"). */
at = put_rec(buf, 0, 479, 447, "eboot");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 0);
/* A record claiming to be larger than the buffer is malformed, not a
* clean truncation, so it reports unknown. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
put_i32(buf, 999999);
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == -1);
assert(procwalk_first_structsize(buf, at) == 0);
/* Bad arguments rejected rather than crashed on. */
assert(procwalk_count_comm(NULL, 16, PROCWALK_EBOOT) == -1);
assert(procwalk_count_comm(buf, 16, NULL) == -1);
assert(procwalk_count_comm(buf, 16, "") == -1);
assert(procwalk_first_structsize(NULL, 16) == 0);
}
static void test_fw(void) {
int off = -1, minrec = -1;
/* verified table entry */
assert(fw_kinfo_for("9.00", &off, &minrec) == 0);
assert(off == 447 && minrec == 479);
/* unknown FW: no promise */
assert(fw_kinfo_for("13.52", &off, &minrec) == -1);
assert(fw_kinfo_for(NULL, &off, &minrec) == -1);
assert(fw_kinfo_for("bogus", &off, &minrec) == -1);
/* version string always well-formed */
char ver[16];
fw_version(ver, sizeof ver);
assert(ver[0] != 0);
/* exact-offset match on a synthetic 9.00-style record */
unsigned char rec[512];
memset(rec, 0, sizeof rec);
memcpy(rec + 447, "eboot.bin", 10);
(void)ver;
/* NOTE: fw_match_* use the live platform table; on non-9.00 hosts
* they take the bounded-scan path, which must also match here. */
assert(fw_match_eboot(rec, sizeof rec) == 1);
/* no name, no match */
unsigned char blank[512];
memset(blank, 0, sizeof blank);
assert(fw_match_eboot(blank, sizeof blank) == 0);
/* guards: NULL, empty, truncated */
assert(fw_match_eboot(NULL, 512) == 0);
assert(fw_match_eboot(rec, 0) == 0);
assert(fw_match_eboot(rec, 5) == 0);
/* unterminated needle: "eboot.binX" must not match */
unsigned char evil[64];
memset(evil, 0, sizeof evil);
memcpy(evil + 10, "eboot.binX", 10);
assert(fw_match_eboot(evil, sizeof evil) == 0);
/* payload pid match: name present (pid path is FW-gated) */
unsigned char pl[512];
memset(pl, 0, sizeof pl);
memcpy(pl + 100, "Payload", 8);
assert(fw_match_payload_pid(pl, sizeof pl, 1234) == 1);
assert(fw_match_payload_pid(pl, sizeof pl, 0) == 0);
assert(fw_match_payload_pid(blank, sizeof blank, 1234) == 0);
assert(fw_match_payload_pid(NULL, 512, 1234) == 0);
/* --- procwalk: record lookup + offset-free pid search -----------------
* lock.c and evict.c used to read the process name at byte 447 and the pid at
* byte 72 of every kinfo_proc record. Those are 9.00 offsets, so on newer
* firmware they addressed the wrong bytes and every live payload looked dead:
* evict deleted the lock while the daemon kept running, and lock.c let a
* second daemon start alongside the first. procwalk narrows by exact name
* first, then searches the record for the pid. */
static void test_procwalk_find_and_pid(void) {
unsigned char buf[4096];
size_t at, recsz;
const unsigned char *rec;
/* the layout lock.c/evict.c used to assume still works */
at = put_rec(buf, 0, 479, 447, "Payload");
put_i32(buf + 72, 1234);
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == 0);
assert(rec == buf && recsz == 479);
assert(procwalk_record_has_i32(rec, recsz, 1234) == 1);
assert(procwalk_record_has_i32(rec, recsz, 9999) == 0);
/* shifted firmware: record grew, name and pid both moved */
at = put_rec(buf, 0, 512, 300, "SystemUI");
at = put_rec(buf, at, 736, 611, "Payload");
put_i32(buf + 479 + 91, 1234);
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == 0);
assert(rec == buf + 512 && recsz == 736);
assert(procwalk_record_has_i32(rec, recsz, 1234) == 1);
assert(procwalk_record_has_i32(rec, recsz, 4321) == 0);
/* prefix names must not match: PayloadHelper is not Payload, and kill()
* on the wrong pid is the failure this guards */
at = put_rec(buf, 0, 479, 447, "PayloadHelper");
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == -2);
at = put_rec(buf, 0, 479, 447, "Payload");
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == 0);
/* nothing matches: -2, distinct from malformed (-1), outputs cleared */
at = put_rec(buf, 0, 479, 447, "SystemUI");
at = put_rec(buf, at, 600, 500, "eboot.bin");
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == -2);
assert(rec == NULL && recsz == 0);
/* malformed table -> -1 */
memset(buf, 0, 16);
assert(procwalk_find_comm(buf, 16, "Payload", &rec, &recsz) == -1);
put_i32(buf, 479);
put_i32(buf + 479, -3);
assert(procwalk_find_comm(buf, 483, "Payload", &rec, &recsz) == -1);
/* empty and bad args */
assert(procwalk_find_comm(buf, 0, "Payload", &rec, &recsz) == -2);
assert(procwalk_find_comm(NULL, 16, "Payload", &rec, &recsz) == -1);
assert(procwalk_find_comm(buf, 16, NULL, &rec, &recsz) == -1);
assert(procwalk_find_comm(buf, 16, "Payload", NULL, &recsz) == -1);
/* the int must lie wholly inside the record */
at = put_rec(buf, 0, 16, 0, "Payload");
put_i32(buf + 12, 77);
assert(procwalk_record_has_i32(buf, 16, 77) == 1);
at = put_rec(buf, 0, 15, 0, "Payload");
put_i32(buf + 12, 77);
assert(procwalk_record_has_i32(buf, 15, 77) == 0);
/* degenerate args */
assert(procwalk_record_has_i32(NULL, 100, 1) == 0);
assert(procwalk_record_has_i32(buf, 0, 1) == 0);
assert(procwalk_record_has_i32(buf, 3, 1) == 0);
/* Caveat, pinned down: records are zero-padded and a process name ends in
* a NUL, so a record routinely contains the int32 0 in its padding. A hit
* is corroboration, not proof -- which is why callers reject pid <= 0
* first. Recorded so this is a documented decision, not a surprise. */
at = put_rec(buf, 0, 64, 0, "Payload");
assert(procwalk_record_has_i32(buf, 64, 0) == 1);
}
/* --- bigapp: foreground-app classification ----------------------------
* The daemon's entire "is a game running" signal. The distinctions that matter
* are the ones a single boolean would throw away:
* -1 no big app -> a game may genuinely have closed
* 0 System UI in front -> a game is probably still running underneath
* -1 unreadable -> hold whatever we had, never clear
* Getting "system UI" or "unreadable" wrong is how presence disappeared while
* a game was still open. */
static void test_bigapp(void) {
char out[16];
/* nothing in front: the only reading that may clear presence */
assert(bigapp_classify(-1, -1, "", out, sizeof out) == BIGAPP_NONE);
assert(out[0] == 0);
/* System UI reported as app id 0, and the NPXS namespace: hold, not close */
assert(bigapp_classify(0, 0, "", out, sizeof out) == BIGAPP_SYSTEM);
assert(bigapp_classify(1, 0, "NPXS20001", out, sizeof out) == BIGAPP_SYSTEM);
assert(out[0] == 0);
/* a real title */
assert(bigapp_classify(1, 0, "CUSA00740", out, sizeof out) == BIGAPP_GAME);
assert(strcmp(out, "CUSA00740") == 0);
/* PS5 backports and PS2 titles use other prefixes; accept all [A-Z0-9] */
assert(bigapp_classify(1, 0, "PPSA01234", out, sizeof out) == BIGAPP_GAME);
assert(bigapp_classify(1, 0, "ELUA01234", out, sizeof out) == BIGAPP_GAME);
/* a failed title lookup is unknown, not "no game" */
assert(bigapp_classify(1, -1, "", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0x80990003, "CUSA00740", out, sizeof out) == BIGAPP_UNKNOWN);
assert(out[0] == 0);
/* malformed title ids: wrong length, lowercase, punctuation, empty */
assert(bigapp_classify(1, 0, "", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA0074", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA007400", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "cusa00740", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA-0740", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA0074\x80", out, sizeof out) == BIGAPP_UNKNOWN);
assert(out[0] == 0);
/* a negative app id other than -1: unknown, never "nothing running" */
assert(bigapp_classify(-2, 0, "", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify((int32_t)0x80990003, 0, "", out, sizeof out) == BIGAPP_UNKNOWN);
/* NULL title must not crash */
assert(bigapp_classify(1, 0, NULL, out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, NULL, NULL, 0) == BIGAPP_UNKNOWN);
/* a real title but nowhere to write it: unknown rather than a silent
* partial copy */
assert(bigapp_classify(1, 0, "CUSA00740", NULL, 0) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA00740", out, 4) == BIGAPP_UNKNOWN);
/* validator directly */
assert(bigapp_titleid_valid("CUSA00740") == 1);
assert(bigapp_titleid_valid("CUSA0074") == 0);
assert(bigapp_titleid_valid("cusa00740") == 0);
assert(bigapp_titleid_valid(NULL) == 0);
}
/* --- pkgzone: the homebrew name scrape ---------------------------------
* pkg-zone.com is scraped, not queried, so the parsing is pinned down here
* against synthetic pages. A layout change upstream must degrade to "no name"
* (the caller keeps the raw title id), never to a garbled presence. */
static void test_pkgzone(void) {
char name[128], url[256];
/* --- which titles are even worth asking about --- */
/* retail: TMDB already owns these, never spend a request on them */
assert(pkgzone_wants("CUSA00740") == 0);
/* retro: deferred, deliberately excluded */
assert(pkgzone_wants("SLUS20001") == 0);
assert(pkgzone_wants("SCES50361") == 0);
assert(pkgzone_wants("ELUA01234") == 0);
/* The regression that sent SCUS97399 to pkg-zone at 302/500 every poll:
* SCUS is in the retro tables (ps1 + ps2) and must be excluded here. */
assert(pkgzone_wants("SCUS97399") == 0);
/* and the exclusion is delegated to retro_wants(), not re-listed, so the
* two tables cannot drift apart again */
assert(retro_wants("SCUS97399") == 1);
assert(pkgzone_wants("KOEI12345") == 0);
assert(pkgzone_wants("UCUS12345") == 0);
/* homebrew prefixes: 4 letters + 5 digits, e.g. LAPY20009 */
assert(pkgzone_wants("LAPY20009") == 1);
assert(pkgzone_wants("HT0000001") == 1);
assert(pkgzone_wants("JBUA20001") == 1);
/* shape violations: lowercase, wrong length, non-alnum */
assert(pkgzone_wants("lapy20009") == 0);
assert(pkgzone_wants("LAPY2000") == 0);
assert(pkgzone_wants("LAPY200099") == 0);
assert(pkgzone_wants("LAPY-2009") == 0);
/* malformed */
assert(pkgzone_wants("CUSA") == 0);
assert(pkgzone_wants("") == 0);
assert(pkgzone_wants(NULL) == 0);
/* --- a realistic page: boilerplate heading is skipped --- */
{
const char *html =
"<html><head><title>pkg-zone</title></head><body>"
"<h1>Install HB-Store on your Playstation</h1>"
"<div class=\"image\" style=\"background-image: "
"url(/images/LAPY20009/cover.png)\"></div>"
"<h1>Some Homebrew App</h1>"
"</body></html>";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == 0);
assert(strcmp(name, "Some Homebrew App") == 0);
assert(strcmp(url, "https://pkg-zone.com/images/LAPY20009/cover.png") == 0);
}
/* --- boilerplate only: no usable name --- */
{
const char *html = "<h1>Install HB-Store on your Playstation</h1>";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == -1);
assert(name[0] == 0);
}
/* --- whitespace and entities are normalised --- */
{
const char *html = "<h1> Retro Tool &amp; Co </h1>";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == 0);
assert(strcmp(name, "Retro Tool & Co") == 0);
}
/* --- unclosed / absent tags must not read past the buffer --- */
{
const char *html = "<h1>No closing tag here";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == -1);
const char *nohead = "<body><p>no headings</p></body>";
assert(pkgzone_parse(nohead, strlen(nohead), "LAPY20009",
name, sizeof name, url, sizeof url) == -1);
/* an empty <h1> must be skipped in favour of the next real one */
const char *empty_then_real = "<h1></h1><h1>Real Title</h1>";
assert(pkgzone_parse(empty_then_real, strlen(empty_then_real), "LAPY20009",
name, sizeof name, url, sizeof url) == 0);
assert(strcmp(name, "Real Title") == 0);
}
/* --- bad arguments --- */
assert(pkgzone_parse(NULL, 10, "LAPY20009", name, sizeof name, url, sizeof url) == -1);
assert(pkgzone_parse("<h1>x</h1>", 11, NULL, name, sizeof name, url, sizeof url) == -1);
assert(pkgzone_parse("<h1>x</h1>", 11, "LAPY20009", NULL, 0, url, sizeof url) == -1);
/* title id must be 9 chars: the cover URL is built from it */
assert(pkgzone_parse("<h1>x</h1>", 11, "SHORT", name, sizeof name, url, sizeof url) == -1);
}
/* --- games_cache: the persistent id -> {name, cover} store -------------
* Replaces both the config.json titles map and artwork_cache.json. Cover art
* was never persisted before this, so a reboot lost it and the presence fell
* back to a pack URL that may not exist. */
static void test_gamecache(void) {
gamecache_entry_t e[8];
const int64_t now = 1700000000;
/* --- id validation --- */
assert(gamecache_id_valid("CUSA32836") == 1);
assert(gamecache_id_valid("LAPY20009") == 1);
assert(gamecache_id_valid("cusa32836") == 0); /* lowercase */
assert(gamecache_id_valid("CUSA-3283") == 0); /* punctuation */
assert(gamecache_id_valid("CUSA3283612345678") == 0); /* too long */
assert(gamecache_id_valid("") == 0);
assert(gamecache_id_valid(NULL) == 0);
/* --- parse a realistic document --- */
{
const char *doc =
"{\"schema\":1,\"games\":["
"{\"id\":\"CUSA32836\",\"name\":\"Some Game\",\"art\":\"https://cdn/x.png\",\"at\":1699999000},"
"{\"id\":\"LAPY20009\",\"name\":\"Homebrew\",\"art\":\"\",\"at\":1699999000}"
"]}";
int n = gamecache_parse(doc, strlen(doc), e, 8, now);
assert(n == 2);
assert(!strcmp(e[0].id, "CUSA32836"));
assert(!strcmp(e[0].name, "Some Game"));
assert(!strcmp(e[0].art, "https://cdn/x.png"));
assert(!strcmp(e[1].id, "LAPY20009"));
assert(e[1].art[0] == 0); /* empty art is allowed */
}
/* --- malformed rows are skipped, not fatal --- */
{
const char *doc =
"{\"games\":["
"{\"id\":\"CUSA32836\",\"name\":\"Good\",\"at\":1699999000},"
"{\"id\":\"bad-id\",\"name\":\"Bad\",\"at\":1699999000},"
"{\"id\":\"CUSA11111\"},"
"{\"name\":\"NoId\",\"at\":1699999000},"
"\"notanobject\","
"{\"id\":\"CUSA22222\",\"name\":\"Also Good\",\"at\":1699999000}"
"]}";
int n = gamecache_parse(doc, strlen(doc), e, 8, now);
assert(n == 2);
assert(!strcmp(e[0].id, "CUSA32836"));
assert(!strcmp(e[1].id, "CUSA22222"));
}
/* --- unusable documents --- */
assert(gamecache_parse("not json", 8, e, 8, now) == -1);
assert(gamecache_parse("[]", 2, e, 8, now) == -1); /* array at top */
assert(gamecache_parse("{}", 2, e, 8, now) == 0); /* no games key */
assert(gamecache_parse(NULL, 10, e, 8, now) == -1);
assert(gamecache_parse("{}", 2, NULL, 8, now) == -1);
assert(gamecache_parse("{}", 2, e, 0, now) == -1);
/* --- staleness: an old entry is dropped so it gets re-resolved --- */
{
char doc[256];
snprintf(doc, sizeof doc,
"{\"games\":[{\"id\":\"CUSA32836\",\"name\":\"Old\",\"at\":%lld}]}",
(long long)(now - GAMECACHE_TTL_SECS - 60));
assert(gamecache_parse(doc, strlen(doc), e, 8, now) == 0);
/* fresh entry survives */
snprintf(doc, sizeof doc,
"{\"games\":[{\"id\":\"CUSA32836\",\"name\":\"New\",\"at\":%lld}]}",
(long long)(now - 10));
assert(gamecache_parse(doc, strlen(doc), e, 8, now) == 1);
}
/* a missing timestamp is treated as stale, not trusted blindly */
{
const char *doc = "{\"games\":[{\"id\":\"CUSA32836\",\"name\":\"NoAt\"}]}";
assert(gamecache_parse(doc, strlen(doc), e, 8, now) == 0);
}
/* --- round trip, including quotes and backslashes in the name --- */
{
gamecache_entry_t in[2], out[8];
memset(in, 0, sizeof in);
strcpy(in[0].id, "CUSA32836");
strcpy(in[0].name, "He said \"hi\" \\ back");
strcpy(in[0].art, "https://cdn/a.png");
in[0].at = now;
strcpy(in[1].id, "LAPY20009");
strcpy(in[1].name, "Homebrew Tool");
in[1].at = now;
char *json = gamecache_serialize(in, 2);
assert(json != NULL);
int n = gamecache_parse(json, strlen(json), out, 8, now);
assert(n == 2);
assert(!strcmp(out[0].id, "CUSA32836"));
assert(!strcmp(out[0].name, "He said \"hi\" \\ back"));
assert(!strcmp(out[0].art, "https://cdn/a.png"));
assert(!strcmp(out[1].name, "Homebrew Tool"));
assert(out[1].art[0] == 0);
free(json);
}
/* --- serialise edges --- */
assert(gamecache_serialize(NULL, 1) == NULL);
assert(gamecache_serialize(e, 0) == NULL);
/* --- `max` really caps --- */
{
const char *doc =
"{\"games\":["
"{\"id\":\"CUSA00001\",\"name\":\"A\",\"at\":1699999000},"
"{\"id\":\"CUSA00002\",\"name\":\"B\",\"at\":1699999000},"
"{\"id\":\"CUSA00003\",\"name\":\"C\",\"at\":1699999000}"
"]}";
assert(gamecache_parse(doc, strlen(doc), e, 2, now) == 2);
}
/* --- live store: put then get, and art round-trips --- */
gamecache_reset();
{
char nm[64], ar[128];
assert(gamecache_get("CUSA32836", nm, sizeof nm, ar, sizeof ar) == 0);
assert(gamecache_put("CUSA32836", "Cached Game",
"https://cdn/cover.png", "tmdb") == 1);
assert(gamecache_get("CUSA32836", nm, sizeof nm, ar, sizeof ar) == 1);
assert(!strcmp(nm, "Cached Game"));
assert(!strcmp(ar, "https://cdn/cover.png"));
/* id echo is not a name */
assert(gamecache_put("CUSA99999", "CUSA99999", "", "tmdb") == 0);
/* bad id rejected */
assert(gamecache_put("bad id", "X", "", "tmdb") == 0);
/* update replaces */
assert(gamecache_put("CUSA32836", "Renamed", "", "pkgzone") == 1);
assert(gamecache_get("CUSA32836", nm, sizeof nm, ar, sizeof ar) == 1);
assert(!strcmp(nm, "Renamed"));
}
gamecache_reset();
}
/* --- firmware version normalisation ---------------------------------
* The presence card shows this string, so a bad trim is user-visible.
* Sony packs the minor as three digits with a trailing sub-patch zero,
* which is why "13.520.001" has to render as "13.52", not "13.520". */
/* fw.c host stub: the only thing it needs from the console is the
* firmware string. fw_normalize() itself is pure and fully covered. */
typedef struct {
unsigned long long reserved;
char version_string[0x1c];
unsigned int version;
} fw_info_stub_t;
/* What the stub reports. Overridden by the padded case below so fw_version()
* can be driven end-to-end through the whole path, not just fw_normalize(). */
static const char *s_stub_fw_string = "13.520.001";
int sceKernelGetSystemSwVersion(fw_info_stub_t *info){
if(!info) return -1;
memset(info, 0, sizeof *info);
snprintf(info->version_string, sizeof info->version_string, "%s",
s_stub_fw_string);
return 0;
}
static void test_fw_normalize(void) {
static const struct { const char *in; const char *want; } cases[] = {
{ "13.520.001", "13.52" },
{ "13.520", "13.52" },
{ "13.52", "13.52" },
{ "9.000", "9.00" },
{ "9.000.001", "9.00" },
{ "8.500", "8.50" },
{ "11.020", "11.02" },
{ "12.000", "12.00" },
{ "5.050", "5.05" },
{ "7.550", "7.55" },
{ "13", "13" }, /* no minor: left alone */
/* Padded by Sony on some firmware -- 9.00 sends " 9.008.031",
* verified on console 2026-10-05. digits_ok() rejected the space and
* every poll logged "unrecognised version string", so the presence
* fell back to "Firmware unknown". */
{ " 9.008.031", "9.00" },
{ " 9.000", "9.00" }, /* more than one space */
{ " 5.050.123", "5.05" }, /* padded single-digit major */
{ "\t9.008.031", "9.00" }, /* tab padding */
{ "9.008.031 ", "9.00" }, /* trailing padding */
};
for (unsigned i = 0; i < sizeof cases / sizeof cases[0]; i++) {
char v[32];
snprintf(v, sizeof v, "%s", cases[i].in);
fw_normalize(v);
assert(!strcmp(v, cases[i].want));
}
/* end-to-end: fw_version() must render "13.52" for the console the
* stub above describes -- exactly what the presence card shows. */
{
char fw[16];
assert(fw_version(fw, sizeof fw) == 0);
assert(!strcmp(fw, "13.52"));
}
/* The regression that reported "unrecognised version string" forever on
* 9.00. fw_version() normalises before validating, so a padded string
* reaches digits_ok() trimmed. */
{
static const struct { const char *raw; const char *want; } pad[] = {
{ " 9.008.031", "9.00" },
{ "9.008.031", "9.00" },
{ " 5.050.123", "5.05" },
{ " 13.520.001", "13.52" },
};
for (unsigned i = 0; i < sizeof pad / sizeof pad[0]; i++) {
char fw[16];
s_stub_fw_string = pad[i].raw;
assert(fw_version(fw, sizeof fw) == 0);
assert(!strcmp(fw, pad[i].want));
}
s_stub_fw_string = "13.520.001";
}
/* Still rejects genuine garbage rather than passing it through. */
{
char fw[16];
s_stub_fw_string = "not-a-version";
assert(fw_version(fw, sizeof fw) != 0);
assert(!strcmp(fw, "unknown"));
s_stub_fw_string = "13.520.001";
}
}
/* --- NPXS is system; app/media ids resolve like anything else -------
* Console klog 2026-10-03: NPXS20001 backs SceShellUI plus
* SecureUIProcess/SecureWebProcess and is where every AppFocusChanged
* event starts, so it must never be posted as a game. ItemzFlow is
* ITEM00001, a different namespace, and does resolve. */
static void test_bigapp_namespaces(void) {
char out[16];
/* NPXS is never a playing game. */
assert(bigapp_classify(1, 0, "NPXS20001", out, sizeof out) == BIGAPP_SYSTEM);
assert(bigapp_classify(1, 0, "NPXS21002", out, sizeof out) == BIGAPP_SYSTEM);
assert(bigapp_classify(1, 0, "NPXS10001", out, sizeof out) == BIGAPP_SYSTEM);
assert(out[0] == 0); /* nothing written for a system id */
/* App and media containers post normally. */
assert(bigapp_classify(1, 0, "ITEM00001", out, sizeof out) == BIGAPP_GAME);
assert(!strcmp(out, "ITEM00001"));
assert(bigapp_classify(1, 0, "CUSA00127", out, sizeof out) == BIGAPP_GAME);
assert(bigapp_classify(1, 0, "LAPY20009", out, sizeof out) == BIGAPP_GAME);
assert(bigapp_classify(1, 0, "CUSA03887", out, sizeof out) == BIGAPP_GAME);
}
/* --- asset keys land in the right fields ---------------------------
* Every parameter here is const char*, so a mis-ordered signature still
* compiles: it shipped once and put "ps_logo_full" in the details line.
* Assert on values, not on arity. */
static void test_activity_assets(void) {
/* home: idle asset as large image, no badge */
jl_val_t *a = discord_build_activity("Firmware 13.52", "PlayStation 4",
"Idling on Home Menu", "ps_logo_full", NULL,
"home", "app", NULL, NULL, NULL, 0, "");
assert(a);
const jl_val_t *v = jl_obj_get(a, "details");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Idling on Home Menu"));
const jl_val_t *as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "large_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "ps_logo_full"));
assert(jl_obj_get(as, "small_image") == NULL); /* no badge on home */
jl_free(a);
/* playing: game icon large, playing asset as the badge */
a = discord_build_activity("Firmware 13.52", "Some Game",
"Playing on PlayStation 4", NULL, "ps_logo_blue",
"CUSA11995", "app", NULL, NULL, NULL, 1700000000LL, "");
assert(a);
v = jl_obj_get(a, "details");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Playing on PlayStation 4"));
as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "large_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "cusa11995"));
v = jl_obj_get(as, "small_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "ps_logo_blue"));
jl_free(a);
/* an empty key falls back rather than sending an empty asset */
a = discord_build_activity("Firmware 13.52", "Some Game",
"Playing on PlayStation 4", "", "",
"CUSA11995", "app", NULL, NULL, NULL, 0, "");
assert(a);
as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
assert(jl_obj_get(as, "small_image") == NULL);
jl_free(a);
}
/* --- retro: prefix routing and the entry extractor -----------------
* Pure logic only; the fetch half is payload-only. Driven against the real
* index shapes verified on console 2026-10-05: compact, BOM-free, a bare
* array of flat {"id","name","cover"} entries. */
static void test_retro(void) {
unsigned char c[3];
/* PS1-only prefixes: one file, no wasted fetches */
assert(retro_wants("SND00123") == 1);
assert(retro_candidates_for("SND00123", c) == 1 && c[0] == 0);
/* LSD is a 3-character prefix: comparing a fixed 4 bytes would miss it */
assert(retro_wants("LSD00123") == 1);
assert(retro_candidates_for("LSD00123", c) == 1 && c[0] == 0);
/* PS2-only */
assert(retro_wants("KOEI12345") == 1);
assert(retro_candidates_for("KOEI12345", c) == 1 && c[0] == 1);
/* PSP-only */
assert(retro_wants("UCUS12345") == 1);
assert(retro_candidates_for("UCUS12345", c) == 1 && c[0] == 2);
/* shared prefixes: SLPM and PBPX are in both PS1 and PS2, and must
* come out in check order so the first hit is deterministic */
assert(retro_candidates_for("SLPM55265", c) == 2);
assert(c[0] == 0 && c[1] == 1);
assert(retro_candidates_for("PBPX12345", c) == 2);
assert(c[0] == 0 && c[1] == 1);
/* retail and unknowns are never routed here */
assert(retro_wants("CUSA32836") == 0);
assert(retro_wants("LAPY20009") == 0);
assert(retro_wants("ITEM00001") == 0);
assert(retro_wants("") == 0);
assert(retro_wants(NULL) == 0);
/* extraction from a real index body */
{
const char *body =
"[{\"id\":\"SLPM87404\",\"name\":\"A GAME\",\"cover\":\"https://x/a.jpg\"},"
"{\"id\":\"SLPM55146\",\"name\":\"OTHER GAME\",\"cover\":\"https://x/b.jpg\"}]";
char name[RETRO_MAX_NAME], url[RETRO_MAX_URL];
assert(retro_extract(body, strlen(body), "\"id\":\"SLPM55146\"",
name, sizeof name, url, sizeof url) == 1);
assert(!strcmp(name, "OTHER GAME"));
assert(!strcmp(url, "https://x/b.jpg"));
/* a miss must not invent a name */
name[0] = 'X';
assert(retro_extract(body, strlen(body), "\"id\":\"NOPE00000\"",
name, sizeof name, url, sizeof url) == 0);
assert(name[0] == 0);
}
/* names carry quotes and backslashes; raw bytes would show \\ and \" */
{
const char *body =
"[{\"id\":\"ABCD12345\",\"name\":\"He said \\\"hi\\\" \\\\ away\",\"cover\":\"\"}]";
char name[RETRO_MAX_NAME], url[RETRO_MAX_URL];
assert(retro_extract(body, strlen(body), "\"id\":\"ABCD12345\"",
name, sizeof name, url, sizeof url) == 1);
assert(!strcmp(name, "He said \"hi\" \\ away"));
assert(url[0] == 0); /* empty cover is valid, not fatal */
}
/* the 164-char name in ps2.json must survive intact */
{
const char *body =
"[{\"id\":\"SLPM00001\",\"name\":\"Kidou Senshi Gundam Giren no Yabou - Zeon Dokuritsu Sensouden & Kidou Senshi Gundam Giren no Yabou - Zeon Dokuritsu Sensouden - Kouryaku Shireisho (Gundam The Best)\",\"cover\":\"https://x/g.jpg\"}]";
char name[RETRO_MAX_NAME], url[RETRO_MAX_URL];
assert(retro_extract(body, strlen(body), "\"id\":\"SLPM00001\"",
name, sizeof name, url, sizeof url) == 1);
assert(strlen(name) == 164);
assert(strstr(name, "Gundam The Best") != NULL);
}
}
/* --- visibility flags + homebrew classification -------------------
* The four show_* flags are a daemon-side decision, so what is testable
* here is that they default to on and that is_homebrew_id puts the right
* ids in the right bucket -- in particular that retro is never homebrew. */
static void test_show_flags(void) {
cfg_t c;
cfg_defaults(&c);
/* all on by default: a config that predates the keys keeps today's
* behaviour rather than silently hiding everything */
assert(c.show_firmware == 1);
assert(c.show_idle == 1);
assert(c.show_media == 1);
assert(c.show_homebrew == 1);
/* is_homebrew_id: the pkg-zone namespace */
assert(is_homebrew_id("LAPY20009") == 1); /* payload-dir homebrew */
assert(is_homebrew_id("HT0000001") == 1);
assert(is_homebrew_id("ITEM00001") == 1); /* app container */
/* retail and system are never homebrew */
assert(is_homebrew_id("CUSA32836") == 0);
assert(is_homebrew_id("NPXS20001") == 0);
/* the point of the exclusion: retro must survive show_homebrew:false */
assert(is_homebrew_id("SLPM55146") == 0);
assert(is_homebrew_id("UCUS12345") == 0);
assert(is_homebrew_id("KOEI12345") == 0);
/* degenerate input */
assert(is_homebrew_id(NULL) == 0);
assert(is_homebrew_id("") == 0);
assert(is_homebrew_id("AB") == 0);
}
int main(void) {
/* First: pure parsing, no I/O, no fixtures. The pre-existing
* test_appdb failure aborts the binary, so anything listed after it
* would never run. */
test_activity_assets();
test_bigapp();
test_retro();
test_show_flags();
test_bigapp_namespaces();
test_pkgzone();
test_gamecache();
test_fw_normalize();
test_procwalk();
test_procwalk_find_and_pid();
test_json();
test_gateway_op_spoof();
test_json_oom_safe();
@@ -738,11 +1412,8 @@ int main(void) {
test_cfg_titles();
test_cfg_learn();
test_installer_cfg();
test_daemon_state();
test_appdb();
test_discord_builder();
test_focus();
test_fw();
puts("utility tests passed");
return 0;
}
+105
View File
@@ -0,0 +1,105 @@
/* evict.c - stop a running orbisRPC daemon so a fresh payload can take over.
* Reads /data/orbisRPC/daemon.lock, verifies the holder is a live "Payload"
* process via the same offset-free sysctl walk as lock.c/procwalk.c, then
* SIGTERM (clean stop: banks session, clears presence, releases lock) with a
* SIGKILL fallback. Refuses to signal anything it cannot confirm is a live
* Payload peer.
* Build: PS4_PAYLOAD_SDK=... ./scripts/build_evict.sh -> build-sdk/evict.elf
* Run: send via elfldr:9021 or GoldHEN BinLoader:9020, then send the fresh
* orbisrpc_sdk.elf the same way.
* Result: /data/orbisRPC/evict.txt (+ klog). Takes up to ~34 s: 24 x 1 s
* waiting for a clean stop before the SIGKILL fallback. */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/sysctl.h>
#include "../orbisrpc/procwalk.h"
#define LOCK_PATH "/data/orbisRPC/daemon.lock"
#define RESULT_PATH "/data/orbisRPC/evict.txt"
static void report(const char *msg){
printf("evict: %s\n", msg);
int fd = open(RESULT_PATH, O_CREAT|O_TRUNC|O_WRONLY, 0644);
if(fd >= 0){ (void)write(fd, msg, strlen(msg)); (void)write(fd, "\n", 1); close(fd); }
}
/* 1 = pid is a live "Payload" process, 0 otherwise. Mirrors lock.c.
*
* This used to read the name at byte 447 and the pid at byte 72 of each
* kinfo_proc record. Those are 9.00 offsets; on newer firmware they address
* the wrong bytes, so every live payload looked dead and evict would delete
* the lock and report STALE while the daemon kept running -- leaving exactly
* the two-daemon pileup it exists to prevent.
*
* Narrow by exact process name first, then search the record for the pid.
* Unlike lock.c the bias is the other way here: this gates a kill(), so an
* unreadable table must report "not live" and let the caller do nothing. */
static int payload_live(int pid){
if(pid <= 0 || pid == (int)getpid()) return 0;
int mib[4] = { 1, 14, 8, 0 };
size_t sz = 0;
if(sysctl(mib, 4, NULL, &sz, NULL, 0) != 0) return 0;
static unsigned char buf[256*1024];
if(sz > sizeof buf) return 0; /* unreadable table: fail closed, do not kill */
if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return 0;
const unsigned char *rec = NULL;
size_t recsz = 0;
int rc = procwalk_find_comm(buf, sz, "Payload", &rec, &recsz);
if(rc != 0) return 0; /* malformed, or no Payload process at all */
return procwalk_record_has_i32(rec, recsz, pid);
}
int main(void){
char msg[128];
int fd = open(LOCK_PATH, O_RDONLY);
if(fd < 0){ report("NO_LOCK nothing running"); return 0; }
char b[32]; ssize_t n = read(fd, b, sizeof b - 1); close(fd);
if(n <= 0){ report("LOCK_UNREADABLE"); return 1; }
b[n] = 0;
int pid = 0;
if(sscanf(b, "%d", &pid) != 1 || pid <= 0){
remove(LOCK_PATH);
report("LOCK_GARBAGE removed");
return 0;
}
if(!payload_live(pid)){
remove(LOCK_PATH);
snprintf(msg, sizeof msg, "STALE holder=%d not a live Payload; lock removed", pid);
report(msg);
return 0;
}
if(kill(pid, SIGTERM) != 0 && errno == ESRCH){
remove(LOCK_PATH);
report("HOLDER_GONE lock removed");
return 0;
}
/* Grace period: clean stop banks session + releases lock. */
for(int i = 0; i < 24; i++){
sleep(1);
if(!payload_live(pid)){
remove(LOCK_PATH);
snprintf(msg, sizeof msg, "EVICTED holder=%d clean stop", pid);
report(msg);
return 0;
}
}
/* Wedged (e.g. stuck in blocking connect): SIGKILL fallback. */
(void)kill(pid, SIGKILL);
for(int i = 0; i < 10; i++){
sleep(1);
if(!payload_live(pid)){
remove(LOCK_PATH);
snprintf(msg, sizeof msg, "EVICTED holder=%d SIGKILL fallback", pid);
report(msg);
return 0;
}
}
snprintf(msg, sizeof msg, "STUCK holder=%d still alive; reboot console", pid);
report(msg);
return 2;
}