mirror of
https://github.com/SirHumza/orbisRPC.git
synced 2026-10-17 11:01:40 +02:00
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:
1 parent
d8f6e541d9
commit
e35c47b3f5
86 files changed
+14097
-2000
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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 == '&'){
|
||||
/* & " ' & — enough for a heading */
|
||||
if(i + 4 < len && !strncmp(src + i, "&", 5)){ dst[o++] = '&'; i += 4; continue; }
|
||||
if(i + 5 < len && !strncmp(src + i, """, 6)){ dst[o++] = '"'; i += 5; continue; }
|
||||
if(i + 4 < len && !strncmp(src + i, " ", 6)){ dst[o++] = ' '; i += 5; continue; }
|
||||
if(i + 4 < len && !strncmp(src + i, "'", 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
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)))
|
||||
@@ -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)))
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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
@@ -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
@@ -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 & 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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user