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; -}