Home screen forwarders start a game with --rom and --core

From mihawk-99/PS5_RetroArch#34 (Martin Pham). A forwarder, a small app
with a home screen tile of its own, starts the title with --rom <file>
[--core <core>] [--exit-after-game] (each also as --x=<value>). On a
launch that names no mode, src/frontend_mode_ps5.cpp takes it as game
mode takes a frontend's request: the content (absolute, or inside
/app0/content/ and never above it) and the core are checked by
ps5_game_check, RetroArch starts with -L, and afterwards the title
opens as from the home screen, or closes with --exit-after-game.
Without --core the playlists choose, else the one core whose info file
lists the extension (Genesis Plus GX for the Mega Drive extensions both
Sega cores list) or, for a folder, the one core that opens folders. A
refusal goes to trace.txt and the launch goes on as from the home
screen; a handover's --ps5-mode wins over --rom.

Changed from the pull request: its README examples and test fixtures no
longer name games and use the ES-DE folder names content/ has now; one
header comment is moved to its declaration. Its line-ending rewrites of
the PowerShell scripts are dropped (they are gone, 8f14452), and its
GitHub workflow is parked in parked/github-build-workflow, with what
would finish it at the top of build.yml: it cannot run here, and it
does not fetch PS5_MihawkGen, which the frontend compiles now.

To prove it on a console, whose control payload launches without
arguments: the relaunch test takes its restarts' arguments from the
lines after the arm line, and tools/run-title.sh --relaunch-arg writes
them.

Evidence: tests/frontend_mode_ps5_test.cpp (parsing, paths, cores by
playlist, extension and folder, refusals, after the game);
tests/relaunch_ps5_test.cpp (arguments carried; a control character or
a ninth argument arms nothing). Gates: format, unit (204), build
(staged cores reused), integration, evidence PASS. Console: with this
series' deploy.
This commit is contained in:
Martin Pham authored and Mihawk committed 2026-10-17 21:42:17 -04:00
1 parent 6f03aa224a
commit 20dd7fe082
15 files changed
+1551 -24

No files matched your search

+29
View File
@@ -250,6 +250,35 @@ the title folder. With [ShadowMountPlus](https://github.com/drakmor/ShadowMountP
`/mnt/ext1`), only when a drive is mounted there. Without it, the sandbox hides them
and **EXTERNAL** (`/mnt`) may be empty.
<a id="forwarders"></a>
## <img src="webui/assets/fluent/game-chat.svg" width="26" height="26" alt=""> Start a game from a home screen forwarder
A forwarder is a small app with its own home screen tile that launches `PPSA99169` with
launch arguments. The title then starts that game in RetroArch with the core given, instead of
opening the picker or a frontend.
| Argument | Meaning |
| --- | --- |
| `--rom <file>` or `--rom=<file>` | The content to start: an absolute path (`/mnt/usb0/roms/snes/Game.zip`), or a path inside the title's `content/` folder (`/app0/content/`), such as `snes/Game.zip`. A folder is content for a core that opens folders, one whose info file lists `/` among its extensions: DOSBox Pure (`--rom /mnt/usb0/roms/dos/w95 --core dosbox_pure`), PUAE, VICE. A relative path may not contain `..`. |
| `--core <core>` or `--core=<core>` | The core to run it with, one of the title's: `snes9x`, `snes9x_libretro`, `snes9x_libretro.so` and `/app0/cores/snes9x_libretro.so` all name `/app0/cores/snes9x_libretro.so`. Without it, the core RetroArch's playlists associate with the content, or else the one core whose info file lists the file's extension (`.sfc` is Snes9x's; a Mega Drive `.md` or `.gen` is Genesis Plus GX's). An extension several cores take (`.cue`, `.chd`, `.iso`, `.zip`, `.bin`) needs `--core`. |
| `--exit-after-game` | When RetroArch quits (Close Content or Quit), the title closes and the console returns to the home screen. Without it, the title then opens as from the home screen. |
```text
--rom "snes/Game (USA).zip" --core snes9x
--rom /mnt/usb0/roms/psx/Game.chd --core mednafen_psx_hw --exit-after-game
```
The game runs as a frontend's game does in game mode (`src/ps5_game.h`): the content and core
are checked the same way, RetroArch starts with `-L <core> <content>` and its own configuration,
overrides and saves apply, and its log is `retroarch-game.log`. If the content is missing, the
core is not one of the title's, or no core is named and none can be chosen, the reason is
written to `trace.txt` (`forwarder: ... not run: ...`) and the title opens as from the home
screen. A launch that names a mode (`--ps5-mode=...`) wins over `--rom`. The title's own restarts
carry no arguments, so the forwarded game never starts twice. Arguments only reach a new
process: if the title is already running, a forwarder should close it first. Parsing is in
`src/frontend_mode_ps5.cpp`, checked by `tests/frontend_mode_ps5_test.cpp`.
<a id="what-has-been-verified"></a>
## <img src="webui/assets/fluent/checkmark-circle.svg" width="26" height="26" alt=""> Compatibility and known limits
+506
View File
@@ -0,0 +1,506 @@
# PS5 RetroArch - the title (README's "Build from source") and a ready-to-install PPSA99169 bundle,
# on GitHub-hosted runners.
# SPDX-License-Identifier: GPL-3.0-or-later
#
# PARKED (mihawk-99/PS5_RetroArch#34, Martin Pham): not run, and not accepted as working. It
# was written against the tree of 2026-10-08. The frontend now compiles frame generation from
# PS5_MihawkGen beside this repository, which this file does not fetch; PS5_VULKAN_REVISION is
# the beta.1 one; scripts/core-plan.py has to be re-checked against tools/build-title.sh's
# optional cores and every seed script against the current pins. Done means: moved back to
# .github/workflows/ (its scripts to .github/scripts/), a run on a fork whose zip passes
# tools/check-manifest.sh and tools/check-notices.py, and that zip loading on a console.
#
# README's workspace, made from public sources at their pins, with this checkout as PS5_RetroArch:
#
# PS5_Vulkan mihawk-99/PS5_Vulkan at PS5_VULKAN_REVISION below: its RADV release archive
# (tools/build-radv.sh release), its link recipe (tools/radv-link.sh)
# PS5_Mesa the Mesa fork at the revision PS5_Vulkan pins, which RADV is built from
# PS5_PayloadSDK the payload SDK fork at the revision each of the two projects pins
#
# The jobs:
#
# plan the cores tools/build-title.sh builds (.github/scripts/core-plan.py), but for those
# CI_SKIP_CORES names
# radv RADV, as PS5_Vulkan builds it (LLVM 18, Mesa's host needs), cached by its pins
# core one job per core: its tools/build-<core>.sh, as build-title.sh runs it. A core is
# cached with its stamp (tools/core-stamp.sh: the script, its patches, the SDK and the
# loader), so a core whose inputs did not change is not built again; MAME, the longest
# build, also keeps its ccache, so a build cut short by the time limit carries on
# title tools/build-title.sh, with every core in place and up to date (each script finds its
# stamp and skips), RADV from the radv job, and its output folder zipped as
# dist/PPSA99169.zip: the PPSA99169 folder, ready for /data/homebrew or a folder
# ShadowMountPlus scans
#
# What a development build here leaves out, as tools/build-title.sh does without PS5_RELEASE_TAG:
# the frontends (EmulationStation and the picker, PS5_FRONTENDS), which need PS5_OpenGL's SDK,
# its SDL2 build and PS5_VulkanTemplate. Without them the title opens RetroArch, as it always has,
# and a forwarder (--rom, --core) works the same.
#
# The cores, the frontend and the title are built in Ubuntu 26.04 with LLVM 22, the toolchain
# README's /usr/bin/clang is: the core ABI check (tools/check-core.py) wants FreeBSD/PS5
# libraries, which the SDK's clang links from LLVM 20, and the title's link takes
# -z nodynamic-undefined-weak, which LLD has from 21 (.github/scripts/install-host-tools.sh).
# Host steps README's environment had from elsewhere:
# - RetroArch's configure links a vkCreateInstance test with the console's compiler, against a
# libvulkan the SDK does not carry: a stub archive answers it (the driver is RADV, linked
# into eboot.bin later; nothing links the stub)
# - tools/fetch-retroarch.sh checks for libretro-common/include/retroarch.h, which the pinned
# tag does not have; it takes the tree pinned in place instead, a clone at the pinned commit
# - tools/build-title.sh checks ps5vk's four archives are there before it links either
# driver; a RADV link does not read them, so empty archives stand in for them
# - zlib (zlib.net), Mesa's zlib wrap (wrapdb) and libmicrohttpd (ftp.gnu.org) from GitHub or
# Ubuntu's archive, checked against each script's own pin (.github/scripts/seed-*.sh)
name: Build
on:
push:
branches: [main]
tags: ['v*']
paths-ignore: ['**.md', 'evidence/**', 'releases/**']
pull_request:
paths-ignore: ['**.md', 'evidence/**', 'releases/**']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Cores left out of this build (space-separated libretro names, as in tools/build-title.sh's
# core_names): no job builds them and the title does not carry them. MAME, the longest build by
# far, is left out for now; empty this to build it again.
CI_SKIP_CORES: ''
# PS5_Vulkan main when the beta.1 release was built (radv-link.sh binds strcasestr, mkstemp,
# readlink, link and symlink, which tools/build-title.sh's wraps expect)
PS5_VULKAN_REVISION: 5b5e4fc2d80fdb67a4f61ba9e6a8a424026bb8a5
jobs:
plan:
name: Plan the cores
runs-on: ubuntu-24.04
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- id: plan
run: |
# shellcheck disable=SC2086 # a list of names
python3 .github/scripts/core-plan.py skip $CI_SKIP_CORES
matrix=$(python3 .github/scripts/core-plan.py matrix)
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
python3 -c 'import json, sys; [print(c["core"], c["script"]) for c in json.loads(sys.argv[1])]' "$matrix"
radv:
name: RADV (PS5_Vulkan)
runs-on: ubuntu-24.04
timeout-minutes: 120
container:
image: ubuntu:24.04
env:
DEBIAN_FRONTEND: noninteractive
defaults:
run:
shell: bash
steps:
- name: Install the build tools
run: |
apt-get update
# PS5_Vulkan's: LLVM 18 with Clang, its runtime builtins and development files, libclc, the
# SPIR-V translator, glslangValidator, meson from pip (Mesa needs 1.4; Ubuntu 24.04 has 1.3),
# rsync for build-radv.sh's source tree, rev and flock for the SDK's scripts
apt-get install --yes --no-install-recommends \
ca-certificates git curl wget unzip zip xz-utils file make build-essential pkgconf patch \
bsdextrautils util-linux rsync ccache binutils cmake ninja-build bison flex glslang-tools \
spirv-tools python3 python3-pip \
clang-18 lld-18 llvm-18 libclang-rt-18-dev llvm-18-dev libclang-18-dev libclc-18-dev \
libllvmspirvlib-18-dev llvm-spirv-18 zlib1g-dev
python3 -m pip install --break-system-packages --no-cache-dir meson mako packaging pyyaml
git config --global --add safe.directory '*'
for tool in clang-18 ld.lld-18 llvm-config-18 meson ninja rsync rev flock; do
command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 2; }
done
- uses: actions/checkout@v7
with:
path: PS5_RetroArch
- name: Fetch the pinned sources
run: |
fetch_at() { # fetch_at <dir> <url> <commit>: just that commit, checked out
rm -rf "$1"
git init -q "$1"
git -C "$1" remote add origin "$2"
git -C "$1" fetch -q --depth 1 origin "$3"
git -C "$1" checkout -q --detach FETCH_HEAD
}
fetch_at PS5_Vulkan https://github.com/mihawk-99/PS5_Vulkan.git "$PS5_VULKAN_REVISION"
sdk=$(sed -n 's/^sdk_revision=\([0-9a-f]\{40\}\).*/\1/p' PS5_Vulkan/tools/setup-native-dependencies.sh)
mesa=$(sed -n 's/^mesa_revision=\([0-9a-f]\{40\}\).*/\1/p' PS5_Vulkan/tools/build-radv.sh)
[[ -n $sdk && -n $mesa ]] || { echo "PS5_Vulkan's SDK or Mesa pin was not found" >&2; exit 2; }
fetch_at PS5_PayloadSDK https://github.com/mihawk-99/PS5_PayloadSDK.git "$sdk"
fetch_at PS5_Mesa https://github.com/mihawk-99/PS5_Mesa.git "$mesa"
{
echo "RADV_KEY=ps5ra-radv-$mesa-$sdk-$PS5_VULKAN_REVISION"
echo "CI_DOWNLOADS=$GITHUB_WORKSPACE/ci-downloads"
} >> "$GITHUB_ENV"
echo "PS5_Vulkan ${PS5_VULKAN_REVISION:0:12}: SDK fork ${sdk:0:12}, Mesa ${mesa:0:12}"
- name: Restore the RADV driver
id: radv
uses: actions/cache/restore@v6
with:
path: PS5_Vulkan/.deps/native/radv-release
key: ${{ env.RADV_KEY }}
- name: Build RADV (PS5_Vulkan tools/build-radv.sh release)
if: steps.radv.outputs.cache-hit != 'true'
run: |
bash PS5_RetroArch/.github/scripts/seed-zlib.sh PS5_Vulkan
BUILD_JOBS=$(nproc) bash PS5_Vulkan/tools/setup-native-dependencies.sh
bash PS5_RetroArch/.github/scripts/seed-mesa-wrap.sh PS5_Vulkan PS5_Mesa
if ! bash PS5_Vulkan/tools/build-radv.sh release; then
for log in PS5_Vulkan/.deps/work/*.log; do
[[ -f $log ]] && { echo "::group::$log"; tail -n 60 "$log"; echo "::endgroup::"; }
done
exit 1
fi
- name: Save the RADV driver
if: steps.radv.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: PS5_Vulkan/.deps/native/radv-release
key: ${{ env.RADV_KEY }}
- run: cat PS5_Vulkan/.deps/native/radv-release/PROVENANCE.txt
- uses: actions/upload-artifact@v7
with:
name: radv-release
path: PS5_Vulkan/.deps/native/radv-release
retention-days: 3
core:
name: Core ${{ matrix.core }}
needs: plan
runs-on: ubuntu-24.04
timeout-minutes: 355
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.matrix) }}
container:
image: ubuntu:26.04
# SDKs preinstalled on the runner, mounted so the job can delete them for disk space
volumes:
- /usr/share/dotnet:/host-free/dotnet
- /usr/local/lib/android:/host-free/android
- /opt/ghc:/host-free/ghc
- /usr/local/.ghcup:/host-free/ghcup
- /opt/hostedtoolcache:/host-free/toolcache
env:
DEBIAN_FRONTEND: noninteractive
CORE: ${{ matrix.core }}
CORE_CACHE: ps5ra-core-${{ matrix.core }}
# ccache for MAME alone (tools/build-mame.sh sets its base directory): the other cores
# are cached whole by their stamps, and every ccache would not fit the repository's cache
PS5_DISABLE_CCACHE: ${{ matrix.core == 'mame' && '0' || '1' }}
CCACHE_MAXSIZE: 5G
defaults:
run:
shell: bash
steps:
- name: Free disk space
run: |
find /host-free -mindepth 2 -maxdepth 2 -exec rm -rf {} + 2>/dev/null || true
df -h "$GITHUB_WORKSPACE"
# git before the checkout, so it is a repository (tools/stage-notices.py reads its revision)
- name: Install git
run: apt-get update && apt-get install --yes --no-install-recommends ca-certificates git
- uses: actions/checkout@v7
with:
path: PS5_RetroArch
- name: Install the build tools
run: bash PS5_RetroArch/.github/scripts/install-host-tools.sh
- name: The payload SDK and zlib (tools/setup-native-dependencies.sh)
run: |
echo "CI_DOWNLOADS=$GITHUB_WORKSPACE/ci-downloads" >> "$GITHUB_ENV"
export CI_DOWNLOADS=$GITHUB_WORKSPACE/ci-downloads
bash PS5_RetroArch/.github/scripts/fetch-sdk-fork.sh
bash PS5_RetroArch/.github/scripts/seed-zlib.sh PS5_RetroArch
BUILD_JOBS=$(nproc) bash PS5_RetroArch/tools/setup-native-dependencies.sh
- name: Restore the core
id: restore
uses: actions/cache/restore@v6
with:
path: ci-core
key: ${{ env.CORE_CACHE }}-${{ github.run_id }}
restore-keys: ${{ env.CORE_CACHE }}-
- name: Restore ccache
if: matrix.core == 'mame'
uses: actions/cache/restore@v6
with:
path: ci-ccache
key: ps5ra-ccache-mame-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: ps5ra-ccache-mame-
- name: Fetch the fork's pinned commit
if: matrix.fork
run: python3 PS5_RetroArch/.github/scripts/core-plan.py prefetch "$CORE"
- name: Build ${{ matrix.core }} (tools/${{ matrix.script }})
timeout-minutes: 330
working-directory: PS5_RetroArch
run: |
# the last build of this core, which its script keeps when its stamp still matches
[[ ! -d ../ci-core ]] || cp -a ../ci-core/. .
JOBS=$(nproc)
export CCACHE_DIR=$GITHUB_WORKSPACE/ci-ccache JOBS
[[ $PS5_DISABLE_CCACHE == 1 ]] || ccache --zero-stats
bash "tools/${{ matrix.script }}"
# a cache entry from before the collected set grew (LRPS2's build report) passes the
# stamp but lacks what the title needs: that core is built again, once
if ! python3 .github/scripts/core-plan.py check "$CORE"; then
echo "::warning::$CORE: the cached build lacks what the title needs; building it again"
PS5_FORCE_CORES=1 bash "tools/${{ matrix.script }}"
python3 .github/scripts/core-plan.py check "$CORE"
fi
[[ $PS5_DISABLE_CCACHE == 1 ]] || ccache --show-stats
- name: Save ccache
if: always() && matrix.core == 'mame'
uses: actions/cache/save@v6
with:
path: ci-ccache
key: ps5ra-ccache-mame-${{ github.run_id }}-${{ github.run_attempt }}
- name: Collect what the title needs
id: collect
run: |
rm -rf ci-core
python3 PS5_RetroArch/.github/scripts/core-plan.py collect "$CORE" ci-core
stamp=$(<"PS5_RetroArch/build/cores/stamps/${{ matrix.stamp }}")
# the stamp, and the version of what collect keeps (a new one saves an entry again)
echo "key=$CORE_CACHE-$stamp-c2" >> "$GITHUB_OUTPUT"
- name: Save the core
if: steps.collect.outputs.key != steps.restore.outputs.cache-matched-key
uses: actions/cache/save@v6
with:
path: ci-core
key: ${{ steps.collect.outputs.key }}
- uses: actions/upload-artifact@v7
with:
name: core-${{ matrix.core }}
path: ci-core
include-hidden-files: true
retention-days: 3
title:
name: Title (PPSA99169)
needs: [radv, core]
runs-on: ubuntu-24.04
timeout-minutes: 120
container:
image: ubuntu:26.04
env:
DEBIAN_FRONTEND: noninteractive
# a development build: no release tag, and no frontends (see the top of this file)
PS5_FRONTENDS: ''
CCACHE_MAXSIZE: 1G
defaults:
run:
shell: bash
steps:
# git before the checkout, so it is a repository (tools/stage-notices.py reads its revision)
- name: Install git
run: apt-get update && apt-get install --yes --no-install-recommends ca-certificates git
- uses: actions/checkout@v7
with:
path: PS5_RetroArch
- name: Install the build tools
run: bash PS5_RetroArch/.github/scripts/install-host-tools.sh
- name: Fetch the pinned sources
run: |
fetch_at() { # fetch_at <dir> <url> <commit>: just that commit, checked out
rm -rf "$1"
git init -q "$1"
git -C "$1" remote add origin "$2"
git -C "$1" fetch -q --depth 1 origin "$3"
git -C "$1" checkout -q --detach FETCH_HEAD
}
fetch_at PS5_Vulkan https://github.com/mihawk-99/PS5_Vulkan.git "$PS5_VULKAN_REVISION"
bash PS5_RetroArch/.github/scripts/fetch-sdk-fork.sh
# PS5_Mesa's licence texts, which tools/stage-notices.py copies for RADV
mesa=$(sed -n 's/^mesa_revision=\([0-9a-f]\{40\}\).*/\1/p' PS5_Vulkan/tools/build-radv.sh)
git init -q PS5_Mesa
git -C PS5_Mesa remote add origin https://github.com/mihawk-99/PS5_Mesa.git
git -C PS5_Mesa sparse-checkout set --no-cone /docs/license.rst '/licenses/'
git -C PS5_Mesa fetch -q --depth 1 --filter=blob:none origin "$mesa"
git -C PS5_Mesa checkout -q --detach FETCH_HEAD
# LLVM's licence, for the SDK's LLVM runtime (stage-notices.py looks in ../PS5_LLVM)
mkdir -p PS5_LLVM/llvm
curl --fail --location --retry 3 --silent --show-error -o PS5_LLVM/llvm/LICENSE.TXT \
https://raw.githubusercontent.com/llvm/llvm-project/llvmorg-18.1.8/llvm/LICENSE.TXT
# RetroArch at tools/fetch-retroarch.sh's pin, as a clone it pins in place
revision=$(sed -n 's/^revision="\([0-9a-f]\{40\}\)".*/\1/p' PS5_RetroArch/tools/fetch-retroarch.sh)
fetch_at PS5_RetroArch/vendor/retroarch https://github.com/libretro/RetroArch.git "$revision"
bash PS5_RetroArch/tools/fetch-retroarch.sh
# ES-DE at tools/build-esde.sh's pin, for the certificates the WebUI's daemon verifies
# HTTPS against (resources/certificates/curl-ca-bundle.crt)
version=$(sed -n 's/^esde_version=\(v[0-9.]*\)$/\1/p' PS5_RetroArch/tools/build-esde.sh)
commit=$(sed -n 's/^esde_commit=\([0-9a-f]\{40\}\)$/\1/p' PS5_RetroArch/tools/build-esde.sh)
git clone -q --depth 1 --branch "$version" https://gitlab.com/es-de/emulationstation-de.git \
PS5_RetroArch/.deps/es-de
[[ $(git -C PS5_RetroArch/.deps/es-de rev-parse HEAD) == "$commit" ]] ||
{ echo "ES-DE $version is not $commit" >&2; exit 2; }
- name: Restore downloads
uses: actions/cache@v6
with:
path: |
ci-downloads
PS5_RetroArch/.deps/downloads
key: ps5ra-downloads-${{ hashFiles('PS5_RetroArch/tools/video-assets.py', 'PS5_RetroArch/assets/**/source.json', 'PS5_RetroArch/tools/build-curl.sh') }}
restore-keys: ps5ra-downloads-
- name: The payload SDK, zlib and libmicrohttpd
run: |
export CI_DOWNLOADS=$GITHUB_WORKSPACE/ci-downloads
bash PS5_RetroArch/.github/scripts/seed-zlib.sh PS5_RetroArch
BUILD_JOBS=$(nproc) bash PS5_RetroArch/tools/setup-native-dependencies.sh
bash PS5_RetroArch/.github/scripts/seed-microhttpd.sh
- uses: actions/download-artifact@v8
with:
name: radv-release
path: PS5_Vulkan/.deps/native/radv-release
- name: Stand-ins for ps5vk's archives
run: |
# build-title.sh requires them before it links either driver; RADV's link replaces them
for archive in build/driver/ps5/libps5vk.ps5.a .deps/native/vulkan-runtime/lib/libvk_runtime.ps5.a \
build/driver/ps5/libpsbc_driver.ps5.a .deps/native/psbc/lib/libpsbc_support.ps5.a; do
mkdir -p "PS5_Vulkan/$(dirname "$archive")"
printf '!<arch>\n' > "PS5_Vulkan/$archive"
done
- uses: actions/download-artifact@v8
with:
pattern: core-*
merge-multiple: true
path: PS5_RetroArch
- name: Check the cores are the ones built
working-directory: PS5_RetroArch
run: |
# shellcheck disable=SC2086 # a list of names
python3 .github/scripts/core-plan.py skip $CI_SKIP_CORES
# every core's library, stamp, build report and licence texts, before anything is built
python3 .github/scripts/core-plan.py check all
# each script finds its stamp and outputs and skips; one that would build again means
# the core jobs and this one differ in an input, which this stops before it builds
python3 .github/scripts/core-plan.py matrix |
python3 -c 'import json, sys; [print(c["script"]) for c in json.load(sys.stdin)]' |
while read -r script; do
output=$(PS5_DISABLE_CCACHE=1 timeout 120 bash "tools/$script" 2>&1 | head -n 5) || true
grep -q 'up to date' <<<"$output" ||
{ echo "tools/$script did not find its core up to date:" >&2; echo "$output" >&2; exit 1; }
echo "$output"
done
# What the title builds besides the cores, kept from the last run: RetroArch's configured tree
# and objects (build-retroarch.sh rebuilds what its fingerprint and patch stamps say moved),
# the WebUI's libmicrohttpd, updater and curl (each stamped by its script), and the title's
# own compiles (ccache)
- name: Restore the title's builds
uses: actions/cache/restore@v6
with:
path: |
ci-ccache
PS5_RetroArch/build/ra
PS5_RetroArch/build/ra-conf
PS5_RetroArch/build/webui-mhd-ps5
PS5_RetroArch/build/webui-update-ps5
PS5_RetroArch/.deps/native/curl-ps5
key: ps5ra-title-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: ps5ra-title-
- name: Build the frontend (tools/build-retroarch.sh)
working-directory: PS5_RetroArch
run: |
sdk=$PWD/.deps/native/ps5-payload-sdk
stub=$GITHUB_WORKSPACE/ci-vulkan-stub
mkdir -p "$stub"
# configure's vkCreateInstance link test (see the top of this file)
printf 'int vkCreateInstance(void *a, void *b, void *c) { (void)a; (void)b; (void)c; return -9; }\n' \
> "$stub/vulkan_stub.c"
"$sdk/bin/prospero-clang" -c "$stub/vulkan_stub.c" -o "$stub/vulkan_stub.o"
"$sdk/bin/prospero-ar" rcs "$stub/libvulkan.a" "$stub/vulkan_stub.o"
PS5_PAYLOAD_SDK=$sdk PS5_CLANG=/usr/bin/clang LDFLAGS="-L$stub" bash tools/build-retroarch.sh
- name: Build the title (tools/build-title.sh)
working-directory: PS5_RetroArch
env:
# the WebUI's ScreenScraper developer account, when the repository has one
PS5_SCREENSCRAPER_DEVID: ${{ secrets.PS5_SCREENSCRAPER_DEVID }}
PS5_SCREENSCRAPER_DEVPASSWORD: ${{ secrets.PS5_SCREENSCRAPER_DEVPASSWORD }}
run: |
export PS5_PAYLOAD_SDK=$PWD/.deps/native/ps5-payload-sdk PS5_CLANG=/usr/bin/clang
JOBS=$(nproc)
export CCACHE_DIR=$GITHUB_WORKSPACE/ci-ccache JOBS
bash tools/build-title.sh
ccache --show-stats || true
- name: Save the title's builds
if: always()
uses: actions/cache/save@v6
with:
path: |
ci-ccache
PS5_RetroArch/build/ra
PS5_RetroArch/build/ra-conf
PS5_RetroArch/build/webui-mhd-ps5
PS5_RetroArch/build/webui-update-ps5
PS5_RetroArch/.deps/native/curl-ps5
key: ps5ra-title-${{ github.run_id }}-${{ github.run_attempt }}
- name: Stage the bundle (dist/PPSA99169.zip)
working-directory: PS5_RetroArch
run: |
# tools/build-title.sh's package: the PPSA99169 folder, checked after extraction
mv dist/PS5_RetroArch.zip dist/PPSA99169.zip
python3 - dist/PPSA99169.zip <<'PY'
import json, sys, zipfile
names = set(zipfile.ZipFile(sys.argv[1]).namelist())
for required in ("PPSA99169/eboot.bin", "PPSA99169/sce_sys/param.json",
"PPSA99169/sce_module/libc.prx", "PPSA99169/retroarch.cfg"):
if required not in names:
sys.exit(f"{required} is not in the bundle")
cores = sorted(n for n in names if n.startswith("PPSA99169/cores/") and n.endswith(".so"))
print(f"{len(names)} files, {len(cores)} cores")
PY
(cd dist && sha256sum PPSA99169.zip > PPSA99169.zip.sha256 && cat PPSA99169.zip.sha256)
echo "SHORT_SHA=${GITHUB_SHA:0:7}" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@v7
with:
# the artifact downloads as a zip itself: PPSA99169.zip is inside it
name: PS5_RetroArch-PPSA99169-${{ env.SHORT_SHA }}
path: |
PS5_RetroArch/dist/PPSA99169.zip
PS5_RetroArch/dist/PPSA99169.zip.sha256
retention-days: 14
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""The cores tools/build-title.sh builds, for the workflow's per-core jobs.
core-plan.py matrix the core_names list of tools/build-title.sh as a JSON
matrix: [{"core", "script", "stamp", "fork", "revision",
"source"}]; fork and revision only for a core built from a
mihawk-99 fork checkout (tools/core-fork.sh, LRPS2's own)
core-plan.py prefetch CORE that fork's pinned commit alone, fetched and checked out in
the tree the build script uses, so it does not clone the whole
history (MAME's is gigabytes); the script still checks it out
at the pin and cleans it when it builds
core-plan.py skip CORE... leave those cores out of this checkout's title (CI_SKIP_CORES
in the workflow): their names out of tools/build-title.sh's
core_names and tools/generate-core-metadata.py's CORES, so the
matrix has no job for them and the title neither builds nor
stages them. An edit to the CI's working tree, never committed
core-plan.py check CORE...|all every one of those cores has what the title needs from it
(stamp, library, info file, build report, licence texts);
exit 1 naming what is missing. A core job rebuilds a core its
cache restored without them; the title job checks them all
before it builds anything
core-plan.py collect CORE OUT what the title job needs from a built core, copied into OUT
at the same paths: build/cores/stage (the library, its info
file, its system/ assets), the stamp that lets the title's
own run of the script skip it, the build and ABI reports
stage-notices.py reads, and the core option sources
tools/generate-core-metadata.py extracts the WebUI's
catalogs from
Each core's script is the one whose core_stamp_skip names <core>_libretro.so, the file
build-title.sh stages.
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TOOLS = ROOT / "tools"
def core_names():
text = (TOOLS / "build-title.sh").read_text()
found = re.search(r"^core_names=\(([^)]*)\)", text, re.M)
if not found:
sys.exit("core-plan: no core_names=(...) in tools/build-title.sh")
return found.group(1).split()
def plan():
scripts = sorted(TOOLS.glob("build-*.sh"))
cores = []
for core in core_names():
output = f"/build/cores/stage/cores/{core}_libretro.so"
matches = []
for script in scripts:
text = script.read_text()
stamp = re.search(r"core_stamp_skip\s+([\w.-]+)((?:\s*\\?\n?\s*\"[^\"]*\")+)", text)
if stamp and output in stamp.group(2):
matches.append((script, text, stamp.group(1)))
if len(matches) != 1:
sys.exit(f"core-plan: {len(matches)} scripts stage {core}_libretro.so")
script, text, stamp = matches[0]
entry = {"core": core, "script": script.name, "stamp": stamp}
fork = re.search(r"core_fork_checkout\s+(PS5_\w+)\s", text) or \
re.search(r"github\.com/mihawk-99/(PS5_\w+)\.git", text)
revision = re.search(r"^revision=([0-9a-f]{40})\b", text, re.M)
if fork and revision:
name = re.search(r"^core_name=([\w.-]+)", text, re.M)
source = re.search(r'source_dir="\$root/(\.deps/[\w.-]+-src)"', text)
entry.update(fork=fork.group(1), revision=revision.group(1),
source=source.group(1) if source else f".deps/{name.group(1)}-src")
cores.append(entry)
return cores
def find(core):
for entry in plan():
if entry["core"] == core:
return entry
sys.exit(f"core-plan: {core} is not in tools/build-title.sh's core_names")
def git(*args, cwd=None):
subprocess.run(["git", *args], cwd=cwd, check=True)
def prefetch(core):
entry = find(core)
if "fork" not in entry:
print(f"==> [{core}] {entry['script']} fetches its own source")
return
tree = ROOT / entry["source"]
if (tree / ".git").is_dir():
print(f"==> [{core}] {entry['source']} is already there")
return
git("init", "-q", str(tree))
# origin is the published fork: a relative submodule URL (../PS5_Dynarmic) resolves against it
git("remote", "add", "origin", f"https://github.com/mihawk-99/{entry['fork']}.git", cwd=tree)
git("fetch", "-q", "--depth", "1", "--no-recurse-submodules", "origin", entry["revision"], cwd=tree)
# checked out too: a core its stamp finds up to date is not checked out by its script, and
# collect still takes its licence texts and option sources from the tree
git("checkout", "-q", "--force", "--detach", entry["revision"], cwd=tree)
print(f"==> [{core}] {entry['fork']} {entry['revision'][:12]} in {entry['source']}")
def metadata_sources(core):
"""The files generate-core-metadata.py reads for CORE: its source and that folder's others."""
spec = __import__("importlib.util").util.spec_from_file_location(
"core_metadata", TOOLS / "generate-core-metadata.py")
module = __import__("importlib.util").util.module_from_spec(spec)
spec.loader.exec_module(module)
files = []
for stem, _name, source, _flags in module.CORES:
if stem == core:
folder = (ROOT / source).parent
files += [path for path in sorted(folder.iterdir()) if path.is_file()]
return files
def notice_components(entry):
"""The core's entries in tooling/notices/components.json: those whose artifacts name its
library. Their build folder (build/cores/<build>/build.json) is not always the stamp's name:
LRPS2's is lrps2, its stamp pcsx2."""
table = json.loads((ROOT / "tooling/notices/components.json").read_text(encoding="utf-8"))
library = f"cores/{entry['core']}_libretro.so"
return [component for component in table["components"]
if component.get("source", {}).get("kind") == "core" and library in component.get("artifacts", [])]
def licence_texts(entry, missing=None):
"""The licence texts stage-notices.py copies for this core, from its source tree; a text
none of whose paths exists goes to missing (or stops the script)."""
paths = []
for component in notice_components(entry):
for text in component.get("texts", []):
if "from" not in text:
continue
choices = text["from"] if isinstance(text["from"], list) else [text["from"]]
found = next((ROOT / choice for choice in choices if (ROOT / choice).exists()), None)
if found:
paths.append(found)
elif missing is None:
sys.exit(f"core-plan: {component['id']}'s licence text {choices} is missing")
else:
missing.append(choices[0])
return paths
def required(entry):
"""What the title cannot be made without, for this core: the stamp its script skips by, the
library and its info file, and the build report stage-notices.py reads
(build/cores/<build>/build.json)."""
stage = ROOT / "build/cores/stage"
files = [ROOT / "build/cores/stamps" / entry["stamp"],
stage / "cores" / f"{entry['core']}_libretro.so", stage / "info" / f"{entry['core']}_libretro.info"]
files += [ROOT / "build/cores" / component["source"]["build"] / "build.json"
for component in notice_components(entry)]
return files
def missing_of(entry):
missing = [str(path.relative_to(ROOT)) for path in required(entry) if not path.is_file()]
licence_texts(entry, missing)
return missing
def check(cores):
"""Every core's required files and licence texts are here; exit 1 naming what is not."""
bad = 0
for core in cores:
missing = missing_of(find(core))
if missing:
bad += 1
print(f"core-plan: {core} is missing {', '.join(missing)}", file=sys.stderr)
if bad:
sys.exit(1)
print(f"==> [cores] {len(cores)} cores have their libraries, stamps, build reports and licences")
def collect(core, out):
entry = find(core)
out = Path(out).resolve()
stage = ROOT / "build/cores/stage"
files = required(entry) + sorted((stage / "cores").glob(f"{core}_libretro.*")) + \
sorted((stage / "info").glob(f"{core}_libretro.*"))
# the ABI reports beside the build reports, in the folder a script names after its stamp, its
# core or its notice entry
builds = {component["source"]["build"] for component in notice_components(entry)}
for folder in sorted({entry["stamp"], core} | builds):
files += [ROOT / "build/cores" / folder / "abi.json"]
files = [path for path in dict.fromkeys(files) if path.is_file() or path in required(entry)]
files += metadata_sources(core)
folders = [stage / "system"] if (stage / "system").is_dir() else []
for path in licence_texts(entry):
(folders if path.is_dir() else files).append(path)
for path in files:
if not path.is_file():
sys.exit(f"core-plan: {core} did not leave {path.relative_to(ROOT)}")
target = out / path.relative_to(ROOT)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
# folders: assets a core stages under build/cores/stage/system (PPSSPP, Dolphin; only this
# job's core is there), and licence folders
for path in folders:
shutil.copytree(path, out / path.relative_to(ROOT), dirs_exist_ok=True)
print(f"==> [{core}] {len(files)} files and {len(folders)} folders collected in {out}")
def skip(cores):
known = core_names()
for core in cores:
if core not in known:
sys.exit(f"core-plan: {core} is not in tools/build-title.sh's core_names")
title = TOOLS / "build-title.sh"
text = title.read_text()
found = re.search(r"^core_names=\(([^)]*)\)", text, re.M)
kept = [core for core in found.group(1).split() if core not in cores]
title.write_text(text[:found.start(1)] + " ".join(kept) + text[found.end(1):])
metadata = TOOLS / "generate-core-metadata.py"
lines = metadata.read_text().splitlines(keepends=True)
entry = re.compile(r"^\s*\('(" + "|".join(map(re.escape, cores)) + r")',")
metadata.write_text("".join(line for line in lines if not entry.match(line)))
print(f"==> [cores] left out of this build: {' '.join(cores)}; {len(kept)} cores")
def main(argv):
if argv[:1] == ["matrix"] and len(argv) == 1:
print(json.dumps(plan()))
elif argv[:1] == ["prefetch"] and len(argv) == 2:
prefetch(argv[1])
elif argv[:1] == ["skip"]:
if argv[1:]:
skip(argv[1:])
elif argv[:1] == ["check"] and len(argv) >= 2:
check(argv[1:] if argv[1:] != ["all"] else [entry["core"] for entry in plan()])
elif argv[:1] == ["collect"] and len(argv) == 3:
collect(argv[1], argv[2])
else:
sys.exit("usage: core-plan.py matrix | prefetch CORE | skip CORE... | check CORE...|all | collect CORE OUT")
if __name__ == "__main__":
main(sys.argv[1:])
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# ../PS5_PayloadSDK, the payload SDK fork, at the commit tools/setup-native-dependencies.sh
# pins: just that commit, fetched and checked out beside this checkout. The setup exports it with
# git archive; tools/stage-notices.py copies its licence texts from the working tree (LICENSE,
# platform/src/regex/COPYRIGHT.musl).
#
# .github/scripts/fetch-sdk-fork.sh
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
fork=$root/../PS5_PayloadSDK
revision=$(sed -n 's/^sdk_revision=\([0-9a-f]\{40\}\).*/\1/p' "$root/tools/setup-native-dependencies.sh")
[[ -n $revision ]] || { echo "fetch-sdk-fork: no sdk_revision in tools/setup-native-dependencies.sh" >&2; exit 2; }
if ! git -C "$fork" cat-file -e "$revision^{commit}" 2>/dev/null; then
[[ -d $fork/.git ]] || {
git init -q "$fork"
git -C "$fork" remote add origin https://github.com/mihawk-99/PS5_PayloadSDK.git
}
git -C "$fork" fetch -q --depth 1 origin "$revision"
fi
git -C "$fork" checkout -q --force --detach "$revision"
[[ -f $fork/LICENSE ]] || { echo "fetch-sdk-fork: no LICENSE in $fork at $revision" >&2; exit 2; }
echo "==> [sdk] PS5_PayloadSDK ${revision:0:12} beside $root"
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# The host tools of the core and title jobs, in an ubuntu:26.04 container: README's (git, wget,
# unzip, make, patch, pkg-config, readelf, host compilers), what the core builds run (CMake,
# Ninja, autotools, Python 3), rev and flock for the SDK's scripts, and LLVM 22 as the unversioned
# clang, clang++ and llvm-config: /usr/bin/clang is the compiler tools/build-title.sh names
# (PS5_CLANG), and the SDK's wrappers run the LLVM its prospero-llvm-config finds, llvm-config
# when no llvm-config-15 to -21 is installed. LLVM 22 links what the project's checks expect:
# FreeBSD/PS5 core libraries (tools/check-core.py) and -z nodynamic-undefined-weak for the
# title's weak references. Its bin directory goes on PATH for the unversioned tools (llvm-objdump).
set -euo pipefail
apt-get update
apt-get install --yes --no-install-recommends \
ca-certificates git curl wget unzip zip xz-utils bzip2 file make build-essential pkgconf \
patch rsync ccache binutils cmake ninja-build autoconf automake libtool bison flex gperf \
bsdextrautils util-linux xxd python3 python3-pip python3-yaml python3-jsonschema \
clang-22 lld-22 llvm-22 libclang-rt-22-dev
git config --global --add safe.directory '*'
for tool in clang clang++ llvm-config; do
ln -sf "../lib/llvm-22/bin/$tool" "/usr/bin/$tool"
done
for version in 15 16 17 18 19 20 21; do
! command -v "llvm-config-$version" >/dev/null || { echo "llvm-config-$version would be the SDK's" >&2; exit 2; }
done
llvm=/usr/lib/llvm-22/bin
[[ -n ${GITHUB_PATH:-} ]] && echo "$llvm" >> "$GITHUB_PATH"
for tool in "$llvm/llvm-objdump" "$llvm/ld.lld" \
"$(clang --print-resource-dir)/lib/linux/libclang_rt.builtins-x86_64.a"; do
[[ -e $tool ]] || { echo "missing $tool" >&2; exit 2; }
done
clang --version | head -n 1
llvm-config --version
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Mesa's zlib subproject (its subprojects/zlib.wrap, which RADV's PS5 build takes with
# --force-fallback-for=zlib) for PS5_Vulkan's recipe: the wrap's source and patch put in the tree's
# subprojects/packagecache, where meson looks before it downloads. The wrap names zlib.net, and
# wrapdb.mesonbuild.com for the patch with no fallback; WrapDB's GitHub releases carry both files,
# checked here against the wrap's own hashes. The recipe keeps packagecache when it refreshes its tree.
#
# .github/scripts/seed-mesa-wrap.sh <PS5_Vulkan checkout> <PS5_Mesa checkout>
set -euo pipefail
vulkan=${1:?usage: seed-mesa-wrap.sh <PS5_Vulkan> <PS5_Mesa>}
mesa=${2:?usage: seed-mesa-wrap.sh <PS5_Vulkan> <PS5_Mesa>}
revision=$(sed -n 's/^mesa_revision=\([0-9a-f]\{40\}\).*/\1/p' "$vulkan/tools/build-radv.sh")
[[ -n $revision ]] || { echo "seed-mesa-wrap: no mesa_revision in $vulkan/tools/build-radv.sh" >&2; exit 2; }
wrap=$(git -C "$mesa" show "$revision:subprojects/zlib.wrap")
field() { sed -n "s/^$1 *= *//p" <<<"$wrap" | head -n 1; }
release=$(field wrapdb_version)
[[ -n $release ]] || { echo "seed-mesa-wrap: the wrap at ${revision:0:12} has no wrapdb_version" >&2; exit 2; }
cache=$vulkan/.deps/work/radv-src/subprojects/packagecache
downloads=${CI_DOWNLOADS:-$HOME/.cache/ps5-retroarch-ci}/mesa-wrap
mkdir -p "$cache" "$downloads"
for kind in source patch; do
name=$(field "${kind}_filename")
hash=$(field "${kind}_hash")
[[ -n $name && $hash =~ ^[0-9a-f]{64}$ ]] || { echo "seed-mesa-wrap: no ${kind}_filename or ${kind}_hash" >&2; exit 2; }
if ! printf '%s %s\n' "$hash" "$downloads/$name" | sha256sum --check --strict --status 2>/dev/null; then
curl --fail --location --retry 3 --silent --show-error -o "$downloads/$name.download" \
"https://github.com/mesonbuild/wrapdb/releases/download/zlib_$release/$name"
mv -- "$downloads/$name.download" "$downloads/$name"
fi
printf '%s %s\n' "$hash" "$downloads/$name" | sha256sum --check --strict
cp -- "$downloads/$name" "$cache/$name"
done
echo "==> [mesa-wrap] zlib $release's source and patch in $cache"
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# libmicrohttpd's release tarball for tools/build-webui-http.sh, put where it looks
# (.deps/webui), so the build does not depend on ftp.gnu.org answering a CI runner. GNU's
# server first, then Ubuntu's archive, whose orig tarball is the same file; either is checked
# against the script's own pin (version, hash) and kept in CI_DOWNLOADS.
#
# .github/scripts/seed-microhttpd.sh
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
script=$root/tools/build-webui-http.sh
version=$(sed -n 's/^version=\([0-9.]*\)$/\1/p' "$script")
hash=$(sed -n 's/^hash=\([0-9a-f]\{64\}\)$/\1/p' "$script")
[[ -n $version && -n $hash ]] || { echo "seed-microhttpd: no version or hash in $script" >&2; exit 2; }
target=$root/.deps/webui/libmicrohttpd-$version.tar.gz
check() { printf '%s %s\n' "$hash" "$1" | sha256sum --check --strict --status 2>/dev/null; }
if check "$target"; then
echo "==> [microhttpd] $target is libmicrohttpd $version"
exit 0
fi
cache=${CI_DOWNLOADS:-$HOME/.cache/ps5-retroarch-ci}/libmicrohttpd-$version.tar.gz
mkdir -p "$(dirname -- "$cache")" "$(dirname -- "$target")"
if ! check "$cache"; then
for url in "https://ftp.gnu.org/gnu/libmicrohttpd/libmicrohttpd-$version.tar.gz" \
"http://archive.ubuntu.com/ubuntu/pool/universe/libm/libmicrohttpd/libmicrohttpd_$version.orig.tar.gz"; do
if curl --fail --location --retry 3 --connect-timeout 30 --silent --show-error \
-o "$cache.download" "$url" && check "$cache.download"; then
mv -- "$cache.download" "$cache"
echo "==> [microhttpd] from $url"
break
fi
rm -f -- "$cache.download"
done
fi
printf '%s %s\n' "$hash" "$cache" | sha256sum --check --strict
cp -- "$cache" "$target"
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# zlib for a checkout whose tools/setup-native-dependencies.sh builds it (this one, PS5_Vulkan):
# the tarball put where that script looks for it, so it does not download it from
# zlib.net, which can answer a CI runner with something other than the tarball. zlib's GitHub release
# carries the same file; it is checked against the script's own pin (zlib_version, zlib_hash), and
# kept in CI_DOWNLOADS.
#
# .github/scripts/seed-zlib.sh <checkout>
set -euo pipefail
checkout=${1:?usage: seed-zlib.sh <checkout>}
script=$checkout/tools/setup-native-dependencies.sh
[[ -f $script ]] || { echo "seed-zlib: no $script" >&2; exit 2; }
version=$(sed -n 's/^zlib_version="\([0-9.]*\)".*/\1/p' "$script")
hash=$(sed -n 's/^zlib_hash="\([0-9a-f]\{64\}\)".*/\1/p' "$script")
[[ -n $version && -n $hash ]] || { echo "seed-zlib: no zlib_version or zlib_hash in $script" >&2; exit 2; }
target=$checkout/.deps/native/zlib/zlib-$version.tar.gz
mkdir -p "$(dirname -- "$target")"
if printf '%s %s\n' "$hash" "$target" | sha256sum --check --strict --status 2>/dev/null; then
echo "==> [zlib] $target is zlib $version"
exit 0
fi
cache=${CI_DOWNLOADS:-$HOME/.cache/ps5-retroarch-ci}/zlib-$version.tar.gz
mkdir -p "$(dirname -- "$cache")"
if ! printf '%s %s\n' "$hash" "$cache" | sha256sum --check --strict --status 2>/dev/null; then
curl --fail --location --retry 3 --silent --show-error -o "$cache.download" \
"https://github.com/madler/zlib/releases/download/v$version/zlib-$version.tar.gz"
mv -- "$cache.download" "$cache"
fi
printf '%s %s\n' "$hash" "$cache" | sha256sum --check --strict
cp -- "$cache" "$target"
echo "==> [zlib] $target: zlib $version from its GitHub release"
+328
View File
@@ -15,6 +15,9 @@
* --ps5-mode=quit a frontend quit (EmulationStation, or RetroArch the
* picker started): the picker, or, when a frontend is
* remembered, the title closes
* --rom <file> [--core <core>] [--exit-after-game]
* a home screen forwarder's game (no mode): run as a frontend's
* game, then the title as from the home screen, or closed
* no mode a launch from the home screen: the frontend remembered
* in config/frontend.cfg (src/ps5_frontend_choice.h), or
* the picker when none is or L1 is held as the title
@@ -46,10 +49,14 @@
*/
#include "frontend_mode_ps5.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <string>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include "ps5_frontend_choice.h"
@@ -67,9 +74,26 @@ std::string launch_mode;
struct ps5_game game;
bool game_running = false;
std::time_t game_started = 0;
/* The game is a forwarder's (--rom), not a frontend's: nothing to hand a result back to */
bool game_forwarded = false;
bool forward_exit_after_game = false;
extern "C" void ps5_permissions_settle(); /* src/permissions_ps5.cpp */
std::string joined(const std::vector<std::string> &names)
{
std::string text;
for (const std::string &name : names)
text += (text.empty() ? "" : ", ") + name;
return text;
}
bool is_folder(const std::string &path)
{
struct stat status;
return stat(path.c_str(), &status) == 0 && S_ISDIR(status.st_mode);
}
bool exists(const std::string &path)
{
std::FILE *file = std::fopen(path.c_str(), "rb");
@@ -172,6 +196,7 @@ bool take_game(const Paths &paths, unsigned replaced_wait_seconds)
}
game = request;
game_running = true;
game_forwarded = false;
game_started = std::time(nullptr);
char line[PS5_GAME_PATH_MAX * 3 + 64];
std::snprintf(line, sizeof line, "game mode: %s with %s, for %s", game.content, game.core,
@@ -179,6 +204,113 @@ bool take_game(const Paths &paths, unsigned replaced_wait_seconds)
ps5::debug::mark(line);
return true;
}
/* A forwarder's game: true when RetroArch is to run it in this process. Not runnable,
* the reason goes to the trace and the launch goes on as one from the home screen. */
bool take_forward(const Paths &paths, const Forward &forward)
{
struct ps5_game request = {};
const std::string content = forward_content(forward.rom, paths.content);
const std::string core = forward_core(forward.core, PS5_GAME_CORES);
std::snprintf(request.content, sizeof(request.content), "%s", content.c_str());
std::snprintf(request.core, sizeof(request.core), "%s", core.c_str());
/* game mode's check wants a frontend to come back to: the title itself */
std::snprintf(request.frontend, sizeof(request.frontend), "%s", paths.eboot.c_str());
const auto refused = [&](const char *why)
{
char line[PS5_GAME_PATH_MAX + 400];
std::snprintf(line, sizeof line, "forwarder: --rom %s --core %s not run: %s",
forward.rom.c_str(), forward.core.empty() ? "(none)" : forward.core.c_str(),
why);
ps5::debug::mark(line);
return false;
};
if (content.empty())
return refused("the content is not a path inside the content folder (no \"..\")");
if (core.size() >= sizeof(request.core) || content.size() >= sizeof(request.content))
return refused("a path is too long");
/* A folder is content for a core that opens folders, as DOSBox Pure opens a DOS or Windows
* installation: its .info lists "/" in supported_extensions. Game mode's check wants a file,
* so a folder's request is checked with the title in its place, for the core and frontend. */
const bool folder = is_folder(content);
const std::vector<std::string> folder_cores =
folder ? forward_folder_cores(paths.info) : std::vector<std::string>();
const auto check = [&](struct ps5_game &candidate)
{
if (!folder)
return ps5_game_check(&candidate);
struct ps5_game probe = candidate;
std::snprintf(probe.content, sizeof(probe.content), "%s", paths.eboot.c_str());
const int result = ps5_game_check(&probe);
std::snprintf(candidate.error, sizeof(candidate.error), "%s", probe.error);
if (result != 0 || !candidate.core[0])
return result;
std::string name = candidate.core;
name = name.substr(name.rfind('/') + 1);
name.erase(name.size() - std::strlen("_libretro.so"));
if (std::find(folder_cores.begin(), folder_cores.end(), name) != folder_cores.end())
return 0;
std::snprintf(candidate.error, sizeof(candidate.error),
"the content is a folder, and %s does not open folders (its .info lists no "
"\"/\"%s%s)",
name.c_str(),
folder_cores.empty() ? "" : "; these do: ", joined(folder_cores).c_str());
return -1;
};
if (check(request) != 0)
return refused(request.error);
/* --core is the user's choice for this tile; without it, RetroArch's playlists choose,
* as they do for a frontend's game. */
if (!request.core[0])
{
struct ps5_game associated = request;
if (ps5_game_playlist_core(paths.playlists.c_str(), request.content, associated.core,
sizeof(associated.core)) &&
check(associated) == 0)
std::snprintf(request.core, sizeof(request.core), "%s", associated.core);
/* nor a playlist: the core whose .info file lists the content's extension, when one
* does (or a shared cartridge extension's own core) */
std::vector<std::string> runnable;
for (const std::string &name :
folder ? folder_cores : forward_extension_cores(paths.info, request.content))
{
struct ps5_game candidate = request;
const std::string path = forward_core(name, PS5_GAME_CORES);
std::snprintf(candidate.core, sizeof(candidate.core), "%s", path.c_str());
if (path.size() < sizeof(candidate.core) && check(candidate) == 0)
runnable.push_back(name);
}
std::string chosen =
runnable.size() == 1 ? runnable[0] : forward_preferred_core(request.content, runnable);
if (!request.core[0] && !chosen.empty())
std::snprintf(request.core, sizeof(request.core), "%s",
forward_core(chosen, PS5_GAME_CORES).c_str());
if (!request.core[0] && runnable.empty())
return refused(folder
? "no core: the content is a folder, and no core of the title "
"opens folders"
: "no core: neither --core nor RetroArch's playlists name one, and "
"no core lists its extension");
if (!request.core[0])
{
const std::string why = "no core: neither --core nor RetroArch's playlists name one, "
"and several cores " +
std::string(folder ? "open folders" : "list its extension") +
" (" + joined(runnable) + "); --core names the one";
return refused(why.c_str());
}
}
game = request;
game_running = true;
game_forwarded = true;
forward_exit_after_game = forward.exit_after_game;
game_started = std::time(nullptr);
char line[PS5_GAME_PATH_MAX * 2 + 64];
std::snprintf(line, sizeof line, "forwarder: %s with %s%s", game.content, game.core,
forward_exit_after_game ? ", closing after it" : "");
ps5::debug::mark(line);
return true;
}
} // namespace
std::string mode_argument(int argc, char **argv)
@@ -190,6 +322,175 @@ std::string mode_argument(int argc, char **argv)
return "";
}
Forward forward_arguments(int argc, char **argv)
{
Forward forward;
const auto value = [&](int &i, const char *flag, std::string &out)
{
const std::size_t length = std::strlen(flag);
if (std::strcmp(argv[i], flag) == 0)
{
if (i + 1 < argc && argv[i + 1])
out = argv[++i];
return true;
}
if (std::strncmp(argv[i], flag, length) == 0 && argv[i][length] == '=')
{
out = argv[i] + length + 1;
return true;
}
return false;
};
for (int i = 0; i < argc && argv && argv[i]; i++)
{
if (std::strcmp(argv[i], "--exit-after-game") == 0)
forward.exit_after_game = true;
else if (!value(i, "--rom", forward.rom))
value(i, "--core", forward.core);
}
return forward;
}
std::string forward_content(const std::string &rom, const std::string &content)
{
std::string path = rom;
while (!path.empty() && (path.back() == ' ' || path.back() == '\r' || path.back() == '\n'))
path.pop_back();
while (path.size() > 1 && path.back() == '/') /* a folder given as "dos/w95/" */
path.pop_back();
if (path.empty())
return "";
if (path[0] == '/')
return path;
for (std::string::size_type at = 0; at <= path.size();)
{
const std::string::size_type slash = path.find('/', at);
const std::string::size_type end = slash == std::string::npos ? path.size() : slash;
if (path.compare(at, end - at, "..") == 0 && end - at == 2)
return "";
at = end + 1;
}
return content + path;
}
std::string forward_core(const std::string &core, const std::string &cores)
{
if (core.empty() || core.find('/') != std::string::npos)
return core;
static const char suffix[] = "_libretro.so";
const std::string::size_type suffix_length = sizeof(suffix) - 1;
std::string name = core;
if (name.size() > 3 && name.compare(name.size() - 3, 3, ".so") == 0)
name.erase(name.size() - 3);
if (name.size() >= suffix_length - 3 &&
name.compare(name.size() - (suffix_length - 3), suffix_length - 3, "_libretro") == 0)
name.erase(name.size() - (suffix_length - 3));
return name.empty() ? "" : cores + name + suffix;
}
namespace
{
std::string lower(std::string text)
{
for (char &c : text)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return text;
}
/* The content's extension, lower case, without the dot; empty for none */
std::string extension_of(const std::string &content)
{
const std::string::size_type slash = content.rfind('/');
const std::string::size_type dot = content.rfind('.');
if (dot == std::string::npos || (slash != std::string::npos && dot < slash) ||
dot + 1 == content.size())
return "";
return lower(content.substr(dot + 1));
}
} // namespace
namespace
{
/* The cores whose .info lists token in supported_extensions ("sfc", or "/" for a folder) */
std::vector<std::string> cores_listing(const std::string &info, const std::string &extension)
{
std::vector<std::string> cores;
if (extension.empty())
return cores;
DIR *folder = opendir(info.c_str());
if (!folder)
return cores;
static const char suffix[] = "_libretro.info";
const std::string::size_type suffix_length = sizeof(suffix) - 1;
for (struct dirent *entry; (entry = readdir(folder)) != nullptr;)
{
const std::string file = entry->d_name;
if (file.size() <= suffix_length ||
file.compare(file.size() - suffix_length, suffix_length, suffix) != 0)
continue;
std::FILE *input = std::fopen((info + file).c_str(), "r");
if (!input)
continue;
char line[4096];
bool listed = false;
while (!listed && std::fgets(line, sizeof line, input))
{
/* supported_extensions = "sfc|smc|swc" */
const char *at = line;
while (*at == ' ' || *at == '\t')
at++;
if (std::strncmp(at, "supported_extensions", 20) != 0)
continue;
const char *open = std::strchr(at, '"');
const char *close = open ? std::strchr(open + 1, '"') : nullptr;
if (!close)
break;
const std::string list = lower(std::string(open + 1, close));
for (std::string::size_type start = 0; start <= list.size();)
{
std::string::size_type bar = list.find('|', start);
if (bar == std::string::npos)
bar = list.size();
if (list.compare(start, bar - start, extension) == 0 &&
bar - start == extension.size())
listed = true;
start = bar + 1;
}
break;
}
std::fclose(input);
if (listed)
cores.push_back(file.substr(0, file.size() - suffix_length));
}
closedir(folder);
std::sort(cores.begin(), cores.end());
return cores;
}
} // namespace
std::vector<std::string> forward_extension_cores(const std::string &info,
const std::string &content)
{
return cores_listing(info, extension_of(content));
}
std::vector<std::string> forward_folder_cores(const std::string &info)
{
return cores_listing(info, "/");
}
std::string forward_preferred_core(const std::string &content,
const std::vector<std::string> &cores)
{
static const char *const sega[] = {"md", "smd", "gen", "sms", "gg", "sg", "68k", "sgd"};
const std::string extension = extension_of(content);
for (const char *shared : sega)
if (extension == shared &&
std::find(cores.begin(), cores.end(), "genesis_plus_gx") != cores.end())
return "genesis_plus_gx";
return "";
}
bool session_start(int argc, char **argv)
{
for (int i = 0; i < argc && argv && argv[i]; i++)
@@ -290,6 +591,14 @@ void run(const Paths &paths, int argc, char **argv, unsigned replaced_wait_secon
if (launch.mode.empty() && !launch.test_run && !launch.picker_test && launch.choice != "ask" &&
launch.picker_present && paths.reopen_held)
launch.reopen_held = paths.reopen_held();
/* A forwarder's game, on a launch that names no mode (a handover's mode wins). */
const Forward forward = forward_arguments(argc, argv);
if (!forward.rom.empty() && launch.mode.empty())
{
if (take_forward(paths, forward))
return; /* RetroArch runs the game */
/* not runnable (the trace says why): the launch goes on as from the home screen */
}
const Next next = decide(launch);
char line[256];
std::snprintf(line, sizeof line,
@@ -336,6 +645,23 @@ void after_retroarch(const Paths &paths, const std::string &mode, int status,
game_running = false;
return;
}
if (game_running && game_forwarded)
{
/* A forwarder's game: no frontend to hand a result to. The title opens as from
* the home screen (the picker, or the frontend remembered), or closes. */
game_running = game_forwarded = false;
char line[128];
std::snprintf(line, sizeof line, "forwarder: RetroArch quit (status %d) after %ld s; %s",
status, static_cast<long>(std::time(nullptr) - game_started),
forward_exit_after_game ? "the title closes"
: "the title as from the home screen");
ps5::debug::mark(line);
if (forward_exit_after_game)
return;
restart_as(paths.eboot, replaced_wait_seconds,
"forwarder: LoadExec did not replace the process; the title closes; result");
return;
}
if (game_running)
{
game_running = false;
@@ -374,6 +700,8 @@ const ps5::frontend_mode::Paths title_paths{"/app0/picker/picker.bin",
PS5_GAME_REQUEST_PATH,
PS5_GAME_RESULT_PATH,
PS5_GAME_PLAYLISTS,
"/app0/content/",
"/app0/info/",
PS5_FRONTEND_CHOICE_PATH,
"/app0/trace.txt",
"/app0/retroarch.log",
+41
View File
@@ -10,6 +10,7 @@
#ifdef __cplusplus
#include <string>
#include <vector>
namespace ps5::frontend_mode
{
@@ -37,6 +38,8 @@ struct Paths
{
std::string picker, es_de, test_run, picker_test, eboot;
std::string request, result, playlists; /* game mode's (src/ps5_game.h) */
std::string content; /* a forwarder's relative --rom is in it */
std::string info; /* the cores' .info files (supported_extensions) */
std::string choice; /* config/frontend.cfg */
std::string trace, retroarch_log, game_log;
/* Reads the pad as the title starts: true when L1 is held. Asked only when a
@@ -47,6 +50,44 @@ struct Paths
/* The mode the process arguments name with --ps5-mode=, from argv[0] on (LoadExec's
* arguments are the whole argv), or an empty string. */
std::string mode_argument(int argc, char **argv);
/* A home screen forwarder's launch: a small app with a home screen tile of its own that
* starts the title with
*
* --rom <file> the content: an absolute path, or one inside /app0/content/
* --core <core> the core: snes9x, snes9x_libretro.so or /app0/cores/snes9x_libretro.so;
* without it, the one RetroArch's playlists associate with the content,
* else the one core whose .info lists its extension (or folders)
* --exit-after-game when RetroArch quits, the title closes rather than opening as from
* the home screen
*
* (each --x <value> also as --x=<value>). The game runs as a frontend's does in game mode
* (src/ps5_game.h): checked, then RetroArch with -L, Close Content quitting it. */
struct Forward
{
std::string rom, core;
bool exit_after_game = false;
};
Forward forward_arguments(int argc, char **argv);
/* The content's path: an absolute one as it is; a relative one inside content (a folder
* ending in '/'), never above it: empty for a path with a ".." part. A trailing '/' goes. */
std::string forward_content(const std::string &rom, const std::string &content);
/* The core's path inside cores (a folder ending in '/'), from a name with or without
* _libretro.so; a path with a '/' as it is (ps5_game_check decides). Empty for none. */
std::string forward_core(const std::string &core, const std::string &cores);
/* The cores whose .info file in info (a folder ending in '/') lists the content's extension in
* supported_extensions, by name (snes9x), sorted: a forwarder without --core whose content no
* playlist names takes the one core that runs it. */
std::vector<std::string> forward_extension_cores(const std::string &info,
const std::string &content);
/* The cores whose .info lists "/" in supported_extensions: they open a folder as content
* (DOSBox Pure, PUAE, VICE). A forwarder's --rom may name a folder for them. */
std::vector<std::string> forward_folder_cores(const std::string &info);
/* Of several such cores, the one a cartridge extension two cores share is meant for (the Mega
* Drive and Master System extensions: Genesis Plus GX before PicoDrive); empty when there is
* no such choice and --core has to name one. */
std::string forward_preferred_core(const std::string &content,
const std::vector<std::string> &cores);
/* A launch that starts a session: no --ps5- argument at all, as from the home screen
* or a test run. Every handover names a mode, and a test's restarts their generation. */
bool session_start(int argc, char **argv);
+30 -4
View File
@@ -14,7 +14,9 @@
*
* /app0/relaunch-test.txt ("<count> <run id> [image]", written by tools/run-title.sh
* --relaunch-test, with --relaunch-image naming another image of the title in /app0)
* arms it. Each process the test starts appends one line to
* arms it; each line after the first (--relaunch-arg) is one more argument the restarts
* carry, as a home screen forwarder's --rom and --core do (src/frontend_mode_ps5.cpp).
* Each process the test starts appends one line to
* /app0/relaunch-test.jsonl: its generation, which is the number of this run's
* lines already in the file (so a restart that lost its arguments still counts,
* and cannot loop), the generation its arguments name, argc and argv, the
@@ -118,7 +120,9 @@ bool parse_arm(const std::string &text, Arm &arm)
unsigned count = 0;
char run[48] = {};
char image[256] = {};
const int fields = std::sscanf(text.c_str(), "%u %47s %255s", &count, run, image);
const std::size_t first_end = text.find('\n');
const int fields =
std::sscanf(text.substr(0, first_end).c_str(), "%u %47s %255s", &count, run, image);
if (fields < 2 || count == 0 || count > max_count)
return false;
for (const char *at = run; *at; at++)
@@ -129,9 +133,28 @@ bool parse_arm(const std::string &text, Arm &arm)
if (fields == 3 && (std::strncmp(image, "/app0/", 6) != 0 || std::strstr(image, "..") ||
std::strpbrk(image, "\"\\")))
return false;
/* The lines after the first are arguments for the restarts, one a line. */
std::vector<std::string> arguments;
for (std::size_t at = first_end; at != std::string::npos && at + 1 < text.size();)
{
const std::size_t end = text.find('\n', at + 1);
std::string argument = text.substr(at + 1, end == std::string::npos ? end : end - at - 1);
at = end;
if (!argument.empty() && argument.back() == '\r')
argument.pop_back();
if (argument.empty())
continue;
if (arguments.size() == 8 || argument.size() > 255)
return false;
for (const char c : argument)
if (static_cast<unsigned char>(c) < 0x20)
return false;
arguments.push_back(argument);
}
arm.count = count;
arm.run = run;
arm.image = fields == 3 ? image : "";
arm.arguments = arguments;
return true;
}
@@ -240,11 +263,14 @@ bool run_test(const Paths &paths, int argc, char **argv, unsigned replaced_wait_
}
const std::string next = std::string(token) + std::to_string(current + 1);
const char *const arguments[] = {next.c_str(), nullptr};
std::vector<const char *> arguments{next.c_str()};
for (const std::string &argument : arm.arguments)
arguments.push_back(argument.c_str());
arguments.push_back(nullptr);
std::fflush(nullptr);
const std::string &image = arm.image.empty() ? paths.image : arm.image;
ps5_permissions_settle();
const int result = sceSystemServiceLoadExec(image.c_str(), arguments);
const int result = sceSystemServiceLoadExec(image.c_str(), arguments.data());
std::string event = line_start(arm.run, current) +
",\"event\":\"loadexec\",\"result\":" + std::to_string(result);
if (result >= 0)
+4 -1
View File
@@ -8,17 +8,20 @@
#ifdef __cplusplus
#include <string>
#include <vector>
namespace ps5::relaunch
{
/* "<count> <run id> [image]": restart the title <count> times (1 to 20) under one
* run id (letters, digits, '-' and '_'), through the image named, a path in /app0,
* or the title's own eboot.bin when none is. */
* or the title's own eboot.bin when none is. Each line after the first is one more
* argument for the restarts (at most 8, printable, under 256 bytes). */
struct Arm
{
unsigned count = 0;
std::string run;
std::string image;
std::vector<std::string> arguments;
};
struct Paths
+173 -14
View File
@@ -5,6 +5,7 @@
* directory for the files the decision reads. */
#include <sys/stat.h>
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <string>
@@ -184,19 +185,12 @@ int main(int argc, char **argv)
}
/* The launches, against files in the scratch directory. */
const ps5::frontend_mode::Paths paths{dir + "/picker.bin",
dir + "/es-de.bin",
dir + "/test-run.txt",
dir + "/picker-test.txt",
dir + "/eboot.bin",
dir + "/game-request.txt",
dir + "/game-result.txt",
dir + "/playlists",
dir + "/frontend.cfg",
dir + "/trace.txt",
dir + "/retroarch.log",
dir + "/retroarch-game.log",
read_l1};
const ps5::frontend_mode::Paths paths{
dir + "/picker.bin", dir + "/es-de.bin", dir + "/test-run.txt",
dir + "/picker-test.txt", dir + "/eboot.bin", dir + "/game-request.txt",
dir + "/game-result.txt", dir + "/playlists", dir + "/content/",
dir + "/info/", dir + "/frontend.cfg", dir + "/trace.txt",
dir + "/retroarch.log", dir + "/retroarch-game.log", read_l1};
touch(paths.picker, true);
touch(paths.es_de, true);
touch(paths.test_run, false);
@@ -413,8 +407,173 @@ int main(int argc, char **argv)
!ps5::frontend_mode::running_game());
assert(marks.back() == "game mode: no request; RetroArch runs");
/* A home screen forwarder: --rom and --core, run as a game; afterwards the title as from
* the home screen, or closed with --exit-after-game. */
{
using ps5::frontend_mode::forward_arguments;
using ps5::frontend_mode::forward_content;
using ps5::frontend_mode::forward_core;
char rom[] = "--rom", rom_value[] = "snes/Game (USA).zip", core_flag[] = "--core=snes9x",
exit_flag[] = "--exit-after-game", dangling[] = "--core";
char *full[] = {rom, rom_value, core_flag, exit_flag, nullptr};
const ps5::frontend_mode::Forward parsed = forward_arguments(4, full);
assert(parsed.rom == "snes/Game (USA).zip" && parsed.core == "snes9x" &&
parsed.exit_after_game);
char *missing[] = {dangling, nullptr};
assert(forward_arguments(1, missing).core.empty() &&
forward_arguments(0, nullptr).rom.empty());
assert(forward_content("snes/Game.zip", "/app0/content/") == "/app0/content/snes/Game.zip");
assert(forward_content("/mnt/usb0/Game.zip\r\n", "/c/") == "/mnt/usb0/Game.zip");
assert(forward_content("../Game.zip", "/c/").empty() &&
forward_content("a/../../x", "/c/").empty());
assert(forward_content("a/..b.zip", "/c/") == "/c/a/..b.zip" &&
forward_content("", "/c/").empty());
for (const char *name : {"snes9x", "snes9x_libretro", "snes9x_libretro.so"})
assert(forward_core(name, "/app0/cores/") == "/app0/cores/snes9x_libretro.so");
assert(forward_core("/app0/cores/mgba_libretro.so", "/x/") ==
"/app0/cores/mgba_libretro.so");
assert(forward_core("", "/app0/cores/").empty() &&
forward_core("_libretro.so", "/c/").empty());
}
mkdir((dir + "/content").c_str(), 0777);
mkdir((dir + "/content/snes").c_str(), 0777);
const std::string forwarded = dir + "/content/snes/Game.zip";
touch(forwarded, true);
touch(paths.eboot, true); /* the title, which game mode's check takes as the frontend */
touch(paths.picker, true);
std::remove(paths.choice.c_str());
exec_mode = Exec::replace;
/* --core named: the game, with that core; the playlists do not override it */
assert(launch(paths, {"--rom", "snes/Game.zip", "--core", "snes9x"}) == "retroarch");
const struct ps5_game *forward_game = ps5::frontend_mode::running_game();
assert(forward_game && forwarded == forward_game->content && core == forward_game->core);
assert(marks.back() == "forwarder: " + forwarded + " with " + core);
assert(ps5::frontend_mode::retroarch_log(paths) == paths.game_log);
/* RetroArch quit: the title as from the home screen, with no result for a frontend */
std::remove(paths.result.c_str());
assert(quit("") == paths.eboot && exec_argument.empty());
assert(!ps5::frontend_mode::exists(paths.result) && !ps5::frontend_mode::running_game());
/* --exit-after-game: the title closes (catchReturnFromMain's LoadExec("exit")) */
{
const std::string absolute = "--rom=" + forwarded;
assert(launch(paths, {absolute.c_str(), "--core=snes9x_libretro.so",
"--exit-after-game"}) == "retroarch");
assert(ps5::frontend_mode::running_game() && quit("") == "closes");
assert(marks.back().find("the title closes") != std::string::npos);
}
/* no --core: RetroArch's playlists choose, as for a frontend's game */
playlist = std::fopen((paths.playlists + "/SNES.lpl").c_str(), "w");
assert(playlist);
std::fprintf(playlist, "{\"items\":[{\"path\":\"%s\",\"core_path\":\"%s\"}]}",
forwarded.c_str(), other.c_str());
std::fclose(playlist);
assert(launch(paths, {"--rom", "snes/Game.zip"}) == "retroarch" &&
other == ps5::frontend_mode::running_game()->core);
assert(quit("") == paths.eboot);
std::remove((paths.playlists + "/SNES.lpl").c_str());
/* not runnable: the trace says why, and the launch goes on as from the home screen */
assert(launch(paths, {"--rom", "snes/Game.zip"}) == paths.picker &&
!ps5::frontend_mode::running_game());
assert(std::find_if(marks.begin(), marks.end(), [](const std::string &m)
{ return m.find("no core: neither --core") != std::string::npos; }) !=
marks.end());
/* nor a playlist: the core whose .info lists the extension (a hack no playlist has) */
mkdir(paths.info.c_str(), 0777);
write_file(paths.info + "snes9x_libretro.info",
"display_name = \"Snes9x\"\nsupported_extensions = \"smc|SFC|swc\"\n");
write_file(paths.info + "bsnes_libretro.info", "supported_extensions = \"sfc|bs\"\n");
write_file(paths.info + "genesis_plus_gx_libretro.info",
" supported_extensions = \"md|gen|cue\"\n");
write_file(paths.info + "picodrive_libretro.info", "supported_extensions = \"md|32x|cue\"\n");
write_file(paths.info + "notes.txt", "supported_extensions = \"sfc\"\n");
const std::string hack = dir + "/content/snes/Hack (patched).SFC";
touch(hack, true);
assert((ps5::frontend_mode::forward_extension_cores(paths.info, hack) ==
std::vector<std::string>{"bsnes", "snes9x"}));
assert(ps5::frontend_mode::forward_extension_cores(paths.info, dir + "/content/snes/x.smc") ==
std::vector<std::string>{"snes9x"});
assert(ps5::frontend_mode::forward_extension_cores(paths.info, dir + "/content/a.b/noext")
.empty());
assert(ps5::frontend_mode::forward_extension_cores(paths.info, dir + "/content/x.sf").empty());
assert(ps5::frontend_mode::forward_preferred_core(
"/x/Racer.md", {"genesis_plus_gx", "picodrive"}) == "genesis_plus_gx");
assert(
ps5::frontend_mode::forward_preferred_core("/x/Game.cue", {"genesis_plus_gx", "picodrive"})
.empty());
/* bsnes lists .sfc but is not one of the title's cores (no library): snes9x runs it */
std::remove(other.c_str());
assert(launch(paths, {"--rom", "snes/Hack (patched).SFC"}) == "retroarch" &&
core == ps5::frontend_mode::running_game()->core);
assert(quit("") == paths.eboot);
/* both are cores of the title: --core has to choose, and the trace names them */
touch(other, true);
assert(launch(paths, {"--rom", "snes/Hack (patched).SFC"}) == paths.picker &&
!ps5::frontend_mode::running_game());
assert(std::find_if(marks.begin(), marks.end(),
[](const std::string &m)
{
return m.find("several cores list its extension (bsnes, snes9x)") !=
std::string::npos;
}) != marks.end());
/* a Mega Drive cartridge both Sega cores list: Genesis Plus GX */
const std::string genesis = std::string(PS5_GAME_CORES) + "genesis_plus_gx_libretro.so";
touch(genesis, true);
touch(std::string(PS5_GAME_CORES) + "picodrive_libretro.so", true);
touch(dir + "/content/Racer.md", true);
assert(launch(paths, {"--rom", "Racer.md"}) == "retroarch" &&
genesis == ps5::frontend_mode::running_game()->core);
assert(quit("") == paths.eboot);
/* a folder, for a core that opens folders (its .info lists "/"): DOSBox Pure's DOS or
* Windows installation */
write_file(paths.info + "dosbox_pure_libretro.info",
"supported_extensions = \"zip|dosz|exe|/\"\n");
const std::string dosbox = std::string(PS5_GAME_CORES) + "dosbox_pure_libretro.so";
touch(dosbox, true);
mkdir((dir + "/content/dos").c_str(), 0777);
mkdir((dir + "/content/dos/w95").c_str(), 0777);
assert(ps5::frontend_mode::forward_folder_cores(paths.info) ==
std::vector<std::string>{"dosbox_pure"});
assert(ps5::frontend_mode::forward_content("dos/w95/", "/c/") == "/c/dos/w95");
{
const std::string w95 = "--rom=" + dir + "/content/dos/w95";
assert(launch(paths, {w95.c_str(), "--core", "dosbox_pure_libretro",
"--exit-after-game"}) == "retroarch" &&
dosbox == ps5::frontend_mode::running_game()->core &&
dir + "/content/dos/w95" == ps5::frontend_mode::running_game()->content);
assert(quit("") == "closes");
}
/* without --core: the one core that opens folders */
assert(launch(paths, {"--rom", "dos/w95/"}) == "retroarch" &&
dosbox == ps5::frontend_mode::running_game()->core);
assert(quit("") == paths.eboot);
/* a core that does not open folders is refused, naming those that do */
assert(launch(paths, {"--rom", "dos/w95", "--core", "snes9x"}) == paths.picker &&
!ps5::frontend_mode::running_game());
const auto marked = [&](const char *text)
{
return std::find_if(marks.begin(), marks.end(), [&](const std::string &m)
{ return m.find(text) != std::string::npos; }) != marks.end();
};
assert(
marked("snes9x does not open folders (its .info lists no \"/\"; these do: dosbox_pure)"));
/* a folder that is not there is still "not a file" */
assert(launch(paths, {"--rom", "dos/w98", "--core", "dosbox_pure"}) == paths.picker &&
marked("--rom dos/w98 --core dosbox_pure not run: the content is not a file"));
for (const std::vector<const char *> &args :
std::vector<std::vector<const char *>>{{"--rom", "snes/Missing.zip", "--core", "snes9x"},
{"--rom", "../Game.zip", "--core", "snes9x"},
{"--rom", "snes/Game.zip", "--core", "nosuchcore"}})
assert(launch(paths, args) == paths.picker && !ps5::frontend_mode::running_game());
/* a handover's mode wins over --rom */
assert(launch(paths, {"--ps5-mode=es-de", "--rom", "snes/Game.zip", "--core", "snes9x"}) ==
paths.es_de &&
!ps5::frontend_mode::running_game());
std::puts(
"frontend_mode_ps5: decisions, mode argument, LoadExec targets, test runs and "
"frontend_mode_ps5: forwarder --rom/--core launches, cores by extension, folders, refusals "
"and "
"after-game PASS; "
"decisions, mode argument, LoadExec targets, test runs and "
"refused or ignored LoadExec PASS; the remembered frontend, L1, quit and closing PASS; "
"session and per-mode logs PASS; back to the picker after RetroArch, no handover after "
"an update; game mode requests, cores, refusals and results PASS");
+23 -2
View File
@@ -24,6 +24,7 @@ enum class Exec
Exec exec_mode = Exec::replace;
int exec_calls = 0;
std::string exec_path, exec_argument;
std::vector<std::string> exec_arguments;
std::vector<std::string> marks;
std::string slurp(const std::string &path)
@@ -79,7 +80,10 @@ extern "C" int sceSystemServiceLoadExec(const char *path, const char *const *arg
exec_calls++;
exec_path = path;
exec_argument = argv && argv[0] ? argv[0] : "";
assert(argv && argv[0] && !argv[1]);
assert(argv && argv[0]);
exec_arguments.assign(argv, argv + 1);
for (const char *const *at = argv + 1; *at; at++)
exec_arguments.emplace_back(*at);
if (exec_mode == Exec::replace)
throw Replaced{};
return exec_mode == Exec::refuse ? static_cast<int>(0x80020002u) : 0;
@@ -128,7 +132,7 @@ int main(int argc, char **argv)
arm(paths, "3 chain-1\n");
assert(launch(paths, {""}, continued));
assert(exec_calls == 1 && exec_path == "/app0/eboot.bin" &&
exec_argument == "--ps5-relaunch=1");
exec_argument == "--ps5-relaunch=1" && exec_arguments.size() == 1);
assert(launch(paths, {"--ps5-relaunch=1"}, continued) && exec_argument == "--ps5-relaunch=2");
// A restart whose arguments were lost still counts from the file; the last
// one finds its token after another argument.
@@ -193,6 +197,23 @@ int main(int argc, char **argv)
std::string::npos);
assert(!launch(paths, {"--ps5-relaunch=1"}, continued) && !continued);
// Arguments for the restarts, one a line after the first: each restart carries them
// after its generation (a home screen forwarder's here). A bad line arms nothing.
arm(paths, "1 forward\n--rom\r\nsnes/Game (USA).zip\n\n--core\nsnes9x\n");
assert(launch(paths, {""}, continued));
assert((exec_arguments == std::vector<std::string>{"--ps5-relaunch=1", "--rom",
"snes/Game (USA).zip", "--core", "snes9x"}));
assert(!launch(paths, {"--ps5-relaunch=1", "--rom", "snes/Game (USA).zip", "--core", "snes9x"},
continued) &&
!continued && !exists(paths.arm));
const int before_bad_arguments = exec_calls;
for (const char *bad : {"1 r\nok\nbad\ttab", "1 r\n1\n2\n3\n4\n5\n6\n7\n8\n9"})
{
arm(paths, bad);
assert(!launch(paths, {""}, continued) && !continued && !exists(paths.arm));
}
assert(exec_calls == before_bad_arguments);
// A record that cannot be written never restarts.
const int calls = exec_calls;
ps5::relaunch::Paths unwritable = paths;
+13 -3
View File
@@ -10,6 +10,9 @@
# tools/run-title.sh --relaunch-test=5 --watch 60 restart the title 5 times, then RetroArch
# tools/run-title.sh --relaunch-test=1 --relaunch-image=probe/eboot-copy.bin --watch 45
# restart through a copy of eboot.bin at /app0/probe/...
# tools/run-title.sh --relaunch-test=1 --relaunch-arg=--rom --relaunch-arg=snes/Game.zip \
# --relaunch-arg=--core --relaunch-arg=snes9x --watch 45
# restart with a home screen forwarder's arguments
# tools/run-title.sh --display-modes-test=180 --watch 60 every display size, then RetroArch
# tools/run-title.sh --relaunch-test=1 --relaunch-image=es-de/es-de.bin --frontend-capture=20,40 --watch 60
# hand over to EmulationStation, picture its frames at 20 and 40 s
@@ -81,6 +84,7 @@ core_test=none
relaunch_test=0
display_modes_test=0
relaunch_image=
relaunch_args=
frontend_capture=
frontend_scroll=
picker_test=
@@ -103,6 +107,7 @@ while (( $# )); do
--relaunch-test) relaunch_test=5 ;;
--relaunch-test=*) relaunch_test=${1#*=} ;;
--relaunch-image=*) relaunch_image=${1#*=} ;;
--relaunch-arg=*) relaunch_args+="${1#*=}"$'\n' ;;
--display-modes-test) display_modes_test=180 ;;
--display-modes-test=*) display_modes_test=${1#*=} ;;
--frontend-capture=*) frontend_capture=${1#*=} ;;
@@ -119,7 +124,7 @@ while (( $# )); do
--menu-capture=*) menu_capture=${1#*=} ;;
--home-launch) home_launch=1 ;;
--retroarch-arg=*) retroarch_args+="${1#*=}"$'\n' ;;
*) echo "usage: ${0##*/} [--no-build] [--no-deploy] [--watch SECONDS] [--gpu-profile 1..60] [--audio-test] [--core-test[=fceumm|mgba|snes9x|fbneo|genesis_plus_gx|ppsspp]] [--relaunch-test[=1..20] [--relaunch-image=PATH]] [--display-modes-test[=frames]] [--frontend-capture=SECONDS[,SECONDS...] [--frontend-scroll] [--frontend-profile] [--frontend-quit] [--frontend-launch]] [--picker-test=FRAMES:retroarch|es-de|none] [--retroarch-frames=N] [--game=CORE:PATH] [--pad-script=FILE] [--pad-monitor] [--menu-capture=FRAMES] [--home-launch] [--retroarch-arg=ARG ...]" >&2; exit 2 ;;
*) echo "usage: ${0##*/} [--no-build] [--no-deploy] [--watch SECONDS] [--gpu-profile 1..60] [--audio-test] [--core-test[=fceumm|mgba|snes9x|fbneo|genesis_plus_gx|ppsspp]] [--relaunch-test[=1..20] [--relaunch-image=PATH] [--relaunch-arg=ARG ...]] [--display-modes-test[=frames]] [--frontend-capture=SECONDS[,SECONDS...] [--frontend-scroll] [--frontend-profile] [--frontend-quit] [--frontend-launch]] [--picker-test=FRAMES:retroarch|es-de|none] [--retroarch-frames=N] [--game=CORE:PATH] [--pad-script=FILE] [--pad-monitor] [--menu-capture=FRAMES] [--home-launch] [--retroarch-arg=ARG ...]" >&2; exit 2 ;;
esac
shift
done
@@ -140,6 +145,9 @@ if (( relaunch_test && watch < 30 )); then
exit 2
fi
relaunch_run=relaunch-$(date +%Y%m%d-%H%M%S)
# Arguments the restarts carry, one a line after the arm line (a forwarder's --rom and --core).
[[ -z $relaunch_args ]] || (( relaunch_test )) ||
{ echo "--relaunch-arg needs --relaunch-test" >&2; exit 2; }
# Another image of the title, a path under /app0: a copy of this build's eboot.bin.
if [[ -n $relaunch_image ]]; then
(( relaunch_test )) || { echo "--relaunch-image needs --relaunch-test" >&2; exit 2; }
@@ -294,7 +302,7 @@ fi
# appeared. A run that does not ask for extras must not inherit them, so the file
# is removed on every run - before the launch, because deleting it afterwards
# would leave it for the next one if this run dies.
python3 - "$title_id" "$profile" "$audio_test" "$core_test" "$relaunch_test" "$relaunch_run" "$display_modes_test" "$relaunch_image" "$frontend_capture" "$capture_run" "$frontend_scroll" "$picker_test" "$retroarch_frames" "$pad_script" "$pad_monitor" "$home_launch" "$game" "$menu_capture" "$retroarch_args" <<'PY'
python3 - "$title_id" "$profile" "$audio_test" "$core_test" "$relaunch_test" "$relaunch_run" "$display_modes_test" "$relaunch_image" "$frontend_capture" "$capture_run" "$frontend_scroll" "$picker_test" "$retroarch_frames" "$pad_script" "$pad_monitor" "$home_launch" "$game" "$menu_capture" "$retroarch_args" "$relaunch_args" <<'PY'
import importlib.util, sys
spec = importlib.util.spec_from_file_location("dt", "tools/deploy-title.py")
dt = importlib.util.module_from_spec(spec); spec.loader.exec_module(dt)
@@ -339,8 +347,10 @@ with connect(**dt.load_settings()) as ftp:
else:
upload_atomic(ftp, Path(f"dist/{sys.argv[1]}/eboot.bin"), f"/data/homebrew/{sys.argv[1]}/{sys.argv[8]}")
print(f" uploaded a copy of this build's eboot.bin to /app0/{sys.argv[8]}")
ftp.storbinary(f"STOR {control}", io.BytesIO(f"{sys.argv[5]} {sys.argv[6]}{image}\n".encode()))
ftp.storbinary(f"STOR {control}", io.BytesIO(f"{sys.argv[5]} {sys.argv[6]}{image}\n{sys.argv[20]}".encode()))
print(f" armed relaunch test: {sys.argv[5]} restarts, run {sys.argv[6]}{image}")
if sys.argv[20]:
print(f" each restart carries: {sys.argv[20].splitlines()}")
control = f"/data/homebrew/{sys.argv[1]}/display-modes-test.txt"
remove_if_present(ftp, control)
if int(sys.argv[7]):