Files
2026-10-13 20:11:17 +02:00

748 lines
39 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# scripts/package-app.sh - build EVO Player as a game-category app module.
#
# ./scripts/package-app.sh build the full FFmpeg-software
# player (Phase 1b task 4+)
# ./scripts/package-app.sh --probe build the sandbox probe instead
# ./scripts/package-app.sh --rebuild-libc force-regenerate the runtime shim
# ./scripts/package-app.sh --ffpfsc also emit a PFS image, like
# ProsperoLight (needs MkPFS)
# ./scripts/package-app.sh --zip emit a folder-title ZIP archive,
# matching Kodi PS5's packaging
# ./scripts/package-app.sh --usb-remote + the scriptable FTP dev remote
# (/mnt/usb0/evo_cmd + evo_status +
# verbose vdec log) — off in release
# ./scripts/package-app.sh --virtual-keyboard debug only: always use
# EVO's own keyboard, never the
# PS5 IME, which the dev remote
# can neither press nor see
# ./scripts/package-app.sh --breadcrumbs + boot-trace notification
# popups (#51, off by default —
# klog carries these otherwise)
# ./scripts/package-app.sh --agc Accepted and redundant: the
# bare-metal sceAgc GPU render
# interface is the only render path
# a player build has. RmlUi renders
# through hardware command buffers
# with vsync pacing. See
# docs/evo-pro/agc-bare-metal-ui.md.
#
# Compilation uses the native-app toolchain (tools/native-app/prospero-clang18:
# -femulated-tls -fno-plt -fno-stack-protector); the LINK + PS5-module
# conversion + FSELF signing + dist assembly come from tools/native-app/
# (vendored from ps5-native-app-boilerplate / ProsperoLight - see its README).
#
# Output: output/app/PPSA99039/{eboot.bin, sce_sys/param.json, sce_module/libc.prx}
# Deploy: ./scripts/deploy-app.sh (FTP -> /data/homebrew/PPSA99039/)
#
# See docs/evo-pro/phase-1b-app-module.md.
# =============================================================================
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
MODE="player"
REBUILD_LIBC=0
FFPFSC=0
ZIP=0
USB_REMOTE=0
BREADCRUMBS=0
VIRTUAL_KB=0
AGC_DEVICE=1 # bare-metal AGC is the only render path
NATIVE_SECONDARY=0
NATIVE_SECONDARY_4K=0
NO_NATIVE_SECONDARY=0
NO_NATIVE_SECONDARY_4K=0
NATIVE_10BIT=1
NO_NATIVE_10BIT=0
CLEAN=0
while (( $# )); do
case "$1" in
--clean) CLEAN=1 ;;
--probe) MODE="probe" ;;
--player) MODE="player" ;;
--rebuild-libc) REBUILD_LIBC=1 ;;
--ffpfsc) FFPFSC=1 ;;
--zip) ZIP=1 ;;
--usb-remote) USB_REMOTE=1 ;; # dev: /mnt/usb0/evo_cmd + evo_status + verbose vdec log
--breadcrumbs) BREADCRUMBS=1 ;; # #51: bring back the on-screen boot-trace popups
--virtual-keyboard) VIRTUAL_KB=1 ;; # debug: EVO's keyboard instead of the PS5 IME
--agc) AGC_DEVICE=1 ;; # accepted and redundant: AGC is the only render path
--gl|--no-gl|--gl-smoke|--gl-hdr-probe)
die "$1 is gone. The OpenGL device path, its ps5-opengl submodule and
the GL smoke/HDR probes were removed - the bare-metal AGC runtime owns
sceAgc and sceVideoOut outright. Just drop the flag." ;;
--native-secondary) NATIVE_SECONDARY=1 ;; # #41: HEVC + VP9 resident decoders are ON BY DEFAULT (4K, since 2026-09-11) — this flag is now a no-op kept for back-compat
--native-secondary-4k) NATIVE_SECONDARY=1; NATIVE_SECONDARY_4K=1 ;; # #41: HEVC/VP9 4K slots are ON BY DEFAULT since 2026-09-11 — this flag is now a no-op kept for back-compat
--no-native-secondary) NO_NATIVE_SECONDARY=1 ;; # #41: escape hatch — AVC-only, rollback to pre-2026-09-11 behaviour
--no-native-secondary-4k) NO_NATIVE_SECONDARY_4K=1 ;; # #41: escape hatch — HEVC/VP9 stay on but drop to 1080p, rollback to pre-2026-09-11 4K behaviour
--native-10bit) NATIVE_10BIT=1; NO_NATIVE_10BIT=0 ;; # #41 Phase D: HEVC Main10 + VP9 Profile 2 resident decoders — ON BY DEFAULT, so this flag is a no-op kept for back-compat and for scripts that state it explicitly
--no-native-10bit) NO_NATIVE_10BIT=1; NATIVE_10BIT=0 ;; # #41 Phase D escape hatch — 10-bit stays on the FFmpeg CPU path. Try this first if thumbnail/poster decode fails to allocate: Phase D's two slots left ~3 MB of flex memory free AT BOOT on 2026-09-11
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
shift
done
# AGC is the player build's render path and the only one. The sandbox probe
# build never presents, so it does not want the AGC runtime linked in.
if [[ "${MODE}" != "player" ]]; then AGC_DEVICE=0; fi
if ! in_container; then
FWD=(--"${MODE}")
(( REBUILD_LIBC )) && FWD+=(--rebuild-libc)
(( FFPFSC )) && FWD+=(--ffpfsc)
(( ZIP )) && FWD+=(--zip)
(( USB_REMOTE )) && FWD+=(--usb-remote)
(( BREADCRUMBS )) && FWD+=(--breadcrumbs)
(( VIRTUAL_KB )) && FWD+=(--virtual-keyboard)
# Forward the RESOLVED choice, never the default, so the in-container build
# cannot disagree with the host-side one.
(( AGC_DEVICE )) && FWD+=(--agc)
(( NATIVE_SECONDARY_4K )) && FWD+=(--native-secondary-4k)
(( NATIVE_SECONDARY && ! NATIVE_SECONDARY_4K )) && FWD+=(--native-secondary)
(( NO_NATIVE_SECONDARY )) && FWD+=(--no-native-secondary)
(( NO_NATIVE_SECONDARY_4K )) && FWD+=(--no-native-secondary-4k)
(( NATIVE_10BIT )) && FWD+=(--native-10bit)
(( NO_NATIVE_10BIT )) && FWD+=(--no-native-10bit)
reexec_in_container "package-app.sh" "${FWD[@]}"
fi
load_sdk
need_cmd clang++ sha256sum python3 make llvm-nm prospero-clang prospero-clang++
: "${PS5_SYSROOT:=${PS5_PAYLOAD_SDK}/target}"
HB="${PS5_SYSROOT}/user/homebrew"
# Target toolchain: the SDK cross-compiler + the two flags the native-app link
# tail needs. NOT prospero-clang18 (which also forces -femulated-tls) - native
# TLS is the task-4 baseline; task 5 revisits emulated TLS if runtime needs it.
TCC="prospero-clang"
TCXX="prospero-clang++"
TFLAGS=(-fno-plt -fno-stack-protector -ffunction-sections -fdata-sections)
# --- Fixed loader / container constants -------------------------------------
# Cross-firmware-validated profile (6.02 + 12.70). Do NOT change these; they
# are what the 12.70 loader + ShadowMountPlus accepted for the Phase 1 gate.
MODULE_SDK=0x02000009
COMPANION_SDK=0x08050001
FSELF_MAGIC=0x1D3D154F
NATIVE="${REPO_ROOT}/tools/native-app"
EVO="${REPO_ROOT}/projects/evoplayer"
SCE_SYS="${EVO}/sce_sys"
PARAM="${SCE_SYS}/param.json"
APP_OUT="${OUTPUT_DIR}/app"
BUILD="${APP_OUT}/.build"
LLD="$(command -v prospero-lld || echo "${PS5_PAYLOAD_SDK}/bin/prospero-lld")"
AR="$(command -v prospero-ar || echo "${PS5_PAYLOAD_SDK}/bin/prospero-ar")"
need_file "${LLD}" "the PS5 payload SDK is missing prospero-lld"
need_file "${AR}" "the PS5 payload SDK is missing prospero-ar"
need_file "${PARAM}" "projects/evoplayer/sce_sys/param.json is missing"
mkdir -p "${BUILD}/obj" "${BUILD}/host"
# ---------------------------------------------------------------------------
# 1. Validate param.json and read the identity out of it.
# ---------------------------------------------------------------------------
begin "validating param.json"
TITLE_ID="$(python3 - "${PARAM}" <<'PY'
import json, re, sys
v = json.load(open(sys.argv[1], encoding="utf-8"))
tid = v.get("titleId", "")
concept = v.get("conceptId", "")
content = v.get("contentId", "")
if not re.fullmatch(r"PPSA\d{5}", tid): raise SystemExit("titleId must be PPSA + 5 digits")
if not re.fullmatch(r"\d{5}", concept): raise SystemExit("conceptId must be 5 digits")
if not re.fullmatch(r"[A-Z]{2}\d{4}-PPSA\d{5}_00-[A-Z0-9]{16}", content) or tid not in content:
raise SystemExit("contentId invalid or does not contain titleId")
if not re.fullmatch(r"\d{2}\.\d{3}\.\d{3}", v.get("contentVersion","")): raise SystemExit("contentVersion must be NN.NNN.NNN")
if not re.fullmatch(r"\d{2}\.\d{2}", v.get("masterVersion","")): raise SystemExit("masterVersion must be NN.NN")
s = v.get("downloadDataSize")
if isinstance(s, bool) or not isinstance(s, int) or s <= 0:
raise SystemExit("downloadDataSize must be a positive integer (so /download0 is writable)")
if (v.get("applicationCategoryType"), v.get("contentBadgeType")) != (0, 1):
raise SystemExit("expected a game module: applicationCategoryType 0, contentBadgeType 1")
intents = v.get("gameIntent", {}).get("permittedIntents", [])
if not any(i.get("intentType") == "launchActivity" for i in intents):
raise SystemExit("game param.json must permit the launchActivity intent")
# Flexible pool: the kernel default is 448 MiB; the console accepts 2 MiB..1 GiB
# here, and the extra comes out of direct memory (docs/hardware/memory-budget.md).
fm = v.get("kernel", {}).get("flexibleMemorySize")
if fm is not None and (isinstance(fm, bool) or not isinstance(fm, int)
or not 2097152 <= fm <= 1073741824 or fm % 2097152):
raise SystemExit("kernel.flexibleMemorySize must be a 2 MiB multiple in 2097152..1073741824")
lp = v.get("localizedParameters", {})
if not lp.get(lp.get("defaultLanguage",""), {}).get("titleName","").strip():
raise SystemExit("default-language titleName is empty")
print(tid)
PY
)" || die "param.json failed validation"
ok "title ${TITLE_ID} (mode: ${MODE})"
APPDIR="${APP_OUT}/${TITLE_ID}"
# ---------------------------------------------------------------------------
# 2. Native-app dependency bootstrap (static zlib for the host converter).
# ---------------------------------------------------------------------------
begin "native-app dependencies"
eval "$("${SCRIPTS_DIR}/setup-native-app-deps.sh")"
need_file "${ZLIB_ARCHIVE:?}" "zlib bootstrap did not yield an archive"
# ---------------------------------------------------------------------------
# 3. Host converter tool.
# ---------------------------------------------------------------------------
begin "building host converter (ps5-native-tool)"
TOOL="${BUILD}/host/ps5-native-tool"
clang++ -std=c++20 -O2 -Wall -Wextra -Werror \
-I "${ZLIB_INCLUDE}" \
"${NATIVE}/native_app_builder.cpp" "${NATIVE}/self_container.cpp" \
"${NATIVE}/elf_object.cpp" "${NATIVE}/sce_module_writer.cpp" \
"${ZLIB_ARCHIVE}" -o "${TOOL}"
# ---------------------------------------------------------------------------
# 4. Clean-room runtime shim (libc.prx).
# ---------------------------------------------------------------------------
LIBC_PRX="${NATIVE}/runtime/libc.prx"
build_libc() {
begin "generating clean-room runtime shim (libc.prx)"
local expected_signed
expected_signed="$(awk '{print $1}' "${NATIVE}/runtime/libc.prx.sha256")"
local work="${BUILD}/runtime-shim"
mkdir -p "${work}"
clang++ -std=c++20 -O2 -Wall -Wextra -Werror \
"${NATIVE}/libc_builder.cpp" -o "${work}/libc-builder"
local copy
for copy in a b; do
"${work}/libc-builder" "${NATIVE}/runtime/api-surface.txt" \
"${NATIVE}/runtime/imports.txt" "${work}/libc-${copy}.raw.elf"
done
cmp --silent "${work}/libc-a.raw.elf" "${work}/libc-b.raw.elf" \
|| die "libc_builder is not deterministic (raw ELF differs between runs)"
for copy in a b; do
"${TOOL}" self --sign --in "${work}/libc-${copy}.raw.elf" \
--out "${work}/libc-${copy}.prx" >/dev/null
done
cmp --silent "${work}/libc-a.prx" "${work}/libc-b.prx" \
|| die "signed libc.prx differs between runs"
local got
got="$(sha256sum "${work}/libc-a.prx" | cut -d' ' -f1)"
if [[ "${got}" != "${expected_signed}" ]]; then
warn "generated libc.prx sha256 ${got}
!= pinned upstream ${expected_signed}. Expected once EVO re-harvests
api-surface.txt (task 4); until then the build tail changed - stopping."
die "libc.prx digest mismatch"
fi
local artifact forbidden
for artifact in "${work}/libc-a.raw.elf" "${work}/libc-a.prx"; do
grep -aFq BlackBearReloaded "${artifact}" \
|| die "generated runtime is missing its attribution marker"
for forbidden in "W:/Build" "J013" "Prospero_Release" "sys/internal"; do
grep -aFq "${forbidden}" "${artifact}" \
&& die "generated runtime contains forbidden text: ${forbidden}"
done
done
cp "${work}/libc-a.prx" "${LIBC_PRX}"
ok "libc.prx (${got})"
}
if (( REBUILD_LIBC )) || [[ ! -f "${LIBC_PRX}" ]]; then
build_libc
else
( cd "${NATIVE}/runtime" && sha256sum -c libc.prx.sha256 >/dev/null 2>&1 ) \
|| warn "existing libc.prx no longer matches libc.prx.sha256 (re-harvested?)"
ok "libc.prx present"
fi
# ---------------------------------------------------------------------------
# 5. Compile the app object set.
# ---------------------------------------------------------------------------
OBJS=()
ARCHIVE_GROUP=()
CXX_RUNTIME=("${PS5_SYSROOT}/lib/libc++.a" "${PS5_SYSROOT}/lib/libc++abi.a" \
"${PS5_SYSROOT}/lib/libunwind.a")
if [[ "${MODE}" == "probe" ]]; then
APP_NAME="sandbox_probe"
begin "compiling ${APP_NAME}"
PROBE_SRCS=("${REPO_ROOT}/projects/sandbox_probe/main.c"
"${REPO_ROOT}/projects/common/src/evo_notify.c")
PROBE_DEFS=()
for src in "${PROBE_SRCS[@]}"; do
obj="${BUILD}/obj/$(echo "${src#"${REPO_ROOT}/"}" | tr '/.' '__').o"
"${TCC}" -std=c11 -O2 -g -Wall -Wextra -Wno-unused-parameter \
"${TFLAGS[@]}" ${PROBE_DEFS[@]+"${PROBE_DEFS[@]}"} \
-I"${REPO_ROOT}/projects/common/include" \
-I"${REPO_ROOT}/projects/evoplayer/include" \
-I"${REPO_ROOT}/projects/evoplayer/media/include" \
-c "${src}" -o "${obj}"
OBJS+=("${obj}")
done
else
APP_NAME="EVO Player (FFmpeg-software)"
begin "compiling ${APP_NAME} objects (${TCC} ${TFLAGS[*]})"
# EVO's Makefile owns the source list, include paths and -D flags; we only
# add the native-app link-tail flags via EXTRA_CFLAGS.
# EVO_APP_MODULE: routes data paths to /download0/evoplayer and directory
# enumeration through getdents (opendir fails EPERM in the sandbox).
# Build fingerprint — main()'s FIRST notification, so a stale ShadowMount /
# cached mount is caught immediately instead of costing a console session.
# Written to a generated header to sidestep -D string quoting through the
# make -> /bin/sh recipe chain.
#
# Plain `git -C "${EVO}"` cannot see any of this inside the container:
# /workspace (REPO_ROOT) has no .git of its own - app/ is a git-subtree
# import, so the real .git lives one level up, outside the bind mount -
# and silently fell back to "unknown-dirty" on every build. docker-
# compose.yml mounts that .git plus a second, read-only view of this
# directory nested under app/, recreating the host's real layout (.git
# next to app/) at /workspace-git, so GIT_DIR/GIT_WORK_TREE below resolve
# exactly like the equivalent `git -C` call does on the host.
GIT_BUILD_DIR="/workspace-git/.git"
GIT_BUILD_TREE="/workspace-git"
if [[ -d "${GIT_BUILD_DIR}" ]]; then
BUILD_SHA="$(GIT_DIR="${GIT_BUILD_DIR}" GIT_WORK_TREE="${GIT_BUILD_TREE}" \
git rev-parse --short=8 HEAD 2>/dev/null || echo unknown)"
GIT_DIR="${GIT_BUILD_DIR}" GIT_WORK_TREE="${GIT_BUILD_TREE}" \
git diff --quiet -- app/projects/evoplayer 2>/dev/null || BUILD_SHA="${BUILD_SHA}-dirty"
else
warn "/workspace-git not mounted (see docker-compose.yml) - build id falls back to 'unknown'"
BUILD_SHA="unknown"
fi
printf '#pragma once\n#define EVO_BUILD_ID "%s_%s"\n' \
"${BUILD_SHA}" "$(date -u +%m%d-%H%M)" > "${EVO}/include/evo_build_id.h"
ok "build id $(sed -n 's/.*"\(.*\)".*/\1/p' "${EVO}/include/evo_build_id.h")"
# main.c bakes EVO_BUILD_ID in but the Makefile tracks source mtimes, not
# this generated header - drop main.o so the id on screen is always current.
rm -f "${EVO}/main.o"
APP_DEFS="-DEVO_APP_MODULE=1 -DEVO_HAVE_BUILD_ID=1"
# Version, from projects/evoplayer/VERSION - the file release.yml checks
# the tag against. Nothing defined this before, so the three duplicated
# #ifndef fallbacks in the C++ were what actually shipped, and they had
# already drifted apart from the file and from launch.rml (0.7.6 in code,
# 0.7.0 on the launch screen). Define it here and the fallbacks stay
# fallbacks.
EVO_VER="$(tr -d '[:space:]' < "${EVO}/VERSION")"
[[ -n "${EVO_VER}" ]] || die "projects/evoplayer/VERSION is empty"
# The quotes must survive two hops - this shell, then the make recipe's
# shell. Written as \\" the backslash escaped the space that followed and
# glued the NEXT -D flag into this one's value, so --usb-remote silently
# did nothing for every build that used it. \\\" is the form the Makefile
# already uses and the only one that round-trips.
APP_DEFS+=" -DEVO_PLAYER_VERSION=\\\"${EVO_VER}\\\""
# --usb-remote: the scriptable dev remote (evo_usb_remote.c) — the
# /mnt/usb0/evo_status snapshot + the evo_cmd command channel. Off by
# default so a release eboot never touches the user's USB stick per frame.
# The diagnostic log (/mnt/usb0/evo.log) is written by every build.
(( USB_REMOTE )) && APP_DEFS+=" -DEVO_USB_REMOTE=1"
# --breadcrumbs (#51): bring back the on-screen boot-trace notification
# popups (evo_bt / evo_boot_log). Off by default - klog
# (tools/klog.sh) carries the same lines unconditionally in the app
# module now, so the popups are only useful watching the TV without klog.
(( BREADCRUMBS )) && APP_DEFS+=" -DEVO_BOOT_TRACE_POPUP=1"
# --virtual-keyboard: debug builds only. The PS5 IME is a system dialog
# that evo-remote.sh can neither press nor screenshot, and while it is up
# EVO routes every pad press to it - one stray prompt wedges a remote
# session. Users keep the IME; the Settings row cannot undo this.
(( VIRTUAL_KB )) && APP_DEFS+=" -DEVO_FORCE_VIRTUAL_KEYBOARD=1"
# --native-secondary (#41): HEVC + VP9 resident sceVideodec2 decoders, at
# 4K since 2026-09-11 — both hardware-verified (evo_vdec_native.c has the
# full evidence). --no-native-secondary / --no-native-secondary-4k are the
# rollback escape hatches.
(( NATIVE_SECONDARY )) && APP_DEFS+=" -DEVO_VDEC_NATIVE_SECONDARY=1"
(( NATIVE_SECONDARY_4K )) && APP_DEFS+=" -DEVO_VDEC_NATIVE_SECONDARY_4K=1"
(( NO_NATIVE_SECONDARY )) && APP_DEFS+=" -DEVO_VDEC_NATIVE_SECONDARY=0"
(( NO_NATIVE_SECONDARY_4K )) && APP_DEFS+=" -DEVO_VDEC_NATIVE_SECONDARY_4K=0"
# --native-10bit / --no-native-10bit (#41 Phase D): the HEVC Main10 + VP9
# Profile 2 resident decoders. ON by default; the escape hatch drops 10-bit
# to the FFmpeg CPU path and gives its flex memory back.
(( NATIVE_10BIT )) && APP_DEFS+=" -DEVO_VDEC_NATIVE_10BIT=1"
(( NO_NATIVE_10BIT )) && APP_DEFS+=" -DEVO_VDEC_NATIVE_10BIT=0"
# EVO_AGC_DEVICE gates evo_agc_* + evo_rmlui_render_agc.cpp
(( AGC_DEVICE )) && APP_DEFS+=" -DEVO_AGC_DEVICE=1"
rm -f "${EVO}/include/evo_autoplay.h"
# The Makefile tracks sources, NOT the -D flag set. The app-module defines
# (EVO_APP_MODULE, EVO_BOOT_TRACE_POPUP, ...) differ from build-evoplayer.sh's, so
# `make objects` would silently reuse payload .o files - which is exactly
# how three console sessions shipped an eboot with none of the app-module
# code. Force a clean object build whenever the flag set changed.
STAMP="${BUILD}/app-cflags.stamp"
WANT="${TFLAGS[*]} ${APP_DEFS}"
if (( CLEAN )) || [[ ! -f "${STAMP}" || "$(cat "${STAMP}" 2>/dev/null)" != "${WANT}" ]]; then
begin "app-module flags changed or --clean requested - clean rebuild"
make -C "${EVO}" clean >/dev/null 2>&1 || true
printf '%s' "${WANT}" > "${STAMP}"
fi
# #60: regenerate the embedded RmlUi asset bundle (rml/rcss/fonts/icons)
# so the .ffpfsc is always built from whatever assets/ currently holds -
# never from a stale evo_rmlui_bundle_data.cpp left over from a previous
# commit. See tools/bundle_rml_assets.py and evo_rmlui_fileinterface.cpp.
begin "bundling RmlUi assets into evo_rmlui_bundle_data.cpp"
python3 "${REPO_ROOT}/tools/bundle_rml_assets.py"
# The roots EVO_NET_VERIFY_TLS trusts (every request with a Twitch token),
# from the image's Mozilla CA bundle. Git-ignored, so made on every build;
# the licence note ships in assets/licenses/ca-certificates.txt.
begin "generating the CA bundle (evo_ca_bundle.c)"
python3 "${REPO_ROOT}/tools/gen_ca_bundle.py"
# The app's name and title id for the C++ (EVO_APP_NAME, EVO_TITLE_ID),
# from the param.json validated above. Git-ignored, like the CA bundle.
python3 "${REPO_ROOT}/tools/gen_identity.py" || die "could not generate include/evo_identity.h"
# ccache (volume /ccache): a flag change or a payload compile check no longer
# means recompiling every unchanged file. EVO_NO_CCACHE=1 turns it off.
CCACHE_BIN=""
if [[ "${EVO_NO_CCACHE:-}" != "1" ]] && command -v ccache >/dev/null 2>&1; then
CCACHE_BIN="ccache"
fi
make -C "${EVO}" objects -j"$(nproc)" \
CCACHE="${CCACHE_BIN}" \
CC="${TCC}" CXX="${TCXX}" \
AGC_DEVICE="${AGC_DEVICE}" \
EXTRA_CFLAGS="${WANT}" \
> "${BUILD}/compile.log" 2>&1 || {
echo "--- last 40 lines of compile.log ---"
tail -40 "${BUILD}/compile.log" | sed 's/^/ /'
die "object compile failed. Full log: ${BUILD#"${REPO_ROOT}/"}/compile.log"
}
while read -r rel; do
OBJS+=("${EVO}/${rel}")
done < <(make -C "${EVO}" -s AGC_DEVICE="${AGC_DEVICE}" print-objects | tr ' ' '\n' | grep -E '\.o$')
ok "compiled ${#OBJS[@]} objects"
# #68: RmlUi puts only `3 + R/6` points on a corner arc, so a 20 px radius is
# a 5-segment polygon that sags a quarter-pixel inside the true circle -
# visible faceting on every rounded card, and a geometry error no rasteriser
# or MSAA can undo. ui_rml/src/rmlui_patch/ carries that one RmlUi TU with a
# finer GetNumPoints(); compile it here and swap it over the matching member
# of a build-local COPY of the pacbrew archive (same RmlUi 6.2 release - the
# public headers are byte-identical - so this is a like-for-like member
# replacement, and the sysroot archive is never touched).
# See ui_rml/src/rmlui_patch/VENDORED.md.
begin "patching librmlui.a corner tessellation (#68)"
RML_PATCH_O="${BUILD}/obj/GeometryBackgroundBorder.cpp.o"
"${TCXX}" -std=c++17 -O2 "${TFLAGS[@]}" \
-I"${HB}/include" \
-c "${EVO}/ui_rml/src/rmlui_patch/GeometryBackgroundBorder.cpp" \
-o "${RML_PATCH_O}"
RML_A="${BUILD}/librmlui.a"
cp -f "${HB}/lib/librmlui.a" "${RML_A}"
llvm-ar r "${RML_A}" "${RML_PATCH_O}"
# The swap is only a swap if the member name matched - a typo would silently
# ADD a member and leave upstream's tessellation linked ahead of it.
[[ "$(llvm-ar t "${RML_A}" | grep -c '^GeometryBackgroundBorder\.cpp\.o$')" == "1" ]] \
|| die "librmlui.a member swap failed - GeometryBackgroundBorder.cpp.o not unique"
ok "librmlui.a patched"
ARCHIVE_GROUP+=("${RML_A}")
# Auto-install libdav1d into sysroot if missing
if [[ ! -f "${HB}/lib/libdav1d.a" && -x "${REPO_ROOT}/scripts/build-dav1d.sh" ]]; then
begin "installing libdav1d into sysroot"
"${REPO_ROOT}/scripts/build-dav1d.sh" --install
fi
# Static archives EVO links (Makefile LIBS + build-evoplayer.sh transitive
# set). Order-independent inside the group.
for a in libSDL2 \
libavformat libavcodec libswresample libavutil libswscale libdav1d \
libass libfreetype libharfbuzz libharfbuzz-subset libfribidi \
libpng16 libsamplerate libssl libcrypto libiconv libxml2 \
libz libbz2 liblzma libzstd libm; do
f="${HB}/lib/${a}.a"
need_file "${f}" "expected port archive missing: ${a}.a (pacbrew sysroot incomplete)"
ARCHIVE_GROUP+=("${f}")
done
fi
# ---------------------------------------------------------------------------
# 6. CRT + C++ allocation runtime + fake libpthread.a.
# ---------------------------------------------------------------------------
begin "compiling app CRT + allocation runtime + libc gap fillers"
"${TCXX}" -std=c++20 -O2 -Wall -Wextra -fno-exceptions -fno-rtti "${TFLAGS[@]}" \
-c "${NATIVE}/app_crt.cpp" -o "${BUILD}/obj/app_crt.o"
"${TCXX}" -std=c++20 -O2 -Wall -Wextra -fno-exceptions -fno-rtti "${TFLAGS[@]}" \
-c "${NATIVE}/app_cpp_runtime.cpp" -o "${BUILD}/obj/app_cpp_runtime.o"
"${AR}" rcs "${BUILD}/obj/libpthread.a" "${BUILD}/obj/app_cpp_runtime.o"
# stubs/libc_ext.c: real C-locale implementations of the ~45 FreeBSD libc names
# (xlocale *_l, _setjmp/_longjmp, gmtime_r, nl_langinfo, dladdr, catgets...) that
# neither the SDK stub nor the console's libSceLibcInternal export. Compiled IN
# as local defs so libc++'s iostream/codecvt static init cannot call a NULL
# import (that was the first-launch SIGSEGV at rip=0 in DoIOSInit).
LIBC_EXT_O=""
EVO_DNS_O=""
MALLOC_SHIM_O=""
if [[ "${MODE}" == "player" ]]; then
LIBC_EXT_O="${BUILD}/obj/libc_ext.o"
"${TCC}" -std=gnu11 -O2 -w "${TFLAGS[@]}" \
-c "${NATIVE}/stubs/libc_ext.c" -o "${LIBC_EXT_O}"
# stubs/evo_dns.c (#91): getaddrinfo & co. on the console's own resolver
# (libSceNet's sceNetResolver*), so a lookup uses the DNS the user set in the
# PS5's network settings. The libSceNet import resolves from the SDK link stub.
EVO_DNS_O="${BUILD}/obj/evo_dns.o"
"${TCC}" -std=gnu11 -O2 -Wall -Wextra "${TFLAGS[@]}" \
-c "${NATIVE}/stubs/evo_dns.c" -o "${EVO_DNS_O}"
# malloc interposer: the clean-room libc.prx heap is bounded and fills up
# (hardware 2026-09-02); route every allocation to an mmap-backed allocator.
MALLOC_SHIM_O="${BUILD}/obj/malloc_shim.o"
"${TCC}" -std=gnu11 -O2 -w "${TFLAGS[@]}" \
-c "${NATIVE}/stubs/malloc_shim.c" -o "${MALLOC_SHIM_O}"
fi
# ---------------------------------------------------------------------------
# 6b. PRX import stubs for system modules the SDK ships no .so for.
# Each tools/native-app/stubs/prx/<name>.syms -> a tiny ELF .so with
# SONAME <name>.sprx and one empty FUNC per symbol, linked POSITIONALLY
# (unconditional DT_NEEDED, like ProsperoLight) and passed to
# native_app_builder as --stub. The converter computes the Sony NID from
# the plain name and the loader auto-loads the .sprx at process start.
# Only built for modules a probe actually uses - an unconditional NEEDED on
# a module the loader refuses would brick the boot.
# Fixes the hardware-proven wall: a fake-signed module cannot
# sceKernelLoadStartModule an undeclared system PRX. See the prx/README.
# ---------------------------------------------------------------------------
PRX_STUB_SOS=()
PRX_STUB_SRC="${NATIVE}/stubs/prx"
PRX_STUB_WANT=()
# libSceVideodec2's own startup load needs the GPU driver stack present
# (sceVideodec2AllocateComputeQueue allocates a GPU compute queue). ProsperoLight
# links libSceAgc + libSceAgcDriver, which pull in libSceGnmDriver and satisfy
# that; EVO must do the same or libSceVideodec2 loads broken. The
# libSceAgc/libSceAgcDriver .syms are comment-only, and the actual symbol list
# is harvested from the AGC runtime's own imports in the augmentation block
# below.
# The native decode backend (media/src/evo_vdec_native.c, #31) is compiled into
# every MODE == player eboot, so libSceVideodec2 + its GPU-driver deps must be
# positional DT_NEEDED.
# #34: the native IME keyboard. libSceImeDialog has a real SDK stub .so and
# resolves through the link tail; libSceCommonDialog has none, and
# sceCommonDialogInitialize() must run before any common dialog will start.
if [[ "${MODE}" == "player" ]]; then
PRX_STUB_WANT+=(libSceVideodec2 libSceAudiodec libSceAgc libSceAgcDriver libSceCommonDialog)
# #101 and #106: the system web browser and the DualSense microphone.
# Nothing uses them since stage 6, but they stay NEEDED with the imports
# the stage 5 build booted with: src/evo_prx_keep.c references every name
# in their .syms, in calls that never run.
PRX_STUB_WANT+=(libSceWebBrowserDialog)
PRX_STUB_WANT+=(libSceAudioIn)
fi
if (( ${#PRX_STUB_WANT[@]} )); then
begin "building PRX import stubs"
mkdir -p "${BUILD}/stubs"
# Every stub is an unconditional DT_NEEDED entry, but only the names the
# objects reference become imports: lld leaves an unused stub symbol out of
# .dynsym, and the module writer builds the import table from .dynsym. Every
# import's NID has to bind on the console: one absent from the firmware's
# .sprx makes the loader REJECT THE WHOLE MODULE -> the app "crashes" before
# main() with CE-108255-1 and no log. This cost a full session once
# (sceAgcDcbDrawIndexOffset, #28). A name no object references is not
# imported, so it is either dead weight or a lost import (a module kept
# NEEDED for its stage 5 imports, see src/evo_prx_keep.c). Refuse to build a
# stub that carries one; annotate a deliberate one with `# keep: <reason>`
# on its line.
OBJ_UNDEF="${BUILD}/obj-undef.txt"
: > "${OBJ_UNDEF}"
for o in "${OBJS[@]}" "${LIBC_EXT_O}" "${EVO_DNS_O}" "${MALLOC_SHIM_O}"; do
[[ -n "${o}" && -f "${o}" ]] && llvm-nm -u "${o}" 2>/dev/null \
| grep -oE '\bsce[A-Za-z0-9_]+' >> "${OBJ_UNDEF}" || true
done
sort -u -o "${OBJ_UNDEF}" "${OBJ_UNDEF}"
dead_total=0
for base in "${PRX_STUB_WANT[@]}"; do
syms="${PRX_STUB_SRC}/${base}.syms"
need_file "${syms}" "missing PRX stub symbol list: ${base}.syms"
so="${BUILD}/stubs/${base}.so"
csrc="${BUILD}/stubs/${base}.c"
# (a comment-only .syms - libSceAgc/libSceAgcDriver post-GL-6 - greps to
# nothing; `|| true` so the empty result isn't a pipeline failure.)
{ grep -vE '^\s*(#|$)' "${syms}" || true; } | awk '{print "void " $1 "(void){}"}' > "${csrc}"
# --agc: add the sceAgc* / sceAgcDriver* names that this .syms doesn't
# already carry. Routed by prefix; sceVideoOut* etc. resolve from
# target/lib/*.so and are left alone.
if (( AGC_DEVICE )) && [[ "${base}" == libSceAgc || "${base}" == libSceAgcDriver ]]; then
existing="$({ grep -vE '^\s*(#|$)' "${syms}" || true; } | awk '{print $1}')"
source_syms=()
while IFS= read -r s; do [[ -n "${s}" ]] && source_syms+=("${s}"); done < "${OBJ_UNDEF}"
for s in "${source_syms[@]}"; do
if [[ "${base}" == libSceAgcDriver ]]; then
[[ "${s}" == sceAgcDriver* ]] || continue
else
[[ "${s}" == sceAgc* && "${s}" != sceAgcDriver* ]] || continue
fi
grep -qxF "${s}" <<<"${existing}" && continue
echo "void ${s}(void){}" >> "${csrc}"
echo " + ${base}: ${s}"
done
fi
# dead-import guard (skip lines annotated `# keep:`)
while read -r sym rest; do
[[ "${rest}" == *"# keep:"* ]] && continue
grep -qxF "${sym}" "${OBJ_UNDEF}" && continue
warn "PRX stub ${base}.syms: '${sym}' is not imported by any object"
echo " -> a dead positional import; if its NID is absent on"
echo " firmware the loader rejects the module at load."
echo " Remove it, or annotate the line with '# keep: <why>'."
dead_total=$((dead_total + 1))
done < <(grep -vE '^\s*(#|$)' "${syms}" || true)
"${TCC}" -shared -nostdlib -nodefaultlibs -fPIC \
-Wl,-soname,"${base}.sprx" -o "${so}" "${csrc}"
PRX_STUB_SOS+=("${so}")
printf ' %-22s %s syms\n' "${base}.sprx" "$(wc -l < "${csrc}")"
done
(( dead_total )) && die "${dead_total} dead PRX import(s) - see above (would brick the app at load)"
ok "built ${#PRX_STUB_SOS[@]} PRX import stubs"
fi
# ---------------------------------------------------------------------------
# 7. Link the intermediate PS5 PIE.
# ---------------------------------------------------------------------------
begin "linking intermediate PIE"
LINK_LOG="${BUILD}/link.log"
LINK_INPUTS=()
[[ -n "${MALLOC_SHIM_O}" ]] && LINK_INPUTS+=("${MALLOC_SHIM_O}")
LINK_INPUTS+=("${BUILD}/obj/app_crt.o" "${BUILD}/obj/app_cpp_runtime.o")
[[ -n "${LIBC_EXT_O}" ]] && LINK_INPUTS+=("${LIBC_EXT_O}")
[[ -n "${EVO_DNS_O}" ]] && LINK_INPUTS+=("${EVO_DNS_O}")
LINK_INPUTS+=("${OBJS[@]}")
# PRX import stubs: POSITIONAL (not --as-needed), matching ProsperoLight's
# tools/build.sh exactly. An --as-needed-derived DT_NEEDED for a system PRX
# would not bind correctly for a fake-signed module (the sceAvPlayerInit /
# sceVideodec2* first-call crashes traced to this).
(( ${#PRX_STUB_SOS[@]} )) && LINK_INPUTS+=("${PRX_STUB_SOS[@]}")
(( ${#ARCHIVE_GROUP[@]} )) && LINK_INPUTS+=(--start-group "${ARCHIVE_GROUP[@]}" --end-group)
(( ${#ARCHIVE_GROUP[@]} )) && LINK_INPUTS+=("${CXX_RUNTIME[@]}")
STUBDIR="${PS5_SYSROOT}/lib"
# The static SDK libc.a supplies __emutls_get_address (emulated-TLS runtime,
# forced by the SDK clang) plus real C-locale implementations of the xlocale
# *_l family, gmtime_r, nl_langinfo, __assert, ... that neither the SDK stub nor
# the console libSceLibcInternal export. Linked last, in its own group so its
# members resolve each other; archive semantics keep malloc/stdio/etc. bound to
# the .so stubs (and, on device, the runtime shim's heap table).
LINK_TAIL=(--as-needed "${STUBDIR}"/*.so)
(( ${#PRX_STUB_SOS[@]} )) && LINK_TAIL+=("${PRX_STUB_SOS[@]}")
[[ "${MODE}" == "player" ]] && \
LINK_TAIL+=(--start-group "${PS5_SYSROOT}/lib/libc.a" --end-group)
LINK_RC=0
if ! "${LLD}" -T "${NATIVE}/ps5-pie.ld" --eh-frame-hdr \
--version-script "${NATIVE}/app-symbols.map" \
--exclude-libs=ALL --error-limit=0 \
\
-L "${BUILD}/obj" \
-e _start -o "${BUILD}/llvm-pie.elf" \
"${LINK_INPUTS[@]}" \
"${LINK_TAIL[@]}" \
2> "${LINK_LOG}"; then
LINK_RC=1
fi
if (( LINK_RC != 0 )); then
UND="${BUILD}/undefined-symbols.txt"
grep -oE "undefined symbol: .*" "${LINK_LOG}" | sed 's/^undefined symbol: //' \
| sort -u > "${UND}" || true
echo ""
warn "intermediate link failed (rc ${LINK_RC})."
if [[ -s "${UND}" ]]; then
n=$(wc -l < "${UND}")
echo " ${n} distinct undefined symbols -> ${UND#"${REPO_ROOT}/"}"
echo " sample:"
head -30 "${UND}" | sed 's/^/ /'
echo ""
echo " Next: classify these (SCE stub gap vs libc re-harvest), then either"
echo " add tools/native-app/stubs/*.c or extend runtime/api-surface.txt."
echo " NID form for the re-harvest:"
while read -r s; do printf ' %-34s %s\n' "$s" "$(prospero-nid "$s" 2>/dev/null || echo '?')"; done \
< <(head -20 "${UND}")
else
echo "--- link.log tail ---"
tail -30 "${LINK_LOG}" | sed 's/^/ /'
fi
die "link incomplete - see above (this is expected on the first task-4 pass)"
fi
ok "linked $(stat -c %s "${BUILD}/llvm-pie.elf") bytes"
# Record what the PIE imports, for the api-surface diff.
llvm-nm -u "${BUILD}/llvm-pie.elf" 2>/dev/null | awk '{print $NF}' | sort -u \
> "${BUILD}/pie-imports.txt" || true
# ---------------------------------------------------------------------------
# 8. Convert LLVM PIE -> PS5 module, then sign to FSELF.
# ---------------------------------------------------------------------------
begin "converting to PS5 module + signing"
CONV_STUB_ARGS=()
for so in ${PRX_STUB_SOS[@]+"${PRX_STUB_SOS[@]}"}; do CONV_STUB_ARGS+=(--stub "${so}"); done
"${TOOL}" link --in "${BUILD}/llvm-pie.elf" --out "${BUILD}/eboot.elf" \
--stub-dir "${STUBDIR}" ${CONV_STUB_ARGS[@]+"${CONV_STUB_ARGS[@]}"} \
--module-sdk "${MODULE_SDK}" --companion-sdk "${COMPANION_SDK}" \
--file-name eboot.elf
rm -rf -- "${APPDIR}"
mkdir -p "${APPDIR}/sce_sys" "${APPDIR}/sce_module"
"${TOOL}" self --sign --in "${BUILD}/eboot.elf" --out "${APPDIR}/eboot.bin" \
--magic "${FSELF_MAGIC}"
# ---------------------------------------------------------------------------
# 9. Assemble the deployment folder.
# ---------------------------------------------------------------------------
cp "${PARAM}" "${APPDIR}/sce_sys/param.json"
cp "${LIBC_PRX}" "${APPDIR}/sce_module/libc.prx"
for asset in icon0.png pic0.png pic1.png snd0.at9; do
[[ -f "${SCE_SYS}/${asset}" ]] && cp "${SCE_SYS}/${asset}" "${APPDIR}/sce_sys/${asset}"
done
# The UI assets are built into the eboot (bundle_rml_assets.py above), so only
# the license texts ship as files, where About says they are: the GPL, the
# qrcodegen and CA bundle notices, and each font's license at its font's path.
if [[ "${MODE}" == "player" ]]; then
mkdir -p "${APPDIR}/assets"
( cd "${EVO}/assets" &&
cp -a --parents LICENSE.txt licenses fonts/*-OFL.txt hui/fonts/*-LICENSE.txt \
"${APPDIR}/assets/" )
fi
begin "inspecting signed containers"
"${TOOL}" self --inspect --file "${APPDIR}/eboot.bin"
# ---------------------------------------------------------------------------
# 10. Optional: PFS-pack the app folder into a single .ffpfsc image, matching
# ProsperoLight's packaging exactly (same MkPFS, same layout). The eboot,
# param.json and libc.prx are byte-identical to the folder above - this is
# only a different container on /data/homebrew.
# ---------------------------------------------------------------------------
if (( FFPFSC )); then
begin "PFS-packing ${TITLE_ID}.ffpfsc (MkPFS)"
if MKPFS="$("${SCRIPTS_DIR}/setup-pfs-tool.sh")"; then
IMG="${APP_OUT}/${TITLE_ID}.ffpfsc"
rm -f -- "${IMG}"
"${MKPFS}" pack folder --no-adjust-output-file-extension \
--version PS5 --verify "${APPDIR}" "${IMG}" \
&& ok "ffpfsc: ${IMG#"${REPO_ROOT}/"} ($(stat -c %s "${IMG}") bytes)" \
|| warn "MkPFS pack failed - the folder output above is still usable"
else
warn "MkPFS unavailable (needs git + python3-venv + network on first run).
Skipping .ffpfsc; deploy the folder with scripts/deploy-app.sh."
fi
fi
if (( ZIP )); then
begin "packaging ${TITLE_ID}.zip (folder title archive)"
ZIP_IMG="${APP_OUT}/${TITLE_ID}.zip"
rm -f -- "${ZIP_IMG}"
python3 "${REPO_ROOT}/tools/package_zip.py" "${APPDIR}" "${ZIP_IMG}" "${TITLE_ID}" \
&& ok "zip: ${ZIP_IMG#"${REPO_ROOT}/"} ($(stat -c %s "${ZIP_IMG}") bytes)" \
|| warn "ZIP packaging failed"
fi
echo ""
ok "app module: ${APPDIR#"${REPO_ROOT}/"}/"
find "${APPDIR}" -type f -printf ' %-42P %s bytes\n' | sort
echo ""
echo " Deploy + launch:"
echo " ./scripts/deploy-app.sh # FTP -> /data/homebrew/${TITLE_ID}/"
echo " # then mount + launch from the Games row via ShadowMountPlus"
echo " Do NOT stack launches - PS button to close before rebuilding."