Files
Mihawk a0ea678ee2 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.
2026-10-18 00:23:06 -04:00

164 lines
5.3 KiB
C

/* 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", &param_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;
}