From a0ea678ee278ffa66410bdc613e94e6c871d463b Mon Sep 17 00:00:00 2001 From: Mihawk <163614687+mihawk-99@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:23:06 -0400 Subject: [PATCH] Game Forwarder tiles: the tile app and its launch payload, built into the title A home screen tile is a title of its own (PPSA99170 to PPSA99269) that starts one game in RetroArch. Titles cannot launch titles (sceSystemServiceLaunchApp returns 0x8094000F from one, sceLncUtilLaunchApp is a null import), so the tile hands a payload to the console's ELF loader on 127.0.0.1:9021 and exits: - forwarder/tile.c: the tile's eboot.bin. Reads its title id from /app0/sce_sys/param.json, stamps it into /app0/launch.elf's slot, sends it to the loader, logs /app0/tile.log, and leaves through LoadExec("exit") (a return from main ends in a SIGSYS error notice). - forwarder/launch.c: the payload. Reads /data/homebrew//forward.txt (one argument a line: --rom, --core, --exit-after-game, src/frontend_mode_ps5.cpp), waits for the tile to leave, and starts PPSA99169 with those arguments through sceLncUtilLaunchApp (sceSystemServiceLaunchApp as the fallback). - tools/build-forwarder.sh builds both; tools/build-title.sh puts them in dist/PPSA99169/forwarder/ and counts them in the title's identity; the notices cover both files. Evidence: - host: python3 -m unittest tests.test_forwarder (the tile stamps and sends the payload byte for byte, refuses a lowercase id, a missing or a doubled slot; the payload passes the five arguments, falls back to SystemService, and an unstamped payload starts nothing) - OK. - console (base PS5, 13.40, ShadowMountPlus 1.7-beta5fix1), the test tile PPSA99170 registered through ShadowMountPlus's manual list: tile.log "tile PPSA99170: launch payload 110832 bytes, 110832 sent to 127.0.0.1:9021 (errno 0)"; launch.log five arguments, sceLncUtilLaunchApp 00002018; RetroArch's trace "forwarder: /app0/content/nes/ with /app0/cores/fceumm_libretro.so, closing after it"; the tile exited cleanly (no error notice), back to the home screen after the game. - gates: format, unit (206 tests), build (tile 11,123 B, payload 110,832 B), integration (206 tests; a first run timed out starting two scraper test servers under load, both passed alone and on the rerun), evidence - PASS. --- forwarder/forwarder.h | 38 ++++++++ forwarder/launch.c | 123 ++++++++++++++++++++++++ forwarder/tile.c | 163 ++++++++++++++++++++++++++++++++ tests/test_forwarder.py | 151 +++++++++++++++++++++++++++++ tooling/notices/components.json | 5 +- tools/build-forwarder.sh | 54 +++++++++++ tools/build-title.sh | 7 ++ tools/check-notices.py | 4 +- tools/stage-notices.py | 5 +- 9 files changed, 544 insertions(+), 6 deletions(-) create mode 100644 forwarder/forwarder.h create mode 100644 forwarder/launch.c create mode 100644 forwarder/tile.c create mode 100644 tests/test_forwarder.py create mode 100755 tools/build-forwarder.sh diff --git a/forwarder/forwarder.h b/forwarder/forwarder.h new file mode 100644 index 0000000..010024c --- /dev/null +++ b/forwarder/forwarder.h @@ -0,0 +1,38 @@ +/* PS5 RetroArch - what a Game Forwarder tile (forwarder/tile.c) and its launch payload + * (forwarder/launch.c) share. + * + * Copyright (C) 2026 Mihawk + * SPDX-License-Identifier: GPL-3.0-or-later + * + * A tile tells the payload which tile it is by writing its title id over + * FORWARDER_SLOT_MARKER in the payload's bytes before it sends them; the payload then reads + * that tile's forward.txt. No file is written for the hand-over, so two tiles cannot race. + */ +#ifndef PS5_RETROARCH_FORWARDER_H +#define PS5_RETROARCH_FORWARDER_H + +#include + +/* The payload's slot holds this until a tile writes its title id over it. */ +#define FORWARDER_SLOT_MARKER "PS5RA-FORWARDER-TILE-ID-SLOT" +#define FORWARDER_SLOT_SIZE 32 +/* A title id and its NUL: four capitals and five digits (PPSA99170). */ +#define FORWARDER_ID_SIZE 10 +/* The arguments a tile's forward.txt may hold, one a line, and the longest line. */ +#define FORWARDER_MAX_ARGUMENTS 16 +#define FORWARDER_MAX_ARGUMENT 512 + +static inline int forwarder_valid_id(const char *id, size_t length) +{ + if (!id || length != 9) + return 0; + for (size_t i = 0; i < 9; i++) + if (i < 4 ? !(id[i] >= 'A' && id[i] <= 'Z') : !(id[i] >= '0' && id[i] <= '9')) + return 0; + return 1; +} + +int forwarder_title_id(const char *param, char id[FORWARDER_ID_SIZE]); +int forwarder_stamp(char *payload, long size, const char *id); + +#endif diff --git a/forwarder/launch.c b/forwarder/launch.c new file mode 100644 index 0000000..6ea0ab6 --- /dev/null +++ b/forwarder/launch.c @@ -0,0 +1,123 @@ +/* PS5 RetroArch - the Game Forwarder's launch payload. + * + * Copyright (C) 2026 Mihawk + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Run by the console's ELF loader, sent there by a tile (forwarder/tile.c) with that tile's + * title id written into tile_slot. A payload may start a title where the tile may not, so + * this waits for the tile to close (up to 15 s), then starts RetroArch (FORWARDER_TARGET) + * with the arguments in /data/homebrew//forward.txt, one a line, through the launch + * calls the console tooling's control payload uses: sceLncUtilLaunchApp as the foreground + * user, then sceSystemServiceLaunchApp. Each step is written to the tile's launch.log. + */ +#include +#include +#include +#include + +#include "forwarder.h" + +#ifndef FORWARDER_HOMEBREW +#define FORWARDER_HOMEBREW "/data/homebrew" +#endif +#ifndef FORWARDER_TARGET +#define FORWARDER_TARGET "PPSA99169" +#endif +#ifndef FORWARDER_WAIT_STEP_US +#define FORWARDER_WAIT_STEP_US 250000 +#endif + +typedef struct +{ + uint32_t size; + int user_id; + uint32_t app_opt; + uint64_t crash_report; + uint64_t check_flag; +} lnc_app_param_t; + +int sceSystemServiceLaunchApp(const char *title_id, const char *argv[], lnc_app_param_t *param); +int sceLncUtilLaunchApp(const char *title_id, const char *argv[], lnc_app_param_t *param); +uint32_t sceLncUtilGetAppIdOfRunningBigApp(void); +int sceLncUtilGetAppTitleId(uint32_t app_id, char *title_id); +int sceUserServiceInitialize(void *param); +int sceUserServiceGetForegroundUser(int *user_id); + +/* The tile writes its title id over the marker in these bytes (forwarder.h). */ +__attribute__((used)) char tile_slot[FORWARDER_SLOT_SIZE] = FORWARDER_SLOT_MARKER; + +/* The running big app's title id, or "" when none runs. */ +static void running_title(char title[16]) +{ + const uint32_t app = sceLncUtilGetAppIdOfRunningBigApp(); + memset(title, 0, 16); + if (app != 0 && app != 0xffffffffu && sceLncUtilGetAppTitleId(app, title) != 0) + memset(title, 0, 16); + title[15] = '\0'; +} + +int main(void) +{ + char tile[FORWARDER_ID_SIZE]; + memcpy(tile, (const char *)tile_slot, sizeof(tile) - 1); + tile[sizeof(tile) - 1] = '\0'; + if (!forwarder_valid_id(tile, strlen(tile))) + { + printf("forwarder payload: no tile's title id in the slot; nothing launched\n"); + return 1; + } + char path[256]; + snprintf(path, sizeof(path), FORWARDER_HOMEBREW "/%s/launch.log", tile); + FILE *log = fopen(path, "w"); + char lines[FORWARDER_MAX_ARGUMENTS][FORWARDER_MAX_ARGUMENT]; + const char *arguments[FORWARDER_MAX_ARGUMENTS + 1]; + int count = 0; + snprintf(path, sizeof(path), FORWARDER_HOMEBREW "/%s/forward.txt", tile); + FILE *input = fopen(path, "r"); + while (input && count < FORWARDER_MAX_ARGUMENTS && fgets(lines[count], sizeof lines[count], input)) + { + lines[count][strcspn(lines[count], "\r\n")] = '\0'; + if (lines[count][0]) + { + arguments[count] = lines[count]; + count++; + } + } + if (input) + fclose(input); + arguments[count] = NULL; + if (log) + fprintf(log, "launch %s: %d argument(s) from forward.txt%s\n", tile, count, + input ? "" : " (none: the file could not be read)"); + const int init = sceUserServiceInitialize(NULL); + int user = -1; + const int foreground = sceUserServiceGetForegroundUser(&user); + char title[16]; + int waited = 0; + for (running_title(title); strcmp(title, tile) == 0 && waited < 60; waited++) + { + usleep(FORWARDER_WAIT_STEP_US); + running_title(title); + } + if (log) + { + fprintf(log, "launch %s: user service %08x, foreground user %d (%08x); waited %d step(s) for the tile, " + "running now: '%s'\n", + tile, (unsigned)init, user, (unsigned)foreground, waited, title); + fflush(log); + } + lnc_app_param_t param = {sizeof(param), user, 0, 0, 0}; + int result = sceLncUtilLaunchApp(FORWARDER_TARGET, arguments, ¶m); + if (log) + fprintf(log, "launch %s: sceLncUtilLaunchApp(%s) = %08x\n", tile, FORWARDER_TARGET, (unsigned)result); + if (result < 0) + { + result = sceSystemServiceLaunchApp(FORWARDER_TARGET, arguments, ¶m); + if (log) + fprintf(log, "launch %s: sceSystemServiceLaunchApp(%s) = %08x\n", tile, FORWARDER_TARGET, + (unsigned)result); + } + if (log) + fclose(log); + return result < 0; +} diff --git a/forwarder/tile.c b/forwarder/tile.c new file mode 100644 index 0000000..35a40b4 --- /dev/null +++ b/forwarder/tile.c @@ -0,0 +1,163 @@ +/* PS5 RetroArch - a Game Forwarder tile: the app each home screen tile the WebUI makes runs. + * + * Copyright (C) 2026 Mihawk + * SPDX-License-Identifier: GPL-3.0-or-later + * + * A tile is a title of its own (/data/homebrew/), and a title cannot start another: + * sceSystemServiceLaunchApp answered a tile 0x8094000f, and sceLncUtilLaunchApp is a null + * import for a title. So the tile hands the launch to a payload: it writes its own title id + * into forwarder/launch.c's slot, sends that payload to the console's ELF loader on + * 127.0.0.1:9021 as eboot.bin sends the WebUI's daemon (src/webui_link.cpp), and closes as + * eboot.bin closes. The payload waits for it to go and starts RetroArch with the arguments + * in the tile's forward.txt (--rom, --core, --exit-after-game: src/frontend_mode_ps5.cpp). + * What happened is written to the tile's tile.log. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "forwarder.h" + +#ifndef FORWARDER_APP0 +#define FORWARDER_APP0 "/app0" +#endif +#ifndef FORWARDER_LOADER_PORT +#define FORWARDER_LOADER_PORT 9021 +#endif + +int sceSystemServiceHideSplashScreen(void); +int sceSystemServiceLoadExec(const char *path, const char *const *argv); + +/* The whole of a small file, NUL-terminated; NULL when it cannot be read. */ +static char *read_file(const char *path, long *size) +{ + FILE *file = fopen(path, "rb"); + char *data = NULL; + long length = -1; + if (file && fseek(file, 0, SEEK_END) == 0 && (length = ftell(file)) >= 0 && + fseek(file, 0, SEEK_SET) == 0 && (data = malloc((size_t)length + 1)) && + fread(data, 1, (size_t)length, file) != (size_t)length) + { + free(data); + data = NULL; + } + if (file) + fclose(file); + if (data) + { + data[length] = '\0'; + *size = length; + } + return data; +} + +/* The "titleId" of this tile's param.json into id; 0 when there is no valid one. */ +int forwarder_title_id(const char *param, char id[FORWARDER_ID_SIZE]) +{ + const char *key = param ? strstr(param, "\"titleId\"") : NULL; + const char *value = key ? strchr(key + 9, '"') : NULL; + if (!value || !forwarder_valid_id(value + 1, 9) || value[10] != '"') + return 0; + memcpy(id, value + 1, 9); + id[9] = '\0'; + return 1; +} + +/* Writes id over the payload's slot, which must be there exactly once; 0 otherwise. */ +int forwarder_stamp(char *payload, long size, const char *id) +{ + const size_t marker = strlen(FORWARDER_SLOT_MARKER); + char *slot = NULL; + for (long at = 0; at + (long)marker <= size; at++) + if (memcmp(payload + at, FORWARDER_SLOT_MARKER, marker) == 0) + { + if (slot) + return 0; + slot = payload + at; + } + if (!slot) + return 0; + memset(slot, 0, marker); + memcpy(slot, id, strlen(id)); + return 1; +} + +/* Sends the payload, whole, to the loader; the bytes sent. */ +static long send_to_loader(const char *payload, long size, int *error) +{ + long sent = 0; + const int fd = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in loader; + memset(&loader, 0, sizeof(loader)); + loader.sin_family = AF_INET; + loader.sin_port = htons(FORWARDER_LOADER_PORT); + loader.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + *error = 0; + if (fd < 0 || connect(fd, (struct sockaddr *)&loader, sizeof(loader)) != 0) + *error = errno; + else + while (sent < size) + { + const ssize_t n = send(fd, payload + sent, (size_t)(size - sent), 0); + if (n <= 0) + { + *error = errno; + break; + } + sent += n; + } + if (fd >= 0) + close(fd); + return sent; +} + +int main(int argc, char **argv) +{ + (void)argc; + (void)argv; + FILE *log = fopen(FORWARDER_APP0 "/tile.log", "w"); + sceSystemServiceHideSplashScreen(); + long param_size = 0, payload_size = 0; + char *param = read_file(FORWARDER_APP0 "/sce_sys/param.json", ¶m_size); + char *payload = read_file(FORWARDER_APP0 "/launch.elf", &payload_size); + char id[FORWARDER_ID_SIZE] = ""; + if (!forwarder_title_id(param, id)) + { + if (log) + fprintf(log, "tile: no title id in sce_sys/param.json\n"); + } + else if (!payload || !forwarder_stamp(payload, payload_size, id)) + { + if (log) + fprintf(log, "tile %s: launch.elf is missing or has no slot for the title id\n", id); + } + else + { + int error = 0; + const long sent = send_to_loader(payload, payload_size, &error); + if (log) + fprintf(log, "tile %s: launch payload %ld bytes, %ld sent to 127.0.0.1:%d (errno %d)\n", id, + payload_size, sent, FORWARDER_LOADER_PORT, error); + } + free(param); + free(payload); + if (log) + fflush(log); + /* A title closes as eboot.bin does (src/main.cpp): returning from main ended one with + * SIGSYS, which the console reports as an error in the game. */ + const int result = sceSystemServiceLoadExec("exit", NULL); + if (log) + { + fprintf(log, "tile %s: LoadExec(exit) = %08x\n", id, (unsigned)result); + fclose(log); + } + if (result >= 0) + for (;;) + usleep(100000); /* the shell ends the process asynchronously */ + return 0; +} diff --git a/tests/test_forwarder.py b/tests/test_forwarder.py new file mode 100644 index 0000000..21c458a --- /dev/null +++ b/tests/test_forwarder.py @@ -0,0 +1,151 @@ +"""The Game Forwarder's tile app and launch payload (forwarder/), built for the host with the +console's calls stubbed: the tile stamps its title id into the payload and sends it to the +loader; the payload reads that tile's arguments, waits for the tile to close and starts +RetroArch with them.""" +import shutil +import socket +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MARKER = b"PS5RA-FORWARDER-TILE-ID-SLOT" + +TILE_STUBS = r''' +int sceSystemServiceHideSplashScreen(void) { return 0; } +int sceSystemServiceLoadExec(const char *path, const char *const *argv) +{ (void)path; (void)argv; return -1; } +''' + +LAUNCH_DRIVER = r''' +#include +#include +#include +#include +typedef struct { uint32_t size; int user_id; uint32_t app_opt; uint64_t crash_report; uint64_t check_flag; } param_t; +extern char tile_slot[]; +int launch_main(void); +static int polls, lnc_result; +static FILE *record; +uint32_t sceLncUtilGetAppIdOfRunningBigApp(void) { return polls-- > 0 ? 0x2018u : 0xffffffffu; } +int sceLncUtilGetAppTitleId(uint32_t app, char *title) { (void)app; strcpy(title, "PPSA99170"); return 0; } +int sceUserServiceInitialize(void *p) { (void)p; return 0; } +int sceUserServiceGetForegroundUser(int *user) { *user = 7; return 0; } +static int launch(const char *call, const char *title, const char *argv[], param_t *param, int result) +{ + fprintf(record, "%s %s user=%d size=%u", call, title, param->user_id, param->size); + for (int i = 0; argv[i]; i++) + fprintf(record, " [%s]", argv[i]); + fprintf(record, "\n"); + return result; +} +int sceLncUtilLaunchApp(const char *title, const char *argv[], param_t *param) +{ return launch("lnc", title, argv, param, lnc_result); } +int sceSystemServiceLaunchApp(const char *title, const char *argv[], param_t *param) +{ return launch("system", title, argv, param, 0); } +/* polls the tile is still running for, the first call's result (hex), the tile id stamped + * into the slot ("-" leaves the marker), the record file */ +int main(int argc, char **argv) +{ + if (argc != 5 || strlen(argv[3]) >= 32) + return 2; + polls = atoi(argv[1]); + lnc_result = (int)strtol(argv[2], NULL, 16); + if (strcmp(argv[3], "-") != 0) + memcpy(tile_slot, argv[3], strlen(argv[3]) + 1); + record = fopen(argv[4], "w"); + const int status = launch_main(); + fclose(record); + return status; +} +''' + + +@unittest.skipUnless(shutil.which('cc'), 'requires a host C compiler') +class Forwarder(unittest.TestCase): + def build(self, folder, name, sources, defines): + binary = folder / name + subprocess.run(['cc', '-std=gnu11', '-Wall', '-Wextra', '-Werror', *defines, + '-I' + str(ROOT / 'forwarder'), *map(str, sources), '-o', str(binary)], check=True) + return binary + + def test_the_tile_stamps_its_id_and_sends_the_payload(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + app0 = folder / 'app0' + (app0 / 'sce_sys').mkdir(parents=True) + stubs = folder / 'stubs.c' + stubs.write_text(TILE_STUBS) + with socket.socket() as server: + server.bind(('127.0.0.1', 0)) + server.listen(1) + port = server.getsockname()[1] + tile = self.build(folder, 'tile', [ROOT / 'forwarder/tile.c', stubs], + [f'-DFORWARDER_APP0="{app0}"', f'-DFORWARDER_LOADER_PORT={port}']) + payload = b'\x7fELF' + b'\x01' * 100 + MARKER + b'\0' * 4 + b'tail' + (app0 / 'launch.elf').write_bytes(payload) + (app0 / 'sce_sys/param.json').write_text('{"contentId": "x", "titleId": "PPSA99170"}') + received = bytearray() + + def accept(): + connection, _ = server.accept() + with connection: + while chunk := connection.recv(4096): + received.extend(chunk) + thread = threading.Thread(target=accept) + thread.start() + subprocess.run([str(tile)], check=True, timeout=10) + thread.join(10) + expected = payload.replace(MARKER, b'PPSA99170' + b'\0' * (len(MARKER) - 9)) + self.assertEqual(bytes(received), expected) + log = (app0 / 'tile.log').read_text() + self.assertIn(f'tile PPSA99170: launch payload {len(payload)} bytes, {len(payload)} sent', log) + + # Nothing is sent without a title id, or without exactly one slot. + (app0 / 'sce_sys/param.json').write_text('{"titleId": "ppsa99170"}') + subprocess.run([str(tile)], check=True, timeout=10) + self.assertIn('no title id in sce_sys/param.json', (app0 / 'tile.log').read_text()) + (app0 / 'sce_sys/param.json').write_text('{"titleId": "PPSA99170"}') + for bad in (b'\x7fELF no slot', b'\x7fELF' + MARKER + MARKER): + (app0 / 'launch.elf').write_bytes(bad) + subprocess.run([str(tile)], check=True, timeout=10) + self.assertIn('has no slot for the title id', (app0 / 'tile.log').read_text()) + + def test_the_payload_waits_and_starts_retroarch_with_the_tiles_arguments(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + homebrew = folder / 'homebrew' + (homebrew / 'PPSA99170').mkdir(parents=True) + driver = folder / 'driver.c' + driver.write_text(LAUNCH_DRIVER) + payload = folder / 'launch.o' + subprocess.run(['cc', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-c', f'-DFORWARDER_HOMEBREW="{homebrew}"', + '-DFORWARDER_TARGET="PPSA99169"', '-DFORWARDER_WAIT_STEP_US=1000', '-Dmain=launch_main', + '-I' + str(ROOT / 'forwarder'), str(ROOT / 'forwarder/launch.c'), '-o', str(payload)], + check=True) + launch = self.build(folder, 'launch', [driver, payload], []) + (homebrew / 'PPSA99170/forward.txt').write_bytes( + b'--rom\r\nsnes/Game (USA).zip\n\n--core\nsnes9x\n--exit-after-game\n') + record = folder / 'record.txt' + subprocess.run([str(launch), '3', '4018', 'PPSA99170', str(record)], check=True, timeout=10) + self.assertEqual(record.read_text(), 'lnc PPSA99169 user=7 size=32 [--rom] [snes/Game (USA).zip] ' + '[--core] [snes9x] [--exit-after-game]\n') + log = (homebrew / 'PPSA99170/launch.log').read_text() + self.assertIn('launch PPSA99170: 5 argument(s) from forward.txt', log) + self.assertIn("waited 3 step(s) for the tile, running now: ''", log) + + # The first call refused: the second is asked. + subprocess.run([str(launch), '0', '80940031', 'PPSA99170', str(record)], timeout=10) + self.assertEqual([line.split(' ')[0] for line in record.read_text().splitlines()], ['lnc', 'system']) + + # Not stamped (the marker is still there): nothing is launched. + result = subprocess.run([str(launch), '0', '0', '-', str(record)], capture_output=True, text=True, + timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(record.read_text(), '') + + +if __name__ == '__main__': + unittest.main() diff --git a/tooling/notices/components.json b/tooling/notices/components.json index e611d34..1d9ff82 100644 --- a/tooling/notices/components.json +++ b/tooling/notices/components.json @@ -142,7 +142,8 @@ "name": "PS5 RetroArch (this port: title code, platform integration, build scripts and patches)", "kind": "code", "always": true, - "artifacts": ["eboot.bin", "retroarch.cfg", "LEGAL.txt", "sce_sys/param.json", "es-de/es-de.bin", "picker/picker.bin"], + "artifacts": ["eboot.bin", "retroarch.cfg", "LEGAL.txt", "sce_sys/param.json", "es-de/es-de.bin", "picker/picker.bin", + "forwarder/eboot.bin", "forwarder/launch.elf"], "licence": "GPL-3.0-or-later", "copyright": [ "Copyright (C) 2026 Mihawk", @@ -180,7 +181,7 @@ "name": "PS5 payload SDK fork and its platform layer (libps5platform.a, headers, import stubs)", "kind": "code", "always": true, - "artifacts": ["eboot.bin", "cores/*.so", "es-de/es-de.bin"], + "artifacts": ["eboot.bin", "cores/*.so", "es-de/es-de.bin", "forwarder/eboot.bin", "forwarder/launch.elf"], "licence": "GPL-3.0-or-later", "copyright": ["Copyright (C) 2022-2026 John Törnblom and ps5-payload-dev contributors", "Copyright (C) 2026 Mihawk (the platform layer)"], "texts": [ diff --git a/tools/build-forwarder.sh b/tools/build-forwarder.sh new file mode 100755 index 0000000..5a31044 --- /dev/null +++ b/tools/build-forwarder.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# PS5 RetroArch - build the Game Forwarder's two programs (forwarder/): the app every home +# screen tile runs (eboot.bin, linked as tools/build.sh links the title's, with nothing of +# RetroArch in it) and the launch payload a tile sends to the console's ELF loader +# (launch.elf, built with the payload SDK as the WebUI's daemon is). The WebUI copies both +# into each tile it makes. Run after the title's build, whose native tool this uses. +# Prints the folder holding them. +set -euo pipefail +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +sdk="$root/.deps/native/ps5-payload-sdk" +native="$root/tooling/native" +tool="$root/build/host/ps5-native-tool" +out="$root/build/forwarder" +[[ -x $sdk/bin/prospero-clang ]] || { echo "error: no SDK at $sdk" >&2; exit 2; } +[[ -x $tool ]] || { echo "error: no $tool; build the title first (tools/build.sh)" >&2; exit 2; } +target=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["titleId"])' "$root/sce_sys/param.json") +[[ $target =~ ^[A-Z]{4}[0-9]{5}$ ]] || { echo "error: no title id in sce_sys/param.json" >&2; exit 2; } +rm -rf -- "$out" +mkdir -p "$out/obj" + +# The launch payload: what it starts is this title. +PS5_PAYLOAD_SDK="$sdk" PS5_CLANG=${PS5_CLANG:-clang} "$sdk/bin/prospero-clang" -std=c11 -O2 -Wall -Wextra -Werror \ + -DFORWARDER_TARGET="\"$target\"" -o "$out/launch.elf" "$root/forwarder/launch.c" \ + -lSceSystemService -lSceUserService +grep -q "PS5RA-FORWARDER-TILE-ID-SLOT" "$out/launch.elf" || + { echo "error: launch.elf has no slot for a tile's title id" >&2; exit 1; } + +# The tile's app. +cc() { PS5_PAYLOAD_SDK="$sdk" sh "$root/tooling/prospero-clang18" "$@"; } +cc -std=c11 -O2 -Wall -Wextra -Werror -ffunction-sections -fdata-sections \ + -c "$root/forwarder/tile.c" -o "$out/obj/tile.o" +for part in app_crt app_cpp_runtime; do + cc -std=c++20 -O2 -Wall -Wextra -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections \ + -c "$native/$part.cpp" -o "$out/obj/$part.o" +done +"$sdk/bin/prospero-lld" -T "$native/ps5-pie.ld" -L "$native" --eh-frame-hdr --error-limit=0 \ + --version-script "$native/app-symbols.map" --exclude-libs=ALL -e _start -o "$out/tile.elf" \ + "$out/obj/app_crt.o" "$out/obj/app_cpp_runtime.o" "$out/obj/tile.o" --as-needed "$sdk"/target/lib/*.so +# As tools/build.sh: an import only libkernel_sys or libScePosixForWebKit defines is null at +# run time (the spike's tile called sceLncUtilLaunchApp, null for a title, and faulted at 0). +mapfile -t null_imports < <(comm -23 \ + <("$sdk/bin/llvm-nm" -D --undefined-only "$out/tile.elf" | awk '$1 == "U" { sub(/@.*/, "", $2); print $2 }' | + sort -u) \ + <(for stub in "$sdk"/target/lib/*.so; do + case $(basename "$stub") in libkernel_sys.so | libScePosixForWebKit.so) continue ;; esac + "$sdk/bin/llvm-nm" -D --defined-only "$stub" 2>/dev/null | awk '{ print $NF }' + done | sort -u)) +((${#null_imports[@]} == 0)) || { echo "error: the tile imports what no module exports: ${null_imports[*]}" >&2; exit 1; } +"$tool" link --in "$out/tile.elf" --out "$out/tile.module.elf" --stub-dir "$sdk/target/lib" \ + --module-sdk 0x02000009 --companion-sdk 0x08050001 --file-name eboot.elf >&2 +"$tool" self --sign --in "$out/tile.module.elf" --out "$out/eboot.bin" --magic 0x1D3D154F >&2 +printf '==> [forwarder] tile app %s bytes, launch payload %s bytes (starts %s)\n' \ + "$(stat -c %s "$out/eboot.bin")" "$(stat -c %s "$out/launch.elf")" "$target" >&2 +printf '%s\n' "$out" diff --git a/tools/build-title.sh b/tools/build-title.sh index fea22e1..5ce0ca3 100755 --- a/tools/build-title.sh +++ b/tools/build-title.sh @@ -331,6 +331,8 @@ inputs += sorted(p for p in (root / "third_party/qrcodegen").rglob("*") if p.is_ inputs += sorted(p for p in (root / "webui").rglob("*") if p.is_file()) inputs += sorted(p for p in (root / "daemon").rglob("*") if p.is_file()) inputs.append(root / "tools/build-webui-daemon.sh") +inputs += sorted(p for p in (root / "forwarder").rglob("*") if p.is_file()) +inputs.append(root / "tools/build-forwarder.sh") inputs.append(root / "tools/xmb-system-icons.py") inputs.append(root / "build/lapy/build/owned_root_daemon-service-verified-client/lapy-root-daemon.elf") inputs += sorted(p for p in (root / "build/webui-core-metadata").rglob("*") if p.is_file()) @@ -500,6 +502,11 @@ mkdir -p "$dist/webui" cp -a "$root/webui/." "$dist/webui/" cp "$webui_daemon" "$dist/webui/ps5-retroarch-webui.elf" cp "$lapy_daemon" "$dist/lapy-root-daemon.elf" +# The Game Forwarder's programs (forwarder/, tools/build-forwarder.sh): the app every home +# screen tile runs and the payload it sends. The WebUI copies both into each tile it makes. +forwarder=$(bash "$root/tools/build-forwarder.sh") +mkdir -p "$dist/forwarder" +cp "$forwarder/eboot.bin" "$forwarder/launch.elf" "$dist/forwarder/" # XMB's playlist tabs show the systems' hardware from the WebUI's artwork, named by # database (issue 35); game rows keep XMB's own content icon. python3 "$root/tools/xmb-system-icons.py" "$dist/assets/xmb/monochrome/png" diff --git a/tools/check-notices.py b/tools/check-notices.py index 4246f7e..002d5c7 100644 --- a/tools/check-notices.py +++ b/tools/check-notices.py @@ -26,8 +26,8 @@ import sys from pathlib import Path COVERED = ("lapy-root-daemon.elf", "eboot.bin", "es-de/*.bin", "picker/*.bin", "sce_module/*.prx", "cores/*.so", "cores/*.info", - "info/*.info") -EXECUTABLE = ("eboot.bin", "es-de.bin", "picker.bin", "lapy-root-daemon.elf", "*.prx", "*.so") + "info/*.info", "forwarder/eboot.bin", "forwarder/launch.elf") +EXECUTABLE = ("eboot.bin", "es-de.bin", "picker.bin", "lapy-root-daemon.elf", "launch.elf", "*.prx", "*.so") def sha256(path): diff --git a/tools/stage-notices.py b/tools/stage-notices.py index 806a92d..3ef32fd 100644 --- a/tools/stage-notices.py +++ b/tools/stage-notices.py @@ -33,8 +33,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent TABLE = ROOT / "tooling/notices/components.json" -# The title's executables: eboot.bin, the frontends beside it, modules and cores. -EXECUTABLE = ("eboot.bin", "es-de.bin", "picker.bin", "lapy-root-daemon.elf", "*.prx", "*.so") +# The title's executables: eboot.bin, the frontends beside it, the Game Forwarder's two +# programs, modules and cores. +EXECUTABLE = ("eboot.bin", "es-de.bin", "picker.bin", "lapy-root-daemon.elf", "launch.elf", "*.prx", "*.so") class NoticeError(Exception):