mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
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/<tile>/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/<game> 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.
This commit is contained in:
1 parent
459e194dc0
commit
a0ea678ee2
9 files changed
+544
-6
No files matched your search
@@ -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 <stddef.h>
|
||||
|
||||
/* 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
|
||||
@@ -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/<tile>/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 <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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/<its id>), 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 <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
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()
|
||||
@@ -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": [
|
||||
|
||||
Executable
+54
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user