diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb5548e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +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 + 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: | + 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/orbisrpc/art.c b/orbisrpc/art.c new file mode 100644 index 0000000..9e36e4e --- /dev/null +++ b/orbisrpc/art.c @@ -0,0 +1,180 @@ +/* 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 +#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] = ""; + +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; + int status = 0; + 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 == 0 && bl + (size_t)r < body_cap){ + /* first chunk: parse status line */ + tmp[r] = 0; + if(!strncmp(tmp, "HTTP/1.", 7)) status = atoi(tmp + 9); + } + 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 || status != 200) return -1; + out_body[bl] = 0; + /* strip headers */ + char *e = strstr(out_body, "\r\n\r\n"); + if(!e) return -1; + size_t hlen = (size_t)(e - out_body) + 4; + if(out_len) *out_len = bl - hlen; + memmove(out_body, e + 4, bl - hlen + 1); + 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; + } + 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"); + return 0; + } + strncpy(s_last_url, url, sizeof s_last_url - 1); + strncpy(s_last_mp, out_mp, sizeof s_last_mp - 1); + 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..3af0f6a --- /dev/null +++ b/orbisrpc/art.h @@ -0,0 +1,18 @@ +/* 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); +/* 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/daemon.c b/orbisrpc/daemon.c index 6593f05..1cb1a2e 100644 --- a/orbisrpc/daemon.c +++ b/orbisrpc/daemon.c @@ -174,12 +174,21 @@ int daemon_run(const char *fixed_game_name){ static int cand_hits = 0, miss_hits = 0; int64_t last_poll = 0; int64_t last_alive = 0; + static int home_posted = 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){ + /* Sleep/wake detection without a suspend API: if the loop + * itself froze (Rest Mode), mono jumps and heartbeats died. + * Log it and force a re-post so presence resumes cleanly. */ + if(last_poll != 0 && now - last_poll > 120){ + log_msg("wake: loop was frozen %llds (likely Rest Mode); resuming", + (long long)(now - last_poll)); + need_post = active && last[0]; + } last_poll = now; if(now - last_alive >= 60){ last_alive = now; @@ -247,7 +256,17 @@ int daemon_run(const char *fixed_game_name){ prev_started = started; prev_end = now; last[0]=0; sess_tid[0]=0; active=0; started=0; + home_posted = 0; } + } else if(!home_posted){ + /* 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; + discord_set_presence_ex(&dc, state, "PlayStation 4", NULL, + g_cfg.application_id, NULL, NULL, 0); + log_msg("presence: home"); + home_posted = 1; } } diff --git a/orbisrpc/discord.c b/orbisrpc/discord.c index 308ef32..54bf118 100644 --- a/orbisrpc/discord.c +++ b/orbisrpc/discord.c @@ -12,6 +12,7 @@ #include "clock.h" #include "b64.h" #include "log.h" +#include "art.h" #include "jsonlite.h" #include #include @@ -249,20 +250,39 @@ int discord_set_presence_ex(discord_t *d, const char *state, const char *name, if(!art_warned){ art_warned = 1; log_msg("art: no application_id or art_base_url; presence sends without artwork"); } } - /* Artwork: asset KEYS into our own application only (verified on - * hardware 2026-09-23). External-URL large_image makes Discord drop - * the ENTIRE activity, so URLs are never sent — upload each title's - * icon in the developer portal (key = lowercase title id) instead. */ - (void)art_url; (void)art_base_url; + /* 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&&title_id[0]&&application_id&&application_id[0]){ - char key[16]; - if(asset_key(title_id, key, sizeof key) >= 4){ + char mp[512] = ""; + const char *src_url = (art_url&&art_url[0]) ? art_url : NULL; + char pack_url[320] = ""; + if(!src_url && art_base_url&&art_base_url[0]&&title_id){ + char key[16]; + if(asset_key(title_id, key, sizeof key) >= 4){ + int n=snprintf(pack_url,sizeof pack_url,"%s%s.png",art_base_url,key); + if(n>0 && (size_t)ntoken[0] && + art_resolve_mp(application_id, d->token, src_url, mp, sizeof mp)){ jl_val_t *as=jl_new_object(); if(as){ - jl_obj_set(as,"large_image",jl_new_string(key)); + jl_obj_set(as,"large_image",jl_new_string(mp)); jl_obj_set(as,"large_text",jl_new_string(name?name:"")); jl_obj_set(act,"assets",as); } + } else { + char key[16]; + if(asset_key(title_id, key, sizeof key) >= 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(name?name:"")); + jl_obj_set(act,"assets",as); + } + } } } jl_val_t *dd=jl_new_object(); diff --git a/scripts/build_sdk.sh b/scripts/build_sdk.sh index a837fd9..882deed 100755 --- a/scripts/build_sdk.sh +++ b/scripts/build_sdk.sh @@ -15,7 +15,7 @@ OUT="build-sdk" mkdir -p "$OUT" CFLAGS="-O2 -Wall -DORBISRPC_SDK_PAYLOAD -Iorbisrpc -Ithird_party/mbedtls/include" echo "=== daemon sources (SDK) ===" -for f in log cfg jsonlite b64 sfo tmdb_crypto tmdb updater updater_util tls ws detect discord daemon compat clock lock timesync main; do +for f in log cfg jsonlite b64 sfo tmdb_crypto tmdb updater updater_util tls ws detect discord daemon compat clock lock timesync art main; do # clock has no .c (header-only helper lives in compat.c); skip if missing [ -f "orbisrpc/$f.c" ] || continue "$CC" $CFLAGS -c -o "$OUT/$f.o" "orbisrpc/$f.c" || { echo "FAIL: $f"; exit 1; } diff --git a/tests/Makefile b/tests/Makefile index 8dddeb7..1d6d89d 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -10,8 +10,8 @@ all: $(TARGET) test: $(TARGET) ./$(TARGET) -$(TARGET): test_utils.c ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c - $(CC) $(CFLAGS) -o $@ test_utils.c ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c +$(TARGET): test_utils.c ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c + $(CC) $(CFLAGS) -o $@ test_utils.c ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c clean: rm -f $(TARGET) diff --git a/tests/test_utils.c b/tests/test_utils.c index ecd9c58..045338a 100644 --- a/tests/test_utils.c +++ b/tests/test_utils.c @@ -4,6 +4,7 @@ #include "../orbisrpc/tmdb_crypto.h" #include "../orbisrpc/updater.h" #include "../orbisrpc/nametable.h" +#include "../orbisrpc/art.h" #include #include #include @@ -182,6 +183,23 @@ static void test_nametable(void) { assert(nametable_lookup("CUSA00740", out, 4) == 0); assert(out[3] == 0); } +static void test_art_parse(void) { + /* Real external-assets response shape (verified against live API). */ + const char *body = "[{\"url\":\"https://example.com/a.png\"," + "\"external_asset_path\":\"external/ABC/https/example.com/a.png\"}," + "{\"url\":\"https://example.com/b.png\"," + "\"external_asset_path\":\"external/DEF/https/example.com/b.png\"}]"; + char out[128] = {0}; + assert(art_parse_mp(body, strlen(body), + "https://example.com/b.png", out, sizeof out) == 1); + assert(strcmp(out, "mp:external/DEF/https/example.com/b.png") == 0); + assert(art_parse_mp(body, strlen(body), + "https://example.com/zzz.png", out, sizeof out) == 0); + assert(art_parse_mp("[]", 2, "x", out, sizeof out) == 0); + assert(art_parse_mp(NULL, 0, "x", out, sizeof out) == 0); + assert(art_resolve_mp("1", "t", "https://example.com/a.png", out, sizeof out) == 0); +} + static void test_base64(void) { char out[32]; assert(b64_encode((const unsigned char *)"", 0, out) == 0); @@ -204,6 +222,7 @@ int main(void) { test_sfo(); test_nametable(); test_base64(); + test_art_parse(); puts("utility tests passed"); return 0; }