mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
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.
304 lines
14 KiB
Python
304 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# Copyright (C) 2026 Mihawk
|
|
"""Write licenses/ into a built title folder.
|
|
|
|
python3 tools/stage-notices.py dist/PPSA99169 [--driver radv|ps5vk] [--release-tag TAG]
|
|
|
|
Every part of the title is listed in tooling/notices/components.json with its licence,
|
|
the licence texts that must travel with it and where its source is. This copies those
|
|
texts from the source trees the build used, and writes two files beside them:
|
|
|
|
licenses/components.json each part's licence, source revision and URL, and the
|
|
sha256 of each executable file it went into
|
|
licenses/README.txt the same for a person reading the folder
|
|
|
|
A core is tied to its source by its build report (build/cores/<core>/build.json): the
|
|
digest recorded there must be the digest of the staged file, or this fails. RADV is tied
|
|
to PS5_Mesa by the release archive's PROVENANCE.txt, whose archive digest is checked.
|
|
tools/check-notices.py verifies the result; tools/source-bundle.py archives the sources.
|
|
"""
|
|
|
|
import argparse
|
|
import fnmatch
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
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, 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):
|
|
pass
|
|
|
|
|
|
def sha256(path):
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as stream:
|
|
for block in iter(lambda: stream.read(1 << 20), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def expand(text, tokens):
|
|
for name, value in tokens.items():
|
|
text = text.replace("${" + name + "}", value)
|
|
return text
|
|
|
|
|
|
def resolve(path, tokens, root):
|
|
candidate = Path(expand(path, tokens))
|
|
return candidate if candidate.is_absolute() else root / candidate
|
|
|
|
|
|
def title_files(title):
|
|
return sorted(p.relative_to(title).as_posix() for p in title.rglob("*")
|
|
if p.is_file() and not p.relative_to(title).as_posix().startswith("licenses/"))
|
|
|
|
|
|
def matches(pattern, files):
|
|
return [f for f in files if fnmatch.fnmatchcase(f, pattern)]
|
|
|
|
|
|
def git(path, *args):
|
|
return subprocess.run(["git", "-C", str(path), *args], check=True, text=True,
|
|
capture_output=True).stdout.strip()
|
|
|
|
|
|
def source_of(component, tokens, root, title):
|
|
"""The revision and URL of a component's source, and whether it is committed."""
|
|
spec = component["source"]
|
|
kind = spec["kind"]
|
|
info = {"kind": kind}
|
|
if kind == "git":
|
|
path = resolve(spec["path"], tokens, root)
|
|
info["revision"] = git(path, "rev-parse", "HEAD")
|
|
# Tracked changes only: ignored build output is not source.
|
|
info["dirty"] = bool(git(path, "status", "--porcelain", "--untracked-files=no"))
|
|
elif kind == "pin":
|
|
text = resolve(spec["file"], tokens, root).read_text(encoding="utf-8")
|
|
found = re.search(r'^\s*' + re.escape(spec["var"]) + r'="?([^"\s#]+)', text, re.M)
|
|
if not found:
|
|
raise NoticeError(f"{component['id']}: no {spec['var']}= in {spec['file']}")
|
|
info["revision"] = found.group(1)
|
|
info["pinned_in"] = spec["file"]
|
|
elif kind == "stamp":
|
|
# The revision the installed tree records, which is what was linked; the pin
|
|
# that installs it must agree, or the build used something else.
|
|
info["revision"] = resolve(spec["file"], tokens, root).read_text().split()[0]
|
|
pinned = source_of({"id": component["id"], "source": {"kind": "pin", **spec["pin"]}},
|
|
tokens, root, title)["revision"]
|
|
if pinned != info["revision"]:
|
|
raise NoticeError(f"{component['id']}: {spec['file']} records {info['revision']}, "
|
|
f"but {spec['pin']['file']} pins {pinned}; rerun the build")
|
|
info["pinned_in"] = spec["pin"]["file"]
|
|
elif kind == "json":
|
|
data = json.loads(resolve(spec["file"], tokens, root).read_text(encoding="utf-8"))
|
|
info["revision"] = data[spec["key"]]
|
|
elif kind == "provenance":
|
|
text = resolve(spec["file"], tokens, root).read_text(encoding="utf-8")
|
|
fields = dict(line.split(":", 1) for line in text.splitlines() if ":" in line)
|
|
fields = {k.strip(): v.strip() for k, v in fields.items()}
|
|
info["revision"] = fields[spec["key"]].split()[0]
|
|
if "archive" in spec:
|
|
archive = resolve(spec["archive"], tokens, root)
|
|
recorded = fields.get("archive sha256")
|
|
if recorded and sha256(archive) != recorded:
|
|
raise NoticeError(f"{component['id']}: {archive} is not the archive its "
|
|
"PROVENANCE.txt describes")
|
|
info["archive_sha256"] = recorded
|
|
elif kind == "core":
|
|
report = json.loads((root / "build/cores" / spec["build"] / "build.json").read_text())
|
|
staged = [f for f in component["artifacts"] if f.endswith(".so")]
|
|
for name in staged:
|
|
path = title / name
|
|
if path.is_file() and sha256(path) != report["sha256"]:
|
|
raise NoticeError(f"{component['id']}: {name} is not the file "
|
|
f"build/cores/{spec['build']}/build.json describes")
|
|
info["revision"] = report["source_revision"]
|
|
for key in ("source_archive_sha256", "source_submodules", "port_inputs_sha256"):
|
|
if report.get(key):
|
|
info[key] = report[key]
|
|
elif kind == "fixed":
|
|
info["revision"] = spec["revision"]
|
|
else:
|
|
raise NoticeError(f"{component['id']}: unknown source kind {kind}")
|
|
if "url" in spec:
|
|
info["url"] = spec["url"].replace("{rev}", info["revision"])
|
|
elif "remote" in spec:
|
|
info["url"] = f"{spec['remote']}/tree/{info['revision']}"
|
|
return info
|
|
|
|
|
|
def copy_text(entry, tokens, root, target):
|
|
"""Copy one licence text (a file, a directory or a tarball member)."""
|
|
destination = target / entry["to"]
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
if "tar" in entry:
|
|
archive = resolve(entry["tar"], tokens, root)
|
|
with tarfile.open(archive) as tar:
|
|
member = tar.extractfile(entry["member"])
|
|
if member is None:
|
|
raise NoticeError(f"{archive} has no {entry['member']}")
|
|
destination.write_bytes(member.read())
|
|
return f"{entry['tar']}:{entry['member']}"
|
|
choices = entry["from"] if isinstance(entry["from"], list) else [entry["from"]]
|
|
for choice in choices:
|
|
source = resolve(choice, tokens, root)
|
|
if source.is_dir():
|
|
shutil.copytree(source, destination)
|
|
return choice
|
|
if source.is_file():
|
|
if "head" in entry:
|
|
lines = source.read_text(encoding="utf-8", errors="replace").splitlines(True)
|
|
destination.write_text("".join(lines[:entry["head"]]), encoding="utf-8")
|
|
else:
|
|
shutil.copyfile(source, destination)
|
|
return choice
|
|
raise NoticeError(f"licence text not found: {' or '.join(choices)}")
|
|
|
|
|
|
def legal_notice():
|
|
"""The title's legal notice: no piracy, RPCS3 only built from source."""
|
|
return (Path(__file__).resolve().parent.parent / "config" / "LEGAL.txt").read_text(encoding="utf-8")
|
|
|
|
|
|
def readme(components, tag):
|
|
lines = [
|
|
"PS5 RetroArch - licences, notices and sources",
|
|
"=" * 45,
|
|
"",
|
|
f"Release: {tag or 'a development build (no release tag given)'}",
|
|
"",
|
|
# The legal notice first (config/LEGAL.txt, also LEGAL.txt beside eboot.bin)
|
|
*legal_notice().splitlines()[3:],
|
|
"",
|
|
"This folder lists every part of this title, the licence each is under, the",
|
|
"licence texts those licences require to travel with it, and the exact source",
|
|
"revision each part was built from. components.json holds the same, with the",
|
|
"sha256 of every executable file.",
|
|
"",
|
|
"Corresponding source: each part's source is published at the address given",
|
|
"for it below, at the revision given. The release page this title was",
|
|
"downloaded from also carries the release's source archives (made by",
|
|
"tools/source-bundle.py in PS5_RetroArch), including this port's patches and",
|
|
"the scripts that build and install the title (tools/build-title.sh).",
|
|
"",
|
|
"Not included: no games, BIOS files, console firmware or decryption keys. The",
|
|
"emulators need the ones you supply from your own hardware and media.",
|
|
"",
|
|
]
|
|
noncommercial = [c for c in components if c.get("noncommercial")]
|
|
if noncommercial:
|
|
lines += ["Non-commercial terms", "--------------------",
|
|
"These parts may not be sold or used commercially under their own licences:",
|
|
*[f" - {c['name']} ({c['licence']})" for c in noncommercial], ""]
|
|
lines += ["Parts", "-----", ""]
|
|
for c in components:
|
|
source = c["source"]
|
|
lines += [
|
|
c["name"],
|
|
f" kind: {c['kind']}",
|
|
f" licence: {c['licence']}",
|
|
*[f" copyright: {line}" for line in c.get("copyright", [])],
|
|
f" texts: {', '.join(c['id'] + '/' + t for t in c['texts']) or '-'}",
|
|
f" source: {source.get('url', source['revision'])}",
|
|
f" revision: {source['revision']}" + (" (uncommitted changes)" if source.get("dirty") else ""),
|
|
f" changes: {c.get('modifications', '-')}",
|
|
f" files: {', '.join(sorted(c['files'])[:6])}" + (" ..." if len(c["files"]) > 6 else ""),
|
|
"",
|
|
]
|
|
lines += ["PlayStation and PS5 are trademarks of Sony Interactive Entertainment. This",
|
|
"is an independent homebrew project, not affiliated with or endorsed by Sony",
|
|
"Interactive Entertainment, the Khronos Group or the libretro project.", ""]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def stage(title, driver, tag, tokens, root=ROOT, table=TABLE):
|
|
"""Write licenses/; on any failure leave no partial folder behind."""
|
|
target = title / "licenses"
|
|
try:
|
|
return write(title, driver, tag, tokens, root, table, target)
|
|
except BaseException:
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
raise
|
|
|
|
|
|
def write(title, driver, tag, tokens, root, table, target):
|
|
table = json.loads(Path(table).read_text(encoding="utf-8"))
|
|
files = title_files(title)
|
|
if target.exists():
|
|
shutil.rmtree(target)
|
|
target.mkdir()
|
|
staged = []
|
|
for component in table["components"]:
|
|
if component.get("driver") not in (None, driver):
|
|
continue
|
|
present = sorted({f for p in component["artifacts"] for f in matches(p, files)})
|
|
if not present and not component.get("always"):
|
|
continue
|
|
if not present:
|
|
raise NoticeError(f"{component['id']}: none of {component['artifacts']} is in {title}")
|
|
texts = []
|
|
folder = target / component["id"]
|
|
folder.mkdir()
|
|
for entry in component["texts"]:
|
|
origin = copy_text(entry, tokens, root, folder)
|
|
record = {"file": entry["to"], "from": origin}
|
|
if entry.get("verbatim"):
|
|
record["sha256"] = sha256(folder / entry["to"])
|
|
texts.append(record)
|
|
executables = {f: sha256(title / f) for f in present
|
|
if any(fnmatch.fnmatchcase(Path(f).name, p) for p in EXECUTABLE)}
|
|
staged.append({
|
|
"id": component["id"], "name": component["name"], "kind": component["kind"],
|
|
"licence": component["licence"], "noncommercial": bool(component.get("noncommercial")),
|
|
"copyright": component.get("copyright", []), "modifications": component.get("modifications"),
|
|
"texts": [t["file"] for t in texts], "text_records": texts,
|
|
"source": source_of(component, tokens, root, title),
|
|
"files": present, "executables": executables,
|
|
})
|
|
(target / "components.json").write_text(
|
|
json.dumps({"schema": 1, "release": tag, "driver": driver, "components": staged},
|
|
indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
(target / "README.txt").write_text(readme(staged, tag), encoding="utf-8")
|
|
return staged
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("title", type=Path)
|
|
parser.add_argument("--driver", default=os.environ.get("PS5_VULKAN_DRIVER", "radv"),
|
|
choices=("radv", "ps5vk"))
|
|
parser.add_argument("--release-tag", default="")
|
|
parser.add_argument("--vulkan-dir", default=os.environ.get("PS5_VULKAN_DIR", "../PS5_Vulkan"))
|
|
parser.add_argument("--sdk-fork", default="../PS5_PayloadSDK")
|
|
parser.add_argument("--llvm", default=".deps/llvm-project")
|
|
args = parser.parse_args(argv)
|
|
tokens = {"VULKAN": args.vulkan_dir, "SDK_FORK": args.sdk_fork, "LLVM": args.llvm}
|
|
try:
|
|
staged = stage(args.title.resolve(), args.driver, args.release_tag, tokens)
|
|
except (NoticeError, OSError, KeyError, subprocess.CalledProcessError) as error:
|
|
print(f"error: [notices] {error}", file=sys.stderr)
|
|
return 2
|
|
dirty = [c["id"] for c in staged if c["source"].get("dirty")]
|
|
print(f"==> [notices] {len(staged)} parts, licences in {args.title}/licenses")
|
|
if dirty:
|
|
print(f"==> [notices] uncommitted source in: {', '.join(dirty)} "
|
|
"(a release needs committed, published source)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|