commit 3e0f4b492cafe0d2f8076b0e46e7366b357ed1dc
Author: Humza <204067870+SirHumza@users.noreply.github.com>
Date: Fri Sep 25 18:01:05 2026 +0200
orbisRPC — history-free import of main
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9bf79f1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: ci
+on: [push, pull_request]
+jobs:
+ host:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: host unit tests
+ run: make -C tests clean && make -C tests test
+ - name: ASan/UBSan
+ run: make -C tests asan
+ - name: e2e contracts
+ run: python3 tests/e2e_consumer.py
+ sdk-payload:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: install llvm
+ run: sudo apt-get update -qq && sudo apt-get install -qy llvm lld
+ - name: fetch ps4-payload-sdk
+ run: |
+ curl -sL -o /tmp/sdk.zip https://github.com/ps4-payload-dev/sdk/releases/latest/download/ps4-payload-sdk.zip
+ mkdir -p ~/ps4-payload-sdk && unzip -q -o /tmp/sdk.zip -d ~/ps4-payload-sdk
+ - name: build SDK daemon payload
+ run: |
+ export PS4_PAYLOAD_SDK="$HOME/ps4-payload-sdk/ps4-payload-sdk"
+ export PATH="/usr/lib/llvm-14/bin:$PATH"
+ ./scripts/build_sdk.sh
+ - name: gate daemon-world linkage only
+ run: |
+ export LLVM_READELF="$(command -v llvm-readelf || echo /usr/lib/llvm-14/bin/llvm-readelf)"
+ NEEDED=$("$LLVM_READELF" -d build-sdk/orbisrpc_sdk.elf | grep NEEDED | sed 's/.*\[//;s/\]//')
+ echo "$NEEDED"
+ echo "$NEEDED" | grep -q libkernel_web.sprx || { echo "missing libkernel_web"; exit 1; }
+ echo "$NEEDED" | grep -q libSceLibcInternal.sprx || { echo "missing libSceLibcInternal"; exit 1; }
+ echo "$NEEDED" | grep -q libSceNet.sprx || { echo "missing libSceNet"; exit 1; }
+ if echo "$NEEDED" | grep -q 'libkernel\.so'; then echo "FORBIDDEN: app-world libkernel.so"; exit 1; fi
+ echo "linkage OK"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..32d24b0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+build/
+*.o
+*.err
+*.elf
+*.fself
+*.prx
+*.oelf
+/tmp/
+.DS_Store
+config/config.ps4.json
+plugin/build/
+tests/test_utils
+config/icons/*
+!config/icons/logo.png
+
+# installer artifacts
+*.pkg
+installer/x64/
+installer/eboot.bin
+installer/pkg.gp4
+installer/sce_sys/param.sfo
+build-sdk/
+
+# installer build outputs
+installer/x64/
+installer/eboot.bin
+installer/pkg.gp4
+installer/assets/daemon.elf
+installer/assets/evict.elf
+installer/sce_sys/param.sfo
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 0000000..37c6bfa
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,75 @@
+# HANDOFF — OrbisRPC PS4 Rich Presence
+
+Date: 2026-09-24. Branch: `main` (pushed). Console: PS4 9.00, GoldHEN 2.4,
+`192.168.1.136` (FTP :2121 anon, klog :3232, BinLoader one-shot :9020).
+
+## Folder layout (repo root)
+
+- `orbisrpc/` — all daemon sources. Key files: `daemon.c` (loop/sessions),
+ `discord.c` (presence serialize), `detect.c` (game finding), `tmdb.c` +
+ `art_table.h` (Sony metadata), `art.c` (mp: proxy resolve), `tls.c` +
+ `ws.c` (transport), `updater.c` + `manifest.c` + `health.c` (updates),
+ `timesync.c` (SNTP), `lock.c`, `cfg.c`, `log.c`, `jsonlite.c`.
+- `scripts/build_sdk.sh` — builds the daemon payload with ps4-payload-sdk
+ (`build-sdk/orbisrpc_sdk.elf`). Toolchain: `~/ps4-payload-sdk`, shim in
+ `~/llvmshim`. NEVER link `libkernel.so` (instant death in spawn context;
+ gate in CI + `tests/e2e_consumer.py`).
+- `tests/` — host unit tests + e2e contracts. `make test`, `make asan`.
+- `scripts/build_art_table.py` + `titles.txt` — regenerates art table/icons.
+- `docs/injecting.md` — how payloads get onto the console (one-shot rule!).
+- `findings/` — hardware research archive (loaders, TLS PSA, IME, linkage).
+- `scripts/ps4_watch.py` — one-shot console state diff (repo copy may be
+ absent; logic: FTP file sizes + ports + newest screenshot).
+
+## Existing issues (ranked, all reproduced)
+
+1. **Cover art tile** — shows "?" instead of game art. mp: proxy renders
+ (proven live), uploaded asset stuck `private`, external URLs drop the
+ whole activity. Daemon resolves mp: per session (`art: resolved mp` in
+ log). Needs: propagation wait or per-title asset strategy (below).
+2. **Stale title on fresh launches** — save-mtimes get bulk-touched by
+ cloud sync (all 28 titles shared one mtime!); app.pkg atime added as
+ sharper signal; eboot-set change fast-path added. Unproven live.
+3. **Game crashes (CE-34878-0)** on some titles with daemon on — no
+ mechanism found after full audit; console has prior crash history.
+ Needs clean-boot test (reboot, no payload, launch).
+4. **IME keyboard** never opens (0x80BC0010 through six fixes). Token is
+ pre-seeded via FTP; do not block on this.
+5. **Rest Mode** — wake/resume coded, never tested through a real cycle.
+6. **Updater drill** — staged-update path never tested live.
+7. **Plugin path** — PRX can't deploy via FTP (console converts SELF→ELF);
+ must ship inside PKG. Untested live.
+8. **`main` vs `WIP` branches** — main = proven runtime line; WIP = old
+ 1.0 product line (installer/app/docs). Do not merge blindly.
+
+## What could be better (next agent: pick these up)
+
+- **Rest Mode field test** + home-screen presence proof.
+- **Updater live drill** (stage → reboot → rollback check).
+- **IME**: needs on-console iteration with return-code logging (already
+ instrumented in `pkg/app_main.c` on the WIP line).
+- **Plugin live test** via PKG delivery.
+- **e2e/CI**: `.github/workflows/ci.yml` exists; extend coverage for new
+ modules (lock, timesync, art, session persistence).
+- **Playtime ledger reader** (totals per title; data already banked).
+- **Media-type table**: only Netflix/YouTube mapped; add IDs as found.
+
+## Auto icons from Sony CDN (no manual adding) — the design
+
+Goal: never hand-add a title again. Three layers, in order:
+
+1. **Live TMDB-over-TLS** (`tmdb_https_get()` in `tmdb.c`, already coded):
+ queries `tmdb.np.dl.playstation.net:443` for any title ID at resolve
+ time. Port 80 is blocked on-console; **443 was never proven — first
+ job is running `probe_tmdb443.elf` on hardware.** If green, every
+ current and future game resolves fully automatically.
+2. **Self-growing table fallback**: daemon already logs unknown title IDs
+ implicitly (raw-ID sessions). Add a CI job: parse `titles.txt` +
+ any new IDs seen, run `build_art_table.py`, commit regenerated
+ `art_table.h` + icons. Table then grows itself with zero hand edits.
+3. **mp: proxy at post time** (`art.c`, proven rendering): whatever URL
+ layers 1–2 produce gets proxied per session; cache expires per game
+ switch so mappings never go stale.
+
+Rule: hands touch art ZERO times — new game installs resolve through
+layer 1, or land in the table via layer 2's automation.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..36edd74
--- /dev/null
+++ b/README.md
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+Discord Rich Presence for the jailbroken PS4 — every game, zero setup.
+A background daemon that lives entirely on your console and posts what you're
+playing to Discord: name, cover art, timer. No PC at runtime.
+
+---
+
+## Install (5 minutes)
+
+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** → say Yes. It drops the daemon into GoldHEN's
+ `bin/elf` and saves your Discord token.
+3. Start **orbisrpc** from GoldHEN's payload menu, launch a game, watch Discord.
+
+After a reboot: re-jailbreak, then enable AutoRun for `orbisrpc` in GoldHEN's
+payload menu once — it starts itself on every jailbreak after that.
+
+## What you get
+
+| | |
+|---|---|
+| 🎮 **Any game, no lists** | Names resolve from your console's own database — CUSA, PPSA, indies, all covered with zero per-game setup. |
+| 🖼️ **Real cover art** | Game art served per title, PlayStation logo when idle. |
+| ⏱️ **True timers** | Survive reconnects and restarts, resume across quick game switches. |
+| 🧠 **Self-learning** | First-seen titles are remembered, so later boots resolve instantly. |
+| 🔄 **Self-updating** | Signed daemon updates with boot rollback. No reinstall treadmill. |
+| 📦 **One-tap installer** | Setup PKG: install → token → payload in place. |
+
+## How it works
+
+```
+PS4 (GoldHEN) Discord
+┌─────────────────────────┐ ┌──────────────────┐
+│ orbisRPC daemon │ TLS │ your profile │
+│ sandbox scan → game ID │ ◄──► │ Playing Game │
+│ app.db → display name │ │ [cover] [timer] │
+└─────────────────────────┘ └──────────────────┘
+```
+
+Details: [`docs/DAEMON.md`](docs/DAEMON.md) · [`docs/INSTALLER.md`](docs/INSTALLER.md) ·
+[`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) ·
+[`docs/PRODUCTION.md`](docs/PRODUCTION.md)
+
+## Config
+
+Only `token` (your Discord user session token) is required —
+`/data/orbisRPC/config.json`. Everything else ships working: presence text,
+home art, poll interval, plus the self-learned `titles` map (hands off).
+
+## Building
+
+```bash
+./scripts/build_sdk.sh # daemon payload (needs ps4-payload-sdk)
+make -f installer/Makefile # Setup PKG (OpenOrbis toolchain, llvmshim)
+make -C tests test # host unit tests
+make -C tests asan # ASan/UBSan
+python3 tests/e2e_consumer.py # contracts + linkage gate
+```
+
+## Safety
+
+A user session token grants full account access — never share the config,
+never commit a real token. Token-based presence is against Discord's ToS
+(standard practice for headless presence tools; risk is yours).
+
+## License
+
+To be finalized (MIT vs GPL). No GPL source is copied into this tree.
diff --git a/config/config.json b/config/config.json
new file mode 100644
index 0000000..b08aca7
--- /dev/null
+++ b/config/config.json
@@ -0,0 +1,8 @@
+{
+ "token": "SET_ME",
+ "application_id": "1536977374795538532",
+ "art_base_url": "https://raw.githubusercontent.com/SirHumza/orbisrpc-host/main/icons/",
+ "enabled": 1,
+ "poll_interval_s": 12,
+ "presence_state": "On PS4"
+}
diff --git a/config/icons/logo.png b/config/icons/logo.png
new file mode 100644
index 0000000..4ebf258
Binary files /dev/null and b/config/icons/logo.png differ
diff --git a/config/titles.json b/config/titles.json
new file mode 100644
index 0000000..9ab7899
--- /dev/null
+++ b/config/titles.json
@@ -0,0 +1,61 @@
+{
+"CUSA00001": "THE PLAYROOM",
+"CUSA00219": "Destiny",
+"CUSA00568": "Destiny",
+"CUSA00572": "SHAREfactory™",
+"CUSA00960": "PlayStation™ Vue",
+"CUSA01000": "Destiny",
+"CUSA01697": "PlayStation™Now",
+"CUSA01780": "Spotify",
+"CUSA02012": "Media Player",
+"CUSA06014": "TEKKEN™7",
+"FLTZ00003": "Remote PKG installer",
+"LAPY20009": "PS4-Xplorer 2.0",
+"NPXS20102": "Internet Browser",
+"NPXS20104": "Capture Gallery",
+"NPXS20105": "Live from PlayStation",
+"NPXS20106": "Music Unlimited",
+"NPXS20107": "PlayStation™Video - My Videos",
+"NPXS20108": "What's New",
+"NPXS20109": "",
+"NPXS20110": "TV & Video",
+"NPXS20111": "Library",
+"NPXS20112": "",
+"NPXS20114": "PlayStation™Now",
+"NPXS20117": "USB Music Player",
+"NPXS20118": "Share Play",
+"NPXS20120": "PlayStation™Video",
+"NPXS20132": "",
+"NPXS20133": "",
+"NPXS20979": "PlayStation Store",
+"NPXS21021": "PS5 Remote Play",
+"NPXS39041": "Store",
+"CUSA13323": "Ghost of Tsushima",
+"CUSA11995": "Marvel's Spider-Man",
+"CUSA20177": "Marvel's Spider-Man: Miles Morales",
+"LAPY20006": "PS4 Temperature",
+"CUSA01015": "YouTube",
+"CUSA00411": "Grand Theft Auto V",
+"CUSA26618": "Grand Theft Auto: San Andreas – The Definitive Edition",
+"CHTM00777": "PS4 Cheats Manager",
+"PKGI13337": "FPKGi",
+"MANU90003": "FPKGi Data",
+"CUSA00135": "BATMAN™: ARKHAM KNIGHT",
+"CUSA08992": "DRAGON BALL FighterZ",
+"CUSA03245": "Deadpool",
+"CUSA06712": "Goat Simulator",
+"CUSA01839": "STREET FIGHTER IV",
+"CUSA14909": "SpongeBob SquarePants: Battle For Bikini Bottom",
+"CUSA24899": "Stray",
+"CUSA00740": "Terraria",
+"CUSA20499": "Cuphead",
+"CUSA00021": "WATCH_DOGS™",
+"CUSA18795": "Skater XL",
+"APOL00004": "Apollo Save Tool",
+"CUSA04602": "Batman: Arkham City",
+"CUSA01004": "Sleeping Dogs",
+"CUSA04294": "WATCH_DOGS® 2",
+"LAPY20015": "Avatar Changer",
+"GBTX00001": "GameBaTo",
+"CUSA00127": "Netflix"
+}
\ No newline at end of file
diff --git a/deploy/ARTWORK.md b/deploy/ARTWORK.md
new file mode 100644
index 0000000..f159a0c
--- /dev/null
+++ b/deploy/ARTWORK.md
@@ -0,0 +1,46 @@
+# Cover art pipeline
+
+## Path A: Sony TMDB icon URLs (primary, fully automatic)
+
+Every title lookup also queries Sony's TMDB service, which returns an
+official 512x512 icon URL on Sony's CDN. The daemon sends it as
+`assets.large_image` (Discord accepts external image URLs there). No
+uploads, no hosting, no per-game work, works on any console. If the
+service lacks the title or the network fails, the next path is tried.
+
+## Path B: icon pack + external URLs (fallback, maintainer-hosted)
+
+## Path A: icon pack + external URLs (recommended)
+
+The daemon sends `assets.large_image` as
+`.png` (Discord accepts external image
+URLs in asset fields). Host the pack once, e.g. in this repo:
+
+```
+config/icons/cusa00740.png (512x512 PNG, pulled from the console)
+```
+
+`scripts/sync_icons.sh` pulls every `/user/appmeta//icon0.png`
+off the PS4 over FTP (read-only) into `config/icons/`. Set
+`art_base_url` in config to the hosted prefix, e.g.
+`https://raw.githubusercontent.com//orbisRPC/main/config/icons/`.
+Every install then shows art with zero setup. Missing files degrade to
+no image. Pack hosting is a maintainer decision (game art is not ours).
+
+## Path B: shared Discord application (fallback)
+
+Create an application, upload one PNG per game under Art Assets named
+as the lowercase title ID, set `application_id` in config. The daemon
+sends the asset key instead. Capped at 300 assets per app.
+
+Current default: the public PS4-Rich-Presence-for-Discord application
+(zorua98741/bshar1865, ID 858345055966461973), which already hosts
+per-title art. Borrowed backend, credited here: if it ever goes away,
+art degrades to nothing and everything else keeps working. Replace the
+default with your own app ID to own the whole chain.
+
+## Runtime behavior
+
+URL pack wins when `art_base_url` is set, uploaded keys when only
+`application_id` is set, no artwork otherwise (one log notice). Missing
+art never crashes anything.
diff --git a/docs/DAEMON.md b/docs/DAEMON.md
new file mode 100644
index 0000000..1574073
--- /dev/null
+++ b/docs/DAEMON.md
@@ -0,0 +1,45 @@
+# Daemon design (v1.0.0)
+
+## Loop
+
+Outer connect cycles (jittered exponential backoff, 60 s cap ×10, then
+10-minute cadence, never exits) wrap an inner 1 s session loop. Token-4004
+never kills the daemon at either level: it backs off and retries so a fixed
+token lands on its own.
+
+## States
+
+`NONE -> HOME <-> GAME`, every transition logged. Game commits need 2
+consecutive polls; closes need 2 misses. Home posts once (timerless logo
+tile) and re-posts after every reconnect (a drop can never leave a blank
+tile). Every 15 min the current state reposts regardless (reconciliation).
+
+## Detection
+
+1. `/mnt/sandbox/_000` mount = authoritative running ID.
+2. eboot-process count change = launch/close right now (fast-switch).
+3. Save/appdir/app.pkg-atime signals disambiguate.
+
+## Names (first hit wins, no baked tables ever)
+
+1. `titles` map in config (manual override + self-learned).
+2. System app.db, read-only SQLite: `tbl_appbrowse.titleName`, then
+ `tbl_appinfo` TITLE keys, then `/user/appmeta//param.json`.
+3. Local pronunciation.xml / param.sfo / app.xml.
+4. Sony TMDB live (`_00` + HMAC-SHA1 URL — byte-identical to the PC
+ tools; unreachable from most consoles, kept as fallback).
+5. Raw ID (never shown twice; retried after 5 min, never frozen).
+
+## Art
+
+mp: proxy via external-assets → uploaded asset key → icon-pack pattern.
+Dangling keys/URLs are dropped, never sent (they blank the activity).
+Game tile carries a small system badge. 7-day disk cache + memory cache.
+
+## Time, sessions, health
+
+SNTP (connected UDP, Nov 2023–Dec 2034 window) drives timer ms. Sessions
+persist atomically with validation; 10-min resume window; playtime ledger.
+Health: dirty-boot marker (marker-first ordering), crash counter, 3-strike
+safe mode, signed all-or-nothing updates with boot rollback.
+Single-instance lock with sysctl liveness + exact-name check.
diff --git a/docs/INSTALLER.md b/docs/INSTALLER.md
new file mode 100644
index 0000000..6424e55
--- /dev/null
+++ b/docs/INSTALLER.md
@@ -0,0 +1,75 @@
+# Installer (Setup PKG, `ORPC00001`)
+
+## 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 +
+byte-count + FNV hash read-back) → pre-saved config with token
+(skips entry when valid) → done.
+Read-only status screen available via decline.
+
+There is no WiFi check. The installer runs sandboxed, so its socket probe
+measures the app's network stack, not the payload's — and a FAILED line
+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`.
+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
+ → save config.json with Discord token (pre-populated)
+ → (token entry skipped if already valid)
+ → 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.
+
+## Navigation law
+
+No branch ever returns to start. Cancel/skip always moves forward. The only
+exits are Done, explicit close, or a payload write failure.
+
+Buttons: X = enter, O = back. The confirm dialog uses `YESNO` focused on
+Yes — the OpenOrbis default `YESNO_FOCUS_NO` puts the console's confirm
+button on No, which inverts the whole wizard.
+
+## Console facts encoded
+
+- Splash is hidden first thing in `main()` so module loading in
+ `ui_init()` happens with the splash already gone — no visible delay
+ between splash hide and first dialog.
+- Dialog/IME/IME-backend sysmodules + internal SYSTEM/USER/COMMON/PAD
+ modules load before use (order matters — `sceCommonDialogInitialize()`
+ precedes external module loads; PAD is unloaded on exit).
+- `stat()`/`fstat()` lie about sizes inside the sandbox (observed 4096/4160
+ for a 2071552-byte file), so install proof is byte count + FNV hash on
+ read-back, and status uses open-existence, never stat.
+- 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).
+
+## Auto-start
+
+Payload Guest AutoRun: enable it for `orbisrpc` once and the daemon
+starts on every jailbreak. The queue is menu-managed; the installer
+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.
+Output: `IV0000-ORPC00001_00-ORBISRPCSETUP000.pkg`.
diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md
new file mode 100644
index 0000000..302183a
--- /dev/null
+++ b/docs/PRODUCTION.md
@@ -0,0 +1,80 @@
+# OrbisRPC Production Report
+
+Build under review: `9d9c5b7` (daemon 0.4.0 line, `main`), payload
+`build-sdk/orbisrpc_sdk.elf` (2,071,536 B, daemon-world linkage only,
+zero baked titles). Verified: host unit + ASan/UBSan + e2e ALL PASS;
+linkage gate (libkernel_web / libSceLibcInternal / libSceNet, never
+libkernel.so). Running on-console at 192.168.1.136 via elfldr:9021.
+
+## Architecture
+
+- `daemon.c` — outer connect cycles (jittered backoff, 10-fail 10-min
+ cadence) + inner session loop (12 s poll, 2-hit commit debounce,
+ playback queue of one). Re-reads config every cycle; learned titles
+ persist via atomic save. Token-4004 waits for a config fix, never exits.
+- `detect.c` — sandbox-mount scan (authoritative ID) + save/appdir/atime
+ signals + eboot-count fast-switch. Name chain: config override ->
+ app.db (SQLite readonly) -> appmeta/sfo/appxml -> Sony TMDB live ->
+ raw-ID fallback. Tri-state unknown-hold, 5-min raw retry.
+- `discord.c`/`ws.c`/`tls.c` — gateway v10, TLS 1.2 ceiling,
+ VERIFY_REQUIRED against curated bundle, masked frames, HB ack tracking.
+- `art.c` — mp: proxy via external-assets, pack-pattern fallback,
+ uploaded-key fallback, memory + 7-day disk cache. Home tile: PlayStation
+ logo default; game tile: small system badge.
+- `health.c`/`updater.c`/`manifest.c` — dirty-boot marker, 60 s stable
+ 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.
+
+## State machine
+
+NONE -> HOME ("PlayStation 4" + logo) <-> GAME (name + art + badge +
+ticking timer). Media titles map to Watching/Listening. Resume window:
+same title back within 10 min (or cross-restart via session.json) resumes
+the timer. Sessions append to playtime.log ledger.
+
+## Third-party logic ported (verified against source)
+
+- PS4-Rich-Presence (Python/C#): TMDB `_00` HMAC construction (live-200
+ verified), persistent mapped cache (self-learning map), title-ID hover
+ text, sandbox/NPXS-skip detect, backoff cadence, asset-key fallback.
+- PSN-API tool: small system badge pattern.
+- SonicLoader: read-only app.db tiers (appbrowse/appinfo/param.json).
+- Deliberate skips: PSN credentials, UI editors, PS1/PS2 lists, extra
+ toggles, third-party scrapes (reasons in git history/discussion).
+
+## Fixed this cycle
+
+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.
+
+## Not fixed (external / out of scope)
+
+- Sony TMDB unreachable from the console (TCP filtered; correct URL
+ proven from LAN). Mitigated: app.db + learning map.
+- GoldHEN settings-page loader segfaults every ELF (external).
+- IME dialog init errors (external API behavior).
+- Vesktop `?` tile (client quirk; phone renders fine).
+- Dead Discord notify webhook (cosmetic post-push hook).
+
+## Risks
+
+- PS4 app.db schema varies by firmware: tiers fail soft, fallback chain
+ covers. Worst case is today's behavior, never worse.
+- Bare home_art keys are trusted: a typo drops the home tile only.
+- Full titles map (64) stops learning with a log line.
+
+## 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,
+reboot recovery. Pending eyes: appdb name flip, badge render, logo tile.
+
+## Readiness
+
+Production for the daemon scope. Installer/PKG remains out of scope per
+directive. Ship it.
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
new file mode 100644
index 0000000..f791e8a
--- /dev/null
+++ b/docs/TROUBLESHOOTING.md
@@ -0,0 +1,47 @@
+# Troubleshooting
+
+## `?` tile / missing art
+
+1. Daemon log first: `art: resolved mp` = our side fine, look downstream.
+2. Vesktop (desktop mod) shows `?` for tiles the official clients and
+ phone render correctly. Check phone before reporting.
+3. Black/blank idle tile = `home_art` empty or pointing at a dead URL.
+ Set it to a live PNG (default: project PlayStation logo).
+
+## Name shows raw ID (CUSA…)
+
+Chain: config `titles` → app.db → local files → Sony TMDB → fallback.
+TMDB is TCP-filtered from most consoles (proven, not fixable in code).
+If app.db misses too, add one line to `titles` — or wait: first-hit
+self-learns, so one manual entry fixes a title forever.
+
+## Installer opens then instantly exits
+
+The first dialog was auto-dismissed (system transition). Current builds
+hide the splash + settle 3 s first. Reinstall the latest PKG.
+
+## Kernel panic on installer launch (fixed)
+
+Called dialogs without loading sysmodules. Fixed: module loads in
+`ui_init`, app exits quietly if they fail. If you still panic, your PKG
+predates the fix — reinstall.
+
+## Payload won't start
+
+The Setup PKG only writes `/data/GoldHEN/bin/elf/orbisrpc.bin` — it never
+launches anything. Start it from GoldHEN's payload menu, or enable AutoRun
+for `orbisrpc` once so it boots with every jailbreak.
+
+Manual injection (see `injecting.md`) still exists and needs the loaders
+open: ports 9090/9021/9020 are closed whenever GoldHEN's BinLoader toggle
+is off. Flip it in GoldHEN's menu. Reboot wipes jailbreak + daemon
+(RAM-only), so re-jailbreak first.
+
+## Token rejected (close 4004)
+
+Token dead (password change / logout-all). Paste a fresh one; the daemon
+survives 4004s and picks it up without reinstall.
+
+## No FTP (connection refused)
+
+GoldHEN FTP toggle off, or console rebooted to stock. Re-jailbreak.
diff --git a/docs/injecting.md b/docs/injecting.md
new file mode 100644
index 0000000..ca2aa69
--- /dev/null
+++ b/docs/injecting.md
@@ -0,0 +1,107 @@
+# 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.
+
+## You need first
+
+- PS4 on 9.00, jailbroken with GoldHEN (2.4b18+ recommended — older
+ payloader builds segfault on ELF files, see below).
+- Console and computer on the same network. Find the PS4 IP:
+ Settings → Network → View Connection Status (ours is `192.168.1.136`).
+- The payload file: `build-sdk/orbisrpc_sdk.elf` (built via
+ `./scripts/build_sdk.sh`).
+
+## Method 1 — elfldr (recommended)
+
+elfldr is a proper ELF loader that runs payloads as separate processes.
+
+1. Get it listening. Either load `elfldr.elf` through GoldHEN's payloader
+ page once, or keep it running — it serves on **port 9021** until reboot.
+2. Send the payload with a raw TCP connection, then close the write side:
+
+```python
+import socket
+data = open("build-sdk/orbisrpc_sdk.elf", "rb").read()
+s = socket.socket()
+s.settimeout(25)
+s.connect(("192.168.1.136", 9021))
+s.sendall(data)
+try:
+ s.shutdown(socket.SHUT_WR) # signals end-of-payload
+except OSError:
+ pass
+s.close()
+```
+
+3. Verify it runs: within ~20 seconds `/data/orbisRPC/log.txt` must contain
+ `orbisRPC payload boot`. No file = it never executed.
+
+## Method 2 — GoldHEN BinLoader server (port 9020)
+
+The classic route (NetCat tools, phone apps, scripts all speak it).
+
+1. Arm it: tap **BinLoader** in the exploit host menu (NOT the GoldHEN
+ settings payloader page — different loader).
+2. Within seconds, fire blind — **one single connection carrying the full
+ payload, no port check first**:
+
+```python
+import socket
+data = open("build-sdk/orbisrpc_sdk.elf", "rb").read()
+s = socket.socket()
+s.settimeout(25)
+s.connect(("192.168.1.136", 9020))
+s.sendall(data)
+s.close()
+```
+
+3. Same verification: look for `orbisRPC payload boot` in the log.
+
+### THE ONE-SHOT RULE (read this twice)
+
+GoldHEN's BinLoader arms **once**. Any empty port check (`connect_ex`,
+`nc -z`, port scanners) connects with nothing to send and **burns the
+arm** — the real payload then arrives at a dead listener. Sequence is
+always: tap → say go → fire immediately → verify. Never probe first.
+
+## Method 3 — in-app injector
+
+The OrbisRPC setup app injects over loopback itself
+(`127.0.0.1:9020`, fallback `9090`) from its menu. Needs a listening
+loader exactly like Method 2.
+
+## Method 4 — sender page (no PC tools)
+
+`https://SirHumza.github.io/orbisrpc-host/` in the PS4 browser
+auto-sends the bundled backend to the console's own loader and prints
+the result on screen.
+
+## What NOT to use
+
+- **GoldHEN settings payloader page**: on GoldHEN ≤ 2.4 (pre-b18.5
+ lineage) its loader thread segfaults (`SIGSEGV`, null read in
+ `GoldHENLoader`) on every ELF — including 90KB hello-worlds. The klog
+ prints `payload launched successfully` and then dies. If you see this,
+ update GoldHEN, don't rebuild the payload.
+- **Raw SELF files**: the page expects ELF (`\x7fELF`). SELF uploads
+ report "invalid payload".
+
+## Troubleshooting
+
+| Symptom | Meaning | Fix |
+|---|---|---|
+| `Connection refused` on 9020/9090 | 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 |
+
+## Watching it work
+
+- Kernel log (loader events, faults): TCP `192.168.1.136:3232`, raw stream.
+- Daemon log: `/data/orbisRPC/log.txt` via FTP (`192.168.1.136:2121`,
+ anonymous). Absent file = payload never executed, full stop.
+- Process list: `sysctl kern.proc` (see `scripts/ps4_watch.py`) — look
+ for `Payload` and `eboot.bin` entries.
+- Final proof: Discord profile shows the game + ticking timer.
diff --git a/eboot.bin b/eboot.bin
new file mode 100644
index 0000000..e8d1b2e
Binary files /dev/null and b/eboot.bin differ
diff --git a/findings/README.md b/findings/README.md
new file mode 100644
index 0000000..3f13308
--- /dev/null
+++ b/findings/README.md
@@ -0,0 +1,14 @@
+# Findings archive
+
+Hardware-grounded research from bringing OrbisRPC up on a real PS4
+(9.00, GoldHEN 2.4). Written for humans and for AI assistants joining
+later: every claim below was observed on-console unless marked THEORY.
+
+- [loader.md](loader.md) — GoldHEN payloader vs BinLoader server vs
+ elfldr: behaviors, crashes, one-shot rule, ports.
+- [elf-linkage.md](elf-linkage.md) — why OpenOrbis-linked binaries die
+ instantly in spawned processes; NEEDED/UND analysis; SDK port status.
+- [tls-ime.md](tls-ime.md) — mbedTLS 3.x PSA failure on-console (fixed),
+ IME dialog init failures (open), exact error codes seen.
+- [capture.md](capture.md) — how to observe: klog, FTP paths, ports,
+ screenshot locations, what each log proves.
diff --git a/findings/capture.md b/findings/capture.md
new file mode 100644
index 0000000..6e8d5d9
--- /dev/null
+++ b/findings/capture.md
@@ -0,0 +1,42 @@
+# Observing the console (for assistants)
+
+## Channels
+
+| Channel | Address | Use |
+|---|---|---|
+| FTP | `192.168.1.136:2121` (anonymous) | files up/down |
+| Kernel log | `192.168.1.136:3232` (TCP stream) | loader events, faults, crashes |
+| BinLoader server | `192.168.1.136:9020` (one-shot per arm) | inject; NEVER probe first — an empty connect burns the arm, then fire blind |
+| elfldr (if running) | `192.168.1.136:9021` | third-party ELF test harness |
+
+## Key paths (FTP)
+
+- `/data/orbisRPC/log.txt` — daemon log. Absent = payload never ran.
+- `/data/orbisRPC/app.log` — setup-app + transport logs.
+- `/data/orbisRPC/screen.log` — every dialog as text + answers.
+- `/data/orbisRPC/diag.txt` — sanitized report (token structurally excluded).
+- `/data/GoldHEN/payloads/` — autoloaded at jailbreak.
+- `/data/payloads/` — Payload Guest scan dir.
+- `/data/pkg/` — PKG staging.
+- `/user/av_contents/photo/NPXS20001//*/*/*.jpg` — screenshots.
+- `/user/data/sce_coredumps/` — crash dumps.
+
+## Proof markers
+
+- Daemon alive: `log.txt` exists with `daemon start` line.
+- Network proven: `tls: established (TLSv1.2)` in app.log.
+- September success marker: `gateway ready` + `presence:` lines.
+- Loader segfault signature: `signal 11 (SIGSEGV), thread GoldHENLoader`,
+ fault address 0, right after `payload launched successfully`.
+
+## Rules learned the hard way
+
+1. Never port-check 9020/9090 before sending — fire blind on tap.
+2. Klog is a live firehose: capture across the send window, not after.
+3. `log.txt` absent always means "never executed", never "crashed later".
+4. Rebuild artifacts go stale fast — verify hashes match before concluding.
+5. **Never FTP-upload `.prx`/SELF files**: the console-side path
+ deterministically converts SELF containers to raw ELF on write
+ (verified: 1,476,032-byte PRX repeatedly lands as a 1,719,072-byte
+ ELF, different hash; random bytes and PKGs round-trip perfectly).
+ Deliver plugins via PKG install only.
diff --git a/findings/elf-linkage.md b/findings/elf-linkage.md
new file mode 100644
index 0000000..6e7ca93
--- /dev/null
+++ b/findings/elf-linkage.md
@@ -0,0 +1,39 @@
+# ELF linkage: why OpenOrbis binaries die in spawned processes
+
+## Observed
+
+- elfldr.elf (ps4-payload-sdk build): runs, serves, no faults.
+- SDK hello_world + SDK getaddrinfo/file probe: run, notification shown,
+ `dns: PASS`.
+- Every OpenOrbis-linked binary (hello 90KB, v0.4.0, 1.0 daemon): instant
+ silent death, no first log line, under both GoldHEN payloader and elfldr.
+
+## Structural comparison (llvm-readelf, verified locally)
+
+| | working (elfldr/sdk) | dying (ours) |
+|---|---|---|
+| NEEDED | libkernel_web.sprx, libSceLibcInternal.sprx, libSceNet.sprx | libkernel.so + app-world `.so` set |
+| Segments | merged RWE LOADs, no RELRO | split R-X/RW, GNU_RELRO, GNU_STACK |
+| Hash | GNU_HASH + SYSV HASH (both have both) | same |
+| Relocations | GLOB_DAT / JUMP_SLOT / RELATIVE only | same families |
+
+elfldr spawns via `rfork_thread(RFPROC|RFCFDG|RFMEM)` then resolves each
+import and SIGKILLs on the first unresolvable one — silent by design.
+Our 89 undefined imports (vs ~26 working) include `libkernel.so` symbols
+with no provider in a bare spawned process, so death occurs before `main`.
+
+## Consequence
+
+The daemon must be rebuilt against daemon-world linkage
+(ps4-payload-sdk: `libkernel_web` + internal libc + `libSceNet`, BSD
+sockets instead of Sony `orbis/Net.h`). Status: toolchain assembled on
+macOS (llvm-shim + SDK bindist), SDK hello proven running, daemon port
+in progress on the `WIP` branch (`ORBISRPC_SDK_PAYLOAD` build flavor;
+`scripts/build_sdk.sh`). First SDK-linked daemon binary exhibits the same
+quiet death — import-set bisect ongoing (getaddrinfo/DNS/file proven
+working; remaining suspects: stdio-heavy and SceNet-derived imports).
+
+## Ruled out
+
+- PIE vs EXEC (both crash), SELF vs ELF (page wants ELF), size (90KB dies),
+ hash style, relocation families, segment permissions.
diff --git a/findings/loader.md b/findings/loader.md
new file mode 100644
index 0000000..40423a9
--- /dev/null
+++ b/findings/loader.md
@@ -0,0 +1,23 @@
+# Loader behavior (observed on PS4 9.00, GoldHEN 2.4)
+
+## The three loaders
+
+| Loader | Port | Behavior observed |
+|---|---|---|
+| GoldHEN settings-page payloader | 9090 (transient) | Accepts bytes, prints "payload launched successfully", then its own `GoldHENLoader` thread SIGSEGVs (null read). Payload never executes. Reproduced with 90KB hello-world, v0.4.0, 1.0 daemon. |
+| GoldHEN BinLoader server | 9020 (one-shot per arm) | The September success path (`gateway ready` + live presence). Any port *check* (empty connect) burns the arm — fire blind, first connection carries the payload. |
+| elfldr (ps4-payload-dev v0.7) | 9021 (serves until reboot) | Third-party ELF runs cleanly (bootstraps, serves, no faults). Our OpenOrbis-linked binaries die silently inside it. |
+
+## Proven facts
+
+- The 9090 segfault is in GoldHEN's loader thread, not the payload:
+ `signal 11 (SIGSEGV), thread GoldHENLoader, fault address 0`.
+- ELF support in the settings-page loader needs GoldHEN ≥ v2.4b18.5;
+ older builds cannot load ELFs at all.
+- 9020 takes bytes with zero kernel reaction when unarmed; refused when down.
+- FTP (2121) and klog (3232) are the observation channels; see capture.md.
+
+## Open
+
+- Whether a newer GoldHEN (v2.4b18+) fixes the 9090 segfault.
+- The exact GoldHEN build on the test console (About screen).
diff --git a/findings/retired-tables/build_art_table.py b/findings/retired-tables/build_art_table.py
new file mode 100644
index 0000000..36407b7
--- /dev/null
+++ b/findings/retired-tables/build_art_table.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""build_art_table.py - resolve Sony CDN artwork for a title list via TMDB.
+
+Runs on the BUILD host (console network blocks TMDB port 80) and emits
+orbisrpc/art_table.h: {titleId, name, icon_url} for every resolvable title.
+Also downloads each icon into icons/ for the art_base_url pack.
+
+Usage: python3 scripts/build_art_table.py titles.txt [--icons icons/]
+Title list: one TITLEID per line (CUSAxxxxx etc.); unknown IDs are skipped.
+"""
+import hashlib
+import hmac
+import json
+import os
+import sys
+import urllib.request
+
+KEY = bytes([
+ 0xF5, 0xDE, 0x66, 0xD2, 0x68, 0x0E, 0x25, 0x5B, 0x2D, 0xF7, 0x9E, 0x74,
+ 0xF8, 0x90, 0xEB, 0xF3, 0x49, 0x26, 0x2F, 0x61, 0x8B, 0xCA, 0xE2, 0xA9,
+ 0xAC, 0xCD, 0xEE, 0x51, 0x56, 0xCE, 0x8D, 0xF2, 0xCD, 0xF2, 0xD4, 0x8C,
+ 0x71, 0x17, 0x3C, 0xDC, 0x25, 0x94, 0x46, 0x5B, 0x87, 0x40, 0x5D, 0x19,
+ 0x7C, 0xF1, 0xAE, 0xD3, 0xB7, 0xE9, 0x67, 0x1E, 0xEB, 0x56, 0xCA, 0x67,
+ 0x53, 0xC2, 0xE6, 0xB0,
+])
+TMDB = "http://tmdb.np.dl.playstation.net"
+
+
+def c_escape(s):
+ return s.replace("\\", "\\\\").replace('"', '\\"')
+
+
+def resolve(tid):
+ mac = hmac.new(KEY, (tid + "_00\x00").encode("latin1")[:12],
+ hashlib.sha1).hexdigest().upper()
+ url = f"{TMDB}/tmdb2/{tid}_00_{mac}/{tid}_00.json"
+ req = urllib.request.Request(url, headers={"User-Agent": "orbisRPC-arttbl"})
+ d = json.loads(urllib.request.urlopen(req, timeout=20).read())
+ name = d["names"][0]["name"]
+ icon = d["icons"][0]["icon"].replace("http://", "https://")
+ return name, icon
+
+
+def main():
+ titles = [l.strip() for l in open(sys.argv[1]) if l.strip()]
+ icons = sys.argv[3] if len(sys.argv) > 3 and sys.argv[2] == "--icons" else None
+ if icons:
+ os.makedirs(icons, exist_ok=True)
+ rows = []
+ for tid in titles:
+ try:
+ name, icon = resolve(tid)
+ except Exception as e:
+ print(f"MISS {tid}: {str(e)[:60]}", flush=True)
+ continue
+ print(f"OK {tid} :: {name}", flush=True)
+ rows.append((tid, name, icon))
+ if icons:
+ try:
+ img = urllib.request.urlopen(icon, timeout=25).read()
+ open(os.path.join(icons, tid.lower() + ".png"), "wb").write(img)
+ except Exception as e:
+ print(f" icon fetch failed: {str(e)[:60]}", flush=True)
+ with open("orbisrpc/art_table.h", "w") as f:
+ f.write("/* art_table.h - GENERATED by scripts/build_art_table.py.\n")
+ f.write(" * Do not edit: Sony CDN artwork baked at build time so the\n")
+ f.write(" * console needs no TMDB network access for art.\n")
+ f.write(f" * Titles: {len(rows)}. */\n")
+ f.write("#ifndef ORBISRPC_ART_TABLE_H\n#define ORBISRPC_ART_TABLE_H\n")
+ f.write("#include \n")
+ f.write("typedef struct { const char *id; const char *name;"
+ " const char *icon; } art_entry_t;\n")
+ f.write("static const art_entry_t ORBISRPC_ART_TABLE[] = {\n")
+ for tid, name, icon in rows:
+ f.write(f' {{ "{tid}", "{c_escape(name)}", "{icon}" }},\n')
+ f.write("};\n#define ORBISRPC_ART_TABLE_N "
+ f"(sizeof ORBISRPC_ART_TABLE / sizeof ORBISRPC_ART_TABLE[0])\n")
+ f.write("#endif\n")
+ print(f"wrote art_table.h with {len(rows)} entries")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/findings/retired-tables/gen_nametable.py b/findings/retired-tables/gen_nametable.py
new file mode 100644
index 0000000..6dc75c7
--- /dev/null
+++ b/findings/retired-tables/gen_nametable.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""gen_nametable.py - build orbisrpc/nametable.h from config/titles.json.
+
+titles.json maps TITLEID -> display name (extracted from the PS4's
+/system_data/priv/mms/app.db tbl_appbrowse_* tables, or extended by hand).
+The generated table is compiled in, so name lookup needs no filesystem
+access and works inside any console sandbox.
+
+Usage: python3 scripts/gen_nametable.py
+"""
+import json
+import os
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SRC = os.path.join(ROOT, "config", "titles.json")
+DST = os.path.join(ROOT, "orbisrpc", "nametable.h")
+
+
+def c_escape(s):
+ out = []
+ for ch in s:
+ o = ord(ch)
+ if ch == '"':
+ out.append('\\"')
+ elif ch == "\\":
+ out.append("\\\\")
+ elif 32 <= o < 127:
+ out.append(ch)
+ else:
+ # octal: unlike \x, always stops after 3 digits, so a hex
+ # digit following the escape can never merge into it.
+ for b in ch.encode("utf-8"):
+ out.append("\\%03o" % b)
+ return "".join(out)
+
+
+def main():
+ with open(SRC, encoding="utf-8") as fh:
+ titles = json.load(fh)
+ items = sorted(titles.items())
+ with open(DST, "w", encoding="utf-8") as fh:
+ fh.write("/* nametable.h - GENERATED by scripts/gen_nametable.py. Do not edit. */\n")
+ fh.write("#ifndef NAMETABLE_H\n#define NAMETABLE_H\n");
+ fh.write("#include \n#include \n")
+ fh.write("typedef struct { const char *id; const char *name; } nt_entry_t;\n")
+ fh.write("static const nt_entry_t g_nametable[] = {\n")
+ for tid, name in items:
+ fh.write(' { "%s", "%s" },\n' % (c_escape(tid), c_escape(name)))
+ fh.write("};\n")
+ fh.write("static const size_t g_nametable_count = sizeof g_nametable / sizeof g_nametable[0];\n")
+ fh.write("static int nametable_lookup(const char *tid, char *out, size_t cap){\n")
+ fh.write(" if(!tid || !out || cap == 0) return -1;\n")
+ fh.write(" for(size_t i = 0; i < g_nametable_count; i++){\n")
+ fh.write(" if(!strcmp(tid, g_nametable[i].id)){\n")
+ fh.write(" strncpy(out, g_nametable[i].name, cap - 1);\n")
+ fh.write(" out[cap - 1] = 0;\n")
+ fh.write(" return out[0] ? 0 : -1;\n")
+ fh.write(" }\n")
+ fh.write(" }\n")
+ fh.write(" return -1;\n")
+ fh.write("}\n#endif\n")
+ print("wrote %s with %d entries" % (DST, len(items)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/findings/retired-tables/titles.txt b/findings/retired-tables/titles.txt
new file mode 100644
index 0000000..72ccb44
--- /dev/null
+++ b/findings/retired-tables/titles.txt
@@ -0,0 +1,37 @@
+APOL00004
+CHTM00777
+CUSA00021
+CUSA00127
+CUSA00135
+CUSA00411
+CUSA00740
+CUSA01004
+CUSA01015
+CUSA01623
+CUSA01839
+CUSA03245
+CUSA04294
+CUSA04602
+CUSA06014
+CUSA06545
+CUSA06712
+CUSA08992
+CUSA09311
+CUSA11995
+CUSA13323
+CUSA14909
+CUSA18795
+CUSA20177
+CUSA20499
+CUSA24899
+CUSA26618
+CUSA33151
+FLTZ00003
+GBTX00001
+LAPY20006
+LAPY20009
+LAPY20015
+MANU90003
+NPXS39041
+PKGI13337
+CUSA09303
diff --git a/findings/tls-ime.md b/findings/tls-ime.md
new file mode 100644
index 0000000..5d45b76
--- /dev/null
+++ b/findings/tls-ime.md
@@ -0,0 +1,62 @@
+# TLS + IME + presence-display findings (hardware-verified)
+
+## Presence display rules (verified 2026-09-23 via live holds + human eyes)
+
+- Game activity with **external-URL art or dangling asset key**: Discord
+ drops the ENTIRE activity (name, timer, everything). No error anywhere.
+- Game activity with **live app id, no art**: renders fully
+ (name/details/state/timer).
+- Game activity with **live app id + uploaded asset key**: renders; tile
+ shows "?" until the asset propagates/caches clear (can take a restart).
+- Bare name activity: renders.
+- Custom status (type 4): renders.
+- `since: null` + integer-ms `start` required (0 wedges timer at 0:00).
+- Asset uploads ARE possible via API: stage file through
+ `POST /channels/{id}/attachments`, PUT bytes to `upload_url`, then
+ `POST /applications/{app}/assets` with `key` + `upload_filename`.
+ New assets report `"visibility": "private"`; display still pending
+ verification after propagation.
+- Lanyard is BLIND to user-token gateway activities (shows [] even for
+ live-rendered ones). Human eyes only. Do not verify with it.
+
+Symptom: every handshake died instantly with return `-1` (not a real
+mbedTLS code), after DNS + TCP succeeded.
+
+Root cause (from mbedTLS debug trace on-console):
+
+```text
+psa_crypto_init() returned -148 (-0x0094, INSUFFICIENT_ENTROPY)
+```
+
+mbedTLS 3.x routes TLS 1.3 through the PSA crypto subsystem, whose init
+fails on PS4 even though `/dev/urandom` reads work fine (ClientHello
+random bytes prove it).
+
+Fix (in tree, verified: full handshake, `TLS-ECDHE-ECDSA-WITH-CHACHA20-
+POLY1305-SHA256`, cert chain verifies, connection test PASSES):
+
+```c
+mbedtls_ssl_conf_max_tls_version(&t->conf, MBEDTLS_SSL_VERSION_TLS1_2);
+```
+
+Lesson: wire mbedTLS's debug layer (`mbedtls_debug_set_threshold` +
+`mbedtls_ssl_conf_dbg`) into the log before guessing at handshake bugs.
+
+## IME keyboard: init refuses (OPEN)
+
+`sceImeDialogInit` fails on-console; codes seen:
+
+- `-2135162872` (`0x80BC0008`) with `userId=0`
+- `-2135162864` (`0x80BC0010`) with real foreground user ID
+
+Tried, none fixed it: real user ID via UserService, centered position
+(960,540), default type/label, per-dialog init/terminate lifecycle,
+stale-session teardown. Reference: Apollo PS4 dialog.c pattern mirrored.
+Workaround in place: token pre-seeded via config; IME still unresolved.
+
+## App lifecycle (fixed, verified)
+
+- Missing `sceMsgDialogInitialize` → every open failed into a black loop.
+ Fixed per Apollo pattern (init + terminate per dialog).
+- 30s dialog watchdog blanked idle screens — removed, blocking waits now.
+- Exit path terminates dialogs, unloads modules, `exit(0)`.
diff --git a/installer/Makefile b/installer/Makefile
new file mode 100644
index 0000000..5dbc3ac
--- /dev/null
+++ b/installer/Makefile
@@ -0,0 +1,84 @@
+# orbisRPC Setup installer (OpenOrbis app).
+# Flow: daemon payload -> evict old -> token -> done.
+# Payload Guest reads /data/payloads/ on this console.
+TITLE := orbisRPC Setup
+VERSION := 1.0.0
+TITLE_ID := ORPC00001
+CONTENT_ID := IV0000-ORPC00001_00-ORBISRPCSETUP000
+
+LIBS := -lc -lkernel -lc++ -lSceMsgDialog -lSceCommonDialog -lSceImeDialog -lSceSysmodule -lSceNet -lSceUserService -lSceSystemService -lScePad
+
+ASSETS := $(wildcard installer/assets/**/*)
+LIBMODULES := $(wildcard installer/sce_module/*)
+
+TOOLCHAIN ?= $(OO_PS4_TOOLCHAIN)
+ifeq ($(TOOLCHAIN),)
+TOOLCHAIN := /Users/mac/PS4Toolchain/OpenOrbis/PS4Toolchain
+endif
+PROJDIR := installer
+COMMONDIR := $(TOOLCHAIN)/samples/_common
+INTDIR := $(PROJDIR)/x64/Debug
+
+CFILES := $(wildcard $(PROJDIR)/*.c)
+CPPFILES := $(wildcard $(PROJDIR)/*.cpp)
+OBJS := $(patsubst $(PROJDIR)/%.c, $(INTDIR)/%.o, $(CFILES)) $(patsubst $(PROJDIR)/%.cpp, $(INTDIR)/%.o, $(CPPFILES)) $(INTDIR)/jsonlite.o
+
+# llvmshim (no brew): export PATH="$HOME/llvmshim:$PATH" before make.
+LLVM := $(HOME)/llvmshim
+CC := $(LLVM)/clang
+CCX := $(LLVM)/clang++
+LD := $(LLVM)/ld.lld
+CDIR := macos
+
+CFLAGS := -DINSTALLER_PS4 -DSETUP_VERSION='"$(VERSION)"' --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -c -isysroot $(TOOLCHAIN) -isystem $(TOOLCHAIN)/include -Iorbisrpc -Iinstaller
+CXXFLAGS := $(CFLAGS) -isystem $(TOOLCHAIN)/include/c++/v1
+LDFLAGS := -m elf_x86_64 -pie --script $(TOOLCHAIN)/link.x --eh-frame-hdr -L$(TOOLCHAIN)/lib $(LIBS) $(TOOLCHAIN)/lib/crt1.o -L$(TOOLCHAIN)/lib
+
+_unused := $(shell mkdir -p $(INTDIR))
+
+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/sce_sys/param.sfo: installer/Makefile
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_new $@
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ APP_TYPE --type Integer --maxsize 4 --value 1
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ APP_VER --type Utf8 --maxsize 8 --value '$(VERSION)'
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ ATTRIBUTE --type Integer --maxsize 4 --value 0
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ CATEGORY --type Utf8 --maxsize 4 --value 'gd'
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ CONTENT_ID --type Utf8 --maxsize 48 --value '$(CONTENT_ID)'
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ DOWNLOAD_DATA_SIZE --type Integer --maxsize 4 --value 0
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ SYSTEM_VER --type Integer --maxsize 4 --value 0
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ TITLE --type Utf8 --maxsize 128 --value '$(TITLE)'
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ TITLE_ID --type Utf8 --maxsize 12 --value '$(TITLE_ID)'
+ $(TOOLCHAIN)/bin/$(CDIR)/PkgTool.Core sfo_setentry $@ VERSION --type Utf8 --maxsize 8 --value '$(VERSION)'
+
+installer/eboot.bin: $(OBJS)
+ $(LD) $(INTDIR)/*.o -o $(INTDIR)/$(PROJDIR).elf $(LDFLAGS)
+ $(TOOLCHAIN)/bin/$(CDIR)/create-fself-macos -in=$(INTDIR)/$(PROJDIR).elf -out=$(INTDIR)/$(PROJDIR).oelf --eboot "installer/eboot.bin" --paid 0x3800000000000011
+
+$(INTDIR)/%.o: $(PROJDIR)/%.c
+ $(CC) $(CFLAGS) -o $@ $<
+
+$(INTDIR)/jsonlite.o: orbisrpc/jsonlite.c
+ $(CC) $(CFLAGS) -o $@ $<
+
+# Stage the daemon + evict payloads + config into the app image.
+# config.json is pre-populated with the Discord token so the
+# installer skips token entry on fresh installs.
+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 $< $@
+
+$(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
diff --git a/installer/assets/config.json b/installer/assets/config.json
new file mode 100644
index 0000000..9d871d2
--- /dev/null
+++ b/installer/assets/config.json
@@ -0,0 +1 @@
+{"schema_version":1,"token":"SET_ME","presence_state":"On PS4"}
diff --git a/installer/config.json b/installer/config.json
new file mode 100644
index 0000000..9d871d2
--- /dev/null
+++ b/installer/config.json
@@ -0,0 +1 @@
+{"schema_version":1,"token":"SET_ME","presence_state":"On PS4"}
diff --git a/installer/icfg.c b/installer/icfg.c
new file mode 100644
index 0000000..6d5916b
--- /dev/null
+++ b/installer/icfg.c
@@ -0,0 +1,193 @@
+/* icfg.c - atomic read-modify-write over the daemon config. */
+#include "icfg.h"
+#include "jsonlite.h"
+#include
+#include
+#include
+#include
+
+#define ICFG_MAX (64u*1024u)
+
+int token_valid(const char *t){
+ size_t n, i;
+ int dots = 0;
+ if(!t) return 0;
+ n = strlen(t);
+ if(n < 40 || n > 150) return 0;
+ if(!strcmp(t, "SET_ME") || !strcmp(t, "PUT_TOKEN_HERE")) return 0;
+ for(i = 0; i < n; i++){
+ char c = t[i];
+ if(c == '.'){ dots++; continue; }
+ if(!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') || c == '_' || c == '-'))
+ return 0;
+ }
+ return dots >= 2;
+}
+
+static jl_val_t *icfg_read(const char *path){
+ FILE *f = fopen(path, "rb");
+ long sz;
+ char *buf;
+ jl_val_t *r;
+ if(!f) return NULL;
+ if(fseek(f, 0, SEEK_END) != 0){ fclose(f); return NULL; }
+ sz = ftell(f);
+ if(sz <= 0 || sz > (long)ICFG_MAX){ fclose(f); return NULL; }
+ if(fseek(f, 0, SEEK_SET) != 0){ fclose(f); return NULL; }
+ buf = (char *)malloc((size_t)sz + 1);
+ if(!buf){ fclose(f); return NULL; }
+ if(fread(buf, 1, (size_t)sz, f) != (size_t)sz){
+ free(buf); fclose(f); return NULL;
+ }
+ fclose(f);
+ buf[sz] = 0;
+ r = jl_parse(buf, (size_t)sz);
+ free(buf);
+ if(!r || r->type != JL_OBJECT){ if(r) jl_free(r); return NULL; }
+ return r;
+}
+
+static int icfg_write(const char *path, jl_val_t *r){
+ char tmp[300];
+ char *s;
+ FILE *f;
+ int ok = 1;
+ if(snprintf(tmp, sizeof tmp, "%s.new", path) <= 0 ||
+ (size_t)strlen(path) + 5 >= sizeof tmp)
+ return -1;
+ s = jl_stringify(r);
+ if(!s) return -1;
+ f = fopen(tmp, "wb");
+ if(!f){ free(s); return -1; }
+ if(fputs(s, f) < 0) ok = 0;
+ if(ok){
+ int fd = fileno(f);
+ if(fd >= 0 && fsync(fd) != 0) ok = 0;
+ }
+ if(ok && fclose(f) != 0) ok = 0;
+ free(s);
+ if(!ok){ remove(tmp); return -1; }
+ if(rename(tmp, path) != 0){ remove(tmp); return -1; }
+ return 0;
+}
+
+int icfg_token_load(const char *path, char *out, size_t cap){
+ jl_val_t *r = icfg_read(path);
+ const jl_val_t *v;
+ if(!out || cap == 0) return -1;
+ out[0] = 0;
+ if(!r) return -1;
+ v = jl_obj_get(r, "token");
+ if(v && v->type == JL_STRING && v->str){
+ strncpy(out, v->str, cap - 1);
+ out[cap - 1] = 0;
+ }
+ jl_free(r);
+ return 0;
+}
+
+int icfg_token_save(const char *path, const char *token){
+ jl_val_t *r;
+ int rc;
+ char back[160];
+ if(!token_valid(token)) return -1;
+ r = icfg_read(path);
+ if(!r){
+ r = jl_new_object();
+ if(!r) return -2;
+ }
+ jl_obj_set(r, "token", jl_new_string(token));
+ rc = icfg_write(path, r);
+ jl_free(r);
+ if(rc != 0) return -2;
+ /* Read-back proof: an OOM inside the JSON build saves silently
+ * WITHOUT the key. Never report success on faith. */
+ back[0] = 0;
+ if(icfg_token_load(path, back, sizeof back) != 0 ||
+ strcmp(back, token) != 0)
+ return -2;
+ return 0;
+}
+
+int icfg_set_str(const char *path, const char *key, const char *val){
+ jl_val_t *r;
+ int rc;
+ char back[256];
+ if(!key || !key[0] || !val) return -1;
+ r = icfg_read(path);
+ if(!r){
+ r = jl_new_object();
+ if(!r) return -1;
+ }
+ jl_obj_set(r, key, jl_new_string(val));
+ rc = icfg_write(path, r);
+ jl_free(r);
+ if(rc != 0) return -1;
+ back[0] = 0;
+ if(icfg_get_str(path, key, back, sizeof back) != 0 ||
+ strcmp(back, val) != 0)
+ return -1;
+ return 0;
+}
+
+int icfg_set_int(const char *path, const char *key, long val){
+ jl_val_t *r;
+ int rc;
+ long back = 0;
+ if(!key || !key[0]) return -1;
+ r = icfg_read(path);
+ if(!r){
+ r = jl_new_object();
+ if(!r) return -1;
+ }
+ jl_obj_set(r, key, jl_new_int(val));
+ rc = icfg_write(path, r);
+ jl_free(r);
+ if(rc != 0) return -1;
+ if(icfg_get_int(path, key, &back) != 0 || back != val) return -1;
+ return 0;
+}
+
+int icfg_get_str(const char *path, const char *key, char *out, size_t cap){
+ jl_val_t *r = icfg_read(path);
+ const jl_val_t *v;
+ if(!out || cap == 0) return -1;
+ out[0] = 0;
+ if(!r || !key){ if(r) jl_free(r); return -1; }
+ v = jl_obj_get(r, key);
+ if(v && v->type == JL_STRING && v->str){
+ strncpy(out, v->str, cap - 1);
+ out[cap - 1] = 0;
+ }
+ jl_free(r);
+ return 0;
+}
+
+int icfg_titles_count(const char *path){
+ jl_val_t *r = icfg_read(path);
+ const jl_val_t *t;
+ int n = 0;
+ if(!r) return 0;
+ t = jl_obj_get(r, "titles");
+ if(t && t->type == JL_OBJECT){
+ for(jl_val_t *p = t->child; p; p = p->next) n++;
+ }
+ jl_free(r);
+ return n;
+}
+
+int icfg_get_int(const char *path, const char *key, long *out){
+ jl_val_t *r = icfg_read(path);
+ const jl_val_t *v;
+ if(!out) return -1;
+ if(!r || !key){ if(r) jl_free(r); return -1; }
+ v = jl_obj_get(r, key);
+ if(v && v->type == JL_NUMBER){
+ *out = v->num_is_int ? (long)v->inum : (long)v->num;
+ jl_free(r);
+ return 0;
+ }
+ jl_free(r);
+ return -1;
+}
diff --git a/installer/icfg.h b/installer/icfg.h
new file mode 100644
index 0000000..3c8a12d
--- /dev/null
+++ b/installer/icfg.h
@@ -0,0 +1,27 @@
+/* icfg.h - installer config read/write for /data/orbisRPC/config.json.
+ * Read-modify-write: preserves learned titles and every other field.
+ * Pure logic (token_valid) is host-tested. */
+#ifndef INSTALLER_ICFG_H
+#define INSTALLER_ICFG_H
+#include
+
+#define ICFG_PATH "/data/orbisRPC/config.json"
+
+/* 1 valid user token, 0 not. Tokens are dot-separated base64-ish,
+ * 40..150 chars. Rejects placeholders and whitespace. */
+int token_valid(const char *t);
+
+/* Load current token ("" when missing). 0 ok, -1 unreadable. */
+int icfg_token_load(const char *path, char *out, size_t cap);
+/* Set token (validated first). 0 saved, -1 invalid, -2 I/O error. */
+int icfg_token_save(const char *path, const char *token);
+/* Set a string/int key, preserving everything else. 0 saved, -1 I/O. */
+int icfg_set_str(const char *path, const char *key, const char *val);
+int icfg_set_int(const char *path, const char *key, long val);
+/* Read a string/int key. 0 found, -1 missing/unreadable. */
+int icfg_get_str(const char *path, const char *key, char *out, size_t cap);
+int icfg_get_int(const char *path, const char *key, long *out);
+/* Count entries in the "titles" map (learned + manual). Fail-soft 0. */
+int icfg_titles_count(const char *path);
+
+#endif
diff --git a/installer/installer.c b/installer/installer.c
new file mode 100644
index 0000000..13be1c2
--- /dev/null
+++ b/installer/installer.c
@@ -0,0 +1,345 @@
+/* installer.c - clean installer, one flow forward.
+ * Payload placement is the whole job: copy to /data/payloads.
+ * Payload Guest reads this directory. */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "ui.h"
+#include "icfg.h"
+
+#ifndef SETUP_VERSION
+#define SETUP_VERSION "1.0.0"
+#endif
+#define DAEMON_ELF "/app0/assets/daemon.elf"
+#define EVICT_ELF "/app0/assets/evict.elf"
+#define INST_DIR "/data/orbisRPC"
+#define INST_LOG "/data/orbisRPC/install.log"
+#define PAYLOAD_BIN "/data/payloads/orbisrpc.bin"
+#define EVICT_BIN "/data/payloads/evict.elf"
+
+/* Stage log: every copy step records errno + sizes to a file we can read
+ * back over FTP. The dialog alone can't say WHICH stage failed. */
+static const char *g_stage = "-";
+
+static void ilog(const char *tag, int err, long expect, long got){
+ FILE *f;
+ g_stage = tag;
+ f = fopen(INST_LOG, "a");
+ if(!f) f = fopen("/data/install.log", "a"); /* fallback if sandbox blocks */
+ if(!f) return;
+ fprintf(f, "%ld %s errno=%d(%s) expect=%ld got=%ld\n",
+ (long)time(NULL), tag, err, err ? strerror(err) : "ok", expect, got);
+ fclose(f);
+}
+
+/* Stage marker without errno semantics (rc values, not errnos). */
+static void ilogv(const char *tag, long a, long b){
+ FILE *f;
+ g_stage = tag;
+ f = fopen(INST_LOG, "a");
+ if(!f) f = fopen("/data/install.log", "a");
+ if(!f) return;
+ fprintf(f, "%ld %s a=%ld b=%ld\n", (long)time(NULL), tag, a, b);
+ fclose(f);
+}
+
+/* --- crash guard ----------------------------------------------------
+ * The wizard has died twice mid-flow with Sony's crash reporter eating
+ * the core file, so the fault address never reached us. These handlers
+ * run async-signal-safe only (no stdio, no malloc): they append the
+ * signal, faulting address and current stage to install.log, then exit
+ * cleanly. A crash becomes evidence instead of a crash dialog. */
+static volatile sig_atomic_t g_crashing = 0;
+
+static size_t h_s(char *b, size_t cap, size_t p, const char *s){
+ while(*s && p + 1 < cap) b[p++] = *s++;
+ return p;
+}
+static size_t h_u(char *b, size_t cap, size_t p, unsigned long v){
+ char t[24];
+ int n = 0;
+ if(v == 0) t[n++] = '0';
+ while(v && n < 24){ t[n++] = (char)('0' + v % 10); v /= 10; }
+ while(n && p + 1 < cap) b[p++] = t[--n];
+ return p;
+}
+static size_t h_x(char *b, size_t cap, size_t p, unsigned long long v){
+ static const char hx[] = "0123456789abcdef";
+ char t[20];
+ int n = 0;
+ if(v == 0) t[n++] = '0';
+ while(v && n < 20){ t[n++] = hx[v & 15]; v >>= 4; }
+ while(n && p + 1 < cap) b[p++] = t[--n];
+ return p;
+}
+
+static void crash_handler(int sig, siginfo_t *si, void *uc){
+ char b[200];
+ size_t p = 0;
+ int fd;
+ ssize_t w;
+ (void)uc;
+ if(g_crashing) _exit(99);
+ g_crashing = 1;
+ p = h_s(b, sizeof b, p, "CRASH sig=");
+ p = h_u(b, sizeof b, p, (unsigned)sig);
+ p = h_s(b, sizeof b, p, " addr=0x");
+ p = h_x(b, sizeof b, p, si ? (unsigned long long)(uintptr_t)si->si_addr : 0);
+ p = h_s(b, sizeof b, p, " stage=");
+ p = h_s(b, sizeof b, p, g_stage ? g_stage : "-");
+ if(p + 1 < sizeof b) b[p++] = '\n';
+ fd = open(INST_LOG, O_WRONLY | O_APPEND | O_CREAT, 0666);
+ if(fd < 0) fd = open("/data/install.log", O_WRONLY | O_APPEND | O_CREAT, 0666);
+ if(fd >= 0){ w = write(fd, b, p); (void)w; close(fd); }
+ _exit(99);
+}
+
+static void crash_guard(void){
+ static const int sigs[] = { SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGABRT, SIGTRAP };
+ struct sigaction sa;
+ size_t i;
+ memset(&sa, 0, sizeof sa);
+ /* Header bug: the sa_sigaction macro expands to a member that does
+ * not exist, so address the union directly. */
+ sa.__sa_handler.__sa_sigaction = crash_handler;
+ sa.sa_flags = SA_SIGINFO;
+ sigemptyset(&sa.sa_mask);
+ for(i = 0; i < sizeof sigs / sizeof sigs[0]; i++)
+ sigaction(sigs[i], &sa, NULL);
+}
+
+/* Preflight: the payload must exist inside this package. A PKG built
+ * or installed without staged assets fails here with a precise message
+ * instead of a mysterious write error three steps later. */
+static int step_assets(void){
+ FILE *a = fopen(DAEMON_ELF, "rb");
+ if(a) fclose(a);
+ if(!a){
+ ui_ok("This install is missing its payload.\n\nReinstall the PKG (do not just relaunch the old bubble), then open it again.");
+ return -1;
+ }
+ return 0;
+}
+
+/* The sandbox makes stat()/fstat() lie about sizes (observed: 4096/4160
+ * for a 2071552-byte file, which made a perfect copy report "denied").
+ * open/read/write are truthful, so the proof is: count and hash what we
+ * wrote, then count and hash what we read back. No stat anywhere. */
+static unsigned long long fnv1a(const unsigned char *p, size_t n,
+ unsigned long long h){
+ size_t i;
+ for(i = 0; i < n; i++){
+ h ^= p[i];
+ h *= 1099511628211ULL;
+ }
+ return h;
+}
+
+static int copy_file(const char *src, const char *dst){
+ FILE *in, *out;
+ static unsigned char buf[65536];
+ size_t n;
+ long written = 0, reread = 0;
+ unsigned long long hw = 14695981039346656037ULL, hr = 14695981039346656037ULL;
+ char detail[96];
+
+ in = fopen(src, "rb");
+ if(!in){ ilog("src-open", errno, -1, -1); return -1; }
+ out = fopen(dst, "wb");
+ if(!out){ ilog("dst-open", errno, -1, -1); fclose(in); return -1; }
+ while((n = fread(buf, 1, sizeof buf, in)) > 0){
+ if(fwrite(buf, 1, n, out) != n){
+ ilog("dst-write", errno, written, -1);
+ fclose(in); fclose(out); return -1;
+ }
+ hw = fnv1a(buf, n, hw);
+ written += (long)n;
+ }
+ fclose(in);
+ if(fclose(out) != 0){ ilog("dst-close", errno, written, -1); return -1; }
+ if(written <= 0){ ilog("src-empty", 0, written, -1); return -1; }
+
+ /* Read-back proof: reopen and re-count/re-hash the destination. */
+ in = fopen(dst, "rb");
+ if(!in){ ilog("verify-open", errno, written, -1); return -1; }
+ while((n = fread(buf, 1, sizeof buf, in)) > 0){
+ hr = fnv1a(buf, n, hr);
+ reread += (long)n;
+ }
+ fclose(in);
+ snprintf(detail, sizeof detail, "h=%016llx/%016llx", hw, hr);
+ if(written == reread && hw == hr){
+ ilog("copy-ok", 0, written, reread);
+ return 0;
+ }
+ ilog("verify-mismatch", 0, written, reread);
+ ilog(detail, 0, 0, 0);
+ g_stage = "verify-mismatch";
+ return -1;
+}
+
+/* Existence check without stat() (the sandbox lies about stat sizes). */
+static int exists(const char *p){
+ FILE *f = fopen(p, "rb");
+ if(!f) return 0;
+ fclose(f);
+ return 1;
+}
+
+/* mkdir -p (parents as needed). 0 ok or already there. */
+static int mkdirs(const char *path){
+ char tmp[256];
+ size_t i, n;
+ if(!path || !path[0]) return -1;
+ n = strlen(path);
+ if(n >= sizeof tmp) return -1;
+ memcpy(tmp, path, n + 1);
+ for(i = 1; i < n; i++){
+ if(tmp[i] == '/'){
+ tmp[i] = 0;
+ mkdir(tmp, 0777);
+ tmp[i] = '/';
+ }
+ }
+ if(mkdir(path, 0777) != 0 && errno != EEXIST) return -1;
+ return 0;
+}
+
+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);
+ }
+ }
+ /* Copy daemon payload. */
+ ok = copy_file(DAEMON_ELF, PAYLOAD_BIN);
+ ilog("daemon-copy", ok, 0, 0);
+ /* Pre-save config from PKG asset so step_token() skips on fresh install.
+ * Copy config.json from /app0/assets/config.json to /data/orbisRPC/config.json. */
+ {
+ FILE *src = fopen("/app0/assets/config.json", "rb");
+ if(src){
+ fclose(src);
+ copy_file("/app0/assets/config.json", ICFG_PATH);
+ ilog("cfg-copy", 0, 0, 0);
+ } else {
+ ilog("cfg-noasset", errno, 0, 0);
+ /* Fallback: write template with pre-set token. */
+ FILE *f = fopen(ICFG_PATH, "wb");
+ if(f){
+ fputs("{\"schema_version\":1,\"token\":\"SET_ME\",\"presence_state\":\"On PS4\"}", f);
+ fclose(f);
+ }
+ }
+ }
+ 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 ? "" : "]");
+ ui_ok(report);
+ if(ok != 0){
+ ui_ok("Install failed: could not write the payload.\n\nStopping here.");
+ return -1;
+ }
+ ilogv("report-done", ok, 0);
+ /* Config template when missing; never clobbers learned titles. */
+ {
+ char tok[160];
+ tok[0] = 0;
+ icfg_token_load(ICFG_PATH, tok, sizeof tok);
+ if(!token_valid(tok)){
+ FILE *f = fopen(ICFG_PATH, "rb");
+ if(!f){
+ f = fopen(ICFG_PATH, "wb");
+ if(f){
+ fputs("{\"schema_version\":1,\"token\":\"SET_ME\",\"presence_state\":\"On PS4\"}", f);
+ fclose(f);
+ }
+ } else {
+ fclose(f);
+ }
+ }
+ }
+ ilogv("cfg-done", 0, 0);
+ return 0;
+}
+
+/* 2: token. Skips silently when one already validates. */
+static void step_token(void){
+ char tok[160];
+ int tries, r;
+ tok[0] = 0;
+ ilogv("token-begun", 0, 0);
+ icfg_token_load(ICFG_PATH, tok, sizeof tok);
+ if(token_valid(tok)){
+ ui_ok("A valid token is already saved.\n\nSkipping.");
+ ilogv("token-skip", 0, 0);
+ return;
+ }
+ 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) return;
+ if(token_valid(tok)){
+ r = icfg_token_save(ICFG_PATH, tok);
+ ui_ok("Config saved.");
+ return;
+ }
+ ui_ok("That doesn't look like a token.\nCheck it and try again, or cancel to skip.");
+ }
+}
+
+/* --- clean exit ---------------------------------------------------
+ * Tear dialogs down, unload modules, then _exit. Returning from
+ * main runs the CRT teardown path which has been killing the
+ * wizard on every crash. */
+static void finish(int code){
+ ilogv(code == 0 ? "exit" : "fatal", code, 0);
+ ui_shutdown();
+ _exit(code);
+}
+
+int main(void){
+ /* Hide the PS4 splash first — dialogs opened while the splash
+ * is visible get auto-dismissed (half-second flash) or never
+ * surface. Hiding it early means ui_init() and all dialogs
+ * run with the splash already gone, so no visible lag. */
+ sceSystemServiceHideSplashScreen();
+ crash_guard();
+ if(ui_init() != 0) finish(1);
+ ui_ok("orbisRPC");
+ if(step_assets() != 0) finish(1);
+ ilogv("assets-ok", 0, 0);
+ if(step_files() != 0) finish(1);
+ step_token();
+ ilogv("post-token", 0, 0);
+ ui_ok("Done.\n\n"
+ "Payloads in /data/payloads.\n"
+ "Launch orbisrpc from the payload launcher.");
+ finish(0);
+}
diff --git a/installer/nettest.c b/installer/nettest.c
new file mode 100644
index 0000000..1370b88
--- /dev/null
+++ b/installer/nettest.c
@@ -0,0 +1,43 @@
+/* nettest.c - connect-only probes. A closed port and a filtered host
+ * both read as unreachable; that honesty is the point (Sony TMDB from
+ * the console genuinely fails while Discord succeeds). */
+#include "nettest.h"
+#include "send.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* Non-blocking connect with a hard deadline lives in send.c (shared);
+ * blocking connect ignores SNDTIMEO on filtered hosts (75 s hole). */
+
+int net_probe(const char *host, int port, int timeout_s){
+ struct addrinfo hints, *res = NULL, *rp;
+ char svc[8];
+ int ok = 0;
+ if(!host || !host[0] || port <= 0 || port > 65535) return 0;
+ if(net_init() != 0) return 0; /* stack dead: everything unreachable */
+ if(timeout_s < 1) timeout_s = 1;
+ if(timeout_s > 10) timeout_s = 10;
+ snprintf(svc, sizeof svc, "%d", port);
+ memset(&hints, 0, sizeof hints);
+ hints.ai_family = AF_INET; /* v4 only: deterministic on console stacks */
+ hints.ai_socktype = SOCK_STREAM;
+ if(getaddrinfo(host, svc, &hints, &res) != 0 || !res) return 0;
+ for(rp = res; rp; rp = rp->ai_next){
+ int fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
+ if(fd < 0) continue;
+ if(sock_connect_deadline(fd, rp->ai_addr, rp->ai_addrlen, timeout_s) == 0) ok = 1;
+ close(fd);
+ if(ok) break;
+ }
+ freeaddrinfo(res);
+ return ok;
+}
diff --git a/installer/nettest.h b/installer/nettest.h
new file mode 100644
index 0000000..32054a5
--- /dev/null
+++ b/installer/nettest.h
@@ -0,0 +1,8 @@
+/* nettest.h - honest TCP reachability probes (no traffic beyond connect). */
+#ifndef INSTALLER_NETTEST_H
+#define INSTALLER_NETTEST_H
+
+/* 1 reachable, 0 not. timeout_s clamped 1..10. Host-testable. */
+int net_probe(const char *host, int port, int timeout_s);
+
+#endif
diff --git a/installer/sce_sys/about/right.sprx b/installer/sce_sys/about/right.sprx
new file mode 100644
index 0000000..b3b01d0
Binary files /dev/null and b/installer/sce_sys/about/right.sprx differ
diff --git a/installer/sce_sys/icon0.png b/installer/sce_sys/icon0.png
new file mode 100644
index 0000000..3db6651
Binary files /dev/null and b/installer/sce_sys/icon0.png differ
diff --git a/installer/sce_sys/pic1.png b/installer/sce_sys/pic1.png
new file mode 100644
index 0000000..b9b49f3
Binary files /dev/null and b/installer/sce_sys/pic1.png differ
diff --git a/installer/send.c b/installer/send.c
new file mode 100644
index 0000000..ce97c0b
--- /dev/null
+++ b/installer/send.c
@@ -0,0 +1,136 @@
+/* send.c - loopback payload injection: file bytes over TCP to the
+ * console's own loaders. Chunked send (no whole-file malloc). */
+#include "send.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#ifdef INSTALLER_PS4
+#include
+#include
+#endif
+
+#define SEND_CHUNK (64u*1024u)
+static const int SEND_PORTS[] = { 9090, 9021, 9020 };
+
+int net_init(void){
+#ifdef INSTALLER_PS4
+ static int done = 0;
+ int probe;
+ if(done) return 0;
+ /* Internal NET module (Payload Guest boot pattern) + best-effort
+ * stack init. Ground truth is a probe socket, not return codes. */
+ (void)sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_NET);
+ (void)sceNetInit();
+ (void)sceNetPoolCreate("orbisrpc", 64*1024, 0);
+ probe = socket(AF_INET, SOCK_STREAM, 0);
+ if(probe < 0) return -1;
+ close(probe);
+ done = 1;
+#endif
+ return 0;
+}
+
+int sock_connect_deadline(int fd, const struct sockaddr *sa, socklen_t len, int timeout_s){
+ int flags, rc, err = 0;
+ socklen_t elen = sizeof err;
+ fd_set wf;
+ struct timeval tv;
+ if(timeout_s < 1) timeout_s = 1;
+ if(timeout_s > 15) timeout_s = 15;
+ flags = fcntl(fd, F_GETFL, 0);
+ if(flags < 0) return -1;
+ if(fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return -1;
+ rc = connect(fd, sa, len);
+ if(rc == 0){
+ fcntl(fd, F_SETFL, flags);
+ return 0;
+ }
+ if(errno != EINPROGRESS){
+ fcntl(fd, F_SETFL, flags);
+ return -1;
+ }
+ FD_ZERO(&wf);
+ FD_SET(fd, &wf);
+ tv.tv_sec = timeout_s;
+ tv.tv_usec = 0;
+ rc = select(fd + 1, NULL, &wf, NULL, &tv);
+ fcntl(fd, F_SETFL, flags);
+ if(rc <= 0) return -1;
+ if(getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen) != 0) return -1;
+ return err == 0 ? 0 : -1;
+}
+
+static int send_one(int fd, const char *path, void (*progress)(unsigned)){
+ FILE *f = fopen(path, "rb");
+ /* Static, not stack: 64 KB is a real fraction of an app thread stack. */
+ static unsigned char buf[SEND_CHUNK];
+ long total = 0, sent = 0;
+ size_t n;
+ if(!f) return -1;
+ if(fseek(f, 0, SEEK_END) != 0){ fclose(f); return -1; }
+ total = ftell(f);
+ if(total <= 0 || total > 16*1024*1024){ fclose(f); return -1; }
+ if(fseek(f, 0, SEEK_SET) != 0){ fclose(f); return -1; }
+ if(progress) progress(0);
+ while((n = fread(buf, 1, sizeof buf, f)) > 0){
+ size_t off = 0;
+ int stalls = 0;
+ while(off < n){
+ ssize_t w = send(fd, buf + off, n - off, 0);
+ if(w <= 0){
+ /* Blocking socket + SNDTIMEO: EAGAIN means the loader
+ * stopped reading. Retry briefly, then fail instead of
+ * spinning forever. */
+ if((errno == EAGAIN || errno == EWOULDBLOCK) && ++stalls < 4)
+ continue;
+ fclose(f);
+ return -1;
+ }
+ stalls = 0;
+ off += (size_t)w;
+ }
+ sent += (long)n;
+ if(progress) progress((unsigned)(sent * 100 / total));
+ }
+ fclose(f);
+ if(progress) progress(100);
+ return 0;
+}
+
+int send_file_loopback(const char *path, int *port_used, void (*progress)(unsigned)){
+ unsigned i;
+ if(!path || !path[0]) return -1;
+ if(net_init() != 0) return -1; /* no stack, no sockets: fail, don't crash */
+ for(i = 0; i < sizeof SEND_PORTS/sizeof SEND_PORTS[0]; i++){
+ int fd = socket(AF_INET, SOCK_STREAM, 0);
+ struct sockaddr_in sa;
+ struct timeval tv = { 20, 0 };
+ if(fd < 0) continue;
+ memset(&sa, 0, sizeof sa);
+ sa.sin_family = AF_INET;
+ sa.sin_port = htons((uint16_t)SEND_PORTS[i]);
+ sa.sin_addr.s_addr = inet_addr("127.0.0.1");
+ if(sock_connect_deadline(fd, (struct sockaddr *)&sa, sizeof sa, 5) < 0){
+ close(fd);
+ continue;
+ }
+ /* Back to blocking for the bulk send, WITH a send timeout so a
+ * stalled loader surfaces EAGAIN (capped retries below) instead
+ * of wedging forever. */
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
+ if(send_one(fd, path, progress) == 0){
+ close(fd);
+ if(port_used) *port_used = SEND_PORTS[i];
+ return 0;
+ }
+ close(fd);
+ }
+ return -1;
+}
diff --git a/installer/send.h b/installer/send.h
new file mode 100644
index 0000000..df95af7
--- /dev/null
+++ b/installer/send.h
@@ -0,0 +1,19 @@
+/* send.h - loopback payload injection (clean-room, standard sockets).
+ * Tries native GoldHEN BinLoader 9090, then elfldr 9021, then 9020.
+ * 9020 is one-shot: accepted bytes count as sent. */
+#ifndef INSTALLER_SEND_H
+#define INSTALLER_SEND_H
+#include
+
+/* progress(pct) may be NULL. Returns 0 + port_used set on success. */
+int send_file_loopback(const char *path, int *port_used, void (*progress)(unsigned pct));
+
+/* Non-blocking connect with a hard deadline (filtered hosts can't stall
+ * past timeout_s). 0 connected, -1 failed. Leaves fd blocking. */
+int sock_connect_deadline(int fd, const struct sockaddr *sa, socklen_t len, int timeout_s);
+
+/* One-time network stack init (sceNetInit + pool). 0 ready, -1 dead.
+ * No-op returning 0 on host test builds. Safe to call repeatedly. */
+int net_init(void);
+
+#endif
diff --git a/installer/ui.c b/installer/ui.c
new file mode 100644
index 0000000..d612c2d
--- /dev/null
+++ b/installer/ui.c
@@ -0,0 +1,277 @@
+/* ui.c - native PS4 dialogs: MsgDialog (ok / yes-no / progress) + ImeDialog.
+ * Everything here needs the console (dialog system calls); pure logic
+ * (validation, config merge) lives in icfg.c and is host-tested. */
+#include "ui.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static void ime_dbg(const char *msg){
+ int fd = open("/data/ime_debug.log", O_WRONLY|O_CREAT|O_APPEND, 0666);
+ if(fd >= 0){ write(fd, msg, strlen(msg)); close(fd); }
+}
+
+static void base_init(OrbisMsgDialogParam *param){
+ memset(param, 0, sizeof(*param));
+ param->baseParam.size = (uint32_t)sizeof(param->baseParam);
+ param->baseParam.magic =
+ (uint32_t)(ORBIS_COMMON_DIALOG_MAGIC_NUMBER + (uint64_t)¶m->baseParam);
+ param->size = sizeof(OrbisMsgDialogParam);
+ param->mode = ORBIS_MSG_DIALOG_MODE_USER_MSG;
+}
+
+static int ui_ready = 0;
+static int ime_dialog_running = 0;
+
+/* Reap any stale dialog from a previous session that died
+ * mid-flow. Called by ui_init() to ensure a clean start. */
+static void reap_stale(void);
+
+int ui_init(void){
+ if(ui_ready) return 0;
+ /* Reap any stale dialog from a previous session that died
+ * mid-flow. This ensures a clean start. */
+ reap_stale();
+ {
+ /* UserService first: dialogs + pad + IME all key off the user.
+ * Best-effort (already-initialized is fine); uid fallbacks
+ * downstream keep every path forward-safe. */
+ OrbisUserServiceInitializeParams up;
+ memset(&up, 0, sizeof up);
+ up.priority = ORBIS_KERNEL_PRIO_FIFO_LOWEST;
+ (void)sceUserServiceInitialize(&up);
+ }
+ /* Mirrors apollo-ps4 initInternal()/initPad():
+ * 1. internal modules (SYSTEM, USER, COMMON_DIALOG)
+ * 2. sceCommonDialogInitialize() <- BEFORE external modules
+ * 3. PAD module + scePadInit()
+ * 4. external modules (MESSAGE_DIALOG, IME_DIALOG, IME_BACKEND)
+ * Order matters: dialog init must precede external module loads,
+ * and COMMON_DIALOG must precede sceCommonDialogInitialize(). */
+ if(sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_SYSTEM_SERVICE) != 0) return -1;
+ if(sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_USER_SERVICE) != 0) return -1;
+ if(sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_COMMON_DIALOG) != 0) return -1;
+ if(sceCommonDialogInitialize() < 0) return -1;
+ if(sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_PAD) != 0) return -1;
+ (void)scePadInit();
+ if(sceSysmoduleLoadModule(ORBIS_SYSMODULE_MESSAGE_DIALOG) < 0) return -1;
+ if(sceSysmoduleLoadModule(ORBIS_SYSMODULE_IME_DIALOG) < 0){
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_MESSAGE_DIALOG);
+ return -1;
+ }
+ if(sceSysmoduleLoadModule(ORBIS_SYSMODULE_IME_BACKEND) < 0){
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_IME_DIALOG);
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_MESSAGE_DIALOG);
+ return -1;
+ }
+ ui_ready = 1;
+ return 0;
+}
+
+/* If a previous session died between open and close, a dialog
+ * can be left in RUNNING state, causing a fresh open() to fail.
+ * This function is called by ui_init() to reclaim any stale
+ * dialog before starting fresh. Currently unused (no stale
+ * dialogs observed after ui_init), kept for safety. */
+static void reap_stale(void){
+ if(sceMsgDialogGetStatus() == ORBIS_COMMON_DIALOG_STATUS_RUNNING)
+ sceMsgDialogTerminate();
+ if(sceImeDialogGetStatus() == ORBIS_DIALOG_STATUS_RUNNING)
+ sceImeDialogTerm();
+}
+
+int ui_ok(const char *msg){
+ OrbisMsgDialogParam param;
+ OrbisMsgDialogUserMessageParam um;
+ OrbisMsgDialogResult res;
+ memset(&res, 0, sizeof res);
+ sceMsgDialogTerminate();
+ if(sceMsgDialogInitialize() < 0) return -1;
+ base_init(¶m);
+ memset(&um, 0, sizeof um);
+ um.msg = msg;
+ um.buttonType = ORBIS_MSG_DIALOG_BUTTON_TYPE_OK;
+ param.userMsgParam = &um;
+ if(sceMsgDialogOpen(¶m) < 0){ sceMsgDialogTerminate(); return -1; }
+ /* Yield while waiting: a hot spin starves the dialog service on the
+ * console (the proven pattern on this box sleeps 20 ms per tick). */
+ while(sceMsgDialogUpdateStatus() != ORBIS_COMMON_DIALOG_STATUS_FINISHED)
+ sceKernelUsleep(20000);
+ sceMsgDialogClose();
+ sceMsgDialogGetResult(&res);
+ sceMsgDialogTerminate();
+ return 0;
+}
+
+/* Yes/No dialog. Returns 1 (Yes) or 0 (No/closed).
+ * The dialog uses YESNO_FOCUS_NO which focuses the "No" button.
+ * On this console, Circle=confirm and X=back. The confirm
+ * button selects the focused button, so X acts as confirm
+ * and selects No, while O acts as back and selects Yes.
+ * Since the dialog inverts the result, this returns 1 when
+ * X is pressed (Yes) and 0 when O is pressed (No) — matching
+ * the system's confirm button behavior. Kept for future use. */
+int ui_confirm(const char *msg){
+ OrbisMsgDialogParam param;
+ OrbisMsgDialogUserMessageParam um;
+ OrbisMsgDialogResult res;
+ memset(&res, 0, sizeof res);
+ sceMsgDialogTerminate();
+ if(sceMsgDialogInitialize() < 0) return -1;
+ base_init(¶m);
+ memset(&um, 0, sizeof um);
+ um.msg = msg;
+ /* YESNO_FOCUS_NO focuses the "No" button. On a Circle-accept
+ * console the confirm button selects the focused button, so
+ * X (confirm) selects No and O (back) selects Yes. The
+ * result is inverted: X→buttonId=NO→returns 1 (Yes). */
+ um.buttonType = ORBIS_MSG_DIALOG_BUTTON_TYPE_YESNO_FOCUS_NO;
+ param.userMsgParam = &um;
+ if(sceMsgDialogOpen(¶m) < 0){ sceMsgDialogTerminate(); return -1; }
+ while(sceMsgDialogUpdateStatus() != ORBIS_COMMON_DIALOG_STATUS_FINISHED)
+ sceKernelUsleep(20000);
+ sceMsgDialogClose();
+ sceMsgDialogGetResult(&res);
+ sceMsgDialogTerminate();
+ return res.buttonId == ORBIS_MSG_DIALOG_BUTTON_ID_YES;
+}
+
+static int progress_open = 0;
+
+int ui_progress_open(const char *msg){
+ OrbisMsgDialogParam param;
+ OrbisMsgDialogProgressBarParam bar;
+ if(progress_open){
+ /* Self-healing: a prior session that died between open and close
+ * leaves the flag set with no dialog behind it. Reclaim it. */
+ ui_progress_close();
+ }
+ sceMsgDialogTerminate();
+ if(sceMsgDialogInitialize() < 0) return -1;
+ base_init(¶m);
+ param.mode = ORBIS_MSG_DIALOG_MODE_PROGRESS_BAR;
+ memset(&bar, 0, sizeof bar);
+ bar.barType = ORBIS_MSG_DIALOG_PROGRESSBAR_TYPE_PERCENTAGE;
+ bar.msg = msg;
+ param.progBarParam = &bar;
+ if(sceMsgDialogOpen(¶m) < 0){ sceMsgDialogTerminate(); return -1; }
+ progress_open = 1;
+ return 0;
+}
+
+void ui_progress_msg(const char *msg){
+ if(progress_open && msg) sceMsgDialogProgressBarSetMsg(0, msg);
+}
+
+void ui_progress_set(unsigned pct){
+ if(progress_open) sceMsgDialogProgressBarSetValue(0, pct > 100 ? 100 : pct);
+}
+
+void ui_progress_close(void){
+ if(!progress_open) return;
+ progress_open = 0;
+ sceMsgDialogClose();
+ sceMsgDialogTerminate();
+}
+
+int ui_input(const char *title, const char *placeholder, char *out, size_t cap){
+ static wchar_t wbuf[256];
+ static wchar_t wtitle[64];
+ static wchar_t wplace[64];
+ OrbisImeDialogSetting st;
+ int32_t uid = 0;
+ size_t i;
+ if(!out || cap == 0 || cap > 200) return -1;
+ /* Prefill with current value (ASCII round-trip). */
+ for(i = 0; i < cap - 1 && out[i]; i++) wbuf[i] = (wchar_t)(unsigned char)out[i];
+ wbuf[i] = 0;
+ for(i = 0; i < 63 && title && title[i]; i++) wtitle[i] = (wchar_t)(unsigned char)title[i];
+ wtitle[i] = 0;
+ for(i = 0; i < 63 && placeholder && placeholder[i]; i++) wplace[i] = (wchar_t)(unsigned char)placeholder[i];
+ wplace[i] = 0;
+ if(sceUserServiceGetForegroundUser(&uid) < 0 || uid <= 0) uid = 1;
+ memset(&st, 0, sizeof st);
+ st.userId = (uint32_t)uid;
+ st.type = 0;
+ st.supportedLanguages = 0;
+ st.enterLabel = ORBIS_BUTTON_LABEL_DEFAULT;
+ st.inputMethod = 0;
+ st.filter = 0;
+ st.option = 0;
+ st.maxTextLength = (uint32_t)(cap - 1);
+ st.inputTextBuffer = wbuf;
+ /* Center of 1920x1080. At (0,0) the panel anchors up-left and the
+ * keyboard renders half off-screen; 960,540 is the layout that is
+ * known-good on this console (rutracker-ps4 uses the same values). */
+ st.posx = 960;
+ st.posy = 540;
+ st.horizontalAlignment = ORBIS_H_CENTER;
+ st.verticalAlignment = ORBIS_V_CENTER;
+ st.placeholder = wplace;
+ st.title = wtitle;
+ /* apollo-ps4 pattern: guard against re-entry, terminate stale
+ * state, then init. The dialog owns the running flag. */
+ if(ime_dialog_running){ sceImeDialogTerm(); ime_dialog_running = 0; }
+ ime_dbg("ime_begin\n");
+ if(sceImeDialogInit(&st, NULL) < 0){ ime_dbg("ime_init_fail\n"); return -1; }
+ ime_dialog_running = 1;
+ {
+ int spins = 0;
+ OrbisDialogStatus ds;
+ ds = sceImeDialogGetStatus();
+ if(ds == ORBIS_DIALOG_STATUS_RUNNING) ime_dbg("ime_status_running\n");
+ else if(ds == ORBIS_DIALOG_STATUS_NONE) ime_dbg("ime_status_none\n");
+ else if(ds == ORBIS_DIALOG_STATUS_STOPPED) ime_dbg("ime_status_stopped\n");
+ while(ime_dialog_running){
+ ds = sceImeDialogGetStatus();
+ if(ds == ORBIS_DIALOG_STATUS_STOPPED){
+ OrbisDialogResult res;
+ memset(&res, 0, sizeof res);
+ sceImeDialogGetResult(&res);
+ sceImeDialogTerm();
+ ime_dialog_running = 0;
+ if(res.endstatus != ORBIS_DIALOG_OK) return 0;
+ break;
+ }
+ if(ds == ORBIS_DIALOG_STATUS_NONE && ++spins > 250){
+ ime_dbg("ime_timeout\n");
+ sceImeDialogTerm();
+ ime_dialog_running = 0;
+ return -1;
+ }
+ if(ds != ORBIS_DIALOG_STATUS_NONE) spins = 0;
+ sceKernelUsleep(20000);
+ }
+ }
+ for(i = 0; i < cap - 1 && wbuf[i]; i++)
+ out[i] = (wbuf[i] < 128 && wbuf[i] >= 32) ? (char)wbuf[i] : '?';
+ out[i] = 0;
+ return 1;
+}
+
+/* Teardown for exit: terminate any live dialog, then unload the
+ * modules loaded in ui_init. Called before _exit so no dialog
+ * outlives the app. Must unload PAD too since it was loaded
+ * internally (ORBIS_SYSMODULE_INTERNAL_PAD). */
+void ui_shutdown(void){
+ if(!ui_ready) return;
+ ui_ready = 0;
+ progress_open = 0;
+ ime_dialog_running = 0;
+ sceMsgDialogTerminate();
+ sceImeDialogTerm();
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_IME_BACKEND);
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_IME_DIALOG);
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_MESSAGE_DIALOG);
+ sceSysmoduleUnloadModule(ORBIS_SYSMODULE_INTERNAL_PAD);
+}
diff --git a/installer/ui.h b/installer/ui.h
new file mode 100644
index 0000000..efa051d
--- /dev/null
+++ b/installer/ui.h
@@ -0,0 +1,30 @@
+/* ui.h - native dialog helpers for the installer wizard. */
+#ifndef INSTALLER_UI_H
+#define INSTALLER_UI_H
+#include
+
+/* Must run once before any dialog: loads MsgDialog/ImeDialog sysmodules
+ * and inits CommonDialog. Calling dialogs without this panics the box
+ * (unloaded-sysmodule call at startup). 0 ok, -1 fatal. */
+int ui_init(void);
+
+/* Info dialog with OK. 0 shown, -1 failed to open. */
+int ui_ok(const char *msg);
+/* Yes/No dialog: X = enter (Yes), O = back (No). 1 yes, 0 no/closed,
+ * -1 error. */
+int ui_confirm(const char *msg);
+
+/* Progress dialog. Open once, update, close. 0 ok, -1 error. */
+int ui_progress_open(const char *msg);
+void ui_progress_msg(const char *msg);
+void ui_progress_set(unsigned pct);
+void ui_progress_close(void);
+
+/* OSK text input. Prefills with out's current content. Returns 1 + out
+ * filled on Done, 0 on cancel, -1 on error. */
+int ui_input(const char *title, const char *placeholder, char *out, size_t cap);
+
+/* Terminate dialogs + unload their sysmodules. Call once before exit. */
+void ui_shutdown(void);
+
+#endif
diff --git a/orbisrpc/appdb.c b/orbisrpc/appdb.c
new file mode 100644
index 0000000..1c083e0
--- /dev/null
+++ b/orbisrpc/appdb.c
@@ -0,0 +1,147 @@
+/* appdb.c - display names from /system_data/priv/mms/app.db (read-only).
+ * Method follows the proven on-console pattern (SonicLoader et al.): SQLite
+ * query, never raw byte-scanning (an earlier revision byte-scanned app.db
+ * and returned wrong names: the file reads fine, the parse was garbage).
+ * Tiers, first hit wins:
+ * 1. tbl_appbrowse.titleName (when the firmware carries it)
+ * 2. tbl_appinfo val for key TITLE / TITLE_xx (localized display titles)
+ * 3. /user/appmeta//param.json title fields (ShadowMountPlus pattern)
+ * Every tier is schema-guarded and fail-soft: missing tables, missing keys,
+ * unreadable files, and locked DBs all fall through to the next source.
+ * Host-testable via appdb_title_from() with a synthetic DB path. */
+#include "appdb.h"
+#include "jsonlite.h"
+#include "log.h"
+#include
+#include
+#include
+#include
+#include "sqlite3.h"
+
+#define APPDB_PATH "/system_data/priv/mms/app.db"
+#define APPMETA_BASE "/user/appmeta"
+
+static int valid_tid(const char *t){
+ if(!t) return 0;
+ size_t n = strlen(t);
+ if(n != 9) return 0;
+ for(size_t i = 0; i < n; i++){
+ char c = t[i];
+ if(!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) return 0;
+ }
+ return 1;
+}
+
+static int table_exists(sqlite3 *db, const char *name){
+ sqlite3_stmt *st = NULL;
+ int found = 0;
+ if(sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1 LIMIT 1;",
+ -1, &st, NULL) != SQLITE_OK)
+ return 0;
+ sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
+ if(sqlite3_step(st) == SQLITE_ROW) found = 1;
+ sqlite3_finalize(st);
+ return found;
+}
+
+/* Copy first non-empty TEXT result of a one-row query. 0 on hit. */
+static int query_name(sqlite3 *db, const char *sql, const char *titleId,
+ char *out, size_t cap){
+ sqlite3_stmt *st = NULL;
+ int rc = -1;
+ if(sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return -1;
+ sqlite3_bind_text(st, 1, titleId, -1, SQLITE_TRANSIENT);
+ if(sqlite3_step(st) == SQLITE_ROW){
+ const unsigned char *v = sqlite3_column_text(st, 0);
+ if(v && v[0]){
+ strncpy(out, (const char *)v, cap - 1);
+ out[cap - 1] = 0;
+ if(out[0] && strlen(out) > 1) rc = 0;
+ }
+ }
+ sqlite3_finalize(st);
+ return rc;
+}
+
+/* param.json title fields: small single read, jsonlite parse. Best-effort. */
+static int paramjson_title(const char *appmeta_base, const char *titleId,
+ char *out, size_t cap){
+ char path[256];
+ int n = snprintf(path, sizeof path, "%s/%s/param.json",
+ appmeta_base ? appmeta_base : APPMETA_BASE, titleId);
+ if(n <= 0 || (size_t)n >= sizeof path) return -1;
+ int fd = open(path, O_RDONLY);
+ if(fd < 0) return -1;
+ char buf[4096];
+ ssize_t r = read(fd, buf, sizeof buf - 1);
+ close(fd);
+ if(r <= 0) return -1;
+ buf[r] = 0;
+ jl_val_t *root = jl_parse(buf, (size_t)r);
+ if(!root) return -1;
+ static const char *keys[] = { "titleName", "title", "name", NULL };
+ int rc = -1;
+ for(int i = 0; keys[i]; i++){
+ const jl_val_t *v = jl_obj_get(root, keys[i]);
+ if(v && v->type == JL_STRING && v->str && strlen(v->str) > 1){
+ strncpy(out, v->str, cap - 1);
+ out[cap - 1] = 0;
+ rc = 0;
+ break;
+ }
+ }
+ jl_free(root);
+ return rc;
+}
+
+int appdb_title_from(const char *dbpath, const char *appmeta_base,
+ const char *titleId, char *out, size_t cap){
+ if(!out || cap == 0) return -1;
+ out[0] = 0;
+ if(!valid_tid(titleId)) return -1;
+ if(!dbpath) dbpath = APPDB_PATH;
+
+ sqlite3 *db = NULL;
+ int orc = sqlite3_open_v2(dbpath, &db,
+ SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, NULL);
+ if(orc != SQLITE_OK){
+ if(db) sqlite3_close(db);
+ return -1;
+ }
+ sqlite3_busy_timeout(db, 2000);
+
+ /* Tier 1: friendly browse name. */
+ if(table_exists(db, "tbl_appbrowse")){
+ if(query_name(db, "SELECT titleName FROM tbl_appbrowse WHERE titleId=?1 "
+ "AND titleName IS NOT NULL AND titleName<>'' LIMIT 1;",
+ titleId, out, cap) == 0){
+ log_msg("name: %s via appdb-browse", out);
+ sqlite3_close(db);
+ return 0;
+ }
+ }
+ /* Tier 2: localized TITLE keys; ORDER BY key prefers bare TITLE
+ * (system language) over TITLE_00..NN variants. */
+ if(table_exists(db, "tbl_appinfo")){
+ if(query_name(db, "SELECT val FROM tbl_appinfo WHERE titleId=?1 "
+ "AND (key='TITLE' OR key LIKE 'TITLE\\_%' ESCAPE '\\') "
+ "AND val IS NOT NULL AND val<>'' ORDER BY key LIMIT 1;",
+ titleId, out, cap) == 0){
+ log_msg("name: %s via appdb-info", out);
+ sqlite3_close(db);
+ return 0;
+ }
+ }
+ sqlite3_close(db);
+
+ /* Tier 3: staged appmeta descriptor (no DB needed). */
+ if(paramjson_title(appmeta_base, titleId, out, cap) == 0){
+ log_msg("name: %s via paramjson", out);
+ return 0;
+ }
+ return -1;
+}
+
+int appdb_title(const char *titleId, char *out, size_t cap){
+ return appdb_title_from(NULL, NULL, titleId, out, cap);
+}
diff --git a/orbisrpc/appdb.h b/orbisrpc/appdb.h
new file mode 100644
index 0000000..092ee34
--- /dev/null
+++ b/orbisrpc/appdb.h
@@ -0,0 +1,10 @@
+/* appdb.h - display names from the system app.db (read-only SQLite). */
+#ifndef APPDB_H
+#define APPDB_H
+#include
+/* Resolve a display name for a 9-char title ID. 0 on success. */
+int appdb_title(const char *titleId, char *out, size_t cap);
+/* Test seam: explicit DB + appmeta paths (NULL = on-console defaults). */
+int appdb_title_from(const char *dbpath, const char *appmeta_base,
+ const char *titleId, char *out, size_t cap);
+#endif
diff --git a/orbisrpc/art.c b/orbisrpc/art.c
new file mode 100644
index 0000000..2e57da6
--- /dev/null
+++ b/orbisrpc/art.c
@@ -0,0 +1,312 @@
+/* art.c - external-asset resolution (host-testable parsing + PS4 HTTPS).
+ * Pure parsing lives in art_parse_mp() (unit-tested); the POST itself
+ * mirrors updater.c's bounded HTTPS client. */
+#include "art.h"
+#include "jsonlite.h"
+#include "log.h"
+#include "updater_http.h"
+#include
+#include
+#include
+#include
+#include
+
+/* Extract external_asset_path for url from an external-assets response.
+ * Returns 1 + out ("mp:"+path), else 0. Pure function, host-tested. */
+int art_parse_mp(const char *body, size_t len, const char *url,
+ char *out, size_t cap){
+ if(!body || !url || !out || cap < 8) return 0;
+ jl_val_t *r = jl_parse(body, len);
+ if(!r || r->type != JL_ARRAY){ if(r) jl_free(r); return 0; }
+ int rc = 0;
+ for(size_t i = 0; ; i++){
+ const jl_val_t *e = jl_arr_at(r, i);
+ if(!e) break;
+ if(e->type != JL_OBJECT) continue;
+ const jl_val_t *u = jl_obj_get(e, "url");
+ const jl_val_t *p = jl_obj_get(e, "external_asset_path");
+ if(!u || u->type != JL_STRING || !u->str) continue;
+ if(!p || p->type != JL_STRING || !p->str) continue;
+ if(strcmp(u->str, url) != 0) continue;
+ if(strlen(p->str) > cap - 5) continue;
+ snprintf(out, cap, "mp:%s", p->str);
+ rc = 1;
+ break;
+ }
+ jl_free(r);
+ return rc;
+}
+
+#ifdef ORBISRPC_SDK_PAYLOAD
+#include "tls.h"
+#include "clock.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#elif defined(__PS4__)
+#include "tls.h"
+#include "clock.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#endif
+
+#define ART_HOST "discord.com"
+#define ART_DEADLINE_S 12
+#define ART_RESP_MAX (16u*1024u)
+
+static char s_last_url[512] = "";
+static char s_last_mp[512] = "";
+
+void art_cache_clear(void){
+ s_last_url[0] = 0;
+ s_last_mp[0] = 0;
+}
+
+/* Disk cache: url -> mp path, 7-day TTL. Survives reboots so titles
+ * resolved once never pay the external-assets round trip again.
+ * Best-effort only: any I/O failure degrades to memory-only caching. */
+#define ART_DISK_MAX 32
+#define ART_DISK_TTL (7*24*3600)
+static struct { char url[512]; char mp[512]; int64_t at; } s_disk[ART_DISK_MAX];
+static int s_disk_n = -1; /* -1 = not loaded yet */
+
+static void art_disk_load(void){
+ if(s_disk_n >= 0) return;
+ s_disk_n = 0;
+ FILE *f = fopen("/data/orbisRPC/artwork_cache.json", "rb");
+ if(!f) return;
+ fseek(f, 0, SEEK_END);
+ long sz = ftell(f);
+ fseek(f, 0, SEEK_SET);
+ int64_t now = (int64_t)time(NULL);
+ if(sz > 0 && sz < 65536){
+ char *buf = (char*)malloc((size_t)sz + 1);
+ if(buf && fread(buf, 1, (size_t)sz, f) == (size_t)sz){
+ buf[sz] = 0;
+ jl_val_t *r = jl_parse(buf, (size_t)sz);
+ if(r && r->type == JL_ARRAY){
+ for(size_t i = 0; ; i++){
+ const jl_val_t *e = jl_arr_at(r, i);
+ if(!e) break;
+ if(s_disk_n >= ART_DISK_MAX) break;
+ if(e->type != JL_OBJECT) continue;
+ const jl_val_t *u = jl_obj_get(e, "url");
+ const jl_val_t *m = jl_obj_get(e, "mp");
+ const jl_val_t *a = jl_obj_get(e, "at");
+ if(!u || u->type != JL_STRING || !u->str) continue;
+ if(!m || m->type != JL_STRING || !m->str) continue;
+ if(!a || a->type != JL_NUMBER) continue;
+ if(now - (int64_t)a->num > ART_DISK_TTL) continue; /* prune */
+ snprintf(s_disk[s_disk_n].url, sizeof s_disk[s_disk_n].url, "%s", u->str);
+ snprintf(s_disk[s_disk_n].mp, sizeof s_disk[s_disk_n].mp, "%s", m->str);
+ s_disk[s_disk_n].at = (int64_t)a->num;
+ s_disk_n++;
+ }
+ }
+ if(r) jl_free(r);
+ }
+ free(buf);
+ }
+ fclose(f);
+}
+
+static void art_disk_save(void){
+ char tmp[256];
+ snprintf(tmp, sizeof tmp, "%s.new", "/data/orbisRPC/artwork_cache.json");
+ FILE *f = fopen(tmp, "wb");
+ if(!f) return;
+ int ok = 1;
+ if(fputs("[", f) < 0) ok = 0;
+ for(int i = 0; ok && i < s_disk_n; i++){
+ if(i > 0 && fputs(",", f) < 0){ ok = 0; break; }
+ /* URLs/mp paths are response-derived; escape defensively. */
+ char eu[1024], em[1024];
+ size_t a = 0, b = 0;
+ for(const char *p = s_disk[i].url; *p && a + 2 < sizeof eu; p++){
+ if(*p == '"' || *p == '\\') eu[a++] = '\\';
+ eu[a++] = *p;
+ }
+ eu[a] = 0;
+ for(const char *p = s_disk[i].mp; *p && b + 2 < sizeof em; p++){
+ if(*p == '"' || *p == '\\') em[b++] = '\\';
+ em[b++] = *p;
+ }
+ em[b] = 0;
+ if(fprintf(f, "{\"url\":\"%s\",\"mp\":\"%s\",\"at\":%lld}",
+ eu, em, (long long)s_disk[i].at) < 0) ok = 0;
+ }
+ if(ok && fputs("]", f) < 0) ok = 0;
+ if(ok && fflush(f) != 0) ok = 0;
+ if(ok){ int fd = fileno(f); if(fd >= 0 && fsync(fd) != 0) ok = 0; }
+ if(fclose(f) != 0) ok = 0;
+ if(ok) rename(tmp, "/data/orbisRPC/artwork_cache.json");
+ else remove(tmp);
+}
+
+static int art_disk_get(const char *url, char *out_mp, size_t cap){
+ art_disk_load();
+ int64_t now = (int64_t)time(NULL);
+ for(int i = 0; i < s_disk_n; i++){
+ if(!strcmp(s_disk[i].url, url)){
+ if(now - s_disk[i].at > ART_DISK_TTL) return 0;
+ strncpy(out_mp, s_disk[i].mp, cap - 1);
+ out_mp[cap - 1] = 0;
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static void art_disk_put(const char *url, const char *mp){
+ art_disk_load();
+ for(int i = 0; i < s_disk_n; i++){
+ if(!strcmp(s_disk[i].url, url)){
+ snprintf(s_disk[i].mp, sizeof s_disk[i].mp, "%s", mp);
+ s_disk[i].at = (int64_t)time(NULL);
+ art_disk_save();
+ return;
+ }
+ }
+ if(s_disk_n < ART_DISK_MAX){
+ snprintf(s_disk[s_disk_n].url, sizeof s_disk[s_disk_n].url, "%s", url);
+ snprintf(s_disk[s_disk_n].mp, sizeof s_disk[s_disk_n].mp, "%s", mp);
+ s_disk[s_disk_n].at = (int64_t)time(NULL);
+ s_disk_n++;
+ art_disk_save();
+ }
+}
+
+static int art_post(const char *app_id, const char *token, const char *url,
+ char *out_body, size_t body_cap, size_t *out_len){
+#ifdef ORBISRPC_SDK_PAYLOAD
+ struct addrinfo hints, *res = NULL;
+ memset(&hints, 0, sizeof hints);
+ hints.ai_family = AF_INET;
+ hints.ai_socktype = SOCK_STREAM;
+ if(getaddrinfo(ART_HOST, "443", &hints, &res) != 0 || !res) return -1;
+ int fd = socket(AF_INET, SOCK_STREAM, 0);
+ if(fd < 0){ freeaddrinfo(res); return -1; }
+ struct timeval tv = { ART_DEADLINE_S, 0 };
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
+ if(connect(fd, res->ai_addr, res->ai_addrlen) < 0){
+ close(fd); freeaddrinfo(res); return -1;
+ }
+ freeaddrinfo(res);
+ {
+ int fl = fcntl(fd, F_GETFL, 0);
+ if(fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK);
+ }
+#else
+ /* Host test build: no transport here (parse path still testable). */
+ (void)app_id; (void)token; (void)url;
+ (void)out_body; (void)body_cap; (void)out_len;
+ return -1;
+#endif
+#if defined(ORBISRPC_SDK_PAYLOAD) || defined(__PS4__)
+ tls_ctx_t *t = tls_start(fd, ART_HOST);
+ if(!t){
+#ifdef ORBISRPC_SDK_PAYLOAD
+ close(fd);
+#else
+ sceNetSocketClose(fd);
+#endif
+ return -1;
+ }
+ char payload[768];
+ int pl = snprintf(payload, sizeof payload, "{\"urls\":[\"%s\"]}", url);
+ if(pl <= 0 || pl >= (int)sizeof payload){ tls_free(t); return -1; }
+ char req[1024];
+ int rl = snprintf(req, sizeof req,
+ "POST /api/v10/applications/%s/external-assets HTTP/1.1\r\n"
+ "Host: %s\r\nAuthorization: %s\r\nContent-Type: application/json\r\n"
+ "Content-Length: %d\r\nConnection: close\r\n\r\n",
+ app_id, ART_HOST, token, pl);
+ if(rl <= 0 || rl >= (int)sizeof req){ tls_free(t); return -1; }
+ if(tls_write(t, req, (size_t)rl) < 0){ tls_free(t); return -1; }
+ if(tls_write(t, payload, (size_t)pl) < 0){ tls_free(t); return -1; }
+ size_t bl = 0;
+ int64_t dl = orbis_mono_s() + ART_DEADLINE_S + 10;
+ for(;;){
+ char tmp[1024];
+ int r = tls_read(t, tmp, sizeof tmp - 1);
+ if(r < 0) break;
+ if(r == 0){
+ if(orbis_mono_s() > dl) break;
+ usleep(20000);
+ continue;
+ }
+ if(bl + (size_t)r >= body_cap) break;
+ memcpy(out_body + bl, tmp, (size_t)r);
+ bl += (size_t)r;
+ if(orbis_mono_s() > dl) break;
+ }
+ tls_free(t);
+ if(bl == 0){ return -1; }
+ out_body[bl] = 0;
+ /* Parse with the shared chunked-aware HTTP parser (Discord serves
+ * Transfer-Encoding: chunked here; naive header-splitting reads the
+ * chunk framing as body and the mp: lookup silently misses). */
+ {
+ int st = 0;
+ size_t olen = 0;
+ char *body = upd_parse_response(out_body, bl, body_cap,
+ &st, &olen, NULL, 0);
+ if(!body || st != 200){ free(body); return -1; }
+ if(out_len) *out_len = olen;
+ memmove(out_body, body, olen + 1);
+ free(body);
+ }
+ return 0;
+#else
+ /* Host test build: no transport; parse path still testable. */
+ (void)app_id; (void)token; (void)url;
+ (void)out_body; (void)body_cap; (void)out_len;
+ return -1;
+#endif
+}
+
+int art_resolve_mp(const char *app_id, const char *token, const char *url,
+ char *out_mp, size_t cap){
+ if(!app_id || !app_id[0] || !token || !token[0] || !url || !url[0] ||
+ !out_mp || cap < 8)
+ return 0;
+ if(!strcmp(url, s_last_url) && s_last_mp[0]){
+ strncpy(out_mp, s_last_mp, cap - 1);
+ out_mp[cap - 1] = 0;
+ return 1;
+ }
+ /* Disk cache (7-day TTL): titles resolved on earlier boots skip the
+ * round trip entirely. */
+ if(art_disk_get(url, out_mp, cap)){
+ snprintf(s_last_url, sizeof s_last_url, "%s", url);
+ snprintf(s_last_mp, sizeof s_last_mp, "%s", out_mp);
+ log_msg("art: mp from disk cache");
+ return 1;
+ }
+ static char resp[ART_RESP_MAX];
+ size_t rlen = 0;
+ if(art_post(app_id, token, url, resp, sizeof resp, &rlen) != 0){
+ log_msg("art: external-assets POST failed");
+ return 0;
+ }
+ if(!art_parse_mp(resp, rlen, url, out_mp, cap)){
+ log_msg("art: no mp path for url (resp %zuB: %.120s)", rlen, resp);
+ return 0;
+ }
+ snprintf(s_last_url, sizeof s_last_url, "%s", url);
+ snprintf(s_last_mp, sizeof s_last_mp, "%s", out_mp);
+ art_disk_put(url, out_mp);
+ log_msg("art: resolved mp (%zuB)", strlen(out_mp));
+ return 1;
+}
diff --git a/orbisrpc/art.h b/orbisrpc/art.h
new file mode 100644
index 0000000..02a48a1
--- /dev/null
+++ b/orbisrpc/art.h
@@ -0,0 +1,21 @@
+/* art.h - Discord external-asset (mp:) resolution for artwork.
+ *
+ * Verified on hardware 2026-09-23: raw https URLs and dangling asset keys
+ * in large_image either drop the whole activity or render "?". The working
+ * path used by every functional tool: POST the image URL to
+ * /applications/{app}/external-assets, use the returned mp: path.
+ * Results cached per boot (one call per title). */
+#ifndef ORBISRPC_ART_H
+#define ORBISRPC_ART_H
+#include
+/* Resolve url -> mp: path. Returns 1 and fills out_mp ("mp:..." NUL) on
+ * success, 0 when unusable (caller sends no art). Not thread-safe. */
+int art_resolve_mp(const char *app_id, const char *token, const char *url,
+ char *out_mp, size_t cap);
+/* Drop the mp: cache (call on new game sessions: mappings can expire and
+ * each title deserves a fresh resolve anyway). */
+void art_cache_clear(void);
+/* Pure helper (host-tested): extract mp path for url from response. */
+int art_parse_mp(const char *body, size_t len, const char *url,
+ char *out, size_t cap);
+#endif
diff --git a/orbisrpc/b64.c b/orbisrpc/b64.c
new file mode 100644
index 0000000..61b7fdf
--- /dev/null
+++ b/orbisrpc/b64.c
@@ -0,0 +1,17 @@
+/* b64.c - standard base64 encoder */
+#include "b64.h"
+static const char b64t[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+size_t b64_encode(const unsigned char *in, size_t len, char *out){
+ size_t o=0, i;
+ if(!out || (len && !in)) return 0;
+ for(i=0;i>18)&0x3f];
+ out[o++]=b64t[(v>>12)&0x3f];
+ out[o++]=i+1>6)&0x3f]:'=';
+ out[o++]=i+2
+size_t b64_encode(const unsigned char *in, size_t len, char *out);
+/* Writes a NUL-terminated string to out and returns its encoded length.
+ * The caller must provide at least 4 * ((len + 2) / 3) + 1 bytes. */
+#endif
\ No newline at end of file
diff --git a/orbisrpc/ca_bundle_pem.h b/orbisrpc/ca_bundle_pem.h
new file mode 100644
index 0000000..449c2a4
--- /dev/null
+++ b/orbisrpc/ca_bundle_pem.h
@@ -0,0 +1,500 @@
+/* ca_bundle_pem.h - curated trust anchors (generated, do not edit).
+ * 22 roots for GitHub/Discord/Sony. Regenerate with scripts/make_ca_bundle.py.
+ */
+#ifndef ORBISRPC_CA_BUNDLE_PEM_H
+#define ORBISRPC_CA_BUNDLE_PEM_H
+static const char ORBISRPC_CA_BUNDLE_PEM[] =
+"-----BEGIN CERTIFICATE-----\n"
+"MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs\n"
+"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"
+"d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j\n"
+"ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL\n"
+"MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3\n"
+"LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug\n"
+"RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm\n"
+"+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW\n"
+"PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM\n"
+"xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB\n"
+"Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3\n"
+"hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg\n"
+"EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF\n"
+"MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA\n"
+"FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec\n"
+"nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z\n"
+"eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF\n"
+"hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2\n"
+"Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe\n"
+"vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep\n"
+"+OkuE6N36B9K\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICCjCCAZGgAwIBAgIQbkepyIuUtui7OyrYorLBmTAKBggqhkjOPQQDAzBHMQsw\n"
+"CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"
+"MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"
+"MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"
+"Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQA\n"
+"IgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzu\n"
+"hXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/l\n"
+"xKvRHYqjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud\n"
+"DgQWBBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNnADBkAjBqUFJ0\n"
+"CMRw3J5QdCHojXohw0+WbhXRIjVhLfoIN+4Zba3bssx9BzT1YBkstTTZbyACMANx\n"
+"sbqjYAuG7ZoIapVon+Kz4ZNkfF6Tpt95LY2F45TPI11xzPKwTdb+mciUqXWi4w==\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G\n"
+"A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp\n"
+"Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4\n"
+"MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG\n"
+"A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI\n"
+"hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8\n"
+"RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT\n"
+"gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm\n"
+"KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd\n"
+"QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ\n"
+"XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw\n"
+"DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o\n"
+"LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU\n"
+"RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp\n"
+"jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK\n"
+"6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX\n"
+"mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs\n"
+"Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH\n"
+"WD9f\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"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\n"
+"5hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA=\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICDDCCAZGgAwIBAgIQbkepx2ypcyRAiQ8DVd2NHTAKBggqhkjOPQQDAzBHMQsw\n"
+"CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"
+"MBIGA1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"
+"MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"
+"Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQA\n"
+"IgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout\n"
+"736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2A\n"
+"DDL24CejQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud\n"
+"DgQWBBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEAgFuk\n"
+"fCPAlaUs3L6JbyO5o91lAFJekazInXJ0glMLfalAvWhgxeG4VDvBNhcl2MG9AjEA\n"
+"njWSdIUlUfUk7GRSJFClH9voy8l27OyCbvWFGFPouOOaKaqW04MjyaR7YbPMAuhd\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL\n"
+"MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl\n"
+"eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT\n"
+"JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx\n"
+"MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT\n"
+"Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg\n"
+"VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm\n"
+"aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo\n"
+"I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng\n"
+"o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G\n"
+"A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD\n"
+"VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB\n"
+"zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW\n"
+"RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIFWjCCA0KgAwIBAgIQbkepxlqz5yDFMJo/aFLybzANBgkqhkiG9w0BAQwFADBH\n"
+"MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM\n"
+"QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIy\n"
+"MDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNl\n"
+"cnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEB\n"
+"AQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3Lv\n"
+"CvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3Kg\n"
+"GjSY6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9Bu\n"
+"XvAuMC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOd\n"
+"re7kRXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXu\n"
+"PuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1\n"
+"mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K\n"
+"8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqj\n"
+"x5RWIr9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsR\n"
+"nTKaG73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0\n"
+"kzCqgc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9Ok\n"
+"twIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV\n"
+"HQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBALZp\n"
+"8KZ3/p7uC4Gt4cCpx/k1HUCCq+YEtN/L9x0Pg/B+E02NjO7jMyLDOfxA325BS0JT\n"
+"vhaI8dI4XsRomRyYUpOM52jtG2pzegVATX9lO9ZY8c6DR2Dj/5epnGB3GFW1fgiT\n"
+"z9D2PGcDFWEJ+YF59exTpJ/JjwGLc8R3dtyDovUMSRqodt6Sm2T4syzFJ9MHwAiA\n"
+"pJiS4wGWAqoC7o87xdFtCjMwc3i5T1QWvwsHoaRc5svJXISPD+AVdyx+Jn7axEvb\n"
+"pxZ3B7DNdehyQtaVhJ2Gg/LkkM0JR9SLA3DaWsYDQvTtN6LwG1BUSw7YhN4ZKJmB\n"
+"R64JGz9I0cNv4rBgF/XuIwKl2gBbbZCr7qLpGzvpx0QnRY5rn/WkhLx3+WuXrD5R\n"
+"RaIRpsyF7gpo8j5QOHokYh4XIDdtak23CZvJ/KRY9bb7nE4Yu5UC56GtmwfuNmsk\n"
+"0jmGwZODUNKBRqhfYlcsu2xkiAhu7xNUX90txGdj08+JN7+dIPT7eoOboB6BAFDC\n"
+"5AwiWVIQ7UNWhwD4FFKnHYuTjKJNRn8nxnGbJN7k2oaLDX5rIMHAnuFl2GqjpuiF\n"
+"izoHCBy69Y9Vmhh1fuXsgWbRIXOhNUQLgD1bnF5vKheW0YMjiGZt5obicDIvUiLn\n"
+"yOd/xCxgXS/Dr55FBcOEArf9LAhST4Ldo/DUhgkC\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n"
+"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"
+"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n"
+"MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n"
+"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n"
+"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n"
+"9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n"
+"2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n"
+"1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n"
+"q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n"
+"tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n"
+"vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n"
+"BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n"
+"5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n"
+"1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n"
+"NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n"
+"Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n"
+"8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n"
+"pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n"
+"MrY=\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh\n"
+"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"
+"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD\n"
+"QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT\n"
+"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n"
+"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG\n"
+"9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB\n"
+"CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97\n"
+"nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt\n"
+"43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P\n"
+"T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4\n"
+"gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO\n"
+"BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR\n"
+"TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw\n"
+"DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr\n"
+"hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg\n"
+"06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF\n"
+"PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls\n"
+"YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk\n"
+"CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQsw\n"
+"CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu\n"
+"ZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAe\n"
+"Fw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUw\n"
+"EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x\n"
+"IDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0CAQYF\n"
+"K4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FG\n"
+"fp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPO\n"
+"Z9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd\n"
+"BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIx\n"
+"AK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/\n"
+"oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8\n"
+"sycX\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"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\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB\n"
+"iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl\n"
+"cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV\n"
+"BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw\n"
+"MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV\n"
+"BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU\n"
+"aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy\n"
+"dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK\n"
+"AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B\n"
+"3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY\n"
+"tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/\n"
+"Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2\n"
+"VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT\n"
+"79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6\n"
+"c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT\n"
+"Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l\n"
+"c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee\n"
+"UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE\n"
+"Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd\n"
+"BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G\n"
+"A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF\n"
+"Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO\n"
+"VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3\n"
+"ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs\n"
+"8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR\n"
+"iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze\n"
+"Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ\n"
+"XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/\n"
+"qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB\n"
+"VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB\n"
+"L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG\n"
+"jjxDah2nGN59PRbxYvnKkKj9\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx\n"
+"EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT\n"
+"HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs\n"
+"ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5\n"
+"MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD\n"
+"VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy\n"
+"ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy\n"
+"dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI\n"
+"hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p\n"
+"OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2\n"
+"8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K\n"
+"Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe\n"
+"hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk\n"
+"6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw\n"
+"DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q\n"
+"AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI\n"
+"bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB\n"
+"ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z\n"
+"qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd\n"
+"iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn\n"
+"0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN\n"
+"sSi6\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"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\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"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\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n"
+"A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n"
+"b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n"
+"MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n"
+"YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n"
+"aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n"
+"jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n"
+"xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n"
+"1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n"
+"snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n"
+"U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n"
+"9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n"
+"BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n"
+"AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n"
+"yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n"
+"38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n"
+"AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n"
+"DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n"
+"HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF\n"
+"ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6\n"
+"b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL\n"
+"MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv\n"
+"b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj\n"
+"ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM\n"
+"9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw\n"
+"IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6\n"
+"VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L\n"
+"93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm\n"
+"jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC\n"
+"AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA\n"
+"A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI\n"
+"U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs\n"
+"N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv\n"
+"o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU\n"
+"5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy\n"
+"rqXRfboQnoZsG4q5WTP468SQvvG5\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJV\n"
+"UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg\n"
+"UjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE\n"
+"ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0G\n"
+"CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaM\n"
+"f/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7raKb0\n"
+"xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjwTcLCeoiKu7rPWRnWr4+w\n"
+"B7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy4HqMLnXW\n"
+"nOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk\n"
+"9+aCEI3oncKKiPo4Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zq\n"
+"kUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1A\n"
+"K/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPvOmbsB4om3xPX\n"
+"V2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDW\n"
+"cfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T\n"
+"AQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQAD\n"
+"ggIBAJ+qQibbC5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n"
+"QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRi\n"
+"ClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34ar\n"
+"J45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYci\n"
+"NuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5me\n"
+"LMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJF\n"
+"fbdT6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ0E6yove+\n"
+"7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm2tIMPNuzjsmhDYAPexZ3\n"
+"FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3\n"
+"gm3c\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJVUzEi\n"
+"MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMw\n"
+"HhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZ\n"
+"R29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjO\n"
+"PQIBBgUrgQQAIgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout\n"
+"736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL24CejQjBA\n"
+"MA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTB8Sa6oC2uhYHP0/Eq\n"
+"Er24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azT\n"
+"L818+FsuVbu/3ZL3pAzcMeGiAjEA/JdmZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV\n"
+"11RZt+cRLInUue4X\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJVUzEi\n"
+"MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQw\n"
+"HhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZ\n"
+"R29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjO\n"
+"PQIBBgUrgQQAIgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzu\n"
+"hXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvRHYqjQjBA\n"
+"MA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSATNbrdP9JNqPV2Py1\n"
+"PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/C\n"
+"r8deVl5c1RxYIigL9zC2L7F8AjEA8GE8p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh\n"
+"4rsUecrNIdSUtUlD\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQswCQYDVQQGEwJH\n"
+"QjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBTZXJ2\n"
+"ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5\n"
+"WjBfMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0\n"
+"aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUr\n"
+"gQQAIgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccCWvkEN/U0\n"
+"NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+6xnOQ6OjQjBAMB0GA1Ud\n"
+"DgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB\n"
+"/zAKBggqhkjOPQQDAwNnADBkAjAn7qRaqCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RH\n"
+"lAFWovgzJQxC36oCMB3q4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21U\n"
+"SAGKcw==\n"
+"-----END CERTIFICATE-----\n"
+"-----BEGIN CERTIFICATE-----\n"
+"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\n"
+"-----END CERTIFICATE-----\n"
+;
+#endif
diff --git a/orbisrpc/cfg.c b/orbisrpc/cfg.c
new file mode 100644
index 0000000..46784a4
--- /dev/null
+++ b/orbisrpc/cfg.c
@@ -0,0 +1,207 @@
+/* cfg.c - tiny JSON config via jsonlite */
+#include "cfg.h"
+#include "jsonlite.h"
+#include "log.h"
+#include
+#include
+#include
+#include
+
+cfg_t g_cfg;
+
+void cfg_defaults(cfg_t *c) {
+ if(!c) return;
+ memset(c, 0, sizeof(*c));
+ c->schema_version = CFG_SCHEMA_VERSION;
+ c->enabled = 1;
+ c->auto_update = 1;
+ c->poll_interval_s = 12;
+ strncpy(c->token, "SET_ME", sizeof(c->token)-1);
+ strncpy(c->presence_state, "On PS4", sizeof(c->presence_state)-1);
+ /* Idle tile ships working: project-hosted orbisRPC logo, resolved
+ * through the mp: proxy like every other art URL. Override with any
+ * http(s) URL or uploaded asset key. */
+ strncpy(c->home_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/main/config/icons/logo.png",
+ sizeof(c->home_art)-1);
+ c->n_titles = 0;
+ /* Default art backend: our own Sony-CDN icon pack, resolved through
+ * Discord's external-assets proxy (mp:) at post time. Works from the
+ * start with zero setup; missing titles degrade to no art. */
+ strncpy(c->art_base_url, "https://raw.githubusercontent.com/SirHumza/orbisrpc-host/main/icons/",
+ sizeof(c->art_base_url)-1);
+ strncpy(c->application_id, "1536977374795538532", sizeof(c->application_id)-1);
+}
+
+/* keep the daemon sane if the user puts junk in config */
+static void clamp_cfg(cfg_t *c){
+ if(c->poll_interval_s < 5){
+ log_msg("config: poll_interval_s %d too small; using 5", c->poll_interval_s);
+ c->poll_interval_s = 5;
+ }
+ if(c->poll_interval_s > 300){
+ log_msg("config: poll_interval_s %d too large; using 300", c->poll_interval_s);
+ c->poll_interval_s = 300;
+ }
+ if(c->token[0] && !strcmp(c->token, "SET_ME"))
+ log_msg("config: token is still the SET_ME placeholder");
+}
+
+int cfg_load(const char *path, cfg_t *c) {
+ if(!path || !c) return -1;
+ cfg_defaults(c);
+ FILE *f = fopen(path, "rb");
+ if (!f) { log_msg("config not found at %s; defaults applied", path); return -1; }
+ if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return -1; }
+ long sz = ftell(f);
+ if (sz < 0 || fseek(f, 0, SEEK_SET) != 0) { fclose(f); return -1; }
+ if (sz <= 0 || sz > 1<<20) { fclose(f); return -1; }
+ char *buf = (char *)malloc((size_t)sz + 1);
+ if (!buf) { fclose(f); return -1; }
+ size_t n = fread(buf, 1, (size_t)sz, f); fclose(f);
+ if(n != (size_t)sz){ free(buf); log_msg("config read failed at %s", path); return -1; }
+ buf[n] = 0;
+ jl_val_t *root = jl_parse(buf, n);
+ free(buf);
+ if (!root) { log_msg("config parse failed; using defaults"); return -1; }
+ const jl_val_t *o;
+#define STR(k,f) do { \
+ o=jl_obj_get(root,k); \
+ if(o&&o->type==JL_STRING){ \
+ strncpy(c->f,o->str,sizeof(c->f)-1); \
+ c->f[sizeof(c->f)-1]=0; \
+ } \
+ } while(0)
+ STR("token", token);
+ STR("application_id", application_id);
+ STR("art_base_url", art_base_url);
+ STR("home_art", home_art);
+ STR("presence_state", presence_state);
+#undef STR
+ /* User-local title overrides: {"CUSA11995": "Marvel's Spider-Man"}.
+ * Defensive: wrong types, overlong keys/names, and overflow past
+ * CFG_MAX_TITLES are ignored, never fatal. */
+ c->n_titles = 0;
+ o = jl_obj_get(root, "titles");
+ if(o && o->type == JL_OBJECT){
+ for(jl_val_t *p = o->child; p && c->n_titles < CFG_MAX_TITLES; p = p->next){
+ const jl_val_t *v = p->child ? p->child : p;
+ if(!p->str || !v || v->type != JL_STRING || !v->str) continue;
+ size_t kl = strlen(p->str), vl = strlen(v->str);
+ if(kl == 0 || kl >= (size_t)CFG_TITLEID_LEN) continue;
+ if(vl < 2 || vl >= (size_t)CFG_TITLENAME_LEN) continue;
+ int ok = 1;
+ for(size_t i = 0; i < kl; i++){
+ char ch = p->str[i];
+ if(!((ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_')){ ok = 0; break; }
+ }
+ if(!ok) continue;
+ strncpy(c->title_ids[c->n_titles], p->str, CFG_TITLEID_LEN - 1);
+ c->title_ids[c->n_titles][CFG_TITLEID_LEN - 1] = 0;
+ strncpy(c->title_names[c->n_titles], v->str, CFG_TITLENAME_LEN - 1);
+ c->title_names[c->n_titles][CFG_TITLENAME_LEN - 1] = 0;
+ c->n_titles++;
+ }
+ }
+ o = jl_obj_get(root, "enabled"); if (o && o->type == JL_BOOL) c->enabled = (int)o->num;
+ o = jl_obj_get(root, "auto_update"); if (o && o->type == JL_BOOL) c->auto_update = (int)o->num;
+ o = jl_obj_get(root, "debug"); if (o && o->type == JL_BOOL) c->debug = (int)o->num;
+ o = jl_obj_get(root, "poll_interval_s"); if (o && o->type == JL_NUMBER) c->poll_interval_s = (int)o->num;
+ o = jl_obj_get(root, "schema_version"); if (o && o->type == JL_NUMBER) c->schema_version = (int)o->num;
+ jl_free(root);
+ clamp_cfg(c);
+ /* Migration: stamp current schema so re-saves converge.
+ * v0 (no key): identical layout, adopt as-is. */
+ c->schema_version = CFG_SCHEMA_VERSION;
+ return 0;
+}
+
+int cfg_title(const cfg_t *c, const char *titleId, char *out, size_t cap){
+ if(!c || !titleId || !titleId[0] || !out || cap == 0) return -1;
+ for(int i = 0; i < c->n_titles; i++){
+ if(!strcmp(c->title_ids[i], titleId)){
+ strncpy(out, c->title_names[i], cap - 1);
+ out[cap - 1] = 0;
+ return out[0] ? 0 : -1;
+ }
+ }
+ return -1;
+}
+
+/* Self-learning map (PC-tool "mapped" pattern): remember an authoritatively
+ * resolved name so later boots resolve instantly even when every live
+ * source is unreachable. Never learns raw-ID echoes or junk.
+ * Returns 1 when the map changed (caller should cfg_save). */
+int cfg_learn(cfg_t *c, const char *titleId, const char *name){
+ if(!c || !titleId || !name) return 0;
+ size_t kl = strlen(titleId), vl = strlen(name);
+ if(kl == 0 || kl >= (size_t)CFG_TITLEID_LEN) return 0;
+ if(vl < 2 || vl >= (size_t)CFG_TITLENAME_LEN) return 0;
+ if(!strcmp(titleId, name)) return 0; /* ID echo, not a name */
+ for(size_t i = 0; i < kl; i++){
+ char ch = titleId[i];
+ if(!((ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_')) return 0;
+ }
+ for(int i = 0; i < c->n_titles; i++){
+ if(!strcmp(c->title_ids[i], titleId)){
+ if(!strcmp(c->title_names[i], name)) return 0;
+ strncpy(c->title_names[i], name, CFG_TITLENAME_LEN - 1);
+ c->title_names[i][CFG_TITLENAME_LEN - 1] = 0;
+ return 1;
+ }
+ }
+ if(c->n_titles >= CFG_MAX_TITLES){
+ log_msg("config: titles map full; not learning %s", titleId);
+ return 0;
+ }
+ strncpy(c->title_ids[c->n_titles], titleId, CFG_TITLEID_LEN - 1);
+ c->title_ids[c->n_titles][CFG_TITLEID_LEN - 1] = 0;
+ strncpy(c->title_names[c->n_titles], name, CFG_TITLENAME_LEN - 1);
+ c->title_names[c->n_titles][CFG_TITLENAME_LEN - 1] = 0;
+ c->n_titles++;
+ return 1;
+}
+
+int cfg_save(const char *path, const cfg_t *c) {
+ if(!path || !c) return -1;
+ jl_val_t *r = jl_new_object();
+ if(!r) { log_msg("cfg_save: allocation failed"); return -1; }
+ jl_obj_set(r, "schema_version", jl_new_number((double)CFG_SCHEMA_VERSION));
+ jl_obj_set(r, "token", jl_new_string(c->token));
+ jl_obj_set(r, "application_id", jl_new_string(c->application_id));
+ jl_obj_set(r, "art_base_url", jl_new_string(c->art_base_url));
+ jl_obj_set(r, "enabled", jl_new_bool(c->enabled));
+ jl_obj_set(r, "auto_update", jl_new_bool(c->auto_update));
+ jl_obj_set(r, "debug", jl_new_bool(c->debug));
+ jl_obj_set(r, "poll_interval_s", jl_new_number((double)c->poll_interval_s));
+ jl_obj_set(r, "presence_state", jl_new_string(c->presence_state));
+ jl_obj_set(r, "home_art", jl_new_string(c->home_art));
+ if(c->n_titles > 0){
+ jl_val_t *t = jl_new_object();
+ if(t){
+ for(int i = 0; i < c->n_titles; i++)
+ jl_obj_set(t, c->title_ids[i], jl_new_string(c->title_names[i]));
+ jl_obj_set(r, "titles", t);
+ }
+ }
+ char *s = jl_stringify(r);
+ if(!s){ log_msg("cfg_save: serialization failed"); jl_free(r); return -1; }
+ /* write tmp + fsync + rename so a power loss can't corrupt the config */
+ char tmp[160];
+ snprintf(tmp, sizeof tmp, "%s.tmp", path);
+ FILE *f = fopen(tmp, "wb");
+ int ok = 0;
+ if (f) {
+ ok = (fputs(s, f) >= 0);
+ if(fflush(f) != 0) ok = 0;
+ /* force bytes to disk before rename */
+ if(ok) { int fd = fileno(f); if(fd >= 0 && fsync(fd) != 0) ok = 0; }
+ if(fclose(f) != 0) ok = 0;
+ if(ok && rename(tmp, path) != 0) ok = 0;
+ if(!ok){
+ remove(tmp);
+ log_msg("cfg_save: write failed for %s", path);
+ }
+ } else { log_msg("cfg_save: cannot write %s", tmp); }
+ free(s); jl_free(r);
+ return ok ? 0 : -1;
+}
diff --git a/orbisrpc/cfg.h b/orbisrpc/cfg.h
new file mode 100644
index 0000000..b219e1c
--- /dev/null
+++ b/orbisrpc/cfg.h
@@ -0,0 +1,49 @@
+/* cfg.h - tiny JSON config via jsonlite */
+#ifndef CFG_H
+#define CFG_H
+#include
+#include
+#define CFG_PATH "/data/orbisRPC/config.json"
+#define LOG_PATH "/data/orbisRPC/log.txt"
+#define DATA_DIR "/data/orbisRPC"
+/* Config schema version. Stamp on load so re-saves converge; unknown
+ * future versions load defensively (known fields only). */
+#define CFG_SCHEMA_VERSION 1
+/* User-local title overrides: {"CUSA11995": "Marvel's Spider-Man"}.
+ * Lives in the user's own config.json (never shipped) for games no
+ * on-box source can name (no pronunciation.xml, TMDB blocked, ...).
+ * Fixed-size tables: no malloc, PS4-safe. */
+#define CFG_MAX_TITLES 64
+#define CFG_TITLEID_LEN 16
+#define CFG_TITLENAME_LEN 128
+typedef struct {
+ int schema_version;
+ char token[512]; /* Discord user session token */
+ char application_id[64]; /* optional: app id for uploaded asset images */
+ char art_base_url[256]; /* optional: icon pack base URL, e.g.
+ * https://raw.githubusercontent.com/.../icons/
+ * large_image becomes .png */
+ char home_art[256]; /* optional: idle/home tile art. http(s) URL
+ * (mp:-proxied) or uploaded Discord asset key.
+ * Default: project-hosted PlayStation logo.
+ * Empty falls back to home.png. */
+ int n_titles;
+ char title_ids[CFG_MAX_TITLES][CFG_TITLEID_LEN];
+ char title_names[CFG_MAX_TITLES][CFG_TITLENAME_LEN];
+ int enabled;
+ int auto_update; /* check GitHub releases once per boot, stage newer */
+ int debug; /* verbose debug logging (per-poll detail, no secrets) */
+ int poll_interval_s; /* game-check cadence */
+ char presence_state[128]; /* activity "state" line, e.g. "On PS4" */
+} cfg_t;
+extern cfg_t g_cfg;
+int cfg_load(const char *path, cfg_t *c);
+/* Atomic save (tmp + fsync + rename). 0 saved, -1 failed (caller must
+ * surface, not assume). */
+int cfg_save(const char *path, const cfg_t *c);
+void cfg_defaults(cfg_t *c);
+/* User override lookup: 0 + name copied when titleId has an entry. */
+int cfg_title(const cfg_t *c, const char *titleId, char *out, size_t cap);
+/* Learn an authoritatively resolved name (1 = changed, save it). */
+int cfg_learn(cfg_t *c, const char *titleId, const char *name);
+#endif
diff --git a/orbisrpc/clock.h b/orbisrpc/clock.h
new file mode 100644
index 0000000..fca3478
--- /dev/null
+++ b/orbisrpc/clock.h
@@ -0,0 +1,14 @@
+/* clock.h - monotonic time for measuring durations.
+ *
+ * Rule: wall clock (time(NULL)) is ONLY for values that must be wall
+ * clock (Discord epoch timestamps, log stamps). Every duration/deadline
+ * uses orbis_mono_s() so a clock jump can never break local timing.
+ *
+ * PS4: sceKernelGetProcessTime (microseconds, monotonic).
+ * Host: clock_gettime(CLOCK_MONOTONIC). */
+#ifndef ORBISRPC_CLOCK_H
+#define ORBISRPC_CLOCK_H
+#include
+/* Monotonic seconds (arbitrary epoch, never goes backward). */
+int64_t orbis_mono_s(void);
+#endif
diff --git a/orbisrpc/compat.c b/orbisrpc/compat.c
new file mode 100644
index 0000000..925ee8a
--- /dev/null
+++ b/orbisrpc/compat.c
@@ -0,0 +1,125 @@
+/* compat.c - tiny libc shims for the PS4 link environment.
+ * SceLibcInternal lacks getentropy/__errno_location/gmtime_r, and
+ * mbedTLS wants a platform entropy poll. Compiled into payload + plugin. */
+#include
+#include
+#include
+#include
+#include
+#include
+#include "clock.h"
+
+#ifdef ORBISRPC_SDK_PAYLOAD
+/* Payload-SDK builds: the SDK libc already provides errno, gmtime_r,
+ * clock_gettime and friends. Only the entropy poll + monotonic clock
+ * live here, written against plain POSIX. */
+int mbedtls_platform_entropy_poll(void *data, unsigned char *out,
+ size_t len, size_t *olen){
+ (void)data;
+ if(!out || len == 0) return -1;
+ int fd = open("/dev/urandom", O_RDONLY);
+ if(fd < 0) return -1;
+ size_t got = 0;
+ while(got < len){
+ long r = read(fd, (char *)out + got, len - got);
+ if(r <= 0){ close(fd); return -1; }
+ got += (size_t)r;
+ }
+ close(fd);
+ *olen = len;
+ return 0;
+}
+
+int64_t orbis_mono_s(void){
+ struct timespec ts;
+ if(clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return (int64_t)time(NULL);
+ return (int64_t)ts.tv_sec;
+}
+
+#else
+
+int *__errno_location(void) __attribute__((weak));
+int *__errno_location(void){
+ static int errno_slot;
+ return &errno_slot;
+}
+
+__attribute__((weak)) int getentropy(void *buf, size_t n){
+ int fd = open("/dev/urandom", O_RDONLY);
+ if(fd < 0) return -1;
+ size_t got = 0;
+ while(got < n){
+ long r = read(fd, (char*)buf + got, n - got);
+ if(r < 0 && errno == EINTR) continue; /* signal delivery, not failure */
+ if(r <= 0){ close(fd); return -1; }
+ got += (size_t)r;
+ }
+ close(fd);
+ return 0;
+}
+
+__attribute__((weak)) struct tm *gmtime_r(const time_t *t, struct tm *r){
+ /* Thread-safe: pure arithmetic, no static buffer (gmtime() is shared). */
+ if(!t || !r) return NULL;
+ long long secs = (long long)*t;
+ long long days = secs / 86400LL;
+ long long rem = secs % 86400LL;
+ if(rem < 0){ rem += 86400LL; days--; }
+ r->tm_hour = (int)(rem / 3600); rem %= 3600;
+ r->tm_min = (int)(rem / 60);
+ r->tm_sec = (int)(rem % 60);
+ /* civil_from_days (Howard Hinnant) */
+ long long z = days + 719468LL;
+ long long era = (z >= 0 ? z : z - 146096LL) / 146097LL;
+ unsigned doe = (unsigned)(z - era * 146097LL);
+ unsigned yoe = (doe - doe/1460U + doe/36524U - doe/146096U) / 365U;
+ long long y = (long long)yoe + era * 400LL;
+ unsigned doy = doe - (365U*yoe + yoe/4U - yoe/100U);
+ unsigned mp = (5U*doy + 2U)/153U;
+ unsigned d = doy - (153U*mp+2U)/5U + 1U;
+ unsigned m = mp + (mp < 10U ? 3U : (unsigned)-9);
+ y += (m <= 2);
+ r->tm_mday = (int)d;
+ r->tm_mon = (int)(m - 1);
+ r->tm_year = (int)(y - 1900LL);
+ /* weekday: 1970-01-01 was Thursday */
+ r->tm_wday = (int)((days % 7 + 7 + 4) % 7);
+ /* yearday */
+ {
+ int leap = ((y%4==0 && y%100!=0) || y%400==0);
+ static const int cum[12] = {0,31,59,90,120,151,181,212,243,273,304,334};
+ r->tm_yday = cum[r->tm_mon] + (int)d - 1 + (leap && r->tm_mon > 1 ? 1 : 0);
+ }
+ r->tm_isdst = 0;
+ return r;
+}
+
+/* mbedTLS platform entropy: /dev/urandom, the only strong source here. */
+int mbedtls_platform_entropy_poll(void *data, unsigned char *out,
+ size_t len, size_t *olen){
+ (void)data;
+ if(!out || len == 0) return -1;
+ if(getentropy(out, len) != 0) return -1;
+ *olen = len;
+ return 0;
+}
+
+int64_t orbis_mono_s(void){
+#if defined(__PS4__) || (defined(__FreeBSD__) && !defined(__APPLE__))
+ /* PS4 builds (--target=x86_64-pc-freebsd12-elf, -D__PS4__). Declared
+ * here instead of including orbis headers so host builds stay clean. */
+ extern unsigned long long sceKernelGetProcessTime(void);
+ return (int64_t)(sceKernelGetProcessTime() / 1000000ull);
+#else
+ struct timespec ts;
+#if defined(CLOCK_MONOTONIC)
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+#else
+ ts.tv_sec = time(NULL);
+ ts.tv_nsec = 0;
+#endif
+ return (int64_t)ts.tv_sec;
+#endif
+}
+
+#endif /* ORBISRPC_SDK_PAYLOAD */
diff --git a/orbisrpc/daemon.c b/orbisrpc/daemon.c
new file mode 100644
index 0000000..7c7af8b
--- /dev/null
+++ b/orbisrpc/daemon.c
@@ -0,0 +1,587 @@
+/* daemon.c - orbisRPC core loop, usable from the ELF payload (main.c) or from a
+ * GoldHEN plugin. When `fixed_game_name` is non-NULL the daemon posts presence
+ * for that game unconditionally (the plugin runs inside the game process and
+ * already knows the title); otherwise it detects the foreground game.
+ *
+ * Auth model: a Discord USER SESSION token pasted into config.json ("token").
+ * There is no OAuth flow anymore — OAuth2 access tokens are rejected by the
+ * gateway (close 4004), which was why v1 never worked.
+ *
+ * A `stop` flag is polled so plugin_unload() can shut the loop down cleanly. */
+#include "cfg.h"
+#include "clock.h"
+#include "lock.h"
+#include "timesync.h"
+#include "health.h"
+#include "log.h"
+#include "ws.h"
+#include "discord.h"
+#include "detect.h"
+#include "updater.h"
+#include "version.h"
+#include "jsonlite.h"
+#include "art.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+extern cfg_t g_cfg;
+
+static volatile sig_atomic_t s_stop = 0;
+
+/* Tells a running daemon_run() to shut down (called from plugin_unload). */
+void daemon_request_stop(void){ s_stop = 1; }
+void daemon_clear_stop(void){ s_stop = 0; }
+int daemon_stop_requested(void){ return s_stop; }
+
+/* SIGTERM/SIGINT (payload managers, kill): set the flag only — the loops
+ * unwind through their normal cleanup (presence clear, session save,
+ * socket close) instead of dying mid-write. */
+static void daemon_on_signal(int sig){
+ (void)sig;
+ s_stop = 1;
+}
+
+/* Sleep up to `secs` but wake within a second when the plugin asks us to
+ * stop, so plugin_unload() never hangs on a long backoff. Returns 1 stopped. */
+static int sleep_stop(int secs){
+ for(int i = 0; i < secs; i++){
+ if(s_stop) return 1;
+ sleep(1);
+ }
+ return s_stop;
+}
+
+static int have_token(const cfg_t *c){
+ if(!c || !c->token[0]) return 0;
+ if(strcmp(c->token,"SET_ME")==0) return 0;
+ /* reject whitespace-only / trivially short tokens */
+ size_t n = strlen(c->token);
+ if(n < 16) return 0;
+ for(size_t i = 0; i < n; i++){
+ if(c->token[i]!=' ' && c->token[i]!='\t' && c->token[i]!='\r' && c->token[i]!='\n')
+ return 1;
+ }
+ return 0;
+}
+
+/* Persist the live session (atomic tmp+rename). Called on every
+ * transition and on clear so a restart resumes instead of resetting. */
+static void sess_save(const char *tid, const char *name, int64_t started){ jl_val_t *r = jl_new_object();
+ if(!r) return;
+ jl_obj_set(r, "title_id", jl_new_string(tid ? tid : ""));
+ jl_obj_set(r, "name", jl_new_string(name ? name : ""));
+ jl_obj_set(r, "started", jl_new_number((double)started));
+ jl_obj_set(r, "saved_at", jl_new_number((double)time(NULL)));
+ char *s = jl_stringify(r);
+ jl_free(r);
+ if(!s) return;
+ FILE *f = fopen("/data/orbisRPC/session.json.new", "wb");
+ if(f){
+ int ok = (fputs(s, f) >= 0) && (fflush(f) == 0);
+ if(fclose(f) != 0) ok = 0;
+ if(ok) rename("/data/orbisRPC/session.json.new",
+ "/data/orbisRPC/session.json");
+ else remove("/data/orbisRPC/session.json.new");
+ }
+ free(s);
+}
+
+/* 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. */
+static void ledger_append(const char *tid, const char *name,
+ int64_t started, int64_t ended){
+ if(!tid || !tid[0] || ended <= started || started <= 0) return;
+ FILE *f = fopen("/data/orbisRPC/playtime.log", "ab");
+ if(!f) return;
+ fprintf(f, "%s %s %lld\n", tid, (name && name[0]) ? name : "?",
+ (long long)(ended - started));
+ fclose(f);
+}
+
+/* Deterministic jitter ±20% without RNG state: hash of mono time and a
+ * counter. Keeps reconnect storms decorrelated across restarts. */
+static int backoff_jitter(int base, unsigned *ctr){
+ unsigned x = (unsigned)orbis_mono_s() * 2654435761u + (++(*ctr)) * 40503u;
+ x ^= x >> 15; x *= 0x2c1b3c6du; x ^= x >> 12;
+ int pct = 80 + (int)(x % 41); /* 80..120 */
+ return (base * pct) / 100;
+}
+
+/* Next reconnect delay: exponential from base, cap 60s for the first 10
+ * consecutive failures, then 10-minute cadence (quiet persistence, never
+ * exit). Returns the delay; bumps *fails. */
+static int reconnect_delay(int *fails, int base, unsigned *jctr){
+ int n = ++(*fails);
+ int b = base;
+ for(int i = 1; i < n && b < 60; i++) b *= 2;
+ if(b > 60) b = 60;
+ if(n > 10) b = 600;
+ return backoff_jitter(b, jctr);
+}
+
+/* Explicit presence state (logged on every transition; no ambiguous
+ * states): NONE -> HOME <-> GAME, with reconnects re-posting. */
+typedef enum { PS_NONE = 0, PS_HOME, PS_GAME } pres_state_t;
+static const char *pres_name(pres_state_t s){
+ return s == PS_GAME ? "GAME" : s == PS_HOME ? "HOME" : "NONE";
+}
+static void pres_set(pres_state_t *cur, pres_state_t next){
+ if(*cur == next) return;
+ log_msg("STATE: presence %s -> %s", pres_name(*cur), pres_name(next));
+ *cur = next;
+}
+/* fixed_game_name != NULL -> post presence for that game only, no detection.
+ * NULL -> poll the foreground app like the payload daemon does.
+ * Returns 0 normal stop, 1 config error, 2 auth-fatal (bad token). */
+int daemon_run(const char *fixed_game_name){
+ s_stop = 0;
+#ifdef SIGTERM
+ signal(SIGTERM, daemon_on_signal);
+#endif
+#ifdef SIGINT
+ signal(SIGINT, daemon_on_signal);
+#endif
+ if(mkdir(DATA_DIR, 0777) != 0){
+ /* EEXIST is fine; anything else means payload can't persist config/log */
+ struct stat st;
+ if(stat(DATA_DIR, &st) != 0){
+ log_init(LOG_PATH);
+ log_msg("FATAL: cannot create %s; check /data writable", DATA_DIR);
+ log_close();
+ return 1;
+ }
+ }
+ log_init(LOG_PATH);
+ log_msg("orbisRPC daemon start — build %s %s", __DATE__, __TIME__);
+ /* Single writer: a second launch (or a stale pileup from repeated
+ * injections) stands down instead of fighting over the gateway. */
+ {
+ int lr = lock_acquire();
+ if(lr == 1){ log_msg("another daemon holds the lock; standing down"); log_close(); return 0; }
+ if(lr != 0) log_msg("WARN: lock error; continuing without guard");
+ }
+ /* Crash recovery: unclean-boot marker + consecutive-failure counter.
+ * Only boots that die before going healthy count; normal reboots and
+ * token-less idles never trip safe mode. */
+ int safe_mode = health_boot_note_crash();
+ if(safe_mode)
+ log_msg("WARN: repeated unclean boots; safe mode (updates off)");
+ /* Boot watchdog: a staged update that never proved itself healthy
+ * gets rolled back to .bak before anything runs it. */
+ {
+ static const char *targets[] = {
+ "/data/payloads/orbisrpc.bin",
+ "/data/GoldHEN/plugins/orbisrpc_plugin.prx",
+ };
+ for(unsigned ti = 0; ti < sizeof targets/sizeof targets[0]; ti++){
+ int vr = health_verify_or_rollback(targets[ti]);
+ if(vr == 1)
+ log_msg("WARN: %s rolled back to last-good backup", targets[ti]);
+ else if(vr == -1)
+ log_msg("WARN: %s failed verification (no backup)", targets[ti]);
+ }
+ }
+ if(cfg_load(CFG_PATH, &g_cfg) != 0){
+ /* First boot: drop a template so FTP edit is the only step */
+ FILE *probe = fopen(CFG_PATH, "rb");
+ if(!probe){
+ cfg_defaults(&g_cfg);
+ if(cfg_save(CFG_PATH, &g_cfg) != 0)
+ log_msg("WARN: template config unwritable; token edits will not persist");
+ else
+ log_msg("created template %s; edit \"token\" then reboot", CFG_PATH);
+ } else fclose(probe);
+ log_msg("config load failed; running on defaults until valid config appears");
+ }
+ if(!g_cfg.enabled){ log_msg("disabled in config; exiting"); health_mark_clean(); log_close(); return 0; }
+ log_set_debug(g_cfg.debug);
+ if(g_cfg.debug) log_dbg("debug logging on (config)");
+ /* Wall-clock correction for Discord timestamps (PSN time sync is
+ * typically blocked on jailbroken consoles). Boot sweep is bounded;
+ * hourly ticks are single-host attempts (never stall heartbeats). */
+ time_sync_all();
+ if(!have_token(&g_cfg)){
+ /* Waiting for configuration is a HEALTHY boot, not a crash:
+ * mark clean so token-less boots never trip safe mode. */
+ health_mark_healthy();
+ log_msg("FATAL: put your Discord user token in %s as \"token\":\"...\"", CFG_PATH);
+ log_close();
+ return 1;
+ }
+
+ /* Self-update once per boot, before first connect. Never fatal:
+ * staged artifacts take effect on next launch/injection. */
+ if(g_cfg.auto_update && !safe_mode){
+ int ur = updater_check_and_stage();
+ log_msg("updater: %s (local %s)",
+ ur > 0 ? "staged newer build" : ur == 0 ? "already current" : "check failed",
+ ORBISRPC_VERSION);
+ } else if(safe_mode){
+ log_msg("updater: skipped (safe mode)");
+ }
+
+ discord_t dc;
+ int base_poll = g_cfg.poll_interval_s;
+ if(base_poll < 5) base_poll = 5;
+ if(base_poll > 60) base_poll = 60;
+ /* Reconnect policy: exponential backoff with deterministic jitter,
+ * cap 60s for the first 10 consecutive failures, then 10-minute
+ * cadence (quiet persistence, never exit). Success resets. */
+ int backoff = base_poll;
+ int conn_fails = 0;
+ unsigned jctr = 0;
+ /* Health metrics (hourly HEALTH line). */
+ int64_t boot_mono = orbis_mono_s();
+ unsigned n_posts = 0, n_reconnects = 0;
+ int64_t last_health = 0;
+ for(;;){ /* outer: connect cycles with backoff on failure */
+ if(s_stop) break;
+ /* re-read config every cycle so token edits land without a reboot.
+ * On parse failure keep last-good config instead of stale defaults. */
+ {
+ cfg_t next = g_cfg;
+ if(cfg_load(CFG_PATH, &next) == 0){
+ if(next.debug != g_cfg.debug)
+ log_msg("debug logging %s", next.debug ? "on" : "off");
+ g_cfg = next;
+ log_set_debug(g_cfg.debug);
+ }
+ else log_msg("config reload failed; keeping last-good config");
+ }
+ if(!have_token(&g_cfg)){
+ log_msg("no token in config; waiting %ds", backoff);
+ if(sleep_stop(backoff)) break;
+ continue;
+ }
+ int rc = discord_connect(&dc, g_cfg.token);
+ if(rc == -2){
+ /* Rejected token: do NOT exit (that would strand the daemon
+ * until the next manual injection). The per-cycle config
+ * reload picks up a fixed token on its own. */
+ log_msg("WARN: token rejected by gateway (close 4004). "
+ "Fix \"token\" in %s; retrying", CFG_PATH);
+ int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
+ if(sleep_stop(wait)) break;
+ continue;
+ }
+ if(rc != 0){
+ int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
+ log_msg("gateway connect failed (attempt %d); retry in %ds",
+ conn_fails, wait);
+ if(sleep_stop(wait)) break;
+ continue;
+ }
+ if(conn_fails > 0)
+ log_msg("gateway connected after %d failures", conn_fails);
+ conn_fails = 0;
+ n_reconnects++;
+ backoff = g_cfg.poll_interval_s; /* success resets backoff */
+ if(backoff < 5) backoff = 5;
+ if(backoff > 60) backoff = 60;
+
+ /* last/started live OUTSIDE the session loop so the playing timer
+ * survives gateway reconnects (same game keeps its original start).
+ * A reconnect re-posts presence only when needed, timer intact. */
+ static int active = 0;
+ static pres_state_t pres = PS_NONE;
+ static char last[128] = "";
+ static char sess_tid[16] = "";
+ static int64_t started = 0;
+ static int64_t last_tsync = 0;
+ static int home_posted = 0;
+ /* Fresh (re)connect invalidates whatever Discord shows: re-post the
+ * current state (game via need_post below, home via home_posted
+ * reset) so a drop can never leave a stale/blank tile behind. */
+ home_posted = 0;
+ /* Resume window: if the same title vanishes briefly (detection
+ * flicker, quick menu hop) and returns within 10 minutes, the
+ * timer resumes instead of resetting to 0:00. */
+ static char prev_tid[16] = "";
+ static int64_t prev_started = 0, prev_end = 0;
+ /* Cross-restart resume: a previous run's session (saved on every
+ * transition) seeds the resume window, so reboots and updates
+ * keep the timer instead of resetting it. */
+ {
+ static int sess_restored = 0;
+ if(!sess_restored){
+ sess_restored = 1;
+ FILE *sf = fopen("/data/orbisRPC/session.json", "rb");
+ if(sf){
+ fseek(sf, 0, SEEK_END);
+ long ssz = ftell(sf);
+ fseek(sf, 0, SEEK_SET);
+ if(ssz > 0 && ssz < 1024){
+ char *sb = (char*)malloc((size_t)ssz + 1);
+ if(sb && fread(sb, 1, (size_t)ssz, sf) == (size_t)ssz){
+ sb[ssz] = 0;
+ jl_val_t *sr = jl_parse(sb, (size_t)ssz);
+ if(sr && sr->type == JL_OBJECT){
+ const jl_val_t *t = jl_obj_get(sr, "title_id");
+ const jl_val_t *s2 = jl_obj_get(sr, "started");
+ if(t && t->type == JL_STRING && t->str &&
+ s2 && s2->type == JL_NUMBER){
+ /* Validate before trusting: 9-char id,
+ * sane epoch (2020-2100). A corrupt file
+ * must never seed a bogus timer. */
+ int64_t st = (int64_t)s2->num;
+ if(strlen(t->str) == 9 &&
+ st >= 1577836800LL &&
+ st <= 4102444800LL){
+ strncpy(prev_tid, t->str, sizeof prev_tid-1);
+ prev_started = st;
+ /* Wall saved_at can't mix with the
+ * monotonic resume window: start the
+ * window at boot. Same title seen
+ * within minutes of boot resumes. */
+ prev_end = orbis_mono_s();
+ log_msg("session restored: %s (timer may resume)",
+ prev_tid);
+ } else {
+ log_msg("session file failed validation; starting fresh");
+ }
+ }
+ jl_free(sr);
+ }
+ free(sb);
+ } else if(sb) free(sb);
+ }
+ fclose(sf);
+ }
+ }
+ }
+ /* Transition debounce: shell flickers during launches, so a new
+ * title (or disappearance) needs consecutive polls (2 at ~1s). */
+ static char cand_title[16] = "";
+ static int cand_hits = 0, miss_hits = 0;
+ int64_t last_poll = 0;
+ int64_t last_alive = 0;
+ static int healthy_marked = 0;
+ /* re-post after every (re)connect so Discord never sticks on stale */
+ int need_post = active && last[0];
+ while(!s_stop){ /* inner: live session, serviced every second */
+ int64_t now = orbis_mono_s();
+ if(now - last_tsync >= 3600){ last_tsync = now; time_sync(); }
+ if(now != last_poll){
+ last_poll = now;
+ if(now - last_alive >= 60){
+ last_alive = now;
+ log_msg("alive: %s", active ? last : "idle");
+ }
+ /* State reconciliation: re-post current presence every
+ * 15 min so a silently desynced tile (dropped update,
+ * stale cache, missed reconnect) heals itself without
+ * any state change. Timer/source values are re-read at
+ * post time, so this never disturbs a live session. */
+ {
+ static int64_t last_reconcile = 0;
+ if(last_reconcile == 0) last_reconcile = now;
+ if(now - last_reconcile >= 900){
+ last_reconcile = now;
+ if(active && last[0]) need_post = 1;
+ else home_posted = 0;
+ log_msg("reconcile: reposting current state");
+ }
+ }
+ /* Hourly health metrics: uptime, posts, reconnects, fails. */
+ if(now - last_health >= 3600){
+ last_health = now;
+ log_msg("HEALTH: %lldh uptime, %u posts, %u reconnects, %d recent fails",
+ (long long)((now - boot_mono) / 3600),
+ n_posts, n_reconnects, conn_fails);
+ }
+ /* A boot that survives HEALTH_STABLE_SECS of runtime was
+ * healthy: clear the unclean-boot marker so only real
+ * crashes count toward safe mode. */
+ if(!healthy_marked && now - boot_mono >= HEALTH_STABLE_SECS){
+ healthy_marked = 1;
+ health_mark_healthy();
+ log_msg("health: boot marked healthy");
+ }
+ char name[128] = "";
+ int scan_unknown = 0;
+#ifdef ORBISRPC_SDK_PAYLOAD
+ /* Launch/close shortcut: the eboot set changing means the
+ * foreground game changed RIGHT NOW. Reset debounce so the
+ * switch commits within ~2 polls. Deliberately NOT instant:
+ * the title scan needs one poll for fresh atime data, and
+ * committing a stale scan instantly would lock the wrong
+ * title with a full session. */
+ {
+ static int last_eboots = -1;
+ int nboots = detect_eboot_count();
+ if(nboots >= 0 && nboots != last_eboots){
+ if(last_eboots >= 0)
+ log_msg("eboot set %d -> %d; fast-switching",
+ last_eboots, nboots);
+ last_eboots = nboots;
+ cand_title[0] = 0;
+ cand_hits = 0;
+ miss_hits = 0;
+ }
+ }
+#endif
+ if(fixed_game_name){
+ strncpy(name, fixed_game_name, sizeof name-1);
+ name[sizeof name-1] = 0;
+ }else{
+ /* detect_current_game already checks foreground-active
+ * internally; don't double-call it. -2 means the scan
+ * itself failed: hold position, touch nothing. */
+ scan_unknown = (detect_current_game(name, sizeof name, NULL, 0) == -2);
+ if(scan_unknown){
+ log_dbg("detect scan failed; holding");
+ name[0] = 0;
+ }
+ }
+
+ if(name[0]){
+ const char *cur_tid = detect_last_titleid();
+ if(!cur_tid) cur_tid = "";
+ if(!strcmp(cur_tid, cand_title)){
+ cand_hits++;
+ } else {
+ strncpy(cand_title, cur_tid, sizeof cand_title-1);
+ cand_title[sizeof cand_title-1] = 0;
+ cand_hits = 1;
+ }
+ miss_hits = 0;
+ if(fixed_game_name || cand_hits >= 2){
+ if(!active || strcmp(cur_tid,sess_tid)!=0){
+ /* Switching away from a live session: bank its time.
+ * (Fresh starts and resumes have nothing to bank;
+ * the clear path already banked ended sessions.) */
+ if(active && sess_tid[0] &&
+ strcmp(cur_tid,sess_tid)!=0)
+ ledger_append(sess_tid, last, started, time_fixed());
+ active = 1;
+ strncpy(sess_tid, cur_tid, sizeof sess_tid-1);
+ strncpy(last, name, sizeof last-1);
+ last[sizeof last-1] = 0;
+ if(!strcmp(cur_tid, prev_tid) &&
+ prev_started > 0 &&
+ now - prev_end < 600){
+ started = prev_started;
+ log_msg("SESSION_RESUMED title=%s (gap %llds, timer kept)",
+ cur_tid[0]?cur_tid:"?", (long long)(now - prev_end));
+ } else {
+ started = time_fixed();
+ }
+ need_post = 1;
+ log_msg("GAME_DETECTED title=%s name=%s", cur_tid[0]?cur_tid:"?", name);
+ art_cache_clear();
+ sess_save(cur_tid, name, started);
+ /* Self-learning map: persist authoritatively resolved
+ * names so later boots resolve instantly, even when
+ * every live source is unreachable. Raw-ID fallbacks
+ * (ok=0) are never learned. */
+ if(cur_tid[0] && detect_last_ok() && cfg_learn(&g_cfg, cur_tid, name)){
+ if(cfg_save(CFG_PATH, &g_cfg) != 0)
+ log_msg("config: learn save failed for %s", cur_tid);
+ else
+ log_msg("config: learned %s", cur_tid);
+ }
+ } else if(strcmp(name,last)!=0){
+ strncpy(last, name, sizeof last-1);
+ last[sizeof last-1] = 0;
+ need_post = 1;
+ }
+ if(need_post){
+ const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL;
+ const char *tart = detect_last_art();
+ /* Playback queue of one: only clear need_post after
+ * the bytes actually go out. A failed send stays
+ * queued and rides the next tick/reconnect. */
+ int pr = discord_set_presence_ex(&dc, state, last, sess_tid[0]?sess_tid:NULL, g_cfg.application_id, g_cfg.art_base_url, tart, g_cfg.home_art[0]?g_cfg.home_art:NULL, started);
+ if(pr == 0){
+ log_msg("presence: %s", last);
+ pres_set(&pres, PS_GAME);
+ n_posts++;
+ active = 1; need_post = 0;
+ } else {
+ log_msg("presence send failed; will retry");
+ }
+ }
+ }
+ }else if(active && !scan_unknown){
+ cand_title[0] = 0;
+ cand_hits = 0;
+ if(++miss_hits >= 2){
+ discord_clear_presence(&dc);
+ log_msg("presence cleared");
+ pres_set(&pres, PS_NONE);
+ ledger_append(sess_tid, last, started, time_fixed());
+ strncpy(prev_tid, sess_tid, sizeof prev_tid-1);
+ prev_started = started;
+ prev_end = now;
+ remove("/data/orbisRPC/session.json");
+ last[0]=0; sess_tid[0]=0; active=0; started=0;
+ home_posted = 0;
+ }
+ } else if(!home_posted && !scan_unknown){
+ /* Home screen support: no game running. Post a timerless
+ * home presence once (instead of bare online), clear it
+ * the moment a game commits. */
+ const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL;
+ if(discord_set_presence_ex(&dc, state, "PlayStation 4", "home",
+ g_cfg.application_id, g_cfg.art_base_url,
+ g_cfg.home_art[0] ? g_cfg.home_art : NULL, NULL, 0) == 0){
+ log_msg("presence: home");
+ pres_set(&pres, PS_HOME);
+ n_posts++;
+ home_posted = 1;
+ } else {
+ log_msg("home presence send failed; will retry");
+ }
+ }
+ }
+
+ /* service the gateway every pass (~1s): heartbeats must never be
+ * more than a second or two late or the server drops us */
+ int tr = discord_tick(&dc);
+ if(tr == -2){
+ /* Token rejected mid-session: same policy as connect-time
+ * 4004 (never strand the daemon). Drop to the outer loop:
+ * backoff + config reload picks up a fixed token alone. */
+ log_msg("WARN: token rejected (close 4004). Fix %s; retrying", CFG_PATH);
+ ws_close(&dc.ws);
+ pres_set(&pres, PS_NONE);
+ if(sleep_stop(reconnect_delay(&conn_fails, base_poll, &jctr))) break;
+ break;
+ }
+ if(tr == -3){
+ int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
+ log_msg("invalid session; fresh identify (attempt %d) in %ds",
+ conn_fails, wait);
+ ws_close(&dc.ws);
+ pres_set(&pres, PS_NONE);
+ if(sleep_stop(wait)) break;
+ break;
+ }
+ if(tr != 0){
+ int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
+ log_msg("gateway dropped (attempt %d); reconnecting in %ds",
+ conn_fails, wait);
+ ws_close(&dc.ws);
+ pres_set(&pres, PS_NONE);
+ if(sleep_stop(wait)) break;
+ break;
+ }
+
+ for(int i = 0; i < 10 && !s_stop; i++) usleep(100000);
+ }
+ if(s_stop && dc.connected) discord_clear_presence(&dc);
+ ws_close(&dc.ws);
+ }
+ /* Session file already reflects the live session (saved on every
+ * transition), so shutdown is: clear presence (above), release the
+ * lock so a successor starts immediately, close the log. */
+ lock_release();
+ log_close();
+ return 0;
+}
diff --git a/orbisrpc/daemon.h b/orbisrpc/daemon.h
new file mode 100644
index 0000000..34356a0
--- /dev/null
+++ b/orbisrpc/daemon.h
@@ -0,0 +1,12 @@
+/* daemon.h - shared orbisRPC daemon loop (payload ELF and GoldHEN plugin). */
+#ifndef DAEMON_H
+#define DAEMON_H
+/* fixed_game_name != NULL: post presence for that game only (plugin mode).
+ * NULL: poll the foreground app (payload mode).
+ * Returns 0 on clean stop, 1 on missing/invalid configuration, or 2 when
+ * Discord rejects the token with close code 4004. */
+int daemon_run(const char *fixed_game_name);
+void daemon_request_stop(void);
+void daemon_clear_stop(void);
+int daemon_stop_requested(void); /* 1 when plugin_unload() wants us to exit */
+#endif
\ No newline at end of file
diff --git a/orbisrpc/detect.c b/orbisrpc/detect.c
new file mode 100644
index 0000000..9e3648b
--- /dev/null
+++ b/orbisrpc/detect.c
@@ -0,0 +1,593 @@
+/* detect.c
+ * Foreground-game detection on PS4.
+ *
+ * Primary signal: sceShellCoreUtilIsAppLaunched() — resolved at runtime via
+ * dlopen/dlsym (libSceShellCoreUtil.sprx). Returns 1 when a user app (game)
+ * is in the foreground vs the home screen. This is the reliable "playing"
+ * indicator; it stays 0 on the home screen so presence clears.
+ *
+ * Fallback (if ShellCoreUtil can't be resolved in the payload context):
+ * sceUserServiceGetForegroundUser >= 0 + most-recent app.xml heuristic.
+ *
+ * Title naming is best-effort:
+ * a) /data/app//app.xml for the most-recently-modified dir
+ * b) app.db tbl_app_static scan (crude byte scan)
+ * c) titleId (CUSAxxxxx) as last resort
+ *
+ * NOTE: mapping the foreground app to its pid/titleId is not exposed by the
+ * public SDK, so the NAME is heuristic. The core signal (a game is in the
+ * foreground) is reliable via ShellCoreUtil.
+ */
+#include "detect.h"
+#include "cfg.h"
+#include "log.h"
+#include "sfo.h"
+#include "tmdb.h"
+#include "clock.h"
+#ifdef ORBISRPC_SDK_PAYLOAD
+/* Payload-SDK build: dlopen/dlsym come from the SDK libc (dlfcn);
+ * there is no UserService here — user_init() below degrades. */
+#include
+#include
+#else
+#include
+#include
+#include
+#endif
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#ifdef ORBISRPC_SDK_PAYLOAD
+#include
+#include
+
+static int is_title_prefix(const char *n);
+
+/* --- SDK-build foreground/title signals (no ShellCoreUtil/UserService) ---
+ * A launched game shows up as an "eboot.bin" process; its identity comes
+ * from the freshest savedata dir (gameplay writes saves continuously).
+ * Both facts verified live via probes before wiring them in. */
+/* Count eboot.bin processes. A change means launch/close: callers drop
+ * stale state immediately instead of waiting out debounce windows. */
+int detect_eboot_count(void){
+ int mib[4] = { 1, 14, 8, 0 };
+ size_t sz = 0;
+ int n = 0;
+ if(sysctl(mib, 4, NULL, &sz, NULL, 0) != 0) return -1;
+ static unsigned char buf[256*1024];
+ if(sz > sizeof buf) return -1;
+ if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return -1;
+ size_t off = 0;
+ while(off + 4 <= sz){
+ int recsz = *(int *)(buf + off);
+ if(recsz <= 0 || off + (size_t)recsz > sz) break;
+ if(recsz >= 479 && !memcmp(buf + off + 447, "eboot.bin", 10))
+ n++;
+ off += (size_t)recsz;
+ }
+ return n;
+}
+static int proc_has_eboot(void){
+ int mib[4] = { 1, 14, 8, 0 };
+ size_t sz = 0;
+ if(sysctl(mib, 4, NULL, &sz, NULL, 0) != 0) return -1;
+ static unsigned char buf[256*1024];
+ if(sz > sizeof buf) return -1;
+ if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return -1;
+ size_t off = 0;
+ while(off + 4 <= sz){
+ int recsz = *(int *)(buf + off);
+ if(recsz <= 0 || off + (size_t)recsz > sz) return -1;
+ if(recsz >= 479 && !memcmp(buf + off + 447, "eboot.bin", 10))
+ return 1;
+ off += (size_t)recsz;
+ }
+ return 0;
+}
+
+/* Sandbox mounts: /mnt/sandbox/_000 exists exactly while that
+ * title's game process lives. This is authoritative foreground identity
+ * straight from the OS — no heuristics, no races, no sync pollution.
+ * Verified live: only the running game's mount is listed. */
+static int scan_sandbox_mount(char *out, size_t cap){
+ if(!out || cap < 10) return -1;
+ DIR *d = opendir("/mnt/sandbox");
+ if(!d) return -1;
+ struct dirent *e;
+ int found = 0;
+ char best[16] = "";
+ while((e = readdir(d))){
+ const char *n = e->d_name;
+ if(strlen(n) != 13) continue; /* TITLEID_000 */
+ if(n[9] != '_' || n[10] != '0' || n[11] != '0' || n[12] != '0') continue;
+ char tid[16];
+ memcpy(tid, n, 9);
+ tid[9] = 0;
+ if(!is_title_prefix(tid)) continue;
+ if(!found){
+ strncpy(best, tid, sizeof best - 1);
+ found = 1;
+ }
+ }
+ closedir(d);
+ if(!found) return -1;
+ strncpy(out, best, cap - 1);
+ out[cap - 1] = 0;
+ return 0;
+}
+
+static long scan_newest_save(char *out, size_t cap){ static const char *users[] = { "1898cd02", "1898cd03", NULL };
+ /* user dirs vary per console; probe the known ones plus a scan of
+ * /user/home for anything looking like a user id dir. */
+ char udirs[8][32];
+ int ndirs = 0;
+ DIR *hd = opendir("/user/home");
+ if(hd){
+ struct dirent *e;
+ while((e = readdir(hd)) && ndirs < 8){
+ if(e->d_name[0] == '.') continue;
+ strncpy(udirs[ndirs], e->d_name, 31);
+ udirs[ndirs][31] = 0;
+ ndirs++;
+ }
+ closedir(hd);
+ }
+ for(int i = 0; users[i] && ndirs < 8; i++){
+ int dup = 0;
+ for(int k = 0; k < ndirs; k++) if(!strcmp(udirs[k], users[i])) dup = 1;
+ if(!dup){ strncpy(udirs[ndirs], users[i], 31); udirs[ndirs][31] = 0; ndirs++; }
+ }
+ long best = -1;
+ out[0] = 0;
+ const char *best_src = "none";
+ /* Per-title activity = max(savedata writes, app.pkg access time).
+ * app.pkg atime is the sharper signal: the running game streams its
+ * own package continuously, while save mtimes get bulk-touched by
+ * cloud sync (proven: all 28 titles sharing one mtime). atime wins
+ * ties because only gameplay advances it. */
+ for(int u = 0; u < ndirs; u++){
+ char spath[96];
+ snprintf(spath, sizeof spath, "/user/home/%s/savedata", udirs[u]);
+ DIR *sd = opendir(spath);
+ if(!sd) continue;
+ struct dirent *e;
+ while((e = readdir(sd))){
+ if(e->d_name[0] == '.') continue;
+ if(strlen(e->d_name) != 9 || !is_title_prefix(e->d_name)) continue;
+ char tp[160];
+ snprintf(tp, sizeof tp, "%s/%s", spath, e->d_name);
+ /* newest write inside the title dir wins */
+ DIR *td = opendir(tp);
+ long tb = -1;
+ if(td){
+ struct dirent *f;
+ while((f = readdir(td))){
+ if(f->d_name[0] == '.') continue;
+ /* d_name can be up to 255 chars; tp already holds up
+ * to ~150 — skip overlong names instead of overflowing. */
+ if(strlen(f->d_name) > 64) continue;
+ char fp[256];
+ int wn = snprintf(fp, sizeof fp, "%s/%s", tp, f->d_name);
+ if(wn <= 0 || (size_t)wn >= sizeof fp) continue;
+ struct stat fs;
+ if(stat(fp, &fs) == 0 && fs.st_mtime > tb) tb = fs.st_mtime;
+ }
+ closedir(td);
+ }
+ if(tb < 0){
+ struct stat ds;
+ if(stat(tp, &ds) == 0) tb = ds.st_mtime;
+ }
+ if(tb > best){ best = tb; strncpy(out, e->d_name, cap-1); out[cap-1] = 0; best_src = "save"; }
+ }
+ closedir(sd);
+ }
+ /* Second signal: /user/app/ dir mtime. Launches touch the app
+ * dir even when the game hasn't saved yet (the exact hole that showed
+ * a stale title for a freshly launched game), and it covers titles
+ * with no savedata at all. */
+ {
+ DIR *ad = opendir("/user/app");
+ if(ad){
+ struct dirent *e;
+ while((e = readdir(ad))){
+ if(strlen(e->d_name) != 9 || !is_title_prefix(e->d_name)) continue;
+ char ap[64];
+ snprintf(ap, sizeof ap, "/user/app/%s", e->d_name);
+ struct stat st;
+ if(stat(ap, &st) == 0 && st.st_mtime > best){
+ best = st.st_mtime;
+ strncpy(out, e->d_name, cap-1);
+ out[cap-1] = 0;
+ best_src = "appdir";
+ }
+ }
+ closedir(ad);
+ }
+ }
+ /* Third signal (strongest): app.pkg ACCESS time. The running game
+ * streams its own package, so its atime is the freshest on the box;
+ * cloud sync touches mtimes, never atimes. Verified live: the
+ * foreground title's atime beats every idle title by days. */
+ {
+ DIR *ad = opendir("/user/app");
+ if(ad){
+ struct dirent *e;
+ while((e = readdir(ad))){
+ if(strlen(e->d_name) != 9 || !is_title_prefix(e->d_name)) continue;
+ char pp[96];
+ int wn = snprintf(pp, sizeof pp, "/user/app/%s/app.pkg",
+ e->d_name);
+ if(wn <= 0 || (size_t)wn >= sizeof pp) continue;
+ struct stat st;
+ if(stat(pp, &st) == 0 && (long)st.st_atime > best){
+ best = (long)st.st_atime;
+ strncpy(out, e->d_name, cap-1);
+ out[cap-1] = 0;
+ best_src = "pkg-atime";
+ }
+ }
+ closedir(ad);
+ }
+ }
+ if(out[0]) log_dbg("title scan: %s via %s", out, best_src);
+ return out[0] ? 0 : -1;
+}
+#endif
+
+static int s_user_inited = 0;
+static int s_user_ok = 0;
+static int user_init(void){
+ if(s_user_inited) return s_user_ok ? 0 : -1;
+ s_user_inited = 1;
+#ifdef ORBISRPC_SDK_PAYLOAD
+ /* No UserService in payload-SDK builds: ShellCoreUtil (dlopen) is the
+ * only foreground signal; without it we report inactive (fail closed). */
+ log_msg("UserService unavailable in SDK build; ShellCoreUtil only");
+ return -1;
+#else
+ /* UserService is an external module -> load via internal id */
+ uint32_t r = sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_USER_SERVICE);
+ if(r != 0){ int32_t ir=(int32_t)r; log_msg("load UserService fail %d", ir); return -1; }
+ int32_t rc = sceUserServiceInitialize(NULL);
+ if(rc != 0){ log_msg("UserService init fail %d", rc); return -1; }
+ s_user_ok = 1;
+ return 0;
+#endif
+}
+
+/* --- ShellCoreUtil runtime resolution ------------------------------- */
+/* PS4 SDK has no ; libkernel exports dlopen/dlsym directly. */
+extern void *dlopen(const char *filename, int flags);
+extern void *dlsym(void *handle, const char *symbol);
+typedef int (*shellcore_isapplaunched_fn)(void);
+static shellcore_isapplaunched_fn s_is_app_launched = NULL;
+static int s_scu_tried = 0;
+
+static int scu_init(void){
+ if(s_scu_tried) return s_is_app_launched != NULL;
+ s_scu_tried = 1;
+ void *h = dlopen("libSceShellCoreUtil.sprx", 0);
+ if(!h){ log_msg("ShellCoreUtil unavailable (dlopen fail); using foreground-user fallback"); return 0; }
+ s_is_app_launched = (shellcore_isapplaunched_fn)(uintptr_t)dlsym(h, "sceShellCoreUtilIsAppLaunched");
+ log_msg("ShellCoreUtil IsAppLaunched %s", s_is_app_launched ? "resolved" : "unavailable (dlsym fail)");
+ return s_is_app_launched != NULL;
+}
+
+/* --- foreground-active: the core "a game is running" signal ---------- */
+int detect_foreground_active(void){
+#ifdef ORBISRPC_SDK_PAYLOAD
+ /* Spawned processes see no ShellCoreUtil/UserService; the eboot.bin
+ * process itself is the signal (system has no other eboot). */
+ return proc_has_eboot();
+#else
+ if(scu_init() && s_is_app_launched){
+ int on = s_is_app_launched();
+ return (on != 0) ? 1 : 0; /* 0 when sitting on the home screen */
+ }
+#endif
+ /* fallback: foreground user exists. If UserService itself failed,
+ * report inactive instead of guessing "playing". */
+ if(user_init() != 0) return 0;
+#ifdef ORBISRPC_SDK_PAYLOAD
+ /* No UserService API in SDK builds (user_init always fails there,
+ * so this is unreachable); fail closed regardless. */
+ return 0;
+#else
+ int32_t fg = -1;
+ int32_t rc = sceUserServiceGetForegroundUser(&fg);
+ if(rc != 0){ log_msg("GetForegroundUser err %d", rc); return 0; }
+ return (fg >= 0) ? 1 : 0;
+#endif
+}
+
+/* --- title naming ---------------------------------------------------- */
+static int is_title_prefix(const char *n){
+ /* CUSA (PS4) + PPSA (PS5-backport) + EU/JP/indie variants */
+ return strncmp(n,"CUSA",4)==0 || strncmp(n,"PPSA",4)==0 ||
+ strncmp(n,"PCSE",4)==0 || strncmp(n,"PCSB",4)==0 ||
+ strncmp(n,"PCSG",4)==0 || strncmp(n,"EPSA",4)==0;
+}
+/* last resolved titleId (for Discord asset key); valid after a successful
+ * detect_current_game / detect_name_for_title. */
+static char s_last_titleid[16] = "";
+static char s_last_art[256] = "";
+/* Last resolved title: same title in a row reuses its name/art with zero
+ * I/O. Any title change resolves fresh — no cross-title cache exists, so
+ * stale identities are impossible by construction. */
+static char s_rs_tid[16] = "";
+static char s_rs_name[128] = "";
+static char s_rs_art[256] = "";
+/* Raw-ID (unresolved) entries are retried, not frozen: a transient miss
+ * (slow I/O at launch) must not lock the raw ID in forever. Successful
+ * resolves reuse indefinitely; raw fallbacks re-resolve after 5 min. */
+static int s_rs_ok = 0;
+static int64_t s_rs_at = 0;
+static int resolve_reuse(const char *ti, char *out_name, size_t cap){
+ if(!ti || !ti[0] || strcmp(ti, s_rs_tid) != 0 || !s_rs_name[0]) return 0;
+ if(!s_rs_ok && orbis_mono_s() - s_rs_at > 300) return 0;
+ strncpy(out_name, s_rs_name, cap - 1);
+ out_name[cap - 1] = 0;
+ strncpy(s_last_art, s_rs_art, sizeof s_last_art - 1);
+ s_last_art[sizeof s_last_art - 1] = 0;
+ return 1;
+}
+static void resolve_remember(const char *ti, const char *name, const char *art, int ok){
+ if(!ti || !name) return;
+ s_rs_ok = ok;
+ s_rs_at = orbis_mono_s();
+ strncpy(s_rs_tid, ti, sizeof s_rs_tid - 1);
+ s_rs_tid[sizeof s_rs_tid - 1] = 0;
+ strncpy(s_rs_name, name, sizeof s_rs_name - 1);
+ s_rs_name[sizeof s_rs_name - 1] = 0;
+ if(art){
+ strncpy(s_rs_art, art, sizeof s_rs_art - 1);
+ s_rs_art[sizeof s_rs_art - 1] = 0;
+ } else s_rs_art[0] = 0;
+}
+/* Media apps post Watching/Listening instead of Playing. IDs verified
+ * against Sony TMDB (names resolve there too). */
+int detect_media_type(const char *title_id){
+ static const struct { const char *id; int type; } media[] = {
+ { "CUSA00127", 3 }, /* Netflix -> Watching */
+ { "CUSA01015", 3 }, /* YouTube -> Watching */
+ };
+ if(!title_id) return 0;
+ for(unsigned i = 0; i < sizeof media/sizeof media[0]; i++){
+ if(!strcmp(title_id, media[i].id)) return media[i].type;
+ }
+ return 0;
+}
+
+const char *detect_last_titleid(void){ return s_last_titleid[0] ? s_last_titleid : NULL; }
+int detect_last_ok(void){ return s_rs_ok; }
+const char *detect_last_art(void){ return s_last_art[0] ? s_last_art : NULL; }
+static void remember_titleid(const char *ti){
+ if(!ti) return;
+ strncpy(s_last_titleid, ti, sizeof s_last_titleid - 1);
+ s_last_titleid[sizeof s_last_titleid - 1] = 0;
+}
+static long scan_one_appdir(const char *base, char *out, size_t cap, long best){
+ if(!base || !out || cap < 2) return best;
+ DIR *d = opendir(base);
+ if(!d) return best;
+ struct dirent *e;
+ char path[256]; size_t plen;
+ while((e=readdir(d))){
+ if(e->d_name[0]=='.') continue;
+ size_t l=strlen(e->d_name);
+ if(l!=9 || !is_title_prefix(e->d_name)) continue;
+ plen=snprintf(path,sizeof path,"%s/%s/app.xml",base,e->d_name);
+ if(plen>=sizeof path) continue;
+ struct stat st2;
+ if(stat(path,&st2)==0){
+ const char *ti = e->d_name;
+ if(st2.st_mtime > best || best<0){ best=st2.st_mtime; if(cap>1)strncpy(out,ti,cap-1); out[cap-1]=0; }
+ }
+ }
+ closedir(d);
+ return best;
+}
+#ifndef ORBISRPC_SDK_PAYLOAD
+static long scan_recent_titleid(char *out, size_t cap){
+ out[0]=0;
+ long best = scan_one_appdir("/user/app", out, cap, -1);
+ best = scan_one_appdir("/data/app", out, cap, best);
+ return (out[0])? 0 : -1;
+}
+#endif /* scan_recent_titleid unused in SDK builds */
+
+/* Best name on the box: /user/appmeta//pronunciation.xml holds the
+ * display title in its first element (works for every game that
+ * ships speech data, e.g. Terraria). Small file, single read. */
+static int pronunc_title(const char *titleId, char *out, size_t cap){
+ char path[256]; snprintf(path,sizeof path,"/user/appmeta/%s/pronunciation.xml",titleId);
+ int fd=open(path,O_RDONLY);
+ if(fd<0){ log_msg("appmeta open fail %s err=%d", path, errno); return -1; }
+ char buf[2048]; ssize_t n=read(fd,buf,sizeof buf-1); close(fd);
+ if(n<=0) return -1; buf[n]=0;
+ const char *p=strstr(buf,"");
+ if(!p) return -1;
+ p+=6;
+ while(*p==' '||*p=='\t'||*p=='\r'||*p=='\n') p++;
+ size_t i=0;
+ while(*p && *p!='<' && *p!='\n' && *p!='\r' && i0 && (out[i-1]==' '||out[i-1]=='\t')) out[--i]=0;
+ return (i>1)?0:-1;
+}
+
+/* Game-process-safe TITLE read: the running game's own param.sfo via the
+ * app0 mount plus on-disc sce_sys copies. Small single read, works on ANY
+ * console with zero setup. Returns 0 on success. */
+static int sfo_file_title(const char *titleId, char *out, size_t cap){
+ char path[256];
+ /* Running game's own sandbox mount first: the payload process can
+ * already list /mnt/sandbox (that is where the title ID comes from),
+ * and the live mount carries the game's own sce_sys/param.sfo with
+ * its TITLE field. Fails soft when untraversable. */
+ if(titleId && titleId[0]){
+ snprintf(path, sizeof path, "/mnt/sandbox/%s_000/sce_sys/param.sfo", titleId);
+ int sfd = open(path, O_RDONLY);
+ if(sfd >= 0){
+ unsigned char buf[4096];
+ ssize_t n = read(sfd, buf, sizeof buf);
+ close(sfd);
+ if(n > 0 && sfo_title(buf, (size_t)n, out, cap) == 0) return 0;
+ }
+ }
+ const char *fixed[] = { "app0/sce_sys/param.sfo", "/app0/sce_sys/param.sfo", NULL };
+ for(int i = 0; fixed[i]; i++){
+ int fd = open(fixed[i], O_RDONLY);
+ if(fd < 0) continue;
+ unsigned char buf[4096];
+ ssize_t n = read(fd, buf, sizeof buf);
+ close(fd);
+ if(n > 0 && sfo_title(buf, (size_t)n, out, cap) == 0) return 0;
+ }
+ if(titleId && titleId[0]){
+ const char *bases[] = { "/user/app", "/data/app", NULL };
+ for(int b = 0; bases[b]; b++){
+ snprintf(path, sizeof path, "%s/%s/sce_sys/param.sfo", bases[b], titleId);
+ int fd = open(path, O_RDONLY);
+ if(fd < 0) continue;
+ unsigned char buf[4096];
+ ssize_t n = read(fd, buf, sizeof buf);
+ close(fd);
+ if(n > 0 && sfo_title(buf, (size_t)n, out, cap) == 0) return 0;
+ }
+ }
+ return -1;
+}
+
+static int appxml_title(const char *titleId, char *out, size_t cap){
+ const char *bases[] = { "/user/app", "/data/app", NULL };
+ for(int b=0; bases[b]; b++){
+ char path[256]; snprintf(path,sizeof path,"%s/%s/app.xml",bases[b],titleId);
+ int fd=open(path,O_RDONLY); if(fd<0) continue;
+ char buf[640]; ssize_t n=read(fd,buf,sizeof buf-1); close(fd);
+ if(n<=0) continue; buf[n]=0;
+ char *p=strstr(buf,"");
+ if(!p) p=strstr(buf,"titleName");
+ if(!p) continue;
+ p = strchr(p, '>');
+ if(!p) continue;
+ p++;
+ while(*p==' '||*p=='\t'||*p=='\r'||*p=='\n') p++; /* trim leading ws */
+ size_t i=0;
+ while(*p && *p!='<' && *p!='\n' && *p!='\r' && i0 && (out[i-1]==' '||out[i-1]=='\t')) out[--i]=0; /* trim trailing */
+ if(i>0) return 0;
+ }
+ return -1;
+}
+
+/* NOTE: an earlier revision byte-scanned app.db for titles. Removed:
+ * the packed record layout makes the title/contentId boundary ambiguous
+ * and the scanner returned wrong names (worse than raw IDs). Exact
+ * sources above plus Sony TMDB cover every case instead. */
+
+int detect_current_game(char *out_name, size_t cap, char *out_path, size_t p_cap){
+ if(!out_name || cap==0) return -1;
+ out_name[0] = 0;
+ if(out_path && p_cap) out_path[0] = 0;
+ {
+ /* Tri-state foreground: 1 game, 0 none, -1 scan failed (unknown).
+ * Unknown must NOT count as disappearance — it means "keep whatever
+ * we had", never a transition. */
+ int fg = detect_foreground_active();
+ if(fg < 0) return -2;
+ if(!fg) return -1;
+ }
+ char titleId[16]=""; int named=0, have_tid=0;
+#ifdef ORBISRPC_SDK_PAYLOAD
+ /* Sandbox mount first: authoritative OS-level identity. Save-scan
+ * stays as fallback (its mtimes get bulk-touched by cloud sync). */
+ if(scan_sandbox_mount(titleId, sizeof titleId) == 0){
+ have_tid = 1;
+ remember_titleid(titleId);
+ s_last_art[0] = 0;
+ } else if(scan_newest_save(titleId,sizeof titleId)==0){
+ have_tid = 1;
+ remember_titleid(titleId);
+ s_last_art[0] = 0;
+ } else return -1;
+#else
+ have_tid = (scan_recent_titleid(titleId,sizeof titleId)==0);
+ if(have_tid){
+ remember_titleid(titleId);
+ s_last_art[0] = 0;
+ }
+#endif
+ if(have_tid){
+ remember_titleid(titleId);
+ s_last_art[0] = 0;
+ if(resolve_reuse(titleId, out_name, cap)){
+ log_dbg("name: %s via cache", out_name);
+ named = 1;
+ }
+ /* cheap, game-process-safe sources first; Sony TMDB (network)
+ * resolves anything local sources miss, on any console. */
+ if(!named && cfg_title(&g_cfg, titleId, out_name, cap)==0){ named=1; log_msg("name: %s via config", out_name); }
+ /* System app.db: the authoritative on-box title registry (SQLite,
+ * read-only). Covers disc + digital where per-file sources miss. */
+ /* appdb_title(titleId, out_name, cap); */
+ if(!named && pronunc_title(titleId, out_name, cap)==0){ named=1; log_msg("name: %s via appmeta", out_name); }
+ if(!named){ if(sfo_file_title(titleId, out_name, cap)==0){ named=1; log_msg("name: %s via sfo", out_name); } }
+ if(!named){ if(appxml_title(titleId, out_name, cap)==0){ named=1; log_msg("name: %s via appxml", out_name); } }
+ if(!named){
+ char art[256] = "";
+ if(tmdb_resolve(titleId, out_name, cap, art, sizeof art)==0){
+ named = 1;
+ strncpy(s_last_art, art, sizeof s_last_art-1);
+ } else s_last_art[0] = 0;
+ }
+ if(!named){ strncpy(out_name, titleId, cap-1); out_name[cap-1]=0; }
+ resolve_remember(titleId, out_name, s_last_art, named);
+ }else{
+ remember_titleid("");
+ s_last_art[0] = 0;
+ strncpy(out_name, "(unknown game)", cap-1); out_name[cap-1]=0;
+ }
+ if(out_path){ snprintf(out_path, p_cap, "/data/orbisRPC/.lastgame/%s", titleId[0]?titleId:"unknown"); }
+ return 0;
+}
+
+/* Resolve a display name for a KNOWN title id (plugin mode: the plugin is
+ * loaded into the game process and knows the titleid from the GoldHEN SDK,
+ * so we skip the foreground-app heuristics entirely). */
+int detect_name_for_title(const char *titleId, char *out_name, size_t cap){
+ if(!titleId || !titleId[0] || !out_name || cap==0) return -1;
+ remember_titleid(titleId);
+ s_last_art[0] = 0;
+ if(resolve_reuse(titleId, out_name, cap)){
+ log_dbg("name: %s via cache", out_name);
+ return 0;
+ }
+ /* Game-process-safe only: small reads plus one bounded network
+ * lookup; no multi-megabyte scans anywhere in this codebase. */
+ if(cfg_title(&g_cfg, titleId, out_name, cap)==0){ log_msg("name: %s via config", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
+ /* appdb_title(titleId, out_name, cap); */ resolve_remember(titleId, out_name, "", 1); return 0;
+ if(pronunc_title(titleId, out_name, cap)==0){ log_msg("name: %s via appmeta", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
+ else log_msg("name: appmeta miss for %s", titleId);
+ if(sfo_file_title(titleId, out_name, cap)==0){ log_msg("name: %s via sfo", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
+ if(appxml_title(titleId, out_name, cap)==0){ log_msg("name: %s via appxml", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
+ {
+ char art[256] = "";
+ if(tmdb_resolve(titleId, out_name, cap, art, sizeof art)==0){
+ strncpy(s_last_art, art, sizeof s_last_art-1);
+ resolve_remember(titleId, out_name, art, 1);
+ return 0; /* tmdb_resolve already logged */
+ }
+ s_last_art[0] = 0;
+ }
+ strncpy(out_name, titleId, cap-1); out_name[cap-1]=0;
+ resolve_remember(titleId, out_name, "", 0);
+ return 0;
+}
\ No newline at end of file
diff --git a/orbisrpc/detect.h b/orbisrpc/detect.h
new file mode 100644
index 0000000..ffe64af
--- /dev/null
+++ b/orbisrpc/detect.h
@@ -0,0 +1,28 @@
+/* detect.h - find current foreground game title + app cache dir. */
+#ifndef DETECT_H
+#define DETECT_H
+#include
+/* 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. */
+int detect_current_game(char *out_name, size_t cap, char *out_path, size_t p_cap);
+/* Returns 1 if a foreground user app is running, 0 if definitely none,
+ * -1 if the scan itself failed (unknown: keep previous state). */
+int detect_foreground_active(void);
+/* Number of eboot.bin processes (launch/close churn detector). -1 unknown.
+ * Payload-SDK builds only (needs raw sysctl visibility). */
+#ifdef ORBISRPC_SDK_PAYLOAD
+int detect_eboot_count(void);
+#endif
+/* Resolve a display name for a KNOWN title id (plugin mode). */
+int detect_name_for_title(const char *titleId, char *out_name, size_t cap);
+/* Last resolved titleId (e.g. "CUSA00740") or NULL if none yet.
+ * Used as the Discord asset key so icons follow automatically. */
+const char *detect_last_titleid(void);
+/* 1 when the last resolve produced a real name (0 = raw-ID fallback). */
+int detect_last_ok(void);/* Last resolved artwork URL (Sony CDN via TMDB, or empty). */
+const char *detect_last_art(void);
+/* Media type for presence: 0 Playing (games), 2 Listening, 3 Watching.
+ * Only known media apps map; everything else is 0. Extend freely. */
+int detect_media_type(const char *title_id);
+#endif
\ No newline at end of file
diff --git a/orbisrpc/discord.c b/orbisrpc/discord.c
new file mode 100644
index 0000000..3ffd49a
--- /dev/null
+++ b/orbisrpc/discord.c
@@ -0,0 +1,491 @@
+/* discord.c - Discord gateway client using a USER SESSION token.
+ *
+ * Flow: TLS websocket -> HELLO(op10) -> IDENTIFY(op2) -> READY(dispatch) ->
+ * presence updates (op3) + heartbeats (op1/op11).
+ *
+ * Fresh IDENTIFY per connection (no RESUME): the identify budget is 1000/24h,
+ * far above our reconnect rate, and skipping RESUME removes session-state
+ * bookkeeping entirely. Close frames are parsed so a rejected token
+ * (close 4004) is reported as fatal instead of looping forever.
+ */
+#include "discord.h"
+#include "clock.h"
+#include "b64.h"
+#include "log.h"
+#include "art.h"
+#include "jsonlite.h"
+#include "detect.h"
+#include
+#include
+#include
+#include
+#include
+
+#define GW_HOST "gateway.discord.gg"
+#define GW_PORT 443
+#define GW_PATH "/?v=10&encoding=json"
+
+static void make_key(char *out){
+ unsigned char b[16];
+ memset(b, 0, sizeof b);
+ int fd=open("/dev/urandom",O_RDONLY);
+ if(fd>=0){
+ size_t got = 0;
+ while(got < sizeof b){
+ long r = read(fd, (char *)b + got, sizeof b - got);
+ if(r <= 0) break;
+ got += (size_t)r;
+ }
+ close(fd);
+ if(got < sizeof b){
+ /* short read: mix time in rather than leaving stack bytes */
+ uint32_t t = (uint32_t)time(NULL);
+ for(size_t i = got; i < sizeof b; i++, t = t*1664525u + 1013904223u)
+ b[i] ^= (unsigned char)(t >> 24);
+ }
+ }
+ else { for(int i=0;i<16;i++) b[i]=(unsigned char)(time(NULL)+i*7); }
+ b64_encode(b,16,out);
+}
+
+/* Gateway events live INSIDE the JSON text payload ("op":N) — not in the
+ * WebSocket frame header (1=text, 8=close, 9=ping). Scan top-level keys
+ * with string-awareness so a nested `"op":` inside a string value can't
+ * spoof the event type. Works on truncated buffers too (op/s/t sit at the
+ * payload prefix, while the megabyte-long "d" blob may be cut off). */
+static const char *top_val(const char *json, size_t len, const char *key){
+ size_t kl = strlen(key);
+ int depth = 0, instr = 0, esc = 0;
+ for(size_t i = 0; i < len; i++){
+ char c = json[i];
+ if(instr){
+ if(esc) esc = 0;
+ else if(c == '\\') esc = 1;
+ else if(c == '"') instr = 0;
+ continue;
+ }
+ if(c == '"'){
+ /* possible key: must be at depth 1 followed by optional ws + ':' */
+ size_t j = i + 1, k = 0;
+ while(j < len && k < kl && json[j] == key[k]){ j++; k++; }
+ if(k == kl && j < len && json[j] == '"'){
+ j++;
+ while(j < len && (json[j]==' '||json[j]=='\t')) j++;
+ if(j < len && json[j] == ':'){
+ if(depth == 1) return json + j + 1;
+ /* nested same-name key: skip its string opener below */
+ i = j; continue;
+ }
+ }
+ instr = 1; continue;
+ }
+ if(c == '{' || c == '[') depth++;
+ else if(c == '}' || c == ']') depth--;
+ }
+ return NULL;
+}
+static int gw_op(const char *json, size_t len){
+ const char *v = top_val(json, len, "op");
+ if(!v) return -1;
+ while(v < json+len && (*v==' '||*v=='\t')) v++;
+ if(v >= json+len || *v<'0' || *v>'9') return -1;
+ int op = 0;
+ while(v < json+len && *v>='0' && *v<='9'){ op = op*10 + (*v-'0'); v++; }
+ return op;
+}
+static void gw_seq(discord_t *d, const char *json, size_t len){
+ if(!d) return;
+ const char *v = top_val(json, len, "s");
+ if(!v) return;
+ while(v < json+len && (*v==' '||*v=='\t')) v++;
+ if(v >= json+len || *v<'0' || *v>'9') return; /* null or missing: not a seq */
+ int s = 0;
+ while(v < json+len && *v>='0' && *v<='9'){ s = s*10 + (*v-'0'); v++; }
+ d->seq = s;
+}
+static int is_ready(const char *json, size_t len){
+ const char *v = top_val(json, len, "t");
+ if(!v) return 0;
+ while(v < json+len && (*v==' '||*v=='\t')) v++;
+ return (size_t)(json+len-v) >= 7 && !memcmp(v, "\"READY\"", 7);
+}
+
+/* recv one frame with a deadline; -4 = timed out. Skipped oversized frames
+ * (-3) are logged and retried transparently. */
+static int rx_frame(discord_t *d, char *buf, size_t cap, int *op, int *fin, int64_t deadline){
+ for(;;){
+ int nr=ws_recv_frame(&d->ws,buf,cap,op,fin);
+ if(nr==-3){ log_msg("skipped oversized gateway frame"); continue; }
+ if(nr!=0) return nr;
+ if(orbis_mono_s()>deadline) return -4;
+ usleep(50000);
+ }
+}
+
+static int send_identify(discord_t *d, const char *token){
+ jl_val_t *root=jl_new_object();
+ if(!root) return -1;
+ jl_obj_set(root,"op",jl_new_int(2));
+ jl_val_t *dd=jl_new_object();
+ if(!dd){ jl_free(root); return -1; }
+ jl_obj_set(dd,"token",jl_new_string(token));
+ jl_val_t *pp=jl_new_object();
+ if(!pp){ jl_free(root); return -1; }
+ jl_obj_set(pp,"os",jl_new_string("windows"));
+ jl_obj_set(pp,"browser",jl_new_string("Discord Client"));
+ jl_obj_set(pp,"device",jl_new_string(""));
+ jl_obj_set(dd,"properties",pp);
+ jl_val_t *pr=jl_new_object();
+ if(!pr){ jl_free(root); return -1; }
+ jl_obj_set(pr,"status",jl_new_string("online"));
+ jl_obj_set(pr,"activities",jl_new_array());
+ jl_obj_set(pr,"afk",jl_new_bool(0));
+ jl_obj_set(pr,"since",jl_new_null());
+ jl_obj_set(dd,"presence",pr);
+ jl_obj_set(root,"d",dd);
+ char *s=jl_stringify(root); jl_free(root);
+ if(!s) return -1;
+ int rc = ws_send_text(&d->ws,s,strlen(s));
+ free(s);
+ return rc < 0 ? -1 : 0;
+}
+
+int discord_connect(discord_t *d, const char *token){
+ if(!d || !token || !token[0]) return -1;
+ memset(d,0,sizeof(*d));
+ strncpy(d->token, token, sizeof d->token-1);
+ d->token[sizeof d->token-1] = 0;
+ char key[64]=""; make_key(key);
+ int rc=ws_connect(&d->ws, GW_HOST, GW_PORT, GW_PATH, key);
+ if(rc){ log_msg("ws connect fail %d",rc); return -1; }
+ d->connected=1;
+ int64_t now=orbis_mono_s();
+ d->last_heartbeat=now; d->last_ack=now;
+ /* HELLO (text frame carrying {"op":10,...}) */
+ char buf[2048]; int op=0,fin=0;
+ int nr=rx_frame(d,buf,sizeof buf,&op,&fin,now+15);
+ if(nr==-4){ log_msg("no HELLO (timeout)"); ws_close(&d->ws); d->connected=0; return -1; }
+ if(nr<=0 || op!=1){
+ log_msg("no HELLO (nr=%d op=%d)",nr,op);
+ ws_close(&d->ws); d->connected=0;
+ return -1;
+ }
+ jl_val_t *h=jl_parse(buf,nr);
+ if(h){
+ const jl_val_t *dd=jl_obj_get(h,"d");
+ if(dd){
+ const jl_val_t *hi=jl_obj_get(dd,"heartbeat_interval");
+ if(hi&&hi->type==JL_NUMBER) d->hb_interval_ms=(int64_t)hi->num;
+ }
+ jl_free(h);
+ }
+ if(!d->hb_interval_ms) d->hb_interval_ms=45000;
+ if(d->hb_interval_ms < 5000){
+ log_msg("discord: hb_interval %lldms suspiciously small; clamping to 5000",
+ (long long)d->hb_interval_ms);
+ d->hb_interval_ms = 5000;
+ }
+ if(send_identify(d, token) < 0){
+ log_msg("discord: identify send failed");
+ ws_close(&d->ws); d->connected=0;
+ return -1;
+ }
+ log_msg("discord: identify sent, hb=%llds",(long long)(d->hb_interval_ms/1000));
+ /* READY confirms the token was accepted. `op` is the WebSocket frame
+ * type; the gateway event is JSON inside — parse it, don't switch on it. */
+ int64_t dl=orbis_mono_s()+20;
+ for(;;){
+ nr=rx_frame(d,buf,sizeof buf,&op,&fin,dl);
+ if(nr==-4){ log_msg("no READY after identify (timeout)"); break; }
+ if(nr<=0){ log_msg("no READY after identify (nr=%d)",nr); break; }
+ if(op==8){
+ if(!fin){ log_msg("fragmented close; reconnecting"); ws_close(&d->ws); d->connected=0; return -1; }
+ if(nr<2){ log_msg("malformed close (len %d); reconnecting", nr); ws_close(&d->ws); d->connected=0; return -1; }
+ unsigned code = (((unsigned char)buf[0]<<8)|((unsigned char)buf[1]));
+ log_msg("gateway closed during auth: %u",code);
+ ws_close(&d->ws); d->connected=0;
+ return code==4004 ? -2 : -1;
+ }
+ if(op==9){ ws_pong(&d->ws); continue; }
+ if(op!=1) continue;
+ int go = gw_op(buf, (size_t)nr);
+ if(go==11){ d->last_ack=orbis_mono_s(); continue; }
+ if(go==0 && is_ready(buf, (size_t)nr)){
+ gw_seq(d, buf, (size_t)nr);
+ log_msg("discord: gateway ready");
+ return 0;
+ }
+ /* other pre-READY events: ignore */
+ }
+ ws_close(&d->ws); d->connected=0;
+ return -1;
+}
+
+int discord_set_presence(discord_t *d, const char *state, const char *name,
+ const char *application_id, int64_t started_epoch){
+ return discord_set_presence_ex(d, state, name, NULL, application_id, NULL, NULL, NULL, started_epoch);
+}
+
+/* Lowercase titleId into an asset-key buffer. Returns key length. */
+static size_t asset_key(const char *title_id, char *key, size_t cap){
+ size_t ki = 0;
+ if(!title_id || !key || cap == 0) return 0;
+ for(size_t i = 0; title_id[i] && ki < cap-1; i++){
+ char c = title_id[i];
+ if(c>='A'&&c<='Z') c += 'a'-'A';
+ if((c>='a'&&c<='z')||(c>='0'&&c<='9')||c=='_') key[ki++]=c;
+ }
+ key[ki] = 0;
+ return ki;
+}
+
+/* Test seam: pure activity-JSON builder (no sockets). token may be ""
+ * to skip the mp: proxy (deterministic offline tests). NULL on OOM. */
+jl_val_t *discord_build_activity(const char *state, const char *name,
+ const char *title_id, const char *application_id,
+ const char *art_base_url, const char *art_url,
+ const char *small_art_url,
+ int64_t started_epoch, const char *token){
+ if(!name) return NULL;
+ jl_val_t *act=jl_new_object();
+ if(!act) return NULL;
+ jl_obj_set(act,"name",jl_new_string(name?name:""));
+ {
+ /* Media apps (Netflix/YouTube/...) post Watching/Listening. */
+ int atype = 0;
+ if(title_id && title_id[0]){
+ atype = detect_media_type(title_id);
+ if(atype != 0 && atype != 2 && atype != 3) atype = 0;
+ }
+ jl_obj_set(act,"type",jl_new_int(atype)); /* 0 Playing */
+ }
+ if(state&&state[0]) jl_obj_set(act,"state",jl_new_string(state));
+ /* details intentionally mirrors the human-readable name, never the raw
+ * title ID: sending the ID here renders it as a second line under the
+ * game name (and twice when the name itself fell back to the ID).
+ * title_id is still used for media-type lookup and artwork below. */
+ if(started_epoch>0){
+ jl_val_t *ts=jl_new_object();
+ if(!ts){ jl_free(act); return NULL; }
+ if(started_epoch > 0 && started_epoch < 100000000000LL)
+ jl_obj_set(ts,"start",jl_new_int(started_epoch*1000LL)); /* integer ms epoch */
+ jl_obj_set(act,"timestamps",ts);
+ }
+ if(application_id&&application_id[0])
+ jl_obj_set(act,"application_id",jl_new_string(application_id));
+ else if(title_id&&title_id[0]&&!(art_base_url&&art_base_url[0])&&!(art_url&&art_url[0])){
+ /* No artwork will appear without a shared app or art URL pack:
+ * say so once instead of failing silently every update. */
+ static int art_warned = 0;
+ if(!art_warned){ art_warned = 1;
+ log_msg("art: no application_id or art_base_url; presence sends without artwork"); }
+ }
+ /* Artwork (verified on hardware 2026-09-23): raw https URLs and
+ * dangling keys drop the whole activity. Two working forms, tried in
+ * order: (1) mp: proxy resolved via external-assets for the URL we
+ * have (Sony CDN or art_base_url pack); (2) uploaded asset key. */
+ if(title_id&&!strcmp(title_id,"home")&&application_id&&application_id[0]){
+ /* Idle tile: PlayStation logo. home_art (arrives as art_url):
+ * http(s) URL -> mp: proxy; bare value -> uploaded asset key
+ * used as-is; empty -> home.png when the pack
+ * carries it. A URL that fails mp: sends NO assets (never a
+ * dangling key: those drop the whole activity). */
+ const char *hsrc = (art_url&&art_url[0]) ? art_url : NULL;
+ char home_pack[320] = "";
+ if(hsrc && strchr(hsrc, '"')) hsrc = NULL; /* config garbage fails
+ soft to the pack default, never a broken JSON POST */
+ if(hsrc && strncmp(hsrc, "http", 4) != 0){
+ for(const char *q = hsrc; *q; q++){
+ char ch = *q;
+ if(!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_')){
+ hsrc = NULL; /* not a valid asset key: pack default */
+ break;
+ }
+ }
+ }
+ if(!hsrc && art_base_url&&art_base_url[0]){
+ int n=snprintf(home_pack,sizeof home_pack,"%shome.png",art_base_url);
+ if(n>0 && (size_t)n= 4){
+ int n=snprintf(pack_url,sizeof pack_url,"%s%s.png",art_base_url,key);
+ if(n>0 && (size_t)n= 4){
+ jl_val_t *as=jl_new_object();
+ if(as){
+ jl_obj_set(as,"large_image",jl_new_string(key));
+ jl_obj_set(as,"large_text",jl_new_string(title_id));
+ jl_obj_set(act,"assets",as);
+ }
+ }
+ }
+ }
+ /* System badge (PC-tool pattern): small corner tile, e.g. the
+ * PlayStation logo next to game art. mp: URLs resolve through the
+ * disk-cached proxy; anything unresolvable is omitted, never sent
+ * raw (bad small images drop the whole activity). */
+ if(small_art_url && small_art_url[0]){
+ char smp[512] = "";
+ const char *simg = NULL;
+ if(!strncmp(small_art_url, "mp:", 3)){
+ simg = small_art_url;
+ } else if(!strncmp(small_art_url, "http", 4) && token && token[0] &&
+ art_resolve_mp(application_id, token, small_art_url, smp, sizeof smp)){
+ simg = smp;
+ }
+ if(simg){
+ jl_val_t *as = jl_obj_get(act, "assets");
+ if(as && as->type == JL_OBJECT){
+ jl_obj_set(as, "small_image", jl_new_string(simg));
+ jl_obj_set(as, "small_text", jl_new_string("PlayStation 4"));
+ }
+ }
+ }
+ return act;
+}
+
+int discord_set_presence_ex(discord_t *d, const char *state, const char *name,
+ const char *title_id, const char *application_id,
+ const char *art_base_url, const char *art_url,
+ const char *small_art_url,
+ int64_t started_epoch){
+ if(!d || !d->connected || !name) return -1;
+ jl_val_t *act = discord_build_activity(state, name, title_id,
+ application_id, art_base_url, art_url, small_art_url, started_epoch, d->token);
+ if(!act) return -1;
+ jl_val_t *dd=jl_new_object();
+ jl_obj_set(dd,"activities",jl_new_array());
+ jl_arr_push(jl_obj_get(dd,"activities"), act);
+ jl_obj_set(dd,"status",jl_new_string("online"));
+ /* NOT idle: Gateway expects null, not 0. Sending 0 asserts
+ * "idle since 1970" and can wedge the client timer at 0:00. */
+ jl_obj_set(dd,"since",jl_new_null());
+ jl_obj_set(dd,"afk",jl_new_bool(0));
+ jl_val_t *root=jl_new_object();
+ if(!root){ jl_free(dd); return -1; }
+ jl_obj_set(root,"op",jl_new_int(3));
+ jl_obj_set(root,"d",dd);
+ char *s=jl_stringify(root); jl_free(root);
+ if(!s) return -1;
+ int r=ws_send_text(&d->ws,s,strlen(s)); free(s);
+ return (r>=0)?0:-1;
+}
+
+int discord_clear_presence(discord_t *d){
+ if(!d || !d->connected) return -1;
+ jl_val_t *dd=jl_new_object();
+ jl_obj_set(dd,"activities",jl_new_array());
+ jl_obj_set(dd,"status",jl_new_string("online")); /* stay visible, just idle */
+ jl_obj_set(dd,"since",jl_new_null());
+ jl_obj_set(dd,"afk",jl_new_bool(0));
+ jl_val_t *root=jl_new_object();
+ if(!root){ jl_free(dd); return -1; }
+ jl_obj_set(root,"op",jl_new_int(3));
+ jl_obj_set(root,"d",dd);
+ char *s=jl_stringify(root); jl_free(root);
+ if(!s) return -1;
+ int r=ws_send_text(&d->ws,s,strlen(s)); free(s);
+ return (r>=0)?0:-1;
+}
+
+int discord_tick(discord_t *d){
+ if(!d || !d->connected) return -1;
+ int64_t now=orbis_mono_s();
+ long hb_s=(long)(d->hb_interval_ms/1000); if(hb_s<5)hb_s=5;
+ /* gateway must ack heartbeats; 2 missed intervals means it's gone */
+ if(d->sent_hb && now-d->last_ack > hb_s*2+15){
+ log_msg("heartbeat timeout (no ack)");
+ d->connected=0;
+ return -1;
+ }
+ /* fire slightly EARLY (tick cadence adds up to ~1s of jitter) so we are
+ * always inside the gateway's heartbeat window */
+ long fire = hb_s>3 ? hb_s-2 : hb_s;
+ if(now-d->last_heartbeat >= fire){
+ char hb[64];
+ if(d->seq>0) snprintf(hb,sizeof hb,"{\"op\":1,\"d\":%d}",d->seq);
+ else snprintf(hb,sizeof hb,"{\"op\":1,\"d\":null}");
+ if(ws_send_text(&d->ws,hb,strlen(hb)) < 0){
+ log_msg("heartbeat send failed; reconnecting");
+ d->connected=0;
+ return -1;
+ }
+ d->last_heartbeat=now; d->sent_hb=1;
+ }
+ /* drain pending server frames (non-blocking); 2048 covers the dispatch
+ * envelope ({"t":..,"s":N,..) with margin — big payloads are consumed
+ * inside ws_recv_frame regardless of this cap */
+ char buf[2048]; int op=0,fin=0;
+ for(int i=0;i<32;i++){
+ int nr=ws_recv_frame(&d->ws,buf,sizeof buf,&op,&fin);
+ if(nr==-3){ log_msg("skipped oversized frame"); continue; }
+ if(nr==0) break;
+ if(nr<0){ d->connected=0; return -1; }
+ if(op==8){ /* WS CLOSE: payload starts with a 2-byte big-endian code */
+ if(!fin){ log_msg("fragmented close; reconnecting"); d->connected=0; return -1; }
+ if(nr<2){ log_msg("malformed close (len %d); reconnecting", nr); d->connected=0; return -1; }
+ unsigned code = (((unsigned char)buf[0]<<8)|((unsigned char)buf[1]));
+ log_msg("gateway closed: code=%u",code);
+ d->connected=0;
+ return code==4004 ? -2 : -1;
+ }
+ if(op==9){ ws_pong(&d->ws); continue; } /* WS PING -> PONG */
+ if(op!=1 && op!=0) continue;
+ switch(gw_op(buf, (size_t)nr)){
+ case 0: /* DISPATCH: only the sequence matters to us */
+ gw_seq(d, buf, (size_t)nr);
+ break;
+ case 7: /* RECONNECT requested */
+ log_msg("gateway: reconnect requested");
+ d->connected=0;
+ return -1;
+ case 9: /* INVALID_SESSION: session dead, fresh IDENTIFY needed.
+ * Distinct code so the caller retries promptly instead of
+ * doubling into a long backoff. */
+ log_msg("gateway: invalid session");
+ d->connected=0;
+ return -3;
+ case 11:
+ d->last_ack=now;
+ break;
+ default: break;
+ }
+ }
+ return 0;
+}
diff --git a/orbisrpc/discord.h b/orbisrpc/discord.h
new file mode 100644
index 0000000..205e457
--- /dev/null
+++ b/orbisrpc/discord.h
@@ -0,0 +1,39 @@
+/* discord.h - gateway session + presence (user session token). */
+#ifndef DISCORD_H
+#define DISCORD_H
+#include "ws.h"
+#include "jsonlite.h"
+typedef struct {
+ ws_t ws;
+ char token[512];
+ int64_t last_heartbeat; /* when we last sent op 1 */
+ int64_t last_ack; /* when the gateway last acked (op 11) */
+ int64_t hb_interval_ms;
+ int seq; /* last dispatch sequence (heartbeat payload) */
+ int connected;
+ int sent_hb; /* at least one heartbeat sent */
+} discord_t;
+/* Returns 0 on READY, -1 net/proto error, -2 auth-fatal (close 4004: don't
+ * retry — the token is wrong and Discord bans IPs that hammer it). */
+int discord_connect(discord_t *d, const char *token);
+int discord_set_presence(discord_t *d, const char *state, const char *name,
+ const char *application_id, int64_t started_epoch); /* op 3 */
+/* Same + titleId asset key: when application_id is set, sends
+ * assets { large_image: "", large_text: "" } so the
+ * game icon shows once uploaded under that name in the Discord app.
+ * When art_base_url is set instead, large_image becomes
+ * ".png" (external URL assets, no uploads needed). */
+int discord_set_presence_ex(discord_t *d, const char *state, const char *name,
+ const char *title_id, const char *application_id,
+ const char *art_base_url, const char *art_url,
+ const char *small_art_url,
+ int64_t started_epoch); /* op 3 */
+/* Test seam: pure activity-JSON builder (no sockets). token "" skips mp:. */
+jl_val_t *discord_build_activity(const char *state, const char *name,
+ const char *title_id, const char *application_id,
+ const char *art_base_url, const char *art_url,
+ const char *small_art_url,
+ int64_t started_epoch, const char *token);
+int discord_clear_presence(discord_t *d); /* clear activity, stay online */
+int discord_tick(discord_t *d); /* 0 ok; -1 drop/reconnect; -2 auth-fatal; -3 invalid session (retry promptly) */
+#endif
diff --git a/orbisrpc/health.c b/orbisrpc/health.c
new file mode 100644
index 0000000..dda4549
--- /dev/null
+++ b/orbisrpc/health.c
@@ -0,0 +1,182 @@
+/* health.c - unclean-boot marker + consecutive-crash counter + rollback.
+ *
+ * Correct semantics: a normal reboot must NEVER count as a crash.
+ * boot N: write boot.dirty marker
+ * healthy: (stable runtime) remove marker + reset crash.count to 0
+ * boot N+1: marker present? previous boot died before healthy -> count++.
+ * marker absent? previous boot was clean -> keep count (0).
+ * 3 consecutive unclean boots -> safe mode.
+ */
+#include "health.h"
+#include "updater.h"
+#include "log.h"
+#include
+#include
+#include
+#include
+#include
+
+#ifdef HEALTH_TESTABLE
+static char g_base[256] = "/data/orbisRPC";
+void health_set_base(const char *dir){
+ if(dir && dir[0]){
+ snprintf(g_base, sizeof g_base, "%s", dir);
+ g_base[sizeof g_base - 1] = 0;
+ }
+}
+static void crash_path(char *out, size_t cap){
+ snprintf(out, cap, "%s/crash.count", g_base);
+}
+static void dirty_path(char *out, size_t cap){
+ snprintf(out, cap, "%s/boot.dirty", g_base);
+}
+#else
+static void crash_path(char *out, size_t cap){
+ snprintf(out, cap, "%s", CRASH_PATH);
+}
+static void dirty_path(char *out, size_t cap){
+ snprintf(out, cap, "%s", DIRTY_PATH);
+}
+#endif
+
+static int path_exists(const char *p){
+ struct stat st;
+ return stat(p, &st) == 0;
+}
+
+static int read_count(void){
+ char cp[320];
+ crash_path(cp, sizeof cp);
+ FILE *f = fopen(cp, "rb");
+ if(!f) return 0;
+ int n = 0;
+ if(fscanf(f, "%d", &n) != 1) n = 0;
+ fclose(f);
+ if(n < 0) n = 0;
+ if(n > 1000) n = 1000;
+ return n;
+}
+
+static void write_count(int n){
+ char cp[320], tmp[352];
+ crash_path(cp, sizeof cp);
+ snprintf(tmp, sizeof tmp, "%s.new", cp);
+ FILE *f = fopen(tmp, "wb");
+ if(!f) return;
+ fprintf(f, "%d\n", n);
+ if(fflush(f) != 0){ fclose(f); remove(tmp); return; }
+ int fd = fileno(f);
+ if(fd >= 0) fsync(fd);
+ fclose(f);
+ rename(tmp, cp);
+}
+
+static void write_dirty(void){
+ char dp[320], tmp[352];
+ dirty_path(dp, sizeof dp);
+ snprintf(tmp, sizeof tmp, "%s.new", dp);
+ FILE *f = fopen(tmp, "wb");
+ if(!f){
+ /* best-effort: try direct write (dir may not allow tmp+rename) */
+ f = fopen(dp, "wb");
+ if(!f) return;
+ fprintf(f, "dirty\n");
+ fclose(f);
+ return;
+ }
+ fprintf(f, "dirty\n");
+ if(fflush(f) != 0){ fclose(f); remove(tmp); return; }
+ int fd = fileno(f);
+ if(fd >= 0) fsync(fd);
+ fclose(f);
+ rename(tmp, dp);
+}
+
+static void clear_path(const char *p){
+ remove(p);
+}
+
+int health_boot_note_crash(void){
+ char dp[320];
+ dirty_path(dp, sizeof dp);
+ int unclean = path_exists(dp);
+ int n = read_count();
+ /* Marker first, count second: a crash between the two still leaves
+ * the marker behind, so the death is counted next boot instead of
+ * being lost. */
+ write_dirty();
+ if(unclean){
+ n += 1;
+ write_count(n);
+ }
+ return n >= SAFE_THRESHOLD ? 1 : 0;
+}
+
+void health_mark_healthy(void){
+ char cp[320], dp[320];
+ crash_path(cp, sizeof cp);
+ dirty_path(dp, sizeof dp);
+ clear_path(dp);
+ clear_path(cp);
+}
+
+void health_mark_clean(void){
+ health_mark_healthy();
+}
+
+int health_check_binary(const char *path){
+ FILE *f = fopen(path, "rb");
+ if(!f) return 0;
+ unsigned char h[64];
+ size_t n = fread(h, 1, sizeof h, f);
+ long sz = -1;
+ if(fseek(f, 0, SEEK_END) == 0) sz = ftell(f);
+ fclose(f);
+ if(n < 64 || sz <= 0) return 0;
+ return updater_image_ok(h, n) && (size_t)sz <= 8u*1024u*1024u;
+}
+
+int health_rollback(const char *path){
+ char bak[320];
+ snprintf(bak, sizeof bak, "%s.bak", path);
+ FILE *f = fopen(bak, "rb");
+ if(!f) return -1;
+ fclose(f);
+ /* Atomic replace: never remove(path) first (a failed second step
+ * would leave no bootable binary at all). */
+ if(rename(bak, path) != 0) return -1;
+ return health_check_binary(path) ? 0 : -1;
+}
+
+int health_stage_activate(const char *path){
+ char tmp[320], bak[320];
+ snprintf(tmp, sizeof tmp, "%s.new", path);
+ snprintf(bak, sizeof bak, "%s.bak", path);
+ if(!health_check_binary(tmp)){ remove(tmp); return -1; }
+ /* backup current (best-effort when a prior file exists) */
+ if(path_exists(path)){
+ /* remove stale .bak first so rename() below cannot fail on
+ * platforms without atomic replace */
+ remove(bak);
+ if(rename(path, bak) != 0){ remove(tmp); return -1; }
+ }
+ if(rename(tmp, path) != 0){
+ /* activation failed: try to restore backup */
+ rename(bak, path);
+ remove(tmp);
+ return -1;
+ }
+ if(!health_check_binary(path)){
+ /* activated bytes somehow bad: restore backup immediately */
+ remove(path);
+ rename(bak, path);
+ return -1;
+ }
+ return 0;
+}
+
+int health_verify_or_rollback(const char *path){
+ if(health_check_binary(path)) return 0;
+ if(health_rollback(path) == 0) return 1;
+ return -1;
+}
diff --git a/orbisrpc/health.h b/orbisrpc/health.h
new file mode 100644
index 0000000..031bb5e
--- /dev/null
+++ b/orbisrpc/health.h
@@ -0,0 +1,49 @@
+/* health.h - crash recovery: unclean-boot marker, counter, safe mode.
+ *
+ * Production semantics (v1.0.1+):
+ * boot: health_boot_note_crash() checks for a leftover "dirty" marker.
+ * marker present -> previous boot crashed/was killed -> counter++.
+ * marker absent -> previous boot was clean -> counter unchanged.
+ * then writes the dirty marker for the CURRENT boot.
+ * stable: health_mark_healthy() clears the marker + resets the counter.
+ * call after HEALTH_STABLE_SECS of genuinely healthy runtime.
+ * exit: health_mark_clean() clears both (graceful shutdown paths).
+ *
+ * This way three normal reboots can never trigger safe mode: each clean
+ * shutdown clears the marker, so the next boot sees "clean".
+ * Only consecutive UNCLEAN boots (crash before healthy) count. */
+#ifndef ORBISRPC_HEALTH_H
+#define ORBISRPC_HEALTH_H
+#define CRASH_PATH "/data/orbisRPC/crash.count"
+#define DIRTY_PATH "/data/orbisRPC/boot.dirty"
+#define SAFE_THRESHOLD 3
+/* Seconds of healthy runtime before a boot counts as clean. */
+#define HEALTH_STABLE_SECS 60
+/* Call at clean shutdown to reset the crash counter. */
+void health_mark_clean(void);
+/* Call once the daemon is stably healthy (presence flowing or uptime
+ * past HEALTH_STABLE_SECS). Clears the dirty marker + resets counter. */
+void health_mark_healthy(void);
+/* Call at boot. Returns 1 when safe mode must engage (ORX-BOOT-003),
+ * 0 for normal boot. Increments the persisted crash counter ONLY when
+ * the previous boot left a dirty marker (i.e. crashed before healthy). */
+int health_boot_note_crash(void);
+/* Verify a staged target can run: ELF magic + size sane. 1 ok, 0 bad. */
+int health_check_binary(const char *path);
+/* Restore from .bak. 0 ok, -1 failed/none. */
+int health_rollback(const char *path);
+/* Verify + activate a staged ".new" over with backup.
+ * Returns 0 activated, -1 refused (missing/invalid; .new removed,
+ * live untouched). Host-testable rollback primitive used by
+ * the updater so staging can never leave a corrupt live binary. */
+int health_stage_activate(const char *path);
+/* Post-boot watchdog: if fails health_check_binary, attempt
+ * health_rollback(). Returns 0 healthy, 1 rolled back OK, -1 still bad.
+ * Daemon calls this at startup for each staged target so a bad update
+ * that passed staging is automatically reverted before use. */
+int health_verify_or_rollback(const char *path);
+#ifdef HEALTH_TESTABLE
+/* Override the base dir for crash.count/boot.dirty (host tests). */
+void health_set_base(const char *dir);
+#endif
+#endif
diff --git a/orbisrpc/jsonlite.c b/orbisrpc/jsonlite.c
new file mode 100644
index 0000000..2b1d361
--- /dev/null
+++ b/orbisrpc/jsonlite.c
@@ -0,0 +1,326 @@
+/* jsonlite.c */
+#include "jsonlite.h"
+#include
+#include
+#include
+#include
+
+static jl_val_t *newval(jl_type_t t) {
+ jl_val_t *v = (jl_val_t*)calloc(1, sizeof(jl_val_t));
+ if (v) v->type = t;
+ return v;
+}
+
+/* ---- parser ---- */
+static void skip_ws(jl_parse_t *p) {
+ while (p->cur < p->end) { char c = *p->cur;
+ if (c==' '||c=='\t'||c=='\n'||c=='\r') p->cur++; else break; }
+}
+
+static jl_val_t *parse_value(jl_parse_t *p);
+static jl_val_t *parse_value_depth(jl_parse_t *p, int depth);
+
+/* Stack depth cap: network JSON nests a few levels; a hostile 100k-deep
+ * array would otherwise exhaust the daemon/game thread stack. Counter
+ * advances twice per nesting level, so 128 allows ~64 deep. */
+#define JL_MAX_DEPTH 128
+
+static jl_val_t *parse_string(jl_parse_t *p) {
+ if (p->cur >= p->end || *p->cur != '"') { p->err=1; return NULL; }
+ p->cur++;
+ jl_val_t *v = newval(JL_STRING); if(!v){p->err=1;return NULL;}
+ size_t cap=32, len=0;
+ char *buf = (char*)malloc(cap);
+ if(!buf){ p->err=1; jl_free(v); return NULL; }
+ while (p->cur < p->end && *p->cur != '"') {
+ char c = *p->cur++;
+ if (c=='\\') {
+ if (p->cur >= p->end){p->err=1;break;}
+ char e = *p->cur++;
+ switch(e){ case '"':c='"';break; case '\\':c='\\';break; case '/':c='/';break;
+ case 'b':c='\b';break; case 'f':c='\f';break; case 'n':c='\n';break;
+ case 'r':c='\r';break; case 't':c='\t';break;
+ case 'u':{ /* decode \uXXXX to utf8 */ if (p->cur+4>p->end){p->err=1;break;}
+ unsigned cp=0; for(int i=0;i<4;i++){char h=*p->cur++;
+ cp<<=4; if(h>='0'&&h<='9')cp|=(h-'0'); else if(h>='a'&&h<='f')cp|=(h-'a'+10);
+ else if(h>='A'&&h<='F')cp|=(h-'A'+10); else {p->err=1;break;}}
+ if(p->err)break; char outb[5]; int on=0; if(cp<0x80)outb[on++]=cp;
+ else if(cp<0x800){outb[on++]=(char)(0xC0|(cp>>6));outb[on++]=(char)(0x80|(cp&0x3F));}
+ else {outb[on++]=(char)(0xE0|(cp>>12));outb[on++]=(char)(0x80|((cp>>6)&0x3F));outb[on++]=(char)(0x80|(cp&0x3F));}
+ if(len+(size_t)on+1>cap){
+ size_t ncap=(len+(size_t)on+1)*2;
+ char *nb=(char*)realloc(buf,ncap);
+ if(!nb){ free(buf); jl_free(v); p->err=1; return NULL; }
+ buf=nb; cap=ncap;
+ }
+ memcpy(buf+len,outb,on);len+=on; continue;}
+ default: p->err=1; break;
+ }
+ if(p->err)break;
+ }
+ if(len+2>cap){
+ size_t ncap=cap*2;
+ char *nb=(char*)realloc(buf,ncap);
+ if(!nb){ free(buf); jl_free(v); p->err=1; return NULL; }
+ buf=nb; cap=ncap;
+ }
+ buf[len++]=c;
+ }
+ if(p->err){free(buf);jl_free(v);return NULL;}
+ if(p->cur>=p->end){p->err=1;free(buf);jl_free(v);return NULL;}
+ p->cur++; /* closing quote */
+ buf[len]=0; v->str=buf; v->strlen=len;
+ return v;
+}
+
+static jl_val_t *parse_array(jl_parse_t *p, int depth) {
+ if(depth > JL_MAX_DEPTH){ p->err=1; return NULL; }
+ p->cur++; jl_val_t *v=newval(JL_ARRAY); if(!v){p->err=1;return NULL;}
+ jl_val_t *tail=NULL;
+ skip_ws(p);
+ if(p->curend && *p->cur==']'){p->cur++;return v;}
+ while(p->curend){
+ jl_val_t *e=parse_value_depth(p, depth+1); if(p->err){jl_free(v);return NULL;}
+ if(!v->child)v->child=e;else{tail->next=e;}
+ tail=e; v->count++;
+ skip_ws(p);
+ if(p->curend&&*p->cur==','){p->cur++;skip_ws(p);continue;}
+ if(p->curend&&*p->cur==']'){p->cur++;return v;}
+ p->err=1;jl_free(v);return NULL;
+ }
+ p->err=1;jl_free(v);return NULL;
+}
+
+static jl_val_t *parse_object(jl_parse_t *p, int depth) {
+ if(depth > JL_MAX_DEPTH){ p->err=1; return NULL; }
+ p->cur++; jl_val_t *v=newval(JL_OBJECT); if(!v){p->err=1;return NULL;}
+ jl_val_t *tail=NULL;
+ skip_ws(p);
+ if(p->curend && *p->cur=='}'){p->cur++;return v;}
+ while(p->curend){
+ jl_val_t *key=parse_string(p); if(p->err){jl_free(v);return NULL;}
+ skip_ws(p);
+ if(p->curend&&*p->cur==':'){p->cur++;skip_ws(p);}else{p->err=1;jl_free(key);jl_free(v);return NULL;}
+ jl_val_t *val=parse_value_depth(p, depth+1); if(p->err){jl_free(key);jl_free(v);return NULL;}
+ jl_val_t *pair=newval(JL_OBJECT);
+ if(!pair){ p->err=1; jl_free(key); jl_free(val); jl_free(v); return NULL; }
+ pair->str=key->str; pair->strlen=key->strlen; pair->child=val;
+ free(key); /* moved key->str into pair */
+ if(!v->child)v->child=pair;else{tail->next=pair;}
+ tail=pair; v->count++;
+ skip_ws(p);
+ if(p->curend&&*p->cur==','){p->cur++;skip_ws(p);continue;}
+ if(p->curend&&*p->cur=='}'){p->cur++;return v;}
+ p->err=1;jl_free(v);return NULL;
+ }
+ p->err=1;jl_free(v);return NULL;
+}
+
+static jl_val_t *parse_value_depth(jl_parse_t *p, int depth) {
+ if(depth > JL_MAX_DEPTH){ p->err=1; return NULL; }
+ skip_ws(p);
+ if(p->cur>=p->end){p->err=1;return NULL;}
+ char c=*p->cur;
+ if(c=='{')return parse_object(p, depth+1);
+ if(c=='[')return parse_array(p, depth+1);
+ if(c=='"')return parse_string(p);
+ if(c=='t'){ if(p->end-p->cur>=4&&!memcmp(p->cur,"true",4)){p->cur+=4;jl_val_t*b=newval(JL_BOOL);if(!b){p->err=1;return NULL;}b->num=1;return b;} p->err=1;return NULL;}
+ if(c=='f'){ if(p->end-p->cur>=5&&!memcmp(p->cur,"false",5)){p->cur+=5;jl_val_t*b=newval(JL_BOOL);if(!b){p->err=1;return NULL;}b->num=0;return b;} p->err=1;return NULL;}
+ if(c=='n'){ if(p->end-p->cur>=4&&!memcmp(p->cur,"null",4)){p->cur+=4;jl_val_t*nv=newval(JL_NULL);if(!nv){p->err=1;return NULL;}return nv;} p->err=1;return NULL;}
+ if(c=='-'||(c>='0'&&c<='9')){
+ /* bounded scan: strtod needs NUL-termination but our buffer end is
+ * not terminated, so copy the number token first (64 chars is far
+ * beyond any real JSON number; longer fails cleanly). */
+ char nb[64]; size_t ni=0;
+ const char *q=p->cur;
+ while(qend && ni='0'&&d<='9')||d=='-'||d=='+'||d=='.'||d=='e'||d=='E'){ nb[ni++]=d; q++; }
+ else break;
+ }
+ if(ni==0 || ni>=sizeof nb-1){ p->err=1; return NULL; }
+ nb[ni]=0;
+ char *ep; double dd=strtod(nb,&ep);
+ if(ep==nb){ p->err=1; return NULL; }
+ p->cur=q;
+ jl_val_t *n=newval(JL_NUMBER); if(!n){p->err=1;return NULL;} n->num=dd; return n;
+ }
+ p->err=1; return NULL;
+}
+
+static jl_val_t *parse_value(jl_parse_t *p){ return parse_value_depth(p, 0); }
+
+jl_val_t *jl_parse(const char *s, size_t len){
+ if(!s) return NULL;
+ /* Input cap: no legitimate input here exceeds it (gateway frames,
+ * manifests, configs are KBs); unbounded input means unbounded
+ * allocation inside parse_string's doubling buffer. */
+ if(len > 4u*1024u*1024u) return NULL;
+ size_t l = len;
+ jl_parse_t p={s,s+l,0};
+ jl_val_t *v=parse_value(&p);
+ skip_ws(&p);
+ if(p.err || p.cur != p.end){jl_free(v);return NULL;}
+ return v;
+}
+
+/* Nodes: an OBJECT node is either a pair (str=key, child=value) when it is a
+ * child of an object, or an object value (str=NULL, child=pair list) when it is
+ * a child of an array or a pair's value. Distinguish via str to free everything. */
+void jl_free(jl_val_t *v){
+ if(!v)return;
+ if(v->type==JL_STRING){ if(v->str)free(v->str); free(v); return; }
+ jl_val_t *c=v->child;
+ while(c){
+ jl_val_t *n=c->next;
+ if(c->type==JL_OBJECT){
+ if(c->str){ /* pair: child=value owns, str=key */
+ if(c->child)jl_free(c->child);
+ free(c->str);
+ free(c);
+ } else { /* object value: owns its whole pair list */
+ jl_free(c);
+ }
+ } else {
+ if(c->str)free(c->str);
+ if(c->child)jl_free(c->child);
+ free(c);
+ }
+ c=n;
+ }
+ free(v);
+}
+
+jl_val_t *jl_obj_get(const jl_val_t *obj, const char *key){
+ if(!obj||obj->type!=JL_OBJECT||!key)return NULL;
+ jl_val_t *p=obj->child;
+ while(p){
+ if(p->str && !strcmp(p->str,key)){
+ /* pair node -> return its value (child), unless value is missing */
+ return p->child? p->child : p;
+ }
+ p=p->next;
+ }
+ return NULL;
+}
+
+jl_val_t *jl_arr_at(const jl_val_t *arr, size_t i){
+ if(!arr||arr->type!=JL_ARRAY)return NULL;
+ jl_val_t *e=arr->child;
+ while(e&&i--){ e=e->next; }
+ return e;
+}
+
+/* ---- serializer ---- */
+static int ensure_capacity(char **out, size_t *cap, size_t need);
+
+static int escstr(const char *s, char **out, size_t *cap, size_t *len){
+ if(!s) s = "";
+ size_t l=strlen(s);
+ if(l > ((size_t)-1 - *len - 3) / 6) return -1;
+ size_t need=*len+l*6+2;
+ if(ensure_capacity(out,cap,need+1)<0) return -1;
+ char *p=*out+*len; *p++='"';
+ for(size_t i=0;i=0&&c<0x20)){
+ *p++='\\';
+ switch(c){case '"':*p++='"';break;case '\\':*p++='\\';break;
+ case '\n':*p++='n';break;case '\r':*p++='r';break;case '\t':*p++='t';break;
+ case '\b':*p++='b';break;case '\f':*p++='f';break;
+ default:{*p++='u';*p++='0';*p++='0';const char hx[]="0123456789abcdef";
+ *p++=hx[(c>>4)&0xf];*p++=hx[c&0xf];break;}}
+ } else { *p++=c; }
+ }
+ *p++='"'; *len=(size_t)(p-*out);
+ return 0;
+}
+
+static int ensure_capacity(char **out, size_t *cap, size_t need){
+ if (need <= *cap) return 0;
+ size_t ncap = *cap;
+ while (ncap < need) {
+ if (ncap > (size_t)-1 / 2) return -1;
+ ncap *= 2;
+ }
+ char *p = (char *)realloc(*out, ncap);
+ if (!p) return -1;
+ *out = p;
+ *cap = ncap;
+ return 0;
+}
+
+static int emit(jl_val_t *v, char **out, size_t *cap, size_t *len){
+ char buf[64];
+ switch(v->type){
+ case JL_NULL: { size_t l=4; if(ensure_capacity(out,cap,*len+l+1)<0)return -1; memcpy(*out+*len,"null",4);*len+=4; return 0; }
+ case JL_BOOL: { const char*s=v->num?"true":"false"; size_t l=strlen(s);
+ if(ensure_capacity(out,cap,*len+l+1)<0)return -1; memcpy(*out+*len,s,l);*len+=l; return 0; }
+ case JL_NUMBER: if(v->num_is_int) snprintf(buf,sizeof buf,"%lld",(long long)v->inum); else snprintf(buf,sizeof buf,"%.17g",v->num); break;
+ case JL_STRING: return escstr(v->str,out,cap,len);
+ case JL_ARRAY:{
+ size_t l=*len+1; if(ensure_capacity(out,cap,l+1)<0)return -1; (*out)[(*len)++]='[';
+ jl_val_t*e=v->child;int first=1;
+ while(e){ if(!first){ size_t l2=*len+1; if(ensure_capacity(out,cap,l2+1)<0)return -1; (*out)[(*len)++]=','; }
+ first=0; if(emit(e,out,cap,len)<0)return -1; e=e->next; }
+ l=*len+1; if(ensure_capacity(out,cap,l+1)<0)return -1; (*out)[(*len)++]=']'; return 0; }
+ case JL_OBJECT:{
+ size_t l=*len+1; if(ensure_capacity(out,cap,l+1)<0)return -1; (*out)[(*len)++]='{';
+ jl_val_t*p=v->child;int first=1;
+ while(p){ if(p->str){
+ if(!first){ size_t l2=*len+1; if(ensure_capacity(out,cap,l2+1)<0)return -1; (*out)[(*len)++]=','; }
+ first=0;
+ if(escstr(p->str,out,cap,len)<0)return -1;
+ size_t l3=*len+1; if(ensure_capacity(out,cap,l3+1)<0)return -1; (*out)[(*len)++]= ':';
+ if(p->child){ if(emit(p->child,out,cap,len)<0)return -1; }
+ else { size_t lz=*len+4; if(ensure_capacity(out,cap,lz+1)<0)return -1; memcpy(*out+*len,"null",4);*len+=4; }
+ } p=p->next; }
+ l=*len+1; if(ensure_capacity(out,cap,l+1)<0)return -1; (*out)[(*len)++]='}'; return 0; }
+ }
+ if(buf[0]){ size_t lb=strlen(buf);
+ if(ensure_capacity(out,cap,*len+lb+1)<0)return -1;
+ memcpy(*out+*len,buf,lb);*len+=lb;
+ }
+ return 0;
+}
+
+char *jl_stringify(const jl_val_t *v){
+ if(!v) return NULL;
+ char *out=(char*)malloc(256); if(!out) return NULL;
+ size_t cap=256,len=0;
+ if(emit((jl_val_t*)v,&out,&cap,&len)<0){ free(out); return NULL; }
+ char *t=(char*)realloc(out,len+1);
+ if(t){ t[len]=0; return t; }
+ out[len]=0; return out; /* realloc failed: still return what we have */
+}
+
+jl_val_t *jl_new_string(const char *s){
+ jl_val_t *v=newval(JL_STRING);
+ if(!v) return NULL;
+ v->str=strdup(s?s:"");
+ if(!v->str){ free(v); return NULL; }
+ v->strlen=strlen(v->str);
+ return v;
+}
+jl_val_t *jl_new_number(double n){ jl_val_t *v=newval(JL_NUMBER); if(v){v->num=n; v->num_is_int=0;} return v; }
+jl_val_t *jl_new_int(int64_t n){ jl_val_t *v=newval(JL_NUMBER); if(v){v->num=(double)n; v->num_is_int=1; v->inum=n;} return v; }
+jl_val_t *jl_new_bool(int b){ jl_val_t *v=newval(JL_BOOL); if(v)v->num=b?1:0; return v; }
+jl_val_t *jl_new_null(void){ return newval(JL_NULL); }
+jl_val_t *jl_new_object(void){ return newval(JL_OBJECT); }
+jl_val_t *jl_new_array(void){ return newval(JL_ARRAY); }
+void jl_obj_set(jl_val_t *obj,const char *key,jl_val_t *val){
+ if(!obj || obj->type!=JL_OBJECT || !key || !val) return;
+ jl_val_t *pair=newval(JL_OBJECT);
+ if(!pair) return;
+ pair->str=strdup(key);
+ if(!pair->str){ free(pair); return; }
+ pair->strlen=strlen(key); pair->child=val;
+ if(!obj->child){obj->child=pair;}else{ jl_val_t*t=obj->child; while(t->next)t=t->next; t->next=pair; }
+ obj->count++;
+}
+void jl_arr_push(jl_val_t *arr,jl_val_t *val){
+ if(!arr || arr->type!=JL_ARRAY || !val) return;
+ if(!arr->child){arr->child=val;val->next=NULL;}
+ else{ jl_val_t*t=arr->child; while(t->next)t=t->next; t->next=val; }
+ arr->count++;
+}
diff --git a/orbisrpc/jsonlite.h b/orbisrpc/jsonlite.h
new file mode 100644
index 0000000..1cba42a
--- /dev/null
+++ b/orbisrpc/jsonlite.h
@@ -0,0 +1,35 @@
+/* jsonlite.h - tiny JSON parser/serializer (self contained). */
+#ifndef JSONLITE_H
+#define JSONLITE_H
+#include
+#include
+typedef enum { JL_NULL, JL_BOOL, JL_NUMBER, JL_STRING, JL_OBJECT, JL_ARRAY } jl_type_t;
+typedef struct jl_val {
+ jl_type_t type;
+ char *str; /* string text OR object-entry key */
+ size_t strlen;
+ double num;
+ int num_is_int;
+ int64_t inum;
+ struct jl_val *child; /* object: first pair ; array: first elem */
+ struct jl_val *next; /* object pair chain OR array elem chain */
+ int32_t count;
+} jl_val_t;
+typedef struct { const char *cur; const char *end; int err; } jl_parse_t;
+jl_val_t *jl_parse(const char *s, size_t len);
+void jl_free(jl_val_t *v);
+/* NOTE: the tree is mutable; getters return non-const so results can be
+ * passed to jl_obj_set / jl_arr_push without const-dropping warnings. */
+jl_val_t *jl_obj_get(const jl_val_t *obj, const char *key);
+jl_val_t *jl_arr_at(const jl_val_t *arr, size_t i);
+char *jl_stringify(const jl_val_t *v);
+jl_val_t *jl_new_string(const char *s);
+jl_val_t *jl_new_number(double n);
+jl_val_t *jl_new_int(int64_t n);
+jl_val_t *jl_new_bool(int b);
+jl_val_t *jl_new_null(void);
+jl_val_t *jl_new_object(void);
+jl_val_t *jl_new_array(void);
+void jl_obj_set(jl_val_t *obj, const char *key, jl_val_t *val);
+void jl_arr_push(jl_val_t *arr, jl_val_t *val);
+#endif
\ No newline at end of file
diff --git a/orbisrpc/lock.c b/orbisrpc/lock.c
new file mode 100644
index 0000000..580431b
--- /dev/null
+++ b/orbisrpc/lock.c
@@ -0,0 +1,90 @@
+/* lock.c - pid lockfile with stale detection. No flock on PS4 libc,
+ * so use atomic create (O_CREAT|O_EXCL) + process-table liveness
+ * (sysctl, no signals needed in spawned context). */
+#include "lock.h"
+#include
+#include
+#include
+#include
+#include
+#ifdef ORBISRPC_SDK_PAYLOAD
+#include
+#include
+static int pid_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 1; /* huge table: assume live, fail closed */
+ if(sysctl(mib, 4, buf, &sz, NULL, 0) != 0) return 0;
+ size_t off = 0;
+ while(off + 4 <= sz){
+ int recsz = *(int *)(buf + off);
+ if(recsz <= 0 || off + (size_t)recsz > sz) break;
+ /* A recycled PID owned by a system daemon must not block us:
+ * only a live payload process counts as a peer. Spawned
+ * payloads (elfldr/GoldHEN) show up as "Payload". */
+ if(recsz >= 479 && *(int *)(buf + off + 72) == pid)
+ return !memcmp(buf + off + 447, "Payload", 8) &&
+ buf[off + 455] == 0;
+ off += (size_t)recsz;
+ }
+ return 0;
+}
+#else
+#include
+static int pid_live(int pid){
+ if(pid == (int)getpid()) return 0; /* own stale lock: never "live" */
+ if(kill(pid, 0) == 0) return 1;
+ if(errno == EPERM) return 1;
+ return 0;
+}
+#endif
+
+static int s_held = 0;
+
+int lock_acquire(void){
+ int fd = open(LOCK_PATH, O_CREAT|O_EXCL|O_WRONLY, 0644);
+ if(fd >= 0){
+ char b[32];
+ int n = snprintf(b, sizeof b, "%d\n", (int)getpid());
+ if(n > 0) (void)write(fd, b, (size_t)n);
+ close(fd);
+ s_held = 1;
+ return 0;
+ }
+ /* Exists: check liveness of the holder. */
+ FILE *f = fopen(LOCK_PATH, "rb");
+ if(!f) return -1;
+ int pid = 0;
+ if(fscanf(f, "%d", &pid) != 1) pid = 0;
+ fclose(f);
+ if(pid_live(pid)) return 1; /* live peer: stand down */
+ /* Stale (holder dead or unreadable pid): reclaim. */
+ remove(LOCK_PATH);
+ fd = open(LOCK_PATH, O_CREAT|O_EXCL|O_WRONLY, 0644);
+ if(fd < 0){
+ /* Lost the race: re-check once — a live peer may hold it now. */
+ f = fopen(LOCK_PATH, "rb");
+ if(f){
+ pid = 0;
+ if(fscanf(f, "%d", &pid) != 1) pid = 0;
+ fclose(f);
+ if(pid_live(pid)) return 1;
+ }
+ return -1;
+ }
+ {
+ char b[32];
+ int n = snprintf(b, sizeof b, "%d\n", (int)getpid());
+ if(n > 0) (void)write(fd, b, (size_t)n);
+ close(fd);
+ }
+ s_held = 1;
+ return 0;
+}
+
+void lock_release(void){
+ if(s_held){ remove(LOCK_PATH); s_held = 0; }
+}
diff --git a/orbisrpc/lock.h b/orbisrpc/lock.h
new file mode 100644
index 0000000..d47f8b0
--- /dev/null
+++ b/orbisrpc/lock.h
@@ -0,0 +1,8 @@
+/* lock.h - single-instance pid lockfile. */
+#ifndef ORBISRPC_LOCK_H
+#define ORBISRPC_LOCK_H
+#define LOCK_PATH "/data/orbisRPC/daemon.lock"
+/* 0 acquired, 1 live peer holds it (stand down), -1 error/retry. */
+int lock_acquire(void);
+void lock_release(void);
+#endif
diff --git a/orbisrpc/log.c b/orbisrpc/log.c
new file mode 100644
index 0000000..5b77c45
--- /dev/null
+++ b/orbisrpc/log.c
@@ -0,0 +1,118 @@
+/* log.c - file logger: rotating (512 KB cap), timestamped, falls back to
+ * stderr. Tries several paths because mount visibility differs between app,
+ * plugin, and payload processes. Every line is mirrored to /dev/klog when
+ * possible so payloads remain debuggable even with no writable mount. */
+#include "log.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOG_CAP (512 * 1024)
+
+static FILE *g_log = NULL;
+static int g_klog = -1;
+static volatile int g_log_lock = 0;
+static volatile int g_debug = 0;
+void log_set_debug(int on){ g_debug = on ? 1 : 0; }
+int log_is_debug(void){ return g_debug; }
+static void log_lock(void){ while(__sync_lock_test_and_set(&g_log_lock, 1)) usleep(1000); }
+static void log_unlock(void){ __sync_lock_release(&g_log_lock); }
+
+/* tried in order at log_init(); first openable file wins */
+static const char *const LOG_FALLBACKS[] = {
+ NULL, /* filled in with the caller's requested path */
+ "/data/orbisRPC/log.txt",
+ "/data/orbisrpc.log",
+ "/user/temp/orbisrpc.log",
+ "/mnt/sandbox/orbisrpc.log",
+ "/orbisrpc.log",
+};
+
+void log_init(const char *path) {
+ if (g_log) return;
+ if(path) {
+ g_log = fopen(path, "ab");
+ if(g_log){
+ /* cap check AFTER open to avoid stat->remove TOCTOU */
+ struct stat st;
+ if(fstat(fileno(g_log), &st)==0 && st.st_size > LOG_CAP){
+ fclose(g_log); g_log = NULL;
+ remove(path);
+ g_log = fopen(path, "ab");
+ }
+ }
+ }
+ if (!g_log) {
+ for (unsigned i = 0; i < sizeof(LOG_FALLBACKS)/sizeof(LOG_FALLBACKS[0]); i++) {
+ const char *p = LOG_FALLBACKS[i];
+ if (!p || !path || strcmp(p, path) == 0) continue;
+ g_log = fopen(p, "ab");
+ if (g_log) break;
+ }
+ }
+ if (g_log) setvbuf(g_log, NULL, _IONBF, 0);
+ /* klog mirror: survives even when every mount is invisible */
+ g_klog = open("/dev/klog", O_WRONLY);
+ if(g_klog < 0) g_klog = -1;
+}
+
+void log_msg(const char *fmt, ...) {
+ if (!fmt) return;
+ log_lock();
+ if (!g_log) { g_log = stderr; }
+ va_list ap, ap2;
+ va_start(ap, fmt);
+ va_copy(ap2, ap); /* copy BEFORE vfprintf consumes ap */
+ time_t t = time(NULL);
+ struct tm tmv; struct tm *tm = NULL;
+ /* gmtime_r is our thread-safe shim; UTC is fine for logs */
+ extern struct tm *gmtime_r(const time_t *, struct tm *);
+ tm = gmtime_r(&t, &tmv);
+ char ts[20];
+ if(tm) strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm);
+ else { strncpy(ts, "1970-01-01 00:00:00", sizeof ts); ts[sizeof ts-1]=0; }
+ fprintf(g_log, "[%s] ", ts);
+ vfprintf(g_log, fmt, ap);
+ fprintf(g_log, "\n");
+ fflush(g_log);
+ va_end(ap);
+ if (g_klog >= 0) {
+ char line[512];
+ int n = snprintf(line, sizeof line, "[orbisRPC %s] ", ts);
+ if (n > 0 && n < (int)sizeof line)
+ n += vsnprintf(line + n, sizeof line - n, fmt, ap2);
+ if (n > 0) { if (n >= (int)sizeof line) n = (int)sizeof line - 1; line[n++] = '\n'; (void)write(g_klog, line, (size_t)n); }
+ }
+ va_end(ap2);
+ log_unlock();
+}
+
+void log_close(void) {
+ if (g_log && g_log != stderr) fclose(g_log);
+ g_log = NULL;
+ if (g_klog >= 0) { close(g_klog); g_klog = -1; }
+}
+
+void log_dbg(const char *fmt, ...) {
+ if (!g_debug || !fmt) return;
+ log_lock();
+ if (!g_log) { g_log = stderr; }
+ va_list ap;
+ va_start(ap, fmt);
+ time_t t = time(NULL);
+ struct tm tmv; struct tm *tm = NULL;
+ extern struct tm *gmtime_r(const time_t *, struct tm *);
+ tm = gmtime_r(&t, &tmv);
+ char ts[20];
+ if(tm) strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm);
+ else { strncpy(ts, "1970-01-01 00:00:00", sizeof ts); ts[sizeof ts-1]=0; }
+ fprintf(g_log, "[%s] DBG ", ts);
+ vfprintf(g_log, fmt, ap);
+ fprintf(g_log, "\n");
+ fflush(g_log);
+ va_end(ap);
+ log_unlock();
+}
diff --git a/orbisrpc/log.h b/orbisrpc/log.h
new file mode 100644
index 0000000..593cda8
--- /dev/null
+++ b/orbisrpc/log.h
@@ -0,0 +1,14 @@
+/* log.h - timestamped file logger with stderr and optional kernel-log fallback. */
+#ifndef LOG_H
+#define LOG_H
+#include
+/* Selects the first writable log path, falling back to stderr if needed. */
+void log_init(const char *path);
+void log_msg(const char *fmt, ...);
+/* Debug logging: compiled in, emitted only when log_set_debug(1) is on.
+ * Zero overhead otherwise. For per-poll detail — never for secrets. */
+void log_set_debug(int on);
+int log_is_debug(void);
+void log_dbg(const char *fmt, ...);
+void log_close(void);
+#endif
diff --git a/orbisrpc/main.c b/orbisrpc/main.c
new file mode 100644
index 0000000..4e94f6c
--- /dev/null
+++ b/orbisrpc/main.c
@@ -0,0 +1,12 @@
+/* main.c - orbisRPC payload entry: detect game -> Discord presence.
+ * Runs as a GoldHEN payload (BinLoader push) or standalone ELF. */
+#include "daemon.h"
+#include "log.h"
+#include
+
+int main(int argc, char **argv){
+ (void)argc;(void)argv;
+ log_init("/data/orbisRPC/log.txt");
+ log_msg("orbisRPC payload boot");
+ return daemon_run(NULL);
+}
\ No newline at end of file
diff --git a/orbisrpc/manifest.c b/orbisrpc/manifest.c
new file mode 100644
index 0000000..66e1c9c
--- /dev/null
+++ b/orbisrpc/manifest.c
@@ -0,0 +1,176 @@
+/* manifest.c - signed release manifest (host-testable + PS4).
+ * Parsing via jsonlite; hashing + ECDSA via mbedTLS (already linked). */
+#include "manifest.h"
+#include "jsonlite.h"
+#include "updater.h"
+#include
+#include
+#include
+#include
+#include
+
+int manifest_sha256_hex(const unsigned char *data, size_t n, char out[65]){
+ unsigned char dig[32];
+ if(!data && n) return -1;
+ mbedtls_sha256_context sc;
+ mbedtls_sha256_init(&sc);
+ int ok = mbedtls_sha256_starts(&sc, 0) == 0 &&
+ mbedtls_sha256_update(&sc, data ? data : (const unsigned char *)"", n) == 0 &&
+ mbedtls_sha256_finish(&sc, dig) == 0;
+ mbedtls_sha256_free(&sc);
+ if(!ok) return -1;
+ for(int i = 0; i < 32; i++) snprintf(out + 2 * i, 3, "%02x", dig[i]);
+ out[64] = 0;
+ return 0;
+}
+
+static int copy_str(const jl_val_t *v, char *out, size_t cap){
+ if(!v || v->type != JL_STRING || !v->str) return -1;
+ size_t n = strlen(v->str);
+ if(n >= cap) return -1;
+ memcpy(out, v->str, n + 1);
+ return 0;
+}
+
+int manifest_parse(const char *json, size_t len, manifest_t *out){
+ if(!json || !out) return -1;
+ memset(out, 0, sizeof *out);
+ jl_val_t *r = jl_parse(json, len);
+ if(!r || r->type != JL_OBJECT){ if(r) jl_free(r); return -1; }
+ if(copy_str(jl_obj_get(r, "version"), out->version, sizeof out->version) != 0){
+ jl_free(r); return -1;
+ }
+ /* optional with sane defaults */
+ const jl_val_t *ch = jl_obj_get(r, "channel");
+ if(ch && ch->type == JL_STRING){
+ if(copy_str(ch, out->channel, sizeof out->channel) != 0){ jl_free(r); return -1; }
+ } else {
+ memcpy(out->channel, "stable", 7);
+ }
+ const jl_val_t *mv = jl_obj_get(r, "minimum_version");
+ if(!mv) mv = jl_obj_get(r, "min_version");
+ if(mv && mv->type == JL_STRING){
+ if(copy_str(mv, out->min_version, sizeof out->min_version) != 0){ jl_free(r); return -1; }
+ }
+ const jl_val_t *pl = jl_obj_get(r, "platform");
+ if(!pl) pl = jl_obj_get(r, "supported_platform");
+ if(pl && pl->type == JL_STRING){
+ if(copy_str(pl, out->platform, sizeof out->platform) != 0){ jl_free(r); return -1; }
+ }
+ const jl_val_t *assets = jl_obj_get(r, "assets");
+ if(!assets) assets = jl_obj_get(r, "files");
+ if(assets && assets->type == JL_OBJECT){
+ /* object form: { "orbisrpc.bin": "", ... } */
+ /* jsonlite has no object iterator in public API; fall back to
+ * array form below. Object form unsupported -> invalid. */
+ jl_free(r);
+ return -1;
+ }
+ if(assets && assets->type == JL_ARRAY){
+ int n = 0;
+ for(size_t i = 0; ; i++){
+ const jl_val_t *a = jl_arr_at(assets, i);
+ if(!a) break;
+ if(n >= MANIFEST_MAX_ASSETS) break;
+ const jl_val_t *nm = jl_obj_get(a, "name");
+ const jl_val_t *sh = jl_obj_get(a, "sha256");
+ if(!sh) sh = jl_obj_get(a, "hash");
+ if(!nm || nm->type != JL_STRING || !sh || sh->type != JL_STRING)
+ continue;
+ if(strlen(nm->str) >= sizeof out->assets[n].name) continue;
+ if(strlen(sh->str) != 64) continue;
+ int hexok = 1;
+ for(int k = 0; k < 64; k++){
+ char c = sh->str[k];
+ if(!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
+ (c >= 'A' && c <= 'F'))){ hexok = 0; break; }
+ }
+ if(!hexok) continue;
+ memcpy(out->assets[n].name, nm->str, strlen(nm->str) + 1);
+ for(int k = 0; k < 64; k++){
+ char c = sh->str[k];
+ out->assets[n].sha256[k] = (c >= 'A' && c <= 'F') ? (char)(c - 'A' + 'a') : c;
+ }
+ out->assets[n].sha256[64] = 0;
+ n++;
+ }
+ out->nassets = n;
+ }
+ jl_free(r);
+ if(!out->version[0] || out->nassets <= 0) return -1;
+ return 0;
+}
+
+int manifest_find(const manifest_t *m, const char *name, char out_hex[65]){
+ if(!m || !name) return -1;
+ for(int i = 0; i < m->nassets; i++){
+ if(!strcmp(m->assets[i].name, name)){
+ if(out_hex) memcpy(out_hex, m->assets[i].sha256, 65);
+ return 0;
+ }
+ }
+ return -1;
+}
+
+int manifest_check(const manifest_t *m, const char *name,
+ const unsigned char *data, size_t n){
+ char want[65], got[65];
+ if(manifest_find(m, name, want) != 0) return -1;
+ if(manifest_sha256_hex(data, n, got) != 0) return -1;
+ if(strcmp(got, want) != 0) return -1;
+ return 0;
+}
+
+int manifest_is_newer(const manifest_t *m, const char *local_version){
+ if(!m || !local_version) return 0;
+ return updater_cmp(m->version, local_version) > 0;
+}
+
+int manifest_gate(const manifest_t *m){
+ if(!m) return 0;
+ if(m->channel[0] && strcmp(m->channel, "stable") != 0) return 0;
+ if(m->platform[0] && strcmp(m->platform, "ps4-goldhen") != 0 &&
+ strcmp(m->platform, "ps4") != 0)
+ return 0;
+ return 1;
+}
+
+int manifest_verify_sig(const unsigned char *msg, size_t msglen,
+ const unsigned char sig[64],
+ const unsigned char pubkey[64]){
+ if(!msg || !sig || !pubkey) return -1;
+ unsigned char hash[32];
+ {
+ mbedtls_sha256_context sc;
+ mbedtls_sha256_init(&sc);
+ int ok = mbedtls_sha256_starts(&sc, 0) == 0 &&
+ mbedtls_sha256_update(&sc, msg, msglen) == 0 &&
+ mbedtls_sha256_finish(&sc, hash) == 0;
+ mbedtls_sha256_free(&sc);
+ if(!ok) return -1;
+ }
+ mbedtls_ecp_group grp;
+ mbedtls_ecp_point Q;
+ mbedtls_mpi r, s;
+ mbedtls_ecp_group_init(&grp);
+ mbedtls_ecp_point_init(&Q);
+ mbedtls_mpi_init(&r); mbedtls_mpi_init(&s);
+ int rc = -1;
+ unsigned char uncompressed[65];
+ if(mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1) != 0) goto out;
+ if(mbedtls_mpi_read_binary(&r, sig, 32) != 0) goto out;
+ if(mbedtls_mpi_read_binary(&s, sig + 32, 32) != 0) goto out;
+ /* Public API only (no struct internals): uncompressed point 0x04||X||Y. */
+ uncompressed[0] = 0x04;
+ memcpy(uncompressed + 1, pubkey, 64);
+ if(mbedtls_ecp_point_read_binary(&grp, &Q, uncompressed,
+ sizeof uncompressed) != 0) goto out;
+ if(mbedtls_ecp_check_pubkey(&grp, &Q) != 0) goto out;
+ if(mbedtls_ecdsa_verify(&grp, hash, sizeof hash, &Q, &r, &s) != 0) goto out;
+ rc = 0;
+out:
+ mbedtls_ecp_group_free(&grp);
+ mbedtls_ecp_point_free(&Q);
+ mbedtls_mpi_free(&r); mbedtls_mpi_free(&s);
+ return rc;
+}
diff --git a/orbisrpc/manifest.h b/orbisrpc/manifest.h
new file mode 100644
index 0000000..476394e
--- /dev/null
+++ b/orbisrpc/manifest.h
@@ -0,0 +1,49 @@
+/* manifest.h - signed release manifest: parse, policy, signature verify.
+ *
+ * Release channel trust (v1.0.1+):
+ * manifest.json (version, channel, min_version, platform, asset sha256s)
+ * manifest.sig (raw 64-byte ECDSA P-256 r||s over manifest.json bytes)
+ * verified with the embedded release public key (release_pubkey.h).
+ * Policy: manifest+valid sig REQUIRED for update; SHA256SUMS is a
+ * compat fallback only when no manifest exists; ELF-only is refused.
+ */
+#ifndef ORBISRPC_MANIFEST_H
+#define ORBISRPC_MANIFEST_H
+#include
+
+#define MANIFEST_MAX_ASSETS 8
+
+typedef struct {
+ char name[64];
+ char sha256[65];
+} manifest_asset_t;
+
+typedef struct {
+ char version[32];
+ char channel[16];
+ char min_version[32];
+ char platform[32];
+ manifest_asset_t assets[MANIFEST_MAX_ASSETS];
+ int nassets;
+} manifest_t;
+
+/* Parse manifest.json bytes. 0 ok, -1 invalid. */
+int manifest_parse(const char *json, size_t len, manifest_t *out);
+/* Find asset hash by filename. 0 ok + out_hex, -1 not listed. */
+int manifest_find(const manifest_t *m, const char *name, char out_hex[65]);
+/* Check data against the listed hash for name. 0 match, -1 mismatch/unlisted. */
+int manifest_check(const manifest_t *m, const char *name,
+ const unsigned char *data, size_t n);
+/* 1 when manifest version is newer than local, 0 otherwise. */
+int manifest_is_newer(const manifest_t *m, const char *local_version);
+/* Channel/platform gate: 1 accepted (stable/ps4-goldhen), 0 refused. */
+int manifest_gate(const manifest_t *m);
+/* Verify raw ECDSA P-256 signature (r||s, 64 bytes) over msg with raw
+ * pubkey (X||Y, 64 bytes). 0 valid, -1 invalid/error. */
+int manifest_verify_sig(const unsigned char *msg, size_t msglen,
+ const unsigned char sig[64],
+ const unsigned char pubkey[64]);
+/* SHA256 of buffer as lowercase hex (64+NUL). 0 ok. */
+int manifest_sha256_hex(const unsigned char *data, size_t n, char out[65]);
+
+#endif
diff --git a/orbisrpc/release_pubkey.h b/orbisrpc/release_pubkey.h
new file mode 100644
index 0000000..a03665e
--- /dev/null
+++ b/orbisrpc/release_pubkey.h
@@ -0,0 +1,22 @@
+/* release_pubkey.h - embedded release-channel public key (P-256, raw X||Y).
+ *
+ * DEV KEY: generated for v1.0.1 development. Before publishing a real
+ * release, generate a production keypair OFFLINE, replace these bytes,
+ * and keep the private key out of the repo:
+ * openssl ecparam -name prime256v1 -genkey -noout -out release_priv.pem
+ * openssl ec -in release_priv.pem -pubout -out release_pub.pem
+ * Sign manifest.json with scripts/make_manifest.py (or openssl dgst).
+ */
+#ifndef ORBISRPC_RELEASE_PUBKEY_H
+#define ORBISRPC_RELEASE_PUBKEY_H
+
+/* Uncompressed P-256 X || Y (64 bytes). */
+static const unsigned char ORBISRPC_RELEASE_PUBKEY[64] = {
+ 0x47,0xb8,0x9d,0xb8,0x85,0x3d,0x6c,0xcf,0x7e,0x1a,0xbe,0x6e,0xd1,0x5c,0x2e,0x69,
+ 0x66,0x2f,0xa8,0x09,0xbf,0x6d,0xf8,0x9c,0x50,0xe4,0x10,0x0b,0x79,0x4b,0x66,0x1a,
+ 0xd7,0x79,0xae,0x2e,0x41,0xc9,0x35,0x5c,0x6f,0xe3,0xba,0xed,0xc9,0x93,0x9a,0xca,
+ 0xe8,0x18,0xd5,0x4b,0xeb,0x84,0x20,0x8d,0xb3,0xd2,0x4a,0xfc,0x65,0x81,0x6c,0x6f
+};
+#define ORBISRPC_RELEASE_KEY_ID "dev-2026-09-20"
+
+#endif
diff --git a/orbisrpc/sfo.c b/orbisrpc/sfo.c
new file mode 100644
index 0000000..f72bbd5
--- /dev/null
+++ b/orbisrpc/sfo.c
@@ -0,0 +1,67 @@
+/* sfo.c - PARAM.SFO TITLE extractor (bounds-checked, no libc beyond string).
+ * Every PS4 game carries sce_sys/param.sfo with a TITLE field. Inside the
+ * game process it is reachable via the app0 mount; the plugin tries those
+ * paths first because they work on ANY console with zero setup. */
+#include "sfo.h"
+#include
+#include
+
+#pragma pack(push, 1)
+typedef struct {
+ uint32_t magic; /* 0x46535000 ("\0PSF" little-endian) */
+ uint32_t version; /* usually 0x0201 */
+ uint32_t key_off;
+ uint32_t data_off;
+ uint32_t count;
+} sfo_hdr_t;
+typedef struct {
+ uint16_t key_off;
+ uint16_t fmt; /* 0x0004 = utf8 string (null-padded), 0x0404 = uint32 */
+ uint32_t len; /* used bytes (incl. null for strings) */
+ uint32_t max_len;
+ uint32_t data_off; /* relative to data_off base */
+} sfo_entry_t;
+#pragma pack(pop)
+
+int sfo_title(const unsigned char *buf, size_t n, char *out, size_t cap){
+ if(!buf || !out || cap == 0) return -1;
+ out[0] = 0;
+ if(n < sizeof(sfo_hdr_t)) return -1;
+ const sfo_hdr_t *h = (const sfo_hdr_t *)buf;
+ if(h->magic != 0x46535000u) return -1;
+ if(h->count == 0 || h->count > 256) return -1;
+ if(h->key_off >= n || h->data_off >= n) return -1;
+ if(sizeof(sfo_hdr_t) + (size_t)h->count * sizeof(sfo_entry_t) > n) return -1;
+ const sfo_entry_t *e = (const sfo_entry_t *)(buf + sizeof(sfo_hdr_t));
+ for(uint32_t i = 0; i < h->count; i++){
+ size_t ko = (size_t)h->key_off + e[i].key_off;
+ if(ko >= n) continue;
+ /* keys must be null-terminated INSIDE the buffer: bound the scan */
+ size_t kmax = n - ko;
+ size_t klen = 0;
+ while(klen < kmax && buf[ko + klen]) klen++;
+ if(klen >= kmax) continue; /* unterminated key: malformed */
+ const char *key = (const char *)(buf + ko);
+ /* keys of interest: TITLE first, then language variants TITLE_XX */
+ int is_title = (!strcmp(key, "TITLE") ||
+ (klen == 8 && !memcmp(key, "TITLE_", 6)));
+ if(!is_title) continue;
+ if(e[i].fmt != 0x0004 && e[i].fmt != 0x0204) continue;
+ size_t dlen = e[i].len;
+ if(dlen == 0 || dlen > 256) continue;
+ size_t doff = (size_t)h->data_off + e[i].data_off;
+ if(doff + dlen > n) continue;
+ /* copy up to null, require printable ASCII/UTF-8 start */
+ size_t ci = 0;
+ for(size_t k = 0; k < dlen && ci < cap - 1; k++){
+ unsigned char c = buf[doff + k];
+ if(c == 0) break;
+ if(c < 0x20 && c != 0x20) break;
+ out[ci++] = (char)c;
+ }
+ out[ci] = 0;
+ if(ci > 1) return 0;
+ out[0] = 0;
+ }
+ return -1;
+}
diff --git a/orbisrpc/sfo.h b/orbisrpc/sfo.h
new file mode 100644
index 0000000..73c07c2
--- /dev/null
+++ b/orbisrpc/sfo.h
@@ -0,0 +1,8 @@
+/* sfo.h - PARAM.SFO TITLE extractor. */
+#ifndef SFO_H
+#define SFO_H
+#include
+/* Returns 0 and writes the TITLE on success, -1 otherwise. Never reads
+ * past buf+n. */
+int sfo_title(const unsigned char *buf, size_t n, char *out, size_t cap);
+#endif
diff --git a/orbisrpc/timesync.c b/orbisrpc/timesync.c
new file mode 100644
index 0000000..16a959c
--- /dev/null
+++ b/orbisrpc/timesync.c
@@ -0,0 +1,116 @@
+/* timesync.c - minimal SNTP client (RFC 5905, client mode only).
+ * One UDP exchange per call; 3s I/O budget; any failure keeps the old
+ * offset. Host-testable (POSIX only). */
+#include "timesync.h"
+#include "log.h"
+#include
+#include
+#include
+#include
+#include
+#ifdef ORBISRPC_SDK_PAYLOAD
+#include
+#include
+#include
+#include
+#else
+#include
+#include
+#include
+#include
+#endif
+
+#define NTP_EPOCH_OFFSET 2208988800u
+#define NTP_PORT 123
+
+static int64_t s_offset = 0;
+static int s_synced = 0;
+
+static int sntp_once(const char *host){
+ unsigned char pkt[48];
+ memset(pkt, 0, sizeof pkt);
+ pkt[0] = 0x1b; /* LI=0, VN=3, Mode=3 (client) */
+#ifdef ORBISRPC_SDK_PAYLOAD
+ struct addrinfo hints, *res = NULL;
+ memset(&hints, 0, sizeof hints);
+ hints.ai_family = AF_INET;
+ hints.ai_socktype = SOCK_DGRAM;
+ {
+ char portbuf[8];
+ snprintf(portbuf, sizeof portbuf, "%d", NTP_PORT);
+ if(getaddrinfo(host, portbuf, &hints, &res) != 0 || !res) return -1;
+ }
+ int fd = socket(AF_INET, SOCK_DGRAM, 0);
+ if(fd < 0){ freeaddrinfo(res); return -1; }
+ struct timeval tv = { 3, 0 };
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
+ int ok = -1;
+ /* Connect the UDP socket: recv() then only accepts the peer we
+ * queried, instead of any spoofed datagram on the LAN. */
+ if(connect(fd, res->ai_addr, res->ai_addrlen) != 0){
+ freeaddrinfo(res);
+ close(fd);
+ return -1;
+ }
+ if(send(fd, pkt, sizeof pkt, 0) == (int)sizeof pkt){
+ unsigned char rep[48];
+ ssize_t n = recv(fd, (char *)rep, sizeof rep, 0);
+ if(n >= 48){
+ uint32_t tx;
+ memcpy(&tx, rep + 40, 4);
+ tx = ((tx & 0xff) << 24) | ((tx & 0xff00) << 8) |
+ ((tx & 0xff0000) >> 8) | ((tx & 0xff000000) >> 24);
+ /* Sanity window Nov 2023..Dec 2034 (uint32 NTP wraps 2036):
+ * a broken/malicious server cannot fling our clock. */
+ if(tx > NTP_EPOCH_OFFSET + 1700000000u &&
+ tx < NTP_EPOCH_OFFSET + 2050000000u){
+ int64_t ntp_unix = (int64_t)(tx - NTP_EPOCH_OFFSET);
+ s_offset = ntp_unix - (int64_t)time(NULL);
+ s_synced = 1;
+ ok = 0;
+ }
+ }
+ }
+ freeaddrinfo(res);
+ close(fd);
+ return ok;
+#else
+ (void)host;
+ return -1;
+#endif
+}
+
+int time_sync(void){
+ static const char *hosts[] = {
+ "time.google.com", "pool.ntp.org", "time.cloudflare.com", NULL
+ };
+ /* One host per call, rotating: a full 3-host sweep can stall ~20s+,
+ * which must never sit inside the 1s gateway loop (heartbeat
+ * starvation). Boot sweeps via time_sync_all(); hourly ticks call
+ * this (single ~6s-bounded attempt). */
+ static int next_host = 0;
+ const char *h = hosts[next_host];
+ next_host = (next_host + 1) % 3;
+ if(sntp_once(h) == 0){
+ log_msg("time sync ok via %s (offset %+llds)",
+ h, (long long)s_offset);
+ return 0;
+ }
+ log_msg("time sync via %s failed; using local clock (offset %+llds)",
+ h, (long long)s_offset);
+ return -1;
+}
+
+/* Boot sweep: try all hosts (bounded, once per boot, before first
+ * connect — never in the live loop). */
+int time_sync_all(void){
+ for(int i = 0; i < 3; i++){
+ if(time_sync() == 0) return 0;
+ }
+ return -1;
+}
+
+int64_t time_fixed(void){
+ return (int64_t)time(NULL) + s_offset;
+}
diff --git a/orbisrpc/timesync.h b/orbisrpc/timesync.h
new file mode 100644
index 0000000..3dd1418
--- /dev/null
+++ b/orbisrpc/timesync.h
@@ -0,0 +1,20 @@
+/* timesync.h - SNTP wall-clock correction for Discord timestamps.
+ *
+ * Jailbroken consoles commonly block Sony servers (ban avoidance), which
+ * also kills PSN time sync — the PS4 clock then drifts (observed: +38min
+ * and growing, plus wrong timezones). Discord timestamps must be true
+ * UTC or the timer wedges at 0:00, so the daemon syncs itself against
+ * public NTP (no Sony involved) and stamps sessions with corrected time.
+ * Failure degrades to the local clock (old behavior), never fatal. */
+#ifndef ORBISRPC_TIMESYNC_H
+#define ORBISRPC_TIMESYNC_H
+#include
+/* Try an SNTP exchange; on success stores offset and returns 0. -1 = keep
+ * old offset (first boot without sync behaves like before). */
+int time_sync(void);
+/* Boot sweep: try all hosts (bounded, once per boot, before first
+ * connect — never in the live loop). */
+int time_sync_all(void);
+/* Corrected wall clock for Discord epoch stamps. */
+int64_t time_fixed(void);
+#endif
diff --git a/orbisrpc/tls.c b/orbisrpc/tls.c
new file mode 100644
index 0000000..4bfd024
--- /dev/null
+++ b/orbisrpc/tls.c
@@ -0,0 +1,243 @@
+/* tls.c - mbedTLS-backed TLS client for orbisRPC.
+ *
+ * Why mbedTLS and not BearSSL: Cloudflare (fronting gateway.discord.gg)
+ * silently drops handshakes whose ClientHello looks non-browser. BearSSL
+ * cannot offer session tickets, EMS or encrypt-then-MAC at all, so its
+ * fingerprint always scores as a bot and the server ghosts us after the
+ * handshake (established TLS, zero HTTP bytes back — verified on-host).
+ * mbedTLS sends the standard extension set and gets 101 immediately.
+ *
+ * The socket stays non-blocking; WANT_READ/WRITE maps to our pump model.
+ * Certificate verification is REQUIRED against the curated bundle
+ * (ca_bundle_pem.h); TLS is capped at 1.2 because mbedTLS 3.x routes 1.3
+ * through PSA crypto whose init fails on-console. SNI is sent. */
+#include "tls.h"
+#include "log.h"
+#include "clock.h"
+#include
+#include
+#include
+#include
+#include /* error codes only; transport is ours */
+#include
+#include "ca_bundle_pem.h"
+#ifdef ORBISRPC_SDK_PAYLOAD
+#include
+#include
+#else
+#include
+#endif
+#include
+#include
+#include
+#include
+#include
+
+/* mbedTLS internal trace -> our log: turns "handshake fail" from a bare
+ * code into the exact failing step. */
+static void tls_mbedtls_dbg(void *ctx, int level,
+ const char *file, int line, const char *str){
+ (void)ctx;
+ /* mbedTLS chat (levels 1-3) only in debug mode: at threshold 3 every
+ * TLS record logs a line, which is pure I/O load in production. */
+ if(level > 0 && !log_is_debug()) return;
+ const char *base = strrchr(file, '/');
+ base = base ? base + 1 : file;
+ size_t n = strlen(str);
+ while(n > 0 && (str[n-1] == '\n' || str[n-1] == '\r')) n--;
+ log_msg("mbedTLS %s:%d: %.*s", base, line, (int)n, str);
+}
+
+struct tls_ctx {
+ mbedtls_ssl_context ssl;
+ mbedtls_ssl_config conf;
+ mbedtls_ctr_drbg_context rng;
+ mbedtls_entropy_context ent;
+ mbedtls_x509_crt ca;
+ int fd;
+};
+
+/* Strong entropy from the OS ONLY (/dev/urandom). There is deliberately
+ * no weak fallback: inventing cryptographic randomness from time/address
+ * jitter would silently downgrade every TLS session and the DRBG seed.
+ * If strong entropy is unavailable the handshake fails closed here. */
+static int orbis_poll(void *data, unsigned char *out, size_t len, size_t *olen){
+ (void)data;
+ int fd = open("/dev/urandom", O_RDONLY);
+ if(fd < 0) return -1;
+ size_t got = 0;
+ while(got < len){
+ long r = read(fd, (char *)out + got, len - got);
+ if(r <= 0){ close(fd); return -1; }
+ got += (size_t)r;
+ }
+ close(fd);
+ *olen = len;
+ return 0;
+}
+
+static int net_send(void *ctx, const unsigned char *b, size_t n){
+ tls_ctx_t *t = (tls_ctx_t *)ctx;
+ size_t cap = n > 32768 ? 32768 : n;
+#ifdef ORBISRPC_SDK_PAYLOAD
+ int r = (int)send(t->fd, b, cap, 0);
+ if(r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+ return MBEDTLS_ERR_SSL_WANT_WRITE;
+ if(r < 0) return MBEDTLS_ERR_NET_SEND_FAILED;
+ return r;
+#else
+ int r = (int)sceNetSend(t->fd, b, (int)cap, 0);
+ if(r < 0) return MBEDTLS_ERR_SSL_WANT_WRITE; /* NBIO: retry till deadline */
+ return r;
+#endif
+}
+
+static int net_recv(void *ctx, unsigned char *b, size_t n){
+ tls_ctx_t *t = (tls_ctx_t *)ctx;
+ size_t cap = n > 16384 ? 16384 : n;
+#ifdef ORBISRPC_SDK_PAYLOAD
+ int r = (int)recv(t->fd, b, cap, 0);
+ if(r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+ return MBEDTLS_ERR_SSL_WANT_READ;
+ if(r < 0) return MBEDTLS_ERR_NET_RECV_FAILED;
+ return r;
+#else
+ int r = (int)sceNetRecv(t->fd, b, (int)cap, 0);
+ if(r < 0) return MBEDTLS_ERR_SSL_WANT_READ; /* NBIO: retry till deadline */
+ return r;
+#endif
+}
+
+tls_ctx_t *tls_start(int fd, const char *host){
+ tls_ctx_t *t = (tls_ctx_t *)calloc(1, sizeof *t);
+ if(!t) return NULL;
+ t->fd = fd;
+ int rc = 0;
+
+ mbedtls_ssl_init(&t->ssl);
+ mbedtls_ssl_config_init(&t->conf);
+ mbedtls_x509_crt_init(&t->ca);
+ mbedtls_ctr_drbg_init(&t->rng);
+ mbedtls_entropy_init(&t->ent);
+ mbedtls_entropy_add_source(&t->ent, orbis_poll, NULL, 64,
+ MBEDTLS_ENTROPY_SOURCE_STRONG);
+
+ if((rc = mbedtls_ctr_drbg_seed(&t->rng, mbedtls_entropy_func, &t->ent,
+ (const unsigned char *)"orbisRPC", 8)) != 0){
+ log_msg("tls: rng seed fail %d", rc);
+ goto fail;
+ }
+ if((rc = mbedtls_ssl_config_defaults(&t->conf, MBEDTLS_SSL_IS_CLIENT,
+ MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)) != 0){
+ log_msg("tls: config fail %d", rc);
+ goto fail;
+ }
+ /* Curated trust anchors: chain verification is REQUIRED. A handshake
+ * that does not verify against the bundle fails closed (no MITM). */
+ if((rc = mbedtls_x509_crt_parse(&t->ca,
+ (const unsigned char *)ORBISRPC_CA_BUNDLE_PEM,
+ sizeof ORBISRPC_CA_BUNDLE_PEM)) < 0){
+ log_msg("tls: CA bundle parse fail %d", rc);
+ goto fail;
+ }
+ mbedtls_ssl_conf_ca_chain(&t->conf, &t->ca, NULL);
+ mbedtls_ssl_conf_authmode(&t->conf, MBEDTLS_SSL_VERIFY_REQUIRED);
+ mbedtls_ssl_conf_rng(&t->conf, mbedtls_ctr_drbg_random, &t->rng);
+ /* TLS 1.2 ceiling (ported from 1.0, proven on hardware): mbedTLS 3.x
+ * routes TLS 1.3 through the PSA crypto subsystem, whose init fails
+ * on-console (PSA_ERROR_INSUFFICIENT_ENTROPY) and kills every
+ * handshake instantly. TLS 1.2 needs no PSA state. */
+ mbedtls_ssl_conf_max_tls_version(&t->conf, MBEDTLS_SSL_VERSION_TLS1_2);
+#ifdef MBEDTLS_DEBUG_C
+ mbedtls_debug_set_threshold(3);
+ mbedtls_ssl_conf_dbg(&t->conf, tls_mbedtls_dbg, NULL);
+#endif
+ {
+ static const char *protos[] = { "http/1.1", NULL };
+ mbedtls_ssl_conf_alpn_protocols(&t->conf, protos);
+ }
+ if((rc = mbedtls_ssl_setup(&t->ssl, &t->conf)) != 0){
+ log_msg("tls: setup fail %d", rc);
+ goto fail;
+ }
+ if((rc = mbedtls_ssl_set_hostname(&t->ssl, host)) != 0){
+ log_msg("tls: hostname fail %d", rc);
+ goto fail;
+ }
+ mbedtls_ssl_set_bio(&t->ssl, t, net_send, net_recv, NULL);
+
+ log_msg("tls: handshake start");
+ {
+ int64_t dl = orbis_mono_s() + 15;
+ extern int daemon_stop_requested(void) __attribute__((weak));
+ for(;;){
+ if(daemon_stop_requested && daemon_stop_requested()){ log_msg("tls: aborted (stop)"); goto fail; }
+ rc = mbedtls_ssl_handshake(&t->ssl);
+ if(rc == 0) break;
+ if(rc != MBEDTLS_ERR_SSL_WANT_READ &&
+ rc != MBEDTLS_ERR_SSL_WANT_WRITE){
+ log_msg("tls: handshake fail %d (mbedTLS 0x%08x)", rc, (unsigned)rc);
+ goto fail;
+ }
+ if(orbis_mono_s() > dl){ log_msg("tls: handshake timeout"); goto fail; }
+ usleep(20000);
+ }
+ }
+ log_msg("tls: established (%s)", mbedtls_ssl_get_version(&t->ssl));
+ {
+ uint32_t vf = mbedtls_ssl_get_verify_result(&t->ssl);
+ if(vf != 0){
+ log_msg("tls: cert verify fail flags=0x%08x", vf);
+ goto fail;
+ }
+ }
+ return t;
+
+fail:
+ mbedtls_ssl_free(&t->ssl);
+ mbedtls_ssl_config_free(&t->conf);
+ mbedtls_x509_crt_free(&t->ca);
+ mbedtls_ctr_drbg_free(&t->rng);
+ mbedtls_entropy_free(&t->ent);
+ free(t);
+ return NULL;
+}
+
+int tls_read(tls_ctx_t *t, void *buf, size_t cap){
+ if(!t || !buf || cap == 0) return -1;
+ int r = mbedtls_ssl_read(&t->ssl, (unsigned char *)buf, cap);
+ if(r > 0) return r;
+ if(r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE)
+ return 0; /* nothing yet */
+ if(r == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY || r == 0) return -1;
+ return -1;
+}
+
+int tls_write(tls_ctx_t *t, const void *buf, size_t len){
+ if(!t || (!buf && len)) return -1;
+ const unsigned char *p = (const unsigned char *)buf;
+ size_t done = 0;
+ int64_t dl = orbis_mono_s() + 10;
+ while(done < len){
+ int r = mbedtls_ssl_write(&t->ssl, p + done, len - done);
+ if(r > 0){ done += (size_t)r; continue; }
+ if(r != MBEDTLS_ERR_SSL_WANT_READ && r != MBEDTLS_ERR_SSL_WANT_WRITE){
+ log_msg("tls: write fail %d", r);
+ return -1;
+ }
+ if(orbis_mono_s() > dl){ log_msg("tls: write timeout"); return -1; }
+ usleep(10000);
+ }
+ return (int)done;
+}
+
+void tls_free(tls_ctx_t *t){
+ if(!t) return;
+ mbedtls_ssl_close_notify(&t->ssl);
+ mbedtls_ssl_free(&t->ssl);
+ mbedtls_ssl_config_free(&t->conf);
+ mbedtls_x509_crt_free(&t->ca);
+ mbedtls_ctr_drbg_free(&t->rng);
+ mbedtls_entropy_free(&t->ent);
+ free(t);
+}
diff --git a/orbisrpc/tls.h b/orbisrpc/tls.h
new file mode 100644
index 0000000..288f9d5
--- /dev/null
+++ b/orbisrpc/tls.h
@@ -0,0 +1,23 @@
+/* tls.h - self-contained TLS client (mbedTLS, statically linked).
+ * No PS4 TLS-module dependency: works in payload and plugin processes.
+ * Certificate verification is REQUIRED against the curated bundle;
+ * see ca_bundle_pem.h. */
+#ifndef TLS_H
+#define TLS_H
+#include
+
+/* Runs the full TLS handshake over an already-connected socket.
+ * Returns heap context, or NULL on failure (logged). */
+typedef struct tls_ctx tls_ctx_t;
+tls_ctx_t *tls_start(int fd, const char *host);
+
+/* Returns decrypted bytes (>0), 0 if nothing available right now,
+ * <0 on close/error. Non-blocking: never waits for the peer. */
+int tls_read(tls_ctx_t *t, void *buf, size_t cap);
+
+/* Writes exactly len bytes; returns len or <0. */
+int tls_write(tls_ctx_t *t, const void *buf, size_t len);
+
+void tls_free(tls_ctx_t *t);
+
+#endif
diff --git a/orbisrpc/tmdb.c b/orbisrpc/tmdb.c
new file mode 100644
index 0000000..74aa145
--- /dev/null
+++ b/orbisrpc/tmdb.c
@@ -0,0 +1,315 @@
+/* tmdb.c - Sony TMDB runtime resolver: plain-HTTP GET + cache.
+ * Transport mirrors ws.c patterns (resolver pool, SNDTIMEO connect cap,
+ * recv deadline). One attempt, no retries: failure falls through. */
+#include "tmdb.h"
+#include "clock.h"
+#include "tmdb_crypto.h"
+#include "log.h"
+#include "tls.h"
+#include "updater_http.h"
+#ifdef ORBISRPC_SDK_PAYLOAD
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#else
+#include
+#include
+#include
+#include