WIP: mp: art resolution via external-assets + icon pack

This commit is contained in:
SirHumza committed 2026-09-23 20:56:43 +02:00
1 parent 49672ec2c8
commit 7dbb021290
8 files changed
+300 -11

No files matched your search

+33
View File
@@ -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"
+180
View File
@@ -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 <string.h>
#include <stdlib.h>
#include <stdio.h>
/* 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 <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#elif defined(__PS4__)
#include "tls.h"
#include "clock.h"
#include <orbis/Net.h>
#include <orbis/Sysmodule.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#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;
}
+18
View File
@@ -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 <stddef.h>
/* 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
+19
View File
@@ -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;
}
}
+28 -8
View File
@@ -12,6 +12,7 @@
#include "clock.h"
#include "b64.h"
#include "log.h"
#include "art.h"
#include "jsonlite.h"
#include <fcntl.h>
#include <unistd.h>
@@ -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)n<sizeof pack_url) src_url = pack_url;
}
}
if(src_url && d->token[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();
+1 -1
View File
@@ -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; }
+2 -2
View File
@@ -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)
+19
View File
@@ -4,6 +4,7 @@
#include "../orbisrpc/tmdb_crypto.h"
#include "../orbisrpc/updater.h"
#include "../orbisrpc/nametable.h"
#include "../orbisrpc/art.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
@@ -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;
}