From d0972b68e94e2ff4b5d64de39b9aabca77c83013 Mon Sep 17 00:00:00 2001
From: SirHumza <204067870+SirHumza@users.noreply.github.com>
Date: Sun, 27 Sep 2026 14:47:09 +0200
Subject: [PATCH] remaster: any-firmware hardening + installer remake
- detection: probe-first (ShellCoreUtil dlopen, msgbuf AppFocusChanged,
sysctl kinfo via verified-only fw table, sandbox/save fallbacks)
- new orbisrpc/focus.c + fw.c/fw.h (bounded scans, fail-closed unknowns)
- daemon: no-exit everywhere (token wait-loop, 4004 retry), status.json
heartbeat, daemon.gen supersede protocol
- installer: progress UI, token IME retry + FTP fallback, install.log,
drop evict.elf (delete tools/evict.c, build_evict.sh)
- packaging: Apollo parity CATEGORY=gde ATTRIBUTE=32 --authinfo
- security: chmod 0600 on token-bearing files
- docs: firmware-independent injecting guide, SUPPORT.md, release.sh
---
.gitignore | 1 -
README.md | 4 +-
SUPPORT.md | 11 ++++
docs/INSTALLER.md | 32 +++++-----
docs/PRODUCTION.md | 7 ++-
docs/injecting.md | 19 ++++--
installer/Makefile | 12 ++--
installer/icfg.c | 3 +
installer/installer.c | 64 ++++++++++++-------
orbisrpc/cfg.c | 3 +
orbisrpc/daemon.c | 73 ++++++++++++++++++++--
orbisrpc/detect.c | 19 +++++-
orbisrpc/detect.h | 1 +
orbisrpc/focus.c | 135 +++++++++++++++++++++++++++++++++++++++++
orbisrpc/focus.h | 25 ++++++++
orbisrpc/fw.c | 107 ++++++++++++++++++++++++++++++++
orbisrpc/fw.h | 34 +++++++++++
orbisrpc/lock.c | 9 +--
scripts/build.sh | 4 +-
scripts/build_evict.sh | 13 ----
scripts/build_sdk.sh | 2 +-
scripts/release.sh | 17 ++++++
tests/Makefile | 2 +-
tests/test_utils.c | 73 ++++++++++++++++++++++
tools/evict.c | 98 ------------------------------
25 files changed, 587 insertions(+), 181 deletions(-)
create mode 100644 SUPPORT.md
create mode 100644 orbisrpc/focus.c
create mode 100644 orbisrpc/focus.h
create mode 100644 orbisrpc/fw.c
create mode 100644 orbisrpc/fw.h
delete mode 100755 scripts/build_evict.sh
create mode 100755 scripts/release.sh
delete mode 100644 tools/evict.c
diff --git a/.gitignore b/.gitignore
index 679a8c1..53f8a82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,5 @@ 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
diff --git a/README.md b/README.md
index 9f339a2..9c6ca13 100644
--- a/README.md
+++ b/README.md
@@ -22,8 +22,8 @@ playing to Discord: name, cover art, timer. No PC at runtime.
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` + `evict.elf` in
- `/data/payloads`, writes `/data/orbisRPC/config.json`, evicts any old
+2. Open **orbisRPC Setup**. It stages `orbisrpc.bin` in
+ `/data/payloads`, writes `/data/orbisRPC/config.json`, retires 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
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..741d87a
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,11 @@
+# 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:
diff --git a/docs/INSTALLER.md b/docs/INSTALLER.md
index 6424e55..a1faf5a 100644
--- a/docs/INSTALLER.md
+++ b/docs/INSTALLER.md
@@ -3,10 +3,9 @@
## What it does
One linear flow, forward-only (every No skips ahead, nothing loops back):
-confirm → evict old daemon (via `sceKernelLoadStartModule`) → copy
-`evict.elf` + `orbisrpc.bin` to `/data/payloads/` (mkdir -p +
+confirm → copy `orbisrpc.bin` to `/data/payloads/` (mkdir -p +
byte-count + FNV hash read-back) → pre-saved config with token
-(skips entry when valid) → done.
+(skips entry when valid) → generation bump → done.
Read-only status screen available via decline.
There is no WiFi check. The installer runs sandboxed, so its socket probe
@@ -15,24 +14,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 `evict.elf` first
-(removes old orbisrpc instance), then pick `orbisrpc.bin`.
+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).
No loopback ports, no injection, no boot proof to go wrong.
## Install flow
```
-confirm → sceKernelLoadStartModule(evict.elf) — kills old daemon
- → copy evict.elf to /data/payloads/evict.elf
- → copy orbisrpc.bin to /data/payloads/orbisrpc.bin
+confirm → 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 `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.
+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.
## Navigation law
@@ -57,8 +56,9 @@ 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).
-- `evict.elf` is loaded via `sceKernelLoadStartModule` because `kill()`
- is blocked by the sandbox (EPERM).
+- 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.
## 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`, `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.
+`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.
Output: `IV0000-ORPC00001_00-ORBISRPCSETUP000.pkg`.
diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md
index 302183a..a8a86fc 100644
--- a/docs/PRODUCTION.md
+++ b/docs/PRODUCTION.md
@@ -25,7 +25,8 @@ 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.
-- `tools/evict.c` — SIGTERM-then-SIGKILL deploy rotation for the locked daemon.
+- `installer/` + daemon generation protocol — installer bumps
+ `daemon.gen`; older daemons exit cleanly when superseded (no signals).
## State machine
@@ -50,7 +51,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,
-evict.elf deploy tool, CI ASan+e2e jobs.
+generation-based deploy rotation, CI ASan+e2e jobs.
## Not fixed (external / out of scope)
@@ -71,7 +72,7 @@ evict.elf deploy tool, 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, evict clean-stop rotations,
+mp: art serving (phone), duplicate-ID gone, generation clean-stop rotations,
reboot recovery. Pending eyes: appdb name flip, badge render, logo tile.
## Readiness
diff --git a/docs/injecting.md b/docs/injecting.md
index 2e1a4b8..1a5b5dc 100644
--- a/docs/injecting.md
+++ b/docs/injecting.md
@@ -1,12 +1,18 @@
# 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 a real 9.00 console.
+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.
## You need first
-- PS4 on 9.00, jailbroken with GoldHEN (2.4b18+ recommended — older
- payloader builds segfault on ELF files, see below).
+- 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).
- 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
@@ -14,7 +20,8 @@ writing the payload. Everything below was learned on a real 9.00 console.
## Method 1 — elfldr (recommended)
-elfldr is a proper ELF loader that runs payloads as separate processes.
+elfldr (ps4-payload-dev's `ps4-payload-elfldr`) is a proper ELF loader
+that runs payloads as separate processes with runtime symbol resolution.
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.
@@ -88,8 +95,8 @@ the result on screen.
| `Connection refused` on 9021/9020 | No listener armed | Tap BinLoader / open payloader page, retry instantly |
| `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 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 |
+| 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 |
## Watching it work
diff --git a/installer/Makefile b/installer/Makefile
index 2e21400..a41fe43 100644
--- a/installer/Makefile
+++ b/installer/Makefile
@@ -1,5 +1,5 @@
# orbisRPC Setup installer (OpenOrbis app).
-# Flow: daemon payload -> evict old -> token -> done.
+# Flow: daemon payload -> token -> done (gen bump supersedes old daemon).
# Payload Guest reads /data/payloads/ on this console.
TITLE := orbisRPC Setup
VERSION := 1.0.0
@@ -42,8 +42,8 @@ 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/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))"
+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/sce_sys/param.sfo: installer/Makefile
$(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_new $@
@@ -68,13 +68,11 @@ $(INTDIR)/%.o: $(PROJDIR)/%.c
$(INTDIR)/jsonlite.o: orbisrpc/jsonlite.c
$(CC) $(CFLAGS) -o $@ $<
-# Stage the daemon + evict payloads + config into the app image.
+# Stage the daemon payload + 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 $< $@
@@ -82,4 +80,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/evict.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/config.json
diff --git a/installer/icfg.c b/installer/icfg.c
index 6d5916b..b4314ef 100644
--- a/installer/icfg.c
+++ b/installer/icfg.c
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#define ICFG_MAX (64u*1024u)
@@ -60,6 +61,8 @@ 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);
diff --git a/installer/installer.c b/installer/installer.c
index eb1f333..25aae3c 100644
--- a/installer/installer.c
+++ b/installer/installer.c
@@ -20,11 +20,10 @@
#define SETUP_VERSION "1.0.0"
#endif
#define DAEMON_ELF "/app0/assets/daemon.elf"
-#define EVICT_ELF "/app0/assets/evict.elf"
+#define GEN_PATH "/data/orbisRPC/daemon.gen"
#define INST_DIR "/data/orbisRPC"
#define INST_LOG "/data/orbisRPC/install.log"
#define PAYLOAD_BIN "/data/payloads/orbisrpc.bin"
-#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. */
@@ -217,27 +216,17 @@ 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");
- /* 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. */
- {
- 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);
- }
- }
+ ui_progress_open("Installing orbisRPC");
/* Copy daemon payload. */
+ ui_progress_msg("Copying payload");
ok = copy_file(DAEMON_ELF, PAYLOAD_BIN);
ilog("daemon-copy", ok, 0, 0);
+ ui_progress_set(60);
/* 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. */
+ ui_progress_msg("Saving config");
{
FILE *src = fopen("/app0/assets/config.json", "rb");
if(src){
@@ -251,16 +240,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 ? "" : "]",
- EVICT_BIN, evict_ok == 0 ? "OK" : "denied",
- evict_ok == 0 ? "" : " [stage ", evict_ok == 0 ? "" : g_stage, evict_ok == 0 ? "" : "]");
+ ok == 0 ? "" : " [stage ", ok == 0 ? "" : g_stage, ok == 0 ? "" : "]");
+ ui_progress_set(80);
+ ui_progress_close();
ui_ok(report);
if(ok != 0){
ui_ok("Install failed: could not write the payload.\n\nStopping here.");
@@ -279,6 +268,7 @@ 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);
@@ -286,6 +276,26 @@ 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);
+ ilog("gen-bump", (int)(cur + 1), 0, 0);
+ } else {
+ ilog("gen-bump", errno ? errno : -1, 0, 0);
+ }
+ }
return 0;
}
@@ -303,7 +313,19 @@ 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){ ui_ok("Text input failed. Skipping."); return; }
+ 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) return;
if(token_valid(tok)){
r = icfg_token_save(ICFG_PATH, tok);
diff --git a/orbisrpc/cfg.c b/orbisrpc/cfg.c
index 46784a4..a37ad7e 100644
--- a/orbisrpc/cfg.c
+++ b/orbisrpc/cfg.c
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
cfg_t g_cfg;
@@ -191,6 +192,8 @@ 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 */
diff --git a/orbisrpc/daemon.c b/orbisrpc/daemon.c
index c8530e6..6fee0c1 100644
--- a/orbisrpc/daemon.c
+++ b/orbisrpc/daemon.c
@@ -93,6 +93,47 @@ 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){
+ 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, "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 " " per finished
* session. Totals are computed by readers (app/docs); the daemon only
* appends, so a corrupt ledger can never break the runtime. */
@@ -210,11 +251,19 @@ int daemon_run(const char *fixed_game_name){
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. */
+ * mark clean so token-less boots never trip safe mode, then
+ * wait for a token to appear (FTP edit, no reboot, no exit). */
health_mark_healthy();
- log_msg("FATAL: put your Discord user token in %s as \"token\":\"...\"", CFG_PATH);
- log_close();
- return 1;
+ log_msg("no token in %s; waiting (edit \"token\" over FTP)", CFG_PATH);
+ status_write("waiting_token", "");
+ 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");
}
/* Self-update once per boot, before first connect. Never fatal:
@@ -229,6 +278,8 @@ int daemon_run(const char *fixed_game_name){
}
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;
@@ -244,6 +295,18 @@ 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", "");
+ 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. */
{
@@ -374,6 +437,8 @@ int daemon_run(const char *fixed_game_name){
if(now - last_alive >= 60){
last_alive = now;
log_msg("alive: %s", active ? last : "idle");
+ status_write(active ? "playing" : "idle",
+ active ? last : "");
}
/* State reconciliation: re-post current presence every
* 15 min so a silently desynced tile (dropped update,
diff --git a/orbisrpc/detect.c b/orbisrpc/detect.c
index 580b70d..40adaca 100644
--- a/orbisrpc/detect.c
+++ b/orbisrpc/detect.c
@@ -19,6 +19,7 @@
* foreground) is reliable via ShellCoreUtil.
*/
#include "detect.h"
+#include "fw.h"
#include "cfg.h"
#include "log.h"
#include "sfo.h"
@@ -225,7 +226,7 @@ int detect_eboot_count(void){
while(off + 4 <= sz){
int recsz = *(int *)(buf + off);
if(recsz <= 0 || off + (size_t)recsz > sz) break;
- if(recsz >= 479 && !memcmp(buf + off + 447, "eboot.bin", 10))
+ if(fw_match_eboot(buf + off, recsz))
n++;
off += (size_t)recsz;
}
@@ -242,7 +243,7 @@ static int proc_has_eboot(void){
while(off + 4 <= sz){
int recsz = *(int *)(buf + off);
if(recsz <= 0 || off + (size_t)recsz > sz) return -1;
- if(recsz >= 479 && !memcmp(buf + off + 447, "eboot.bin", 10))
+ if(fw_match_eboot(buf + off, recsz))
return 1;
off += (size_t)recsz;
}
@@ -510,6 +511,20 @@ int detect_current_game(char *out_name, size_t cap, char *out_path, size_t p_cap
* 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;
diff --git a/orbisrpc/detect.h b/orbisrpc/detect.h
index ffe64af..c89c1a6 100644
--- a/orbisrpc/detect.h
+++ b/orbisrpc/detect.h
@@ -2,6 +2,7 @@
#ifndef DETECT_H
#define DETECT_H
#include
+#include "focus.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.
* out_path is optional and receives the per-title cache path when provided. */
diff --git a/orbisrpc/focus.c b/orbisrpc/focus.c
new file mode 100644
index 0000000..899c104
--- /dev/null
+++ b/orbisrpc/focus.c
@@ -0,0 +1,135 @@
+/* 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
+#include
+#include
+#include
+
+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;
+}
diff --git a/orbisrpc/focus.h b/orbisrpc/focus.h
new file mode 100644
index 0000000..c88c5fe
--- /dev/null
+++ b/orbisrpc/focus.h
@@ -0,0 +1,25 @@
+/* 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
+
+#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);
+#endif
diff --git a/orbisrpc/fw.c b/orbisrpc/fw.c
new file mode 100644
index 0000000..277b845
--- /dev/null
+++ b/orbisrpc/fw.c
@@ -0,0 +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. */
+#include "fw.h"
+#include
+#include
+#ifndef ORBISRPC_SDK_PAYLOAD
+#include
+#endif
+
+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
+}
+
+/* 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 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;
+ }
+ }
+ return -1;
+}
+
+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);
+}
+
+/* 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;
+ }
+ }
+ 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;
+ 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;
+ }
+ /* 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;
+}
diff --git a/orbisrpc/fw.h b/orbisrpc/fw.h
new file mode 100644
index 0000000..3ce16f2
--- /dev/null
+++ b/orbisrpc/fw.h
@@ -0,0 +1,34 @@
+/* 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. */
+#ifndef ORBISRPC_FW_H
+#define ORBISRPC_FW_H
+
+#include
+
+/* 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);
+
+/* 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);
+
+/* 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
diff --git a/orbisrpc/lock.c b/orbisrpc/lock.c
index 580431b..764911b 100644
--- a/orbisrpc/lock.c
+++ b/orbisrpc/lock.c
@@ -2,6 +2,7 @@
* 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
#include
#include
@@ -24,10 +25,10 @@ static int pid_live(int pid){
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". */
- if(recsz >= 479 && *(int *)(buf + off + 72) == pid)
- return !memcmp(buf + off + 447, "Payload", 8) &&
- buf[off + 455] == 0;
+ * 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;
diff --git a/scripts/build.sh b/scripts/build.sh
index ee6fb9b..286e310 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -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 -lSceNetCtl -lSceSysmodule \
+LIBS="-lc -lkernel -lSceNet -lSceSysmodule \
-lSceUserService"
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 updater_http updater_util tls ws detect discord daemon compat lock timesync art health manifest main; do
+for f in log cfg jsonlite b64 sfo tmdb_crypto tmdb updater updater_http updater_util tls ws detect focus fw 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) ==="
diff --git a/scripts/build_evict.sh b/scripts/build_evict.sh
deleted file mode 100755
index 4896926..0000000
--- a/scripts/build_evict.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/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"
-echo "=== evict (SDK) ==="
-"$CC" -O2 -Wall -DORBISRPC_SDK_PAYLOAD -o "$OUT/evict.elf" tools/evict.c || { echo "FAIL: evict"; exit 1; }
-ls -la "$OUT/evict.elf"
diff --git a/scripts/build_sdk.sh b/scripts/build_sdk.sh
index 32a5b4d..3a57c79 100755
--- a/scripts/build_sdk.sh
+++ b/scripts/build_sdk.sh
@@ -16,7 +16,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 updater_http updater_util tls ws detect discord daemon compat lock timesync art health manifest appdb main; do
+for f in log cfg jsonlite b64 sfo tmdb_crypto tmdb updater updater_http updater_util tls ws detect focus fw 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; }
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000..19c4f1a
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,17 @@
+#!/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-.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"
diff --git a/tests/Makefile b/tests/Makefile
index 211af79..a397736 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -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
+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
INST_SRCS := ../installer/icfg.c
# SQLite amalgamation: -O0 for host iteration speed (payload uses -O2).
SQLITE_DIR := ../third_party/sqlite/sqlite-amalgamation-3510100
diff --git a/tests/test_utils.c b/tests/test_utils.c
index 8a22ed7..99d3437 100644
--- a/tests/test_utils.c
+++ b/tests/test_utils.c
@@ -10,6 +10,8 @@
#include "../orbisrpc/appdb.h"
#include "../orbisrpc/discord.h"
#include "../orbisrpc/detect.h"
+#include "../orbisrpc/focus.h"
+#include "../orbisrpc/fw.h"
#include "../installer/icfg.h"
#include "sqlite3.h"
#include
@@ -618,6 +620,75 @@ static void test_installer_cfg(void) {
assert(icfg_get_str("/nonexistent/x.json", "k", st, sizeof st) != 0);
}
+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);
+}
+
+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);
+}
+
int main(void) {
test_json();
test_gateway_op_spoof();
@@ -637,6 +708,8 @@ int main(void) {
test_installer_cfg();
test_appdb();
test_discord_builder();
+ test_focus();
+ test_fw();
puts("utility tests passed");
return 0;
}
diff --git a/tools/evict.c b/tools/evict.c
deleted file mode 100644
index 78b1aee..0000000
--- a/tools/evict.c
+++ /dev/null
@@ -1,98 +0,0 @@
-/* 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 sysctl walk as lock.c, then SIGTERM (clean stop:
- * banks session, clears presence, releases lock) with a SIGKILL fallback.
- * Refuses to signal anything that is not a live Payload peer.
- * Build: ./scripts/build_evict.sh -> build-sdk/evict.elf
- * Run: send via elfldr:9021, then send the fresh orbisrpc_sdk.elf.
- * Result: /data/orbisRPC/evict.txt + klog printf. */
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#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. */
-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 */
- 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;
- if(recsz >= 479 && *(int *)(buf + off + 72) == pid)
- /* Exact "Payload" + NUL: a prefix match would bless
- * PayloadHelper-style names for the kill list. */
- return !memcmp(buf + off + 447, "Payload", 8) &&
- buf[off + 455] == 0;
- off += (size_t)recsz;
- }
- return 0;
-}
-
-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;
-}