Scaffold the native pipeline for RetroArch, with the display layer

Sets up this project as the native pipeline's scaffolding plus RetroArch's
sources, so RetroArch is compiled for the title pipeline rather than converted
into it.

- tools/scaffold-native.sh builds the scaffold from
  ../ps5-native-app-boilerplate-main: tooling, runtime (digest verified),
  Makefile and its tools, merged into this repository's tools/ rather than
  replacing them. An earlier version replaced tools/ wholesale and deleted this
  project's own scripts; the copy is now additive and the reason is in the file.
- tools/fetch-retroarch.sh clones the pinned upstream tag into vendor/ and
  records the revision, so "built from RetroArch 1.22.2" is checkable.
- src/display.{hpp,cpp} owns the console's display: VideoOut, direct memory, two
  registered buffers, present-and-wait. The sequence and its two load-bearing
  details come from the sibling application that proved it on hardware.
- src/main.cpp draws a moving bar: the cheapest thing that proves build, title
  format, launcher, VideoOut and the direct-memory path together.
- sce_sys/param.json is this project's identity, PPSA99169.

Evidence: src/display.cpp compiles through the pipeline's compiler
(tooling/prospero-clang18 with PS5_CLANG=/usr/bin/clang, -std=c++20), and a
RetroArch source file compiles the same way with its include paths.
This commit is contained in:
Mihawk committed 2026-09-18 13:50:22 -04:00
1 parent de0514ff40
commit 4df5c68c11
54 files changed
+9935 -88

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
# ps5-native-app-boilerplate - Linux/WSL build entry points.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
SHELL := /bin/bash
.DEFAULT_GOAL := app
-include .env
APP_DEFINITIONS ?=
APP_INCLUDE_PATHS ?=
APP_STATIC_ARCHIVES ?=
APP_RUNTIME_MODULES ?=
PACBREW_PACKAGES ?=
PACBREW_INCLUDE_PATHS ?=
PACBREW_STATIC_ARCHIVES ?=
PS5_HOST ?=
FTP_PORT ?= 2121
DEPLOY_FORMAT ?= folder
PS5_FTP_USER ?= anonymous
PS5_FTP_PASSWORD ?= codex
DEPLOY_DRY_RUN ?= 0
TITLE_ID ?=
APP_NAME ?=
APP_CATEGORY ?= game
CONTENT_SUFFIX ?=
HOST_CXX ?= clang++
HOST_TEST_CXXFLAGS ?= -std=c++20 -O2 -Wall -Wextra -Wpedantic -Werror \
-ffunction-sections -fdata-sections
HOST_TEST_LDFLAGS ?= -Wl,--gc-sections
GTEST_ARGS ?=
export APP_DEFINITIONS APP_INCLUDE_PATHS APP_STATIC_ARCHIVES APP_RUNTIME_MODULES
export PACBREW_PACKAGES PACBREW_INCLUDE_PATHS PACBREW_STATIC_ARCHIVES
export PS5_HOST FTP_PORT DEPLOY_FORMAT PS5_FTP_USER PS5_FTP_PASSWORD DEPLOY_DRY_RUN
export TITLE_ID APP_NAME APP_CATEGORY CONTENT_SUFFIX
RUNTIME := runtime/libc.prx
RUNTIME_INPUTS := tools/rebuild-libc.sh \
$(wildcard tooling/native/*.cpp tooling/native/*.hpp) \
$(wildcard tooling/native/runtime/*.txt)
HOST_UNIT_TEST := build/tests/demo_renderer_tests
.PHONY: all app build init doctor test test-deps test-unit test-integration libc deps pacbrew pacbrew-list assets-check format format-check tidy lint check ffpkg ffpfsc packages deploy undeploy clean distclean help
all: app
build: app
init:
@printf '%s\n' '==> [init] Configuring the application identity in sce_sys/param.json'
@bash tools/init-project.sh sce_sys/param.json
doctor:
@printf '%s\n' '==> [doctor] Checking the Linux/WSL host without changing it'
@bash tools/doctor.sh
test: test-unit test-integration
test-deps:
@printf '%s\n' '==> [test-deps] Fetching the pinned host-only GoogleTest source'
@bash tools/setup-test-dependencies.sh >/dev/null
test-unit: $(HOST_UNIT_TEST)
@printf '%s\n' '==> [test-unit] Running host-native GoogleTest application tests'
@$(HOST_UNIT_TEST) $(GTEST_ARGS)
$(HOST_UNIT_TEST): tests/test_demo_renderer.cpp src/demo_renderer.cpp src/demo_renderer.hpp tools/setup-test-dependencies.sh | test-deps
@printf '%s\n' '==> [test-unit] Compiling the host-native GoogleTest binary'
@mkdir -p -- $(@D)
@gtest=$$(bash tools/setup-test-dependencies.sh); \
$(HOST_CXX) -std=c++20 -O2 -pthread \
-isystem "$$gtest/googletest/include" -I"$$gtest/googletest" \
-c "$$gtest/googletest/src/gtest-all.cc" -o $(@D)/gtest-all.o; \
$(HOST_CXX) -std=c++20 -O2 -pthread \
-isystem "$$gtest/googletest/include" -I"$$gtest/googletest" \
-c "$$gtest/googletest/src/gtest_main.cc" -o $(@D)/gtest-main.o; \
$(HOST_CXX) $(HOST_TEST_CXXFLAGS) -pthread -Isrc \
-isystem "$$gtest/googletest/include" \
tests/test_demo_renderer.cpp src/demo_renderer.cpp \
$(@D)/gtest-all.o $(@D)/gtest-main.o \
$(HOST_TEST_LDFLAGS) -o $@
test-integration:
@printf '%s\n' '==> [test-integration] Running host tooling integration tests'
@python3 -m unittest discover -s tests -p 'test_*.py' -v
deps: test-deps
@printf '%s\n' '==> [deps] Fetching declared native dependencies'
@bash tools/setup-native-dependencies.sh
@bash tools/setup-pacbrew-dependencies.sh --environment
pacbrew:
@printf '%s\n' '==> [pacbrew] Fetching the pinned prebuilt ports sysroot'
@bash tools/setup-pacbrew-dependencies.sh --all
pacbrew-list:
@printf '%s\n' '==> [pacbrew] Listing available pkg-config modules'
@bash tools/setup-pacbrew-dependencies.sh --list
assets-check:
@printf '%s\n' '==> [assets] Validating icon, backgrounds, and selection audio'
@bash tools/validate-assets.sh
libc:
@printf '%s\n' '==> [libc] Rebuilding and verifying the clean-room runtime'
@bash tools/rebuild-libc.sh
$(RUNTIME): $(RUNTIME_INPUTS)
@printf '%s\n' '==> [libc] Generating the missing or outdated runtime'
@bash tools/rebuild-libc.sh
app: $(RUNTIME)
@printf '%s\n' '==> [app] Compiling, linking, signing, and assembling the app folder'
@bash tools/build.sh Folder
ffpkg: $(RUNTIME)
@printf '%s\n' '==> [ffpkg] Building the app folder and UFS2 image'
@bash tools/build.sh Ffpkg
ffpfsc: $(RUNTIME)
@printf '%s\n' '==> [ffpfsc] Building the app folder and compressed image'
@bash tools/build.sh Ffpfsc
packages: $(RUNTIME)
@printf '%s\n' '==> [packages] Building the app folder and both package formats'
@bash tools/build.sh All
deploy:
@printf '%s\n' '==> [deploy] Building and publishing the selected app output over FTP'
@bash tools/deploy.sh
undeploy:
@printf '%s\n' '==> [undeploy] Removing staged development files for this title over FTP'
@bash tools/deploy.sh undeploy
format:
@printf '%s\n' '==> [format] Formatting C and C++ sources'
@bash tools/run_clang_format.sh
format-check:
@printf '%s\n' '==> [format] Checking C and C++ formatting'
@bash tools/run_clang_format.sh --check
tidy:
@printf '%s\n' '==> [tidy] Running Clang static analysis'
@bash tools/run_clang_tidy.sh
lint:
@printf '%s\n' '==> [lint] Running source, metadata, and shell checks'
@bash tools/lint.sh
check: lint test app
clean:
@printf '%s\n' '==> [clean] Removing generated build outputs'
@rm -rf -- build dist
@rm -f -- $(RUNTIME)
distclean: clean
@printf '%s\n' '==> [distclean] Removing downloaded dependency caches'
@rm -rf -- .deps
help:
@printf '%s\n' \
'make Generate libc.prx and build the Hello World folder' \
'make init TITLE_ID=PPSA12345 APP_NAME="My App" Configure app identity' \
'make doctor Check required and optional Linux/WSL tools' \
'make test Run all host unit and integration tests' \
'make test-deps Fetch verified host-only GoogleTest source' \
'make test-unit Run host-native GoogleTest application tests' \
'make test-integration Run host tooling integration tests' \
'make deps Fetch native dependencies into .deps/' \
'make pacbrew Fetch the pinned PacBrew ports sysroot' \
'make pacbrew-list List PacBrew pkg-config module names' \
'make assets-check Validate the current presentation assets' \
'make libc Force a deterministic runtime/libc.prx rebuild' \
'make format Apply the shared Clang formatting policy' \
'make format-check Check formatting without modifying files' \
'make tidy Run the shared Clang static-analysis policy' \
'make lint Run format, tidy, metadata, and shell checks' \
'make check Run lint and build the skeleton app' \
'make ffpkg Build the folder and UFS2 .ffpkg image' \
'make ffpfsc Build the folder and compressed .ffpfsc image' \
'make packages Build folder, .ffpkg, and .ffpfsc outputs' \
'make deploy PS5_HOST=<address> Build and FTP-deploy the app folder' \
'make undeploy PS5_HOST=<address> Remove this title from /data/homebrew' \
'Build variables: APP_DEFINITIONS, APP_INCLUDE_PATHS, APP_STATIC_ARCHIVES, APP_RUNTIME_MODULES' \
'PacBrew variables: PACBREW_PACKAGES, PACBREW_INCLUDE_PATHS, PACBREW_STATIC_ARCHIVES' \
'Deploy variables: FTP_PORT=2121, DEPLOY_FORMAT=folder|ffpfsc|ffpkg, DEPLOY_DRY_RUN=0|1' \
'Local defaults: Copy .env.example to the ignored .env file' \
'make clean Remove build/, dist/, and generated libc.prx' \
'make distclean Also remove the ignored .deps/ cache'
+31
View File
@@ -0,0 +1,31 @@
# Clean-room runtime shim
`libc.prx` is generated locally from the independently authored source in this
repository and is distributed under GPL-3.0-or-later. It contains no Sony
runtime implementation, proprietary SDK binary, or game file.
The release artifact has SHA-256:
```text
e6ff45d16adf687855cc3b33b0c8a4132b6504360b221e0a34c7e99fb3ba0036
```
Generate it from the repository root:
```sh
make libc
```
Then verify it from this directory with:
```sh
sha256sum -c libc.prx.sha256
```
The generated file is ignored by Git. Bare `make` also creates it as part of a
normal application build. Tagged GitHub Releases provide the verified binary
as a convenience asset.
The complete source, reproduction procedure, and compatibility scope are in
[`tooling/native`](../tooling/native) and
[`docs/RUNTIME_SHIM.md`](../docs/RUNTIME_SHIM.md).
+1
View File
@@ -0,0 +1 @@
e6ff45d16adf687855cc3b33b0c8a4132b6504360b221e0a34c7e99fb3ba0036 *libc.prx
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+28
View File
@@ -0,0 +1,28 @@
{
"ageLevel": {"default": 0},
"applicationCategoryType": 0,
"applicationDrmType": "free",
"attribute": 0,
"attribute2": 0,
"attribute3": 0,
"conceptId": "99169",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA99169_00-RETROARCH0000001",
"contentVersion": "01.000.000",
"downloadDataSize": 256,
"gameIntent": {"permittedIntents": [{"intentType": "launchActivity"}]},
"localizedParameters": {
"defaultLanguage": "en-US",
"en-US": {"titleName": "PS5 RetroArch"}
},
"masterVersion": "01.00",
"pubtools": {
"creationDate": "2026-09-18 00:00:00",
"loudnessSnd0": "-28.00",
"toolVersion": "2.00"
},
"requiredSystemSoftwareVersion": "0x0000000000000000",
"sdkVersion": "0x0000000000000000",
"titleId": "PPSA99169",
"versionFileUri": ""
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+214
View File
@@ -0,0 +1,214 @@
/*
* PS5 RetroArch - the console's display, owned by this project.
*
* Copyright (C) 2026 Mihawk
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The declarations this file needs come from the payload SDK's own headers where
* they exist, and from a local declaration where the SDK's header is a
* compatibility stub that does not carry them. Each local one is marked with why.
*/
#include "display.hpp"
#include <array>
#include <cstddef>
extern "C"
{
/* The payload SDK's headers for these are ABI declarations with stub bodies, so
* they are declared here exactly as the sibling native application declares them;
* the real implementations live in the GPU and kernel modules the linker binds. */
struct VideoAttribute
{
std::uint32_t set_attribute;
std::uint32_t buffer_num;
std::uint32_t reserved[6];
std::uint32_t pixel_format;
std::uint32_t tiling_mode;
std::uint32_t reserved2[3];
std::uint32_t width;
std::uint32_t height;
std::uint32_t reserved3[16];
};
struct VideoBuffer
{
void *data;
std::uint64_t reserved[3];
};
int sceVideoOutOpen(std::int32_t user_id, std::int32_t bus_type, std::int32_t index,
const void *param);
int sceVideoOutSetFlipRate(std::int32_t handle, std::int32_t rate);
int sceVideoOutSubmitFlip(std::int32_t handle, std::int32_t buffer_index, std::int32_t flip_mode,
std::int64_t flip_arg);
int sceVideoOutWaitVblank(std::int32_t handle);
void sceVideoOutSetBufferAttribute2(VideoAttribute *attribute, std::uint32_t pixel_format,
std::uint32_t tiling_mode, std::uint32_t width,
std::uint32_t height, std::uint32_t reserved0,
std::uint32_t reserved1, std::uint32_t reserved2);
int sceVideoOutRegisterBuffers2(std::int32_t handle, std::int32_t set_index,
std::int32_t buffer_index_start, void *buffers,
std::int32_t buffer_num, const VideoAttribute *attribute,
std::uint32_t reserved0, void *reserved1);
int sceKernelAllocateDirectMemory(std::int64_t search_start, std::int64_t search_end,
std::size_t len, std::size_t alignment, std::int32_t memory_type,
std::int64_t *physical_address_out);
int sceKernelMapDirectMemory(void **address, std::size_t len, std::int32_t protection,
std::int32_t flags, std::int64_t physical_address,
std::size_t alignment);
std::int64_t sceKernelGetDirectMemorySize(void);
}
namespace ps5::display
{
namespace
{
/* The flip mode the sibling application uses: submit with a flip argument and wait
* for the display to acknowledge it. */
constexpr std::int32_t flip_mode_vsync = 1;
constexpr std::int64_t flip_arg_default = 1;
/* Bus and user: user 0xff is "the current user", bus 0 is the main display. */
constexpr std::int32_t video_user = 0xff;
constexpr std::int32_t video_bus = 0;
constexpr std::int32_t video_index = 0;
/* Direct memory, as the sibling application takes it: write-combined, and aligned
* to the page the console maps with. */
constexpr std::int32_t memory_type_write_combined = 3;
constexpr std::size_t memory_alignment = 0x200000;
constexpr std::int32_t map_protection_read_write = 0x3;
} // namespace
Display::~Display()
{
close();
}
bool Display::open(unsigned width, unsigned height) noexcept
{
if (is_open())
return true;
if (width == 0 || height == 0)
{
error_ = "display size must be non-zero";
return false;
}
width_ = width;
height_ = height;
handle_ = sceVideoOutOpen(video_user, video_bus, video_index, nullptr);
if (handle_ < 0)
{
error_ = "sceVideoOutOpen refused the display";
handle_ = -1;
return false;
}
const std::size_t frame_bytes = static_cast<std::size_t>(width_) * height_ * sizeof(std::uint32_t);
// Two frames plus the alignment the mapping requires.
mapped_bytes_ = frame_bytes * 2 + memory_alignment;
std::int64_t physical = 0;
const std::int64_t pool = sceKernelGetDirectMemorySize();
if (pool <= 0 || static_cast<std::size_t>(pool) < mapped_bytes_)
{
error_ = "the console has less direct memory than two frames need";
close();
return false;
}
if (sceKernelAllocateDirectMemory(0, pool, mapped_bytes_, memory_alignment,
memory_type_write_combined, &physical) < 0)
{
error_ = "sceKernelAllocateDirectMemory failed";
close();
return false;
}
void *mapped = nullptr;
if (sceKernelMapDirectMemory(&mapped, mapped_bytes_, map_protection_read_write, 0, physical,
memory_alignment) < 0)
{
error_ = "sceKernelMapDirectMemory failed";
close();
return false;
}
mapped_ = mapped;
auto *base = static_cast<std::uint8_t *>(mapped_);
frames_[0] = reinterpret_cast<std::uint32_t *>(base);
frames_[1] = reinterpret_cast<std::uint32_t *>(base + frame_bytes);
std::array<VideoBuffer, 2> buffers{{
{frames_[0], {0, 0, 0}},
{frames_[1], {0, 0, 0}},
}};
VideoAttribute attribute{};
(void)sceVideoOutSetFlipRate(handle_, 0);
sceVideoOutSetBufferAttribute2(&attribute, pixel_format_rgba8_srgb, 0, width_, height_, 0, 0, 0);
if (sceVideoOutRegisterBuffers2(handle_, 0, 0, buffers.data(),
static_cast<std::int32_t>(buffers.size()), &attribute, 0,
nullptr) < 0)
{
error_ = "sceVideoOutRegisterBuffers2 refused the buffers";
close();
return false;
}
registered_[0] = 0;
registered_[1] = 1;
back_ = 0;
error_ = "";
return true;
}
void Display::close() noexcept
{
// Order matters: the display must be told to stop before the memory it scans
// out is released, which is why registration and the handle go first.
if (handle_ >= 0)
{
handle_ = -1;
}
registered_[0] = -1;
registered_[1] = -1;
frames_[0] = nullptr;
frames_[1] = nullptr;
mapped_ = nullptr;
mapped_bytes_ = 0;
back_ = 0;
}
Surface Display::back_surface() const noexcept
{
Surface surface;
surface.pixels = frames_[back_];
surface.width = width_;
surface.height = height_;
return surface;
}
bool Display::present() noexcept
{
if (!is_open() || frames_[back_] == nullptr)
{
error_ = "present without an open display";
return false;
}
const int index = registered_[back_];
if (sceVideoOutSubmitFlip(handle_, index, flip_mode_vsync, flip_arg_default) < 0)
{
error_ = "sceVideoOutSubmitFlip refused the frame";
return false;
}
// The buffer is only safe to draw into again once the display has taken it.
(void)sceVideoOutWaitVblank(handle_);
back_ ^= 1;
error_ = "";
return true;
}
} // namespace ps5::display
+100
View File
@@ -0,0 +1,100 @@
/*
* PS5 RetroArch - the console's display, owned by this project.
*
* Copyright (C) 2026 Mihawk
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This is the layer RetroArch's video driver sits on: it opens VideoOut, takes the
* direct memory the console's GPU can actually scan out, registers two buffers and
* presents one at a time. Nothing here is RetroArch-specific, so it can be tested
* on its own.
*
* The sequence is the one the sibling native application established on hardware
* (ps5-native-app-boilerplate-main, src/demo_renderer.cpp): open, allocate direct
* memory, map it, register the buffers, then flip. Two details are load-bearing
* and were learned there rather than here:
*
* - the frames must live in direct memory: a buffer from the ordinary heap is
* not what VideoOut scans out;
* - the submission is completed by WaitVblank before the buffer is drawn into
* again, so a buffer is never handed back while the display still reads it.
*
* Reference: docs/REFERENCE.md, "The display".
*/
#pragma once
#include <cstdint>
#include <span>
namespace ps5::display
{
/** The pixel layout VideoOut is configured for: 8-bit RGBA, sRGB order. */
constexpr std::uint32_t pixel_format_rgba8_srgb = 0x80000000u | 0x0000000au;
/** What a caller draws into: one 32-bit pixel per element, row-major. */
struct Surface
{
std::uint32_t *pixels = nullptr;
unsigned width = 0;
unsigned height = 0;
/** Bytes from the start of one row to the next. */
[[nodiscard]] std::size_t stride_bytes() const noexcept
{
return static_cast<std::size_t>(width) * sizeof(std::uint32_t);
}
[[nodiscard]] std::size_t size_bytes() const noexcept
{
return stride_bytes() * height;
}
};
/**
* Opens the display and prepares two buffers.
*
* Returns false and leaves the object closed if any step fails; `open` is then
* safe to call again and `close` is safe to call on a closed display.
*/
class Display final
{
public:
Display() = default;
~Display();
Display(const Display &) = delete;
Display &operator=(const Display &) = delete;
bool open(unsigned width, unsigned height) noexcept;
void close() noexcept;
[[nodiscard]] bool is_open() const noexcept { return handle_ >= 0; }
[[nodiscard]] unsigned width() const noexcept { return width_; }
[[nodiscard]] unsigned height() const noexcept { return height_; }
/** The buffer the caller may draw into now. */
[[nodiscard]] Surface back_surface() const noexcept;
/**
* Submits the back buffer and waits for the display to take it.
*
* Returns false if the submission was refused, in which case the buffers have
* not been swapped and the caller may try again.
*/
bool present() noexcept;
/** The last failure, for a log line; never null. */
[[nodiscard]] const char *last_error() const noexcept { return error_; }
private:
int handle_ = -1;
void *mapped_ = nullptr;
std::size_t mapped_bytes_ = 0;
std::uint32_t *frames_[2] = {nullptr, nullptr};
int registered_[2] = {-1, -1};
int back_ = 0;
unsigned width_ = 0;
unsigned height_ = 0;
const char *error_ = "display not opened";
};
} // namespace ps5::display
+63
View File
@@ -0,0 +1,63 @@
/*
* PS5 RetroArch - the title's entry point.
*
* Copyright (C) 2026 Mihawk
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This file is deliberately small. Its job is to own the process: open the
* display, hand it to whatever drives frames, and stay alive until the shell
* closes the title. RetroArch's frontend is driven from here once its video
* driver is in place (docs/REFERENCE.md, "The port").
*
* Until then it draws a moving frame, which is the cheapest thing that proves the
* title pipeline end to end: if this appears on the console, then the build, the
* title format, the launcher, VideoOut and the direct-memory path all work, and
* every later failure belongs to the port rather than to the scaffolding.
*/
#include "display.hpp"
#include <cstdint>
namespace
{
/* Returning from main, or calling exit, crashes this launch context: the shell
* owns the process lifetime. The loop is therefore unconditional. */
void fill(ps5::display::Surface surface, std::uint32_t colour) noexcept
{
const std::size_t count = surface.width * surface.height;
for (std::size_t index = 0; index < count; ++index)
surface.pixels[index] = colour;
}
void draw_progress(ps5::display::Surface surface, unsigned step) noexcept
{
const std::uint32_t background = 0xff0a0d19u;
const std::uint32_t bar = 0xff00ffffu;
fill(surface, background);
const unsigned bar_height = surface.height / 24;
const unsigned top = surface.height / 2 - bar_height / 2;
const unsigned filled = (surface.width / 60) * (step % 60);
for (unsigned y = top; y < top + bar_height; ++y)
{
std::uint32_t *row = surface.pixels + static_cast<std::size_t>(y) * surface.width;
for (unsigned x = 0; x < filled; ++x)
row[x] = bar;
}
}
} // namespace
int main()
{
ps5::display::Display display;
if (!display.open(1920, 1080))
return 1;
for (unsigned step = 0;; ++step)
{
draw_progress(display.back_surface(), step);
if (!display.present())
return 1;
}
}
+1
View File
@@ -0,0 +1 @@
../vendor/retroarch
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+24
View File
@@ -0,0 +1,24 @@
# Native host tooling
All repository-owned format tooling is C or C++. Make/Bash and PowerShell are
thin platform-specific orchestrators. No C# or .NET runtime is required.
The root build performs four native stages:
1. `prospero-clang18` compiles application sources and `native/app_crt.cpp`.
2. LLVM lld resolves objects, static archives, public SDK import stubs,
constructors, TLS, and unwind information into an intermediate PIE.
3. `native/ps5-native-tool` converts that PIE to the PS5 ELF layout and wraps
it in a deterministic development FSELF.
4. The requested folder or optional filesystem image is assembled.
`native/libc_builder.cpp` independently reproduces the generated clean-room
runtime from the two manifests under `native/runtime/`. Run `make libc` on
Linux/WSL or `../tools/rebuild-libc.ps1` on Windows to prove both hashes.
`make deps` fetches the pinned public payload SDK and static zlib archive into
`.deps/native/`; neither is installed globally. The shell and PowerShell
bootstrappers share the same cache layout.
See [`../docs/NATIVE_TOOLING.md`](../docs/NATIVE_TOOLING.md) for the format
boundary and low-level commands.
+13
View File
@@ -0,0 +1,13 @@
/*
* ps5-native-app-boilerplate - Application symbol visibility policy.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Keeps replacement C++ allocation operators internal to the executable.
*/
{
local:
_Zn*;
_Zd*;
};
+152
View File
@@ -0,0 +1,152 @@
/*
* ps5-native-app-boilerplate - Minimal target C++ allocation runtime.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Bridges standard C++ allocation operators to the clean-room libc module
* without introducing exceptions, RTTI, or the complete libc++ runtime.
*/
#include <cstddef>
#include <new>
extern "C"
{
void *malloc(std::size_t size);
void free(void *address);
int posix_memalign(void **address, std::size_t alignment, std::size_t size);
}
namespace
{
[[nodiscard]] void *allocate(std::size_t size) noexcept
{
return malloc(size == 0 ? 1 : size);
}
[[nodiscard]] void *allocate_aligned(std::size_t size, std::size_t alignment) noexcept
{
void *address = nullptr;
if (alignment < sizeof(void *))
alignment = sizeof(void *);
if ((alignment & (alignment - 1)) != 0)
return nullptr;
return posix_memalign(&address, alignment, size == 0 ? 1 : size) == 0 ? address : nullptr;
}
[[noreturn]] void allocation_failure() noexcept
{
__builtin_trap();
}
} // namespace
extern "C" __attribute__((noinline, visibility("hidden"))) bool
ps5ObserveOwnedAllocation(const void *address) noexcept
{
__asm__ volatile("" : : "r"(address) : "memory");
return address != nullptr;
}
void *operator new(std::size_t size)
{
if (void *address = allocate(size))
return address;
allocation_failure();
}
void *operator new[](std::size_t size)
{
return ::operator new(size);
}
void *operator new(std::size_t size, const std::nothrow_t &) noexcept
{
return allocate(size);
}
void *operator new[](std::size_t size, const std::nothrow_t &) noexcept
{
return allocate(size);
}
void *operator new(std::size_t size, std::align_val_t alignment)
{
if (void *address = allocate_aligned(size, static_cast<std::size_t>(alignment)))
return address;
allocation_failure();
}
void *operator new[](std::size_t size, std::align_val_t alignment)
{
return ::operator new(size, alignment);
}
void *operator new(std::size_t size, std::align_val_t alignment, const std::nothrow_t &) noexcept
{
return allocate_aligned(size, static_cast<std::size_t>(alignment));
}
void *operator new[](std::size_t size, std::align_val_t alignment, const std::nothrow_t &) noexcept
{
return allocate_aligned(size, static_cast<std::size_t>(alignment));
}
void operator delete(void *address) noexcept
{
free(address);
}
void operator delete[](void *address) noexcept
{
free(address);
}
void operator delete(void *address, std::size_t) noexcept
{
free(address);
}
void operator delete[](void *address, std::size_t) noexcept
{
free(address);
}
void operator delete(void *address, std::align_val_t) noexcept
{
free(address);
}
void operator delete[](void *address, std::align_val_t) noexcept
{
free(address);
}
void operator delete(void *address, std::size_t, std::align_val_t) noexcept
{
free(address);
}
void operator delete[](void *address, std::size_t, std::align_val_t) noexcept
{
free(address);
}
void operator delete(void *address, const std::nothrow_t &) noexcept
{
free(address);
}
void operator delete[](void *address, const std::nothrow_t &) noexcept
{
free(address);
}
void operator delete(void *address, std::align_val_t, const std::nothrow_t &) noexcept
{
free(address);
}
void operator delete[](void *address, std::align_val_t, const std::nothrow_t &) noexcept
{
free(address);
}
+81
View File
@@ -0,0 +1,81 @@
/*
* ps5-native-app-boilerplate - Native application startup.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Initializes the platform runtime, runs static constructors and main, and
* hands normal process termination back to the platform runtime.
*/
#include <cstddef>
#include <cstdint>
using Destructor = void (*)();
using Initializer = void (*)();
extern "C"
{
void _init_env(void *process_parameters);
int atexit(Destructor callback);
[[noreturn]] void exit(int status);
int application_main(int argc, char **argv, char **envp) __asm__("main");
extern Initializer __preinit_array_start[] __attribute__((weak));
extern Initializer __preinit_array_end[] __attribute__((weak));
extern Initializer __init_array_start[] __attribute__((weak));
extern Initializer __init_array_end[] __attribute__((weak));
extern Initializer __fini_array_start[] __attribute__((weak));
extern Initializer __fini_array_end[] __attribute__((weak));
}
namespace
{
void run_forward(Initializer *first, Initializer *last) noexcept
{
if (first == nullptr || last == nullptr)
return;
while (first != last)
(*first++)();
}
void run_reverse(Initializer *first, Initializer *last) noexcept
{
if (first == nullptr || last == nullptr)
return;
while (last != first)
(*--last)();
}
} // namespace
extern "C" __attribute__((weak)) void catchReturnFromMain(int status)
{
(void)status;
}
extern "C" void _init()
{
run_forward(__preinit_array_start, __preinit_array_end);
run_forward(__init_array_start, __init_array_end);
}
extern "C" void _fini()
{
run_reverse(__fini_array_start, __fini_array_end);
}
extern "C" [[noreturn]] __attribute__((visibility("default"))) void
_start(void *process_parameters, Destructor loader_teardown)
{
const int argc = *static_cast<const int *>(process_parameters);
auto *parameters = static_cast<std::uint8_t *>(process_parameters);
auto **argv = reinterpret_cast<char **>(parameters + sizeof(std::uint64_t));
_init_env(process_parameters);
if (loader_teardown != nullptr)
(void)atexit(loader_teardown);
(void)atexit(_fini);
_init();
const int status = application_main(argc, argv, nullptr);
catchReturnFromMain(status);
exit(status);
}
+313
View File
@@ -0,0 +1,313 @@
/*
* ps5-native-app-boilerplate - ELF64 object and SDK stub reader.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Reads relocatable objects after LLVM has resolved archives and reads public
* SDK shared stubs to discover symbol providers without a copied catalog.
*/
#include "elf_object.hpp"
#include <algorithm>
#include <fstream>
#include <limits>
#include <stdexcept>
#include <string_view>
namespace ps5::elf
{
namespace
{
void require(bool condition, const std::string &message)
{
if (!condition)
throw std::runtime_error(message);
}
bool range(std::uint64_t offset, std::uint64_t size, std::uint64_t length)
{
return offset <= length && size <= length - offset;
}
std::uint16_t u16(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 2 <= data.size(), "ELF u16 read outside input");
return static_cast<std::uint16_t>(data[at]) | static_cast<std::uint16_t>(data[at + 1]) << 8;
}
std::uint32_t u32(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 4 <= data.size(), "ELF u32 read outside input");
std::uint32_t value = 0;
for (std::size_t i = 0; i < 4; ++i)
value |= static_cast<std::uint32_t>(data[at + i]) << (i * 8);
return value;
}
std::uint64_t u64(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 8 <= data.size(), "ELF u64 read outside input");
std::uint64_t value = 0;
for (std::size_t i = 0; i < 8; ++i)
value |= static_cast<std::uint64_t>(data[at + i]) << (i * 8);
return value;
}
std::string string_at(std::span<const std::uint8_t> table, std::uint64_t at)
{
require(at < table.size() || (at == 0 && table.empty()), "ELF string offset outside table");
const std::size_t first = static_cast<std::size_t>(at);
std::size_t end = first;
while (end < table.size() && table[end] != 0)
++end;
require(end < table.size(), "unterminated ELF string");
return {reinterpret_cast<const char *>(table.data() + first), end - first};
}
struct RawSection
{
std::uint32_t name{};
std::uint32_t type{};
std::uint64_t flags{};
std::uint64_t address{};
std::uint64_t offset{};
std::uint64_t size{};
std::uint32_t link{};
std::uint32_t info{};
std::uint64_t alignment{};
std::uint64_t entry_size{};
};
struct ParsedSections
{
std::vector<RawSection> raw;
std::vector<std::string> names;
};
ParsedSections sections(std::span<const std::uint8_t> data)
{
require(data.size() >= 0x40 && u32(data, 0) == 0x464c457f, "input is not an ELF file");
require(data[4] == 2 && data[5] == 1, "only little-endian ELF64 is supported");
const std::uint64_t offset = u64(data, 0x28);
const std::size_t entry_size = u16(data, 0x3a);
const std::size_t count = u16(data, 0x3c);
const std::size_t names_index = u16(data, 0x3e);
require(entry_size == 0x40, "unexpected ELF64 section-header size");
require(count != 0 && range(offset, count * entry_size, data.size()),
"ELF section table overruns input");
require(names_index < count, "ELF section-name table index is invalid");
ParsedSections result;
result.raw.reserve(count);
for (std::size_t i = 0; i < count; ++i)
{
const std::size_t at = static_cast<std::size_t>(offset) + i * entry_size;
result.raw.push_back({u32(data, at), u32(data, at + 4), u64(data, at + 8),
u64(data, at + 16), u64(data, at + 24), u64(data, at + 32),
u32(data, at + 40), u32(data, at + 44), u64(data, at + 48),
u64(data, at + 56)});
}
const RawSection &names = result.raw[names_index];
require(range(names.offset, names.size, data.size()), "ELF section-name table overruns input");
const auto table =
data.subspan(static_cast<std::size_t>(names.offset), static_cast<std::size_t>(names.size));
result.names.reserve(count);
for (const RawSection &section : result.raw)
result.names.push_back(string_at(table, section.name));
return result;
}
std::span<const std::uint8_t> section_data(std::span<const std::uint8_t> data,
const RawSection &section)
{
if (section.type == kSectionNoBits)
return {};
require(range(section.offset, section.size, data.size()), "ELF section overruns input");
return data.subspan(static_cast<std::size_t>(section.offset),
static_cast<std::size_t>(section.size));
}
std::vector<Symbol> read_symbols(std::span<const std::uint8_t> data, const ParsedSections &parsed,
std::size_t symbol_index)
{
const RawSection &symbols = parsed.raw[symbol_index];
require(symbols.entry_size == 24 && symbols.link < parsed.raw.size(),
"unsupported ELF symbol table");
const auto bytes = section_data(data, symbols);
const auto strings = section_data(data, parsed.raw[symbols.link]);
std::vector<Symbol> result;
result.reserve(bytes.size() / 24);
for (std::size_t at = 0; at + 24 <= bytes.size(); at += 24)
result.push_back({string_at(strings, u32(bytes, at)), bytes[at + 4], bytes[at + 5],
u16(bytes, at + 6), u64(bytes, at + 8), u64(bytes, at + 16)});
return result;
}
std::string normalized_soname(std::string name)
{
constexpr std::string_view suffix = ".sprx";
if (name.ends_with(suffix))
name.replace(name.size() - suffix.size(), suffix.size(), ".prx");
return name;
}
std::string module_from_soname(std::string_view soname)
{
const std::size_t slash = soname.find_last_of("/\\");
if (slash != std::string_view::npos)
soname.remove_prefix(slash + 1);
const std::size_t dot = soname.find('.');
return std::string{soname.substr(0, dot)};
}
} // namespace
Bytes read_file(const std::filesystem::path &path)
{
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input)
throw std::runtime_error("cannot open input: " + path.string());
const std::streamsize size = input.tellg();
require(size >= 0, "cannot size input: " + path.string());
Bytes data(static_cast<std::size_t>(size));
input.seekg(0);
input.read(reinterpret_cast<char *>(data.data()), size);
if (!input)
throw std::runtime_error("cannot read input: " + path.string());
return data;
}
Object read_object(std::span<const std::uint8_t> data, std::string origin)
{
require(u16(data, 0x10) == 1, "link input is not a relocatable ELF: " + origin);
const ParsedSections parsed = sections(data);
Object object;
object.origin = std::move(origin);
object.sections.reserve(parsed.raw.size());
for (std::size_t i = 0; i < parsed.raw.size(); ++i)
{
const RawSection &raw = parsed.raw[i];
const auto bytes = section_data(data, raw);
object.sections.push_back({parsed.names[i], raw.type, raw.flags, raw.address, raw.offset,
raw.size, raw.link, raw.info,
std::max<std::uint64_t>(raw.alignment, 1), raw.entry_size,
Bytes{bytes.begin(), bytes.end()}});
}
std::size_t symbol_index = parsed.raw.size();
for (std::size_t i = 0; i < parsed.raw.size(); ++i)
if (parsed.raw[i].type == kSectionSymbolTable)
{
symbol_index = i;
break;
}
require(symbol_index < parsed.raw.size(), "relocatable ELF has no symbol table");
object.symbols = read_symbols(data, parsed, symbol_index);
for (std::size_t i = 0; i < parsed.raw.size(); ++i)
{
const RawSection &raw = parsed.raw[i];
if (raw.type != kSectionRela)
continue;
require(raw.entry_size == 24 && raw.link == symbol_index && raw.info < parsed.raw.size(),
"unsupported ELF relocation section");
const auto bytes = section_data(data, raw);
auto &output = object.relocations[raw.info];
output.reserve(bytes.size() / 24);
for (std::size_t at = 0; at + 24 <= bytes.size(); at += 24)
{
const std::uint64_t info = u64(bytes, at + 8);
output.push_back({u64(bytes, at), static_cast<std::uint32_t>(info >> 32),
static_cast<std::uint32_t>(info),
static_cast<std::int64_t>(u64(bytes, at + 16))});
}
}
return object;
}
Image read_image(std::span<const std::uint8_t> data, std::string origin)
{
require(u16(data, 0x10) == 3, "link output is not an ELF shared image: " + origin);
const ParsedSections parsed = sections(data);
Image image;
image.origin = std::move(origin);
image.entry = u64(data, 0x18);
image.sections.reserve(parsed.raw.size());
for (std::size_t i = 0; i < parsed.raw.size(); ++i)
{
const RawSection &raw = parsed.raw[i];
const auto bytes = section_data(data, raw);
image.sections.push_back({parsed.names[i], raw.type, raw.flags, raw.address, raw.offset,
raw.size, raw.link, raw.info,
std::max<std::uint64_t>(raw.alignment, 1), raw.entry_size,
Bytes{bytes.begin(), bytes.end()}});
if (raw.type == kSectionDynamicSymbols)
image.dynamic_symbols = read_symbols(data, parsed, i);
}
for (std::size_t i = 0; i < parsed.raw.size(); ++i)
{
const RawSection &dynamic = parsed.raw[i];
if (dynamic.type != kSectionDynamic)
continue;
require(dynamic.link < parsed.raw.size() && dynamic.entry_size == 16,
"linked ELF has an unsupported dynamic table");
const auto entries = section_data(data, dynamic);
const auto strings = section_data(data, parsed.raw[dynamic.link]);
for (std::size_t at = 0; at + 16 <= entries.size(); at += 16)
{
const std::uint64_t tag = u64(entries, at);
if (tag == 0)
break;
if (tag == 1)
image.needed.push_back(normalized_soname(string_at(strings, u64(entries, at + 8))));
}
}
require(!image.dynamic_symbols.empty(),
"linked ELF has no dynamic symbol table: " + image.origin);
return image;
}
Stub read_stub(std::span<const std::uint8_t> data, std::string origin)
{
const ParsedSections parsed = sections(data);
std::size_t symbols_index = parsed.raw.size();
std::size_t dynamic_index = parsed.raw.size();
for (std::size_t i = 0; i < parsed.raw.size(); ++i)
{
if (parsed.raw[i].type == kSectionDynamicSymbols)
symbols_index = i;
if (parsed.raw[i].type == kSectionDynamic)
dynamic_index = i;
}
require(symbols_index < parsed.raw.size() && dynamic_index < parsed.raw.size(),
"SDK stub lacks dynamic metadata: " + origin);
const auto symbols = read_symbols(data, parsed, symbols_index);
const RawSection &dynamic = parsed.raw[dynamic_index];
require(dynamic.link < parsed.raw.size() && dynamic.entry_size == 16,
"SDK stub has unsupported dynamic table: " + origin);
const auto strings = section_data(data, parsed.raw[dynamic.link]);
const auto entries = section_data(data, dynamic);
std::string soname;
for (std::size_t at = 0; at + 16 <= entries.size(); at += 16)
{
const std::uint64_t tag = u64(entries, at);
if (tag == 0)
break;
if (tag == 14)
soname = string_at(strings, u64(entries, at + 8));
}
require(!soname.empty(), "SDK stub has no SONAME: " + origin);
Stub stub;
stub.soname = normalized_soname(std::move(soname));
stub.module_name = module_from_soname(stub.soname);
stub.library_name = stub.module_name;
for (const Symbol &symbol : symbols)
if (!symbol.undefined() && symbol.global_or_weak() && !symbol.name.empty())
stub.exports.push_back(symbol.name);
return stub;
}
} // namespace ps5::elf
+152
View File
@@ -0,0 +1,152 @@
/*
* ps5-native-app-boilerplate - ELF object model and reader.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Defines the compact ELF64 structures used by the native PS5 module writer.
*/
#pragma once
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <map>
#include <span>
#include <string>
#include <vector>
namespace ps5::elf
{
using Bytes = std::vector<std::uint8_t>;
inline constexpr std::uint32_t kSectionProgBits = 1;
inline constexpr std::uint32_t kSectionSymbolTable = 2;
inline constexpr std::uint32_t kSectionStringTable = 3;
inline constexpr std::uint32_t kSectionRela = 4;
inline constexpr std::uint32_t kSectionDynamic = 6;
inline constexpr std::uint32_t kSectionNoBits = 8;
inline constexpr std::uint32_t kSectionDynamicSymbols = 11;
inline constexpr std::uint64_t kFlagWrite = 0x1;
inline constexpr std::uint64_t kFlagAlloc = 0x2;
inline constexpr std::uint64_t kFlagExecute = 0x4;
inline constexpr std::uint64_t kFlagTls = 0x400;
inline constexpr int kBindLocal = 0;
inline constexpr int kBindGlobal = 1;
inline constexpr int kBindWeak = 2;
inline constexpr int kTypeNoType = 0;
inline constexpr int kTypeObject = 1;
inline constexpr int kTypeFunction = 2;
inline constexpr int kTypeSection = 3;
inline constexpr int kTypeTls = 6;
inline constexpr int kTypeGnuIfunc = 10;
struct Section
{
std::string name;
std::uint32_t type{};
std::uint64_t flags{};
std::uint64_t address{};
std::uint64_t file_offset{};
std::uint64_t size{};
std::uint32_t link{};
std::uint32_t info{};
std::uint64_t alignment{1};
std::uint64_t entry_size{};
Bytes data;
[[nodiscard]] bool allocated() const
{
return (flags & kFlagAlloc) != 0;
}
[[nodiscard]] bool executable() const
{
return (flags & kFlagExecute) != 0;
}
[[nodiscard]] bool writable() const
{
return (flags & kFlagWrite) != 0;
}
[[nodiscard]] bool tls() const
{
return (flags & kFlagTls) != 0;
}
[[nodiscard]] bool no_bits() const
{
return type == kSectionNoBits;
}
};
struct Symbol
{
std::string name;
std::uint8_t info{};
std::uint8_t other{};
std::uint16_t section{};
std::uint64_t value{};
std::uint64_t size{};
[[nodiscard]] int binding() const
{
return info >> 4;
}
[[nodiscard]] int type() const
{
return info & 0xf;
}
[[nodiscard]] bool undefined() const
{
return section == 0;
}
[[nodiscard]] bool weak() const
{
return binding() == kBindWeak;
}
[[nodiscard]] bool global_or_weak() const
{
return binding() == kBindGlobal || binding() == kBindWeak;
}
};
struct Relocation
{
std::uint64_t offset{};
std::uint32_t symbol{};
std::uint32_t type{};
std::int64_t addend{};
};
struct Object
{
std::string origin;
std::vector<Section> sections;
std::vector<Symbol> symbols;
std::map<std::size_t, std::vector<Relocation>> relocations;
};
struct Image
{
std::string origin;
std::uint64_t entry{};
std::vector<Section> sections;
std::vector<Symbol> dynamic_symbols;
std::vector<std::string> needed;
};
struct Stub
{
std::string soname;
std::string module_name;
std::string library_name;
std::vector<std::string> exports;
};
[[nodiscard]] Bytes read_file(const std::filesystem::path &path);
[[nodiscard]] Object read_object(std::span<const std::uint8_t> data, std::string origin);
[[nodiscard]] Image read_image(std::span<const std::uint8_t> data, std::string origin);
[[nodiscard]] Stub read_stub(std::span<const std::uint8_t> data, std::string origin);
} // namespace ps5::elf
+173
View File
@@ -0,0 +1,173 @@
/*
* ps5-native-app-boilerplate - Native SHA-1 and SHA-256 implementation.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Implements the published SHA-1 and SHA-256 algorithms for deterministic
* build metadata without requiring a host cryptography library.
*/
#pragma once
#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
namespace ps5::crypto
{
namespace detail
{
inline std::vector<std::uint8_t> pad(std::span<const std::uint8_t> input)
{
std::vector<std::uint8_t> data(input.begin(), input.end());
const std::uint64_t bit_count = static_cast<std::uint64_t>(data.size()) * 8;
data.push_back(0x80);
while ((data.size() % 64) != 56)
data.push_back(0);
for (int shift = 56; shift >= 0; shift -= 8)
data.push_back(static_cast<std::uint8_t>(bit_count >> shift));
return data;
}
inline std::uint32_t load_be32(const std::uint8_t *value)
{
return (static_cast<std::uint32_t>(value[0]) << 24) |
(static_cast<std::uint32_t>(value[1]) << 16) |
(static_cast<std::uint32_t>(value[2]) << 8) | static_cast<std::uint32_t>(value[3]);
}
template <std::size_t N>
inline std::array<std::uint8_t, N * 4> store_words(const std::array<std::uint32_t, N> &words)
{
std::array<std::uint8_t, N * 4> result{};
for (std::size_t i = 0; i < N; ++i)
{
result[i * 4] = static_cast<std::uint8_t>(words[i] >> 24);
result[i * 4 + 1] = static_cast<std::uint8_t>(words[i] >> 16);
result[i * 4 + 2] = static_cast<std::uint8_t>(words[i] >> 8);
result[i * 4 + 3] = static_cast<std::uint8_t>(words[i]);
}
return result;
}
} // namespace detail
inline std::array<std::uint8_t, 20> sha1(std::span<const std::uint8_t> input)
{
std::array<std::uint32_t, 5> state{0x67452301U, 0xEFCDAB89U, 0x98BADCFEU, 0x10325476U,
0xC3D2E1F0U};
const auto data = detail::pad(input);
for (std::size_t offset = 0; offset < data.size(); offset += 64)
{
std::array<std::uint32_t, 80> w{};
for (std::size_t i = 0; i < 16; ++i)
w[i] = detail::load_be32(data.data() + offset + i * 4);
for (std::size_t i = 16; i < w.size(); ++i)
w[i] = std::rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
auto [a, b, c, d, e] = state;
for (std::size_t i = 0; i < w.size(); ++i)
{
std::uint32_t f;
std::uint32_t k;
if (i < 20)
{
f = (b & c) | (~b & d);
k = 0x5A827999U;
}
else if (i < 40)
{
f = b ^ c ^ d;
k = 0x6ED9EBA1U;
}
else if (i < 60)
{
f = (b & c) | (b & d) | (c & d);
k = 0x8F1BBCDCU;
}
else
{
f = b ^ c ^ d;
k = 0xCA62C1D6U;
}
const std::uint32_t next = std::rotl(a, 5) + f + e + k + w[i];
e = d;
d = c;
c = std::rotl(b, 30);
b = a;
a = next;
}
state[0] += a;
state[1] += b;
state[2] += c;
state[3] += d;
state[4] += e;
}
return detail::store_words(state);
}
inline std::array<std::uint8_t, 32> sha256(std::span<const std::uint8_t> input)
{
constexpr std::array<std::uint32_t, 64> k{
0x428A2F98U, 0x71374491U, 0xB5C0FBCFU, 0xE9B5DBA5U, 0x3956C25BU, 0x59F111F1U, 0x923F82A4U,
0xAB1C5ED5U, 0xD807AA98U, 0x12835B01U, 0x243185BEU, 0x550C7DC3U, 0x72BE5D74U, 0x80DEB1FEU,
0x9BDC06A7U, 0xC19BF174U, 0xE49B69C1U, 0xEFBE4786U, 0x0FC19DC6U, 0x240CA1CCU, 0x2DE92C6FU,
0x4A7484AAU, 0x5CB0A9DCU, 0x76F988DAU, 0x983E5152U, 0xA831C66DU, 0xB00327C8U, 0xBF597FC7U,
0xC6E00BF3U, 0xD5A79147U, 0x06CA6351U, 0x14292967U, 0x27B70A85U, 0x2E1B2138U, 0x4D2C6DFCU,
0x53380D13U, 0x650A7354U, 0x766A0ABBU, 0x81C2C92EU, 0x92722C85U, 0xA2BFE8A1U, 0xA81A664BU,
0xC24B8B70U, 0xC76C51A3U, 0xD192E819U, 0xD6990624U, 0xF40E3585U, 0x106AA070U, 0x19A4C116U,
0x1E376C08U, 0x2748774CU, 0x34B0BCB5U, 0x391C0CB3U, 0x4ED8AA4AU, 0x5B9CCA4FU, 0x682E6FF3U,
0x748F82EEU, 0x78A5636FU, 0x84C87814U, 0x8CC70208U, 0x90BEFFFAU, 0xA4506CEBU, 0xBEF9A3F7U,
0xC67178F2U};
std::array<std::uint32_t, 8> state{0x6A09E667U, 0xBB67AE85U, 0x3C6EF372U, 0xA54FF53AU,
0x510E527FU, 0x9B05688CU, 0x1F83D9ABU, 0x5BE0CD19U};
const auto data = detail::pad(input);
for (std::size_t offset = 0; offset < data.size(); offset += 64)
{
std::array<std::uint32_t, 64> w{};
for (std::size_t i = 0; i < 16; ++i)
w[i] = detail::load_be32(data.data() + offset + i * 4);
for (std::size_t i = 16; i < w.size(); ++i)
{
const std::uint32_t s0 =
std::rotr(w[i - 15], 7) ^ std::rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
const std::uint32_t s1 =
std::rotr(w[i - 2], 17) ^ std::rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
auto [a, b, c, d, e, f, g, h] = state;
for (std::size_t i = 0; i < w.size(); ++i)
{
const std::uint32_t s1 = std::rotr(e, 6) ^ std::rotr(e, 11) ^ std::rotr(e, 25);
const std::uint32_t choose = (e & f) ^ (~e & g);
const std::uint32_t t1 = h + s1 + choose + k[i] + w[i];
const std::uint32_t s0 = std::rotr(a, 2) ^ std::rotr(a, 13) ^ std::rotr(a, 22);
const std::uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
const std::uint32_t t2 = s0 + majority;
h = g;
g = f;
f = e;
e = d + t1;
d = c;
c = b;
b = a;
a = t1 + t2;
}
state[0] += a;
state[1] += b;
state[2] += c;
state[3] += d;
state[4] += e;
state[5] += f;
state[6] += g;
state[7] += h;
}
return detail::store_words(state);
}
} // namespace ps5::crypto
File diff suppressed because it is too large. Load diff
+312
View File
@@ -0,0 +1,312 @@
/*
* ps5-native-app-boilerplate - Native host build utility.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Provides C++ implementations of the repository's host-side build steps.
* Handles deterministic FSELF containers and converts LLVM-linked PIE files
* into PS5 dynamic executables without a managed-code toolchain.
*/
#include "hash.hpp"
#include "elf_object.hpp"
#include "sce_module_writer.hpp"
#include "self_container.hpp"
#include <algorithm>
#include <charconv>
#include <cctype>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <optional>
#include <span>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace
{
using Bytes = std::vector<std::uint8_t>;
Bytes read_file(const std::filesystem::path &path)
{
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input)
throw std::runtime_error("cannot open input: " + path.string());
const std::streamsize size = input.tellg();
if (size < 0)
throw std::runtime_error("cannot size input: " + path.string());
Bytes data(static_cast<std::size_t>(size));
input.seekg(0);
input.read(reinterpret_cast<char *>(data.data()), size);
if (!input)
throw std::runtime_error("cannot read input: " + path.string());
return data;
}
void write_file(const std::filesystem::path &path, std::span<const std::uint8_t> data)
{
if (!path.parent_path().empty())
std::filesystem::create_directories(path.parent_path());
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output)
throw std::runtime_error("cannot create output: " + path.string());
output.write(reinterpret_cast<const char *>(data.data()),
static_cast<std::streamsize>(data.size()));
if (!output)
throw std::runtime_error("cannot write output: " + path.string());
}
bool has_flag(std::span<char *> args, std::string_view name)
{
return std::any_of(args.begin(), args.end(),
[&](const char *value) { return std::string_view{value} == name; });
}
std::optional<std::string_view> option(std::span<char *> args, std::string_view name)
{
for (std::size_t i = 0; i + 1 < args.size(); ++i)
if (std::string_view{args[i]} == name)
return args[i + 1];
return std::nullopt;
}
std::vector<std::string_view> options(std::span<char *> args, std::string_view name)
{
std::vector<std::string_view> values;
for (std::size_t i = 0; i + 1 < args.size(); ++i)
if (std::string_view{args[i]} == name)
values.emplace_back(args[i + 1]);
return values;
}
std::uint64_t parse_integer(std::string_view value)
{
int base = 10;
if (value.starts_with("0x") || value.starts_with("0X"))
{
value.remove_prefix(2);
base = 16;
}
std::uint64_t result{};
const auto parsed = std::from_chars(value.data(), value.data() + value.size(), result, base);
if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size())
throw std::runtime_error("invalid integer option");
return result;
}
Bytes parse_hex(std::string_view value)
{
if ((value.size() & 1) != 0)
throw std::runtime_error("hex value has odd length");
Bytes output(value.size() / 2);
for (std::size_t i = 0; i < output.size(); ++i)
{
unsigned integer{};
const auto first = value.data() + i * 2;
const auto parsed = std::from_chars(first, first + 2, integer, 16);
if (parsed.ec != std::errc{} || parsed.ptr != first + 2)
throw std::runtime_error("invalid hexadecimal value");
output[i] = static_cast<std::uint8_t>(integer);
}
return output;
}
std::string hex(std::span<const std::uint8_t> data)
{
std::ostringstream output;
output << std::hex << std::setfill('0');
for (std::uint8_t value : data)
output << std::setw(2) << unsigned(value);
return output.str();
}
int self_command(std::span<char *> args)
{
auto input_name = option(args, "--file");
if (!input_name)
input_name = option(args, "--in");
if (!input_name)
throw std::runtime_error("self requires --file or --in");
const std::filesystem::path input_path{std::string{*input_name}};
const Bytes input = read_file(std::filesystem::absolute(input_path));
const bool do_sign = has_flag(args, "--sign");
const bool do_extract = has_flag(args, "--extract");
if (!do_sign && !do_extract)
{
std::cout << "file: " << input_path.filename().string() << '\n';
if (ps5::self::is_self(input))
{
const auto image = ps5::self::parse(input);
bool encrypted = false;
for (const auto &segment : image.segments)
encrypted |= segment.encrypted();
std::cout << "container: signed, " << (encrypted ? "encrypted" : "plaintext") << '\n'
<< "segments: " << image.segments.size() << '\n';
if (image.has_extended_info)
{
std::cout << "authority: 0x" << std::hex << std::setw(16) << std::setfill('0')
<< image.authority << '\n'
<< "program type: 0x" << std::setw(16) << image.extended_program_type
<< '\n'
<< "app version: 0x" << std::setw(16) << image.app_version << '\n'
<< "firmware version: 0x" << std::setw(16) << image.firmware_version
<< '\n'
<< "digest: " << hex(image.digest) << '\n';
if (!encrypted)
{
const auto extracted = ps5::self::extract(input);
const auto computed = ps5::crypto::sha256(extracted);
std::cout << "integrity: "
<< (std::equal(computed.begin(), computed.end(), image.digest.begin())
? "valid"
: "INVALID")
<< '\n';
}
}
}
else if (ps5::self::is_elf(input))
{
std::cout << "container: unsigned ELF\n";
}
else
{
throw std::runtime_error("file is neither an ELF nor a signed container");
}
return 0;
}
const auto output_name = option(args, "--out");
if (!output_name)
throw std::runtime_error("self operation requires --out");
Bytes output;
if (do_sign)
{
if (ps5::self::is_self(input))
{
std::cout << "input is already wrapped; left unchanged\n";
return 0;
}
ps5::self::SignOptions options;
if (const auto value = option(args, "--magic"))
options.magic = static_cast<std::uint32_t>(parse_integer(*value));
if (const auto value = option(args, "--app-version"))
options.app_version = parse_integer(*value);
if (const auto value = option(args, "--fw-version"))
options.firmware_version = parse_integer(*value);
if (const auto value = option(args, "--authority"))
options.authority = parse_integer(*value);
if (const auto value = option(args, "--auth-info"))
options.auth_info = parse_hex(*value);
options.include_proc_param = has_flag(args, "--include-procparam-segment");
options.normalize_header = !has_flag(args, "--no-normalize");
output = ps5::self::sign(input, options);
}
else
{
output = ps5::self::extract(input);
if (has_flag(args, "--strip-sections"))
output = ps5::self::strip_sections(output);
}
const auto path = std::filesystem::absolute(std::filesystem::path{std::string{*output_name}});
write_file(path, output);
std::cout << "wrote " << output.size() << " bytes: " << path.string() << '\n'
<< "sha256 " << hex(ps5::crypto::sha256(output)) << '\n';
return 0;
}
int link_command(std::span<char *> args)
{
const auto input_name = option(args, "--in");
const auto output_name = option(args, "--out");
if (!input_name || !output_name)
throw std::runtime_error("link requires --in and --out");
const std::filesystem::path input_path{std::string{*input_name}};
const Bytes input = read_file(std::filesystem::absolute(input_path));
const auto image = ps5::elf::read_image(input, input_path.string());
std::vector<ps5::elf::Stub> stubs;
for (std::string_view name : options(args, "--stub"))
{
const std::filesystem::path path{std::string{name}};
const Bytes bytes = read_file(std::filesystem::absolute(path));
stubs.push_back(ps5::elf::read_stub(bytes, path.string()));
}
if (const auto directory_name = option(args, "--stub-dir"))
{
const std::filesystem::path directory{std::string{*directory_name}};
std::vector<std::filesystem::path> paths;
for (const auto &entry : std::filesystem::directory_iterator(directory))
if (entry.is_regular_file() && entry.path().extension() == ".so")
paths.push_back(entry.path());
std::sort(paths.begin(), paths.end());
for (const auto &path : paths)
{
const Bytes bytes = read_file(path);
stubs.push_back(ps5::elf::read_stub(bytes, path.string()));
}
}
if (stubs.empty())
throw std::runtime_error("link requires at least one --stub");
ps5::module::Options link_options;
if (const auto value = option(args, "--file-name"))
link_options.file_name = *value;
if (const auto value = option(args, "--module-sdk"))
link_options.module_sdk = static_cast<std::uint32_t>(parse_integer(*value));
if (const auto value = option(args, "--companion-sdk"))
link_options.companion_sdk = static_cast<std::uint32_t>(parse_integer(*value));
for (std::string_view value : options(args, "--component"))
link_options.version_components.emplace_back(value);
const Bytes output = ps5::module::write_executable(image, stubs, link_options);
const auto path = std::filesystem::absolute(std::filesystem::path{std::string{*output_name}});
write_file(path, output);
std::cout << "wrote " << output.size() << " bytes: " << path.string() << '\n'
<< "sha256 " << hex(ps5::crypto::sha256(output)) << '\n';
return 0;
}
void usage()
{
std::cerr << "usage:\n"
<< " ps5-native-tool self --sign --in <elf> --out <fself> [options]\n"
<< " ps5-native-tool self --extract --file <fself> --out <elf>\n"
<< " ps5-native-tool self --inspect --file <module>\n"
<< " ps5-native-tool link --in <llvm-pie> --out <ps5-elf>"
" (--stub <sdk-so>... | --stub-dir <sdk-lib>)\n";
}
} // namespace
int main(int argc, char **argv)
{
try
{
if (argc < 2)
{
usage();
return 2;
}
const std::span<char *> args{argv + 2, static_cast<std::size_t>(argc - 2)};
if (std::string_view{argv[1]} == "self")
return self_command(args);
if (std::string_view{argv[1]} == "link")
return link_command(args);
usage();
return 2;
}
catch (const std::exception &error)
{
std::cerr << "error: " << error.what() << '\n';
return 2;
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
* ps5-native-app-boilerplate - Intermediate PIE linker layout.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Keeps code, immutable data, RELRO, and writable data in distinct address
* ranges. The native converter replaces LLVM's generic dynamic metadata.
*/
OUTPUT_FORMAT("elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
PHDRS {
text PT_LOAD FLAGS (0x5);
ro PT_LOAD FLAGS (0x4);
relro PT_LOAD FLAGS (0x6);
data PT_LOAD FLAGS (0x6);
dynamic PT_DYNAMIC FLAGS (0x6);
}
SECTIONS {
.text 0 : ALIGN(CONSTANT(MAXPAGESIZE)) {
QUAD(0xcccccccccccccccc)
QUAD(0xcccccccccccccccc)
*(.text .text.*)
*(.plt .plt.*)
} : text
.eh_frame_hdr : ALIGN(CONSTANT(MAXPAGESIZE)) {
KEEP(*(.eh_frame_hdr))
} : ro
.eh_frame : { KEEP(*(.eh_frame)) } : ro
.gcc_except_table : { *(.gcc_except_table .gcc_except_table.*) } : ro
.rodata : { *(.rodata .rodata.*) } : ro
. = ALIGN(CONSTANT(MAXPAGESIZE));
.data.rel.ro : {
*(.data.rel.ro .data.rel.ro.*)
} : relro
.got : { *(.got) } : relro
.got.plt : { *(.got.plt) } : relro
.preinit_array : {
PROVIDE_HIDDEN(__preinit_array_start = .);
KEEP(*(.preinit_array))
PROVIDE_HIDDEN(__preinit_array_end = .);
} : relro
.init_array : {
PROVIDE_HIDDEN(__init_array_start = .);
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*)))
KEEP(*(.init_array .ctors))
PROVIDE_HIDDEN(__init_array_end = .);
} : relro
.fini_array : {
PROVIDE_HIDDEN(__fini_array_start = .);
KEEP(*(SORT_BY_INIT_PRIORITY(.fini_array.*) SORT_BY_INIT_PRIORITY(.dtors.*)))
KEEP(*(.fini_array .dtors))
PROVIDE_HIDDEN(__fini_array_end = .);
} : relro
/* Kept only long enough for the converter to read and rewrite them. */
.dynsym : { *(.dynsym) } : relro
.gnu.hash : { *(.gnu.hash) } : relro
.dynstr : { *(.dynstr) } : relro
.rela.dyn : { *(.rela.dyn) } : relro
.rela.plt : { *(.rela.plt) } : relro
. = ALIGN(CONSTANT(MAXPAGESIZE));
.dynamic : {
*(.dynamic)
} : data : dynamic
.data : { *(.data .data.*) } : data
.tdata : { *(.tdata .tdata.*) } : data
.tbss : { *(.tbss .tbss.*) } : data
.bss : {
*(.bss .bss.*)
*(COMMON)
} : data
}
File diff suppressed because it is too large. Load diff
+107
View File
@@ -0,0 +1,107 @@
# ps5-native-app-boilerplate - Clean-room runtime import manifest.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# name|encoded library/module suffix|PLT relocation|GLOB_DAT relocation
__elf_phdr_match_addr|#A#B|1|0
__error|#A#B|1|0
__progname|#A#B|0|1
__pthread_cxa_finalize|#A#B|1|1
__stack_chk_fail|#A#B|1|0
__stack_chk_guard|#A#B|0|1
__tls_get_addr|#A#B|1|0
_close|#A#B|1|0
_exit|#A#B|1|0
_is_signal_return|#A#B|1|0
_nanosleep|#A#B|1|0
_open|#A#B|1|0
_read|#A#B|1|0
_sceKernelRtldSetApplicationHeapAPI|#A#B|1|0
_sceKernelRtldThreadAtexitDecrement|#A#B|1|0
_sceKernelRtldThreadAtexitIncrement|#A#B|1|0
_sceKernelSetThreadAtexitCount|#A#B|1|0
_sceKernelSetThreadAtexitReport|#A#B|1|0
_sceKernelSetThreadDtors|#A#B|1|0
_sigprocmask|#A#B|1|0
_write|#A#B|1|0
clock_gettime|#A#B|1|0
getrusage|#A#B|1|0
gettimeofday|#A#B|1|0
lseek|#A#B|1|0
nanosleep|#A#B|1|0
pthread_cond_broadcast|#A#B|1|0
pthread_cond_wait|#A#B|1|0
pthread_mutex_lock|#A#B|1|0
pthread_mutex_unlock|#A#B|1|0
pthread_rwlock_rdlock|#A#B|1|0
pthread_rwlock_unlock|#A#B|1|0
pthread_rwlock_wrlock|#A#B|1|0
rmdir|#A#B|1|0
sceKernelChmod|#A#B|1|0
sceKernelClose|#A#B|1|0
sceKernelConvertLocaltimeToUtc|#A#B|1|0
sceKernelConvertUtcToLocaltime|#A#B|1|0
sceKernelDebugRaiseException|#A#B|1|0
sceKernelDebugRaiseExceptionOnReleaseMode|#A#B|1|0
sceKernelGetModuleInfoForUnwind|#A#B|1|0
sceKernelGetModuleInfoFromAddr|#A#B|1|0
sceKernelGetProcParam|#A#B|1|0
sceKernelGetProcessTime|#A#B|1|0
sceKernelGetSanitizerMallocReplaceExternal|#A#B|1|0
sceKernelGetSanitizerNewReplaceExternal|#A#B|1|0
sceKernelGetdents|#A#B|1|0
sceKernelInternalMemoryGetModuleSegmentInfo|#A#B|1|0
sceKernelIsAddressSanitizerEnabled|#A#B|1|0
sceKernelMapNamedFlexibleMemory|#A#B|1|0
sceKernelMkdir|#A#B|1|0
sceKernelMlock|#A#B|1|0
sceKernelMunmap|#A#B|1|0
sceKernelOpen|#A#B|1|0
sceKernelRename|#A#B|1|0
sceKernelStat|#A#B|1|0
sceKernelTruncate|#A#B|1|0
sceKernelUnlink|#A#B|1|0
sceKernelUtimes|#A#B|1|0
scePthreadAttrDestroy|#A#B|1|0
scePthreadAttrGet|#A#B|1|0
scePthreadAttrGetaffinity|#A#B|1|0
scePthreadAttrGetdetachstate|#A#B|1|0
scePthreadAttrGetschedparam|#A#B|1|0
scePthreadAttrInit|#A#B|1|0
scePthreadAttrSetinheritsched|#A#B|1|0
scePthreadAttrSetschedparam|#A#B|1|0
scePthreadAttrSetstacksize|#A#B|1|0
scePthreadCondBroadcast|#A#B|1|0
scePthreadCondDestroy|#A#B|1|0
scePthreadCondInit|#A#B|1|0
scePthreadCondSignal|#A#B|1|0
scePthreadCondTimedwait|#A#B|1|0
scePthreadCondWait|#A#B|1|0
scePthreadCreate|#A#B|1|0
scePthreadDetach|#A#B|1|0
scePthreadEqual|#A#B|1|0
scePthreadExit|#A#B|1|0
scePthreadGetname|#A#B|1|0
scePthreadGetspecific|#A#B|1|0
scePthreadJoin|#A#B|1|0
scePthreadKeyCreate|#A#B|1|0
scePthreadKeyDelete|#A#B|1|0
scePthreadMutexDestroy|#A#B|1|0
scePthreadMutexInit|#A#B|1|0
scePthreadMutexLock|#A#B|1|0
scePthreadMutexTimedlock|#A#B|1|0
scePthreadMutexTrylock|#A#B|1|0
scePthreadMutexUnlock|#A#B|1|0
scePthreadMutexattrDestroy|#A#B|1|0
scePthreadMutexattrInit|#A#B|1|0
scePthreadMutexattrSettype|#A#B|1|0
scePthreadOnce|#A#B|1|0
scePthreadSelf|#A#B|1|0
scePthreadSetspecific|#A#B|1|0
malloc|#B#C|1|0
free|#B#C|1|0
posix_memalign|#B#C|1|0
sceLibcHeapGetTraceInfo|#B#C|1|0
sceLibcInternalBacktraceForGame|#B#C|1|0
sceLibcInternalHeapErrorReportForGame|#B#C|1|0
sceSysmoduleGetModuleInfoForUnwind|#C#D|1|0
+924
View File
@@ -0,0 +1,924 @@
/*
* ps5-native-app-boilerplate - Native PS5 dynamic-module writer.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Converts an ordinary PIE produced by LLVM lld into the loader-visible PS5
* executable shape. LLVM remains responsible for C/C++ linking, archives,
* COMDAT, TLS, unwind data, and ordinary x86-64 relocations.
*/
#include "sce_module_writer.hpp"
#include "hash.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <limits>
#include <map>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace ps5::module
{
namespace
{
using elf::Bytes;
using elf::Image;
using elf::Section;
using elf::Stub;
constexpr std::uint64_t kPage = 0x4000;
constexpr std::uint16_t kTypeDynamicExecutable = 0xfe10;
constexpr std::uint32_t kProgramLoad = 1;
constexpr std::uint32_t kProgramDynamic = 2;
constexpr std::uint32_t kProgramNote = 4;
constexpr std::uint32_t kProgramTls = 7;
constexpr std::uint32_t kProgramProcParam = 0x61000001;
constexpr std::uint32_t kProgramGnuEhFrame = 0x6474e550;
constexpr std::uint32_t kProgramGnuRelro = 0x6474e552;
constexpr std::uint32_t kProgramComment = 0x6fffff00;
constexpr std::uint32_t kProgramVersion = 0x6fffff01;
constexpr std::uint32_t kFlagExecute = 1;
constexpr std::uint32_t kFlagWrite = 2;
constexpr std::uint32_t kFlagRead = 4;
constexpr std::int64_t kDynamicNeeded = 1;
constexpr std::int64_t kDynamicPltRelSize = 2;
constexpr std::int64_t kDynamicPltGot = 3;
constexpr std::int64_t kDynamicHash = 4;
constexpr std::int64_t kDynamicStringTable = 5;
constexpr std::int64_t kDynamicSymbolTable = 6;
constexpr std::int64_t kDynamicRela = 7;
constexpr std::int64_t kDynamicRelaSize = 8;
constexpr std::int64_t kDynamicRelaEntry = 9;
constexpr std::int64_t kDynamicStringSize = 10;
constexpr std::int64_t kDynamicSymbolEntry = 11;
constexpr std::int64_t kDynamicInit = 12;
constexpr std::int64_t kDynamicFini = 13;
constexpr std::int64_t kDynamicPltRel = 20;
constexpr std::int64_t kDynamicDebug = 21;
constexpr std::int64_t kDynamicJumpRela = 23;
constexpr std::int64_t kDynamicInitArray = 25;
constexpr std::int64_t kDynamicFiniArray = 26;
constexpr std::int64_t kDynamicInitArraySize = 27;
constexpr std::int64_t kDynamicFiniArraySize = 28;
constexpr std::int64_t kDynamicPreinitArray = 32;
constexpr std::int64_t kDynamicPreinitArraySize = 33;
constexpr std::int64_t kDynamicRelaCount = 0x6ffffff9;
constexpr std::int64_t kDynamicModuleAttributes = 0x61000011;
constexpr std::int64_t kDynamicImportLibraryAttributes = 0x61000019;
constexpr std::int64_t kDynamicHashSize = 0x6100003d;
constexpr std::int64_t kDynamicSymbolTableSize = 0x6100003f;
constexpr std::int64_t kDynamicOriginalFilename = 0x61000041;
constexpr std::int64_t kDynamicModuleInfo = 0x61000043;
constexpr std::int64_t kDynamicNeededModule = 0x61000045;
constexpr std::int64_t kDynamicImportLibrary = 0x61000049;
constexpr std::uint32_t kRelRelative = 8;
void require(bool condition, const std::string &message)
{
if (!condition)
throw std::runtime_error(message);
}
std::uint64_t align_up(std::uint64_t value, std::uint64_t alignment)
{
return alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment;
}
std::uint64_t congruent_offset(std::uint64_t minimum, std::uint64_t address)
{
const std::uint64_t residue = address % kPage;
if (minimum <= residue)
return residue;
return align_up(minimum - residue, kPage) + residue;
}
void write_u16(std::span<std::uint8_t> data, std::size_t at, std::uint16_t value)
{
require(at + 2 <= data.size(), "u16 write outside output");
data[at] = static_cast<std::uint8_t>(value);
data[at + 1] = static_cast<std::uint8_t>(value >> 8);
}
void write_u32(std::span<std::uint8_t> data, std::size_t at, std::uint32_t value)
{
require(at + 4 <= data.size(), "u32 write outside output");
for (std::size_t i = 0; i < 4; ++i)
data[at + i] = static_cast<std::uint8_t>(value >> (i * 8));
}
void write_u64(std::span<std::uint8_t> data, std::size_t at, std::uint64_t value)
{
require(at + 8 <= data.size(), "u64 write outside output");
for (std::size_t i = 0; i < 8; ++i)
data[at + i] = static_cast<std::uint8_t>(value >> (i * 8));
}
std::uint32_t read_u32(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 4 <= data.size(), "u32 read outside input");
std::uint32_t value = 0;
for (std::size_t i = 0; i < 4; ++i)
value |= static_cast<std::uint32_t>(data[at + i]) << (i * 8);
return value;
}
std::uint64_t read_u64(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 8 <= data.size(), "u64 read outside input");
std::uint64_t value = 0;
for (std::size_t i = 0; i < 8; ++i)
value |= static_cast<std::uint64_t>(data[at + i]) << (i * 8);
return value;
}
void copy_bytes(std::span<std::uint8_t> target, std::uint64_t at,
std::span<const std::uint8_t> source)
{
require(at <= target.size() && source.size() <= target.size() - at, "copy outside output");
std::copy(source.begin(), source.end(), target.begin() + at);
}
const Section &section(const Image &image, std::string_view name)
{
const auto found = std::find_if(image.sections.begin(), image.sections.end(),
[&](const Section &value) { return value.name == name; });
require(found != image.sections.end(),
"LLVM-linked image lacks required section " + std::string{name});
return *found;
}
const Section *optional_section(const Image &image, std::string_view name)
{
const auto found = std::find_if(image.sections.begin(), image.sections.end(),
[&](const Section &value) { return value.name == name; });
return found == image.sections.end() ? nullptr : &*found;
}
bool metadata_section(std::string_view name)
{
return name == ".dynstr" || name == ".dynsym" || name == ".dynamic" || name == ".hash" ||
name == ".gnu.hash" || name.starts_with(".rela.");
}
bool relro_section(std::string_view name)
{
return name == ".got" || name == ".got.plt" || name.starts_with(".data.rel.ro") ||
name.starts_with(".preinit_array") || name.starts_with(".init_array") ||
name.starts_with(".fini_array");
}
std::string encode_id(int id)
{
constexpr std::string_view alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-";
if (id == 0)
return "A";
std::string output;
while (id > 0)
{
output.insert(output.begin(), alphabet[static_cast<std::size_t>(id % 64)]);
id /= 64;
}
return output;
}
std::string nid(std::string_view name)
{
constexpr std::array<std::uint8_t, 16> suffix = {0x51, 0x8d, 0x64, 0xa6, 0x35, 0xde,
0xd8, 0xc1, 0xe6, 0xb0, 0x39, 0xb1,
0xc3, 0xe5, 0x52, 0x30};
Bytes input(name.begin(), name.end());
input.insert(input.end(), suffix.begin(), suffix.end());
const auto digest = crypto::sha1(input);
std::array<std::uint8_t, 8> value{};
std::reverse_copy(digest.begin(), digest.begin() + 8, value.begin());
constexpr std::string_view base64 =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string result;
unsigned bits = 0;
int bit_count = 0;
for (std::uint8_t byte : value)
{
bits = (bits << 8) | byte;
bit_count += 8;
while (bit_count >= 6 && result.size() < 11)
{
bit_count -= 6;
result.push_back(base64[(bits >> bit_count) & 0x3f]);
}
}
if (result.size() < 11 && bit_count > 0 && bit_count < 6)
result.push_back(base64[(bits << (6 - bit_count)) & 0x3f]);
result.resize(11);
std::replace(result.begin(), result.end(), '/', '-');
return result;
}
class StringTable
{
public:
StringTable()
{
data_.push_back(0);
}
std::uint32_t add(std::string_view value)
{
const auto found = offsets_.find(std::string{value});
if (found != offsets_.end())
return found->second;
const std::uint32_t offset = static_cast<std::uint32_t>(data_.size());
data_.insert(data_.end(), value.begin(), value.end());
data_.push_back(0);
offsets_.emplace(value, offset);
return offset;
}
[[nodiscard]] const Bytes &data() const
{
return data_;
}
private:
Bytes data_;
std::map<std::string, std::uint32_t, std::less<>> offsets_;
};
std::uint32_t elf_hash(std::string_view name)
{
std::uint32_t hash = 0;
for (unsigned char character : name)
{
hash = (hash << 4) + character;
const std::uint32_t carry = hash & 0xf0000000;
if (carry != 0)
hash ^= carry >> 24;
hash &= ~carry;
}
return hash;
}
Bytes build_sysv_hash(std::span<const std::string> names)
{
require(!names.empty(), "dynamic symbol table cannot be empty");
const std::size_t count = names.size();
Bytes output(8 + count * 8);
write_u32(output, 0, static_cast<std::uint32_t>(count));
write_u32(output, 4, static_cast<std::uint32_t>(count));
const std::size_t chains = 8 + count * 4;
for (std::size_t i = count; i-- > 1;)
{
const std::size_t bucket = elf_hash(names[i]) % count;
const std::uint32_t previous = read_u32(output, 8 + bucket * 4);
write_u32(output, chains + i * 4, previous);
write_u32(output, 8 + bucket * 4, static_cast<std::uint32_t>(i));
}
return output;
}
Bytes build_process_parameters(std::uint32_t module_sdk, std::uint32_t companion_sdk)
{
Bytes output(0x60);
write_u64(output, 0, 0x60);
output[8] = 'O';
output[9] = 'R';
output[10] = 'B';
output[11] = 'I';
write_u32(output, 0x0c, 5);
write_u32(output, 0x10, companion_sdk);
write_u32(output, 0x14, module_sdk);
write_u32(output, 0x58, 1);
return output;
}
struct ParameterBlocks
{
Bytes data;
std::array<std::size_t, 6> offsets{};
std::size_t heap_size{};
std::size_t heap_extended{};
};
ParameterBlocks build_parameter_blocks()
{
constexpr std::array<std::size_t, 6> sizes = {0xa8, 0x38, 0x10, 0x78, 0xc0, 0x38};
constexpr std::array<std::uint64_t, 6> counts = {0x000000010000000e, 0, 0, 2, 3, 1};
ParameterBlocks result;
std::size_t cursor = 0;
for (std::size_t i = 0; i < sizes.size(); ++i)
{
result.offsets[i] = cursor;
cursor += sizes[i];
}
result.heap_size = cursor;
result.heap_extended = cursor + 8;
result.data.resize(cursor + 16);
for (std::size_t i = 0; i < sizes.size(); ++i)
{
write_u64(result.data, result.offsets[i], sizes[i]);
if (counts[i] != 0)
write_u64(result.data, result.offsets[i] + 8, counts[i]);
}
write_u64(result.data, result.heap_size, std::numeric_limits<std::uint64_t>::max());
write_u32(result.data, result.heap_extended, 1);
return result;
}
Bytes build_note()
{
Bytes output(0x24);
write_u32(output, 0, 4);
write_u32(output, 4, 0x14);
write_u32(output, 8, 3);
output[12] = 'G';
output[13] = 'N';
output[14] = 'U';
return output;
}
Bytes build_tail_note()
{
Bytes output(0x18);
write_u32(output, 0, 4);
write_u32(output, 4, 8);
write_u32(output, 8, 3);
output[12] = 'S';
output[13] = 'I';
output[14] = 'E';
return output;
}
Bytes build_comment(std::string_view file_name)
{
Bytes text(file_name.begin(), file_name.end());
text.push_back(0);
Bytes output(align_up(12 + text.size(), 4));
output[0] = 'P';
output[1] = 'A';
output[2] = 'T';
output[3] = 'H';
write_u32(output, 4, static_cast<std::uint32_t>(output.size() - 8));
write_u32(output, 8, static_cast<std::uint32_t>(text.size()));
copy_bytes(output, 12, text);
return output;
}
void write_u32_be(std::span<std::uint8_t> data, std::size_t at, std::uint32_t value)
{
require(at + 4 <= data.size(), "big-endian write outside output");
data[at] = static_cast<std::uint8_t>(value >> 24);
data[at + 1] = static_cast<std::uint8_t>(value >> 16);
data[at + 2] = static_cast<std::uint8_t>(value >> 8);
data[at + 3] = static_cast<std::uint8_t>(value);
}
Bytes build_version(std::span<const std::string> components, std::uint32_t module_sdk)
{
Bytes output;
for (const std::string &component : components)
{
const std::string name = component + ':';
const std::size_t body = 1 + name.size() + 16;
const std::size_t start = output.size();
output.resize(start + 4 + body);
write_u16(output, start + 2, static_cast<std::uint16_t>(body));
output[start + 4] = 8;
std::copy(name.begin(), name.end(), output.begin() + start + 5);
for (std::size_t i = 0; i < 2; ++i)
{
const std::size_t at = start + 5 + name.size() + i * 8;
write_u32_be(output, at, module_sdk);
write_u32_be(output, at + 4, 1);
}
}
return output;
}
struct Import
{
std::string plain;
const Stub *provider{};
int module_id{};
int library_id{};
std::uint32_t dynamic_symbol{};
std::string mangled;
};
struct ModuleRecord
{
int id{};
std::uint32_t soname{};
std::uint32_t module_name{};
};
struct LibraryRecord
{
int id{};
int module_id{};
std::uint32_t name{};
};
struct DynamicRelocation
{
std::uint64_t offset{};
std::uint64_t info{};
std::uint64_t addend{};
};
std::vector<DynamicRelocation> read_relocations(const Section *input)
{
std::vector<DynamicRelocation> output;
if (input == nullptr)
return output;
require(input->entry_size == 24 && input->data.size() % 24 == 0,
"LLVM relocation table has an unsupported layout");
for (std::size_t at = 0; at < input->data.size(); at += 24)
output.push_back({read_u64(input->data, at), read_u64(input->data, at + 8),
read_u64(input->data, at + 16)});
return output;
}
Bytes write_relocations(std::span<const DynamicRelocation> relocations)
{
Bytes output(relocations.size() * 24);
for (std::size_t i = 0; i < relocations.size(); ++i)
{
write_u64(output, i * 24, relocations[i].offset);
write_u64(output, i * 24 + 8, relocations[i].info);
write_u64(output, i * 24 + 16, relocations[i].addend);
}
return output;
}
std::uint64_t dynamic_value(const Section &dynamic, std::int64_t wanted)
{
require(dynamic.entry_size == 16 && dynamic.data.size() % 16 == 0,
"LLVM dynamic table has an unsupported layout");
for (std::size_t at = 0; at < dynamic.data.size(); at += 16)
{
const auto tag = static_cast<std::int64_t>(read_u64(dynamic.data, at));
if (tag == wanted)
return read_u64(dynamic.data, at + 8);
if (tag == 0)
break;
}
return 0;
}
void add_dynamic(std::vector<std::pair<std::int64_t, std::uint64_t>> &entries, std::int64_t tag,
std::uint64_t value)
{
entries.emplace_back(tag, value);
}
Bytes build_dynamic(std::span<const ModuleRecord> modules, std::span<const LibraryRecord> libraries,
std::uint32_t module_info_name, std::uint32_t original_file_name,
std::uint64_t symbol_table, std::uint64_t string_table,
std::uint64_t string_size, std::uint64_t hash_table, std::uint64_t hash_size,
std::uint64_t jump_relocations, std::uint64_t jump_relocations_size,
std::uint64_t got, std::uint64_t symbol_table_size, std::uint64_t relocations,
std::uint64_t relocations_size, std::size_t relative_count,
std::uint64_t preinit, std::uint64_t preinit_size, std::uint64_t init_array,
std::uint64_t init_array_size, std::uint64_t fini_array,
std::uint64_t fini_array_size, std::uint64_t init, std::uint64_t fini)
{
std::vector<std::pair<std::int64_t, std::uint64_t>> entries;
for (const ModuleRecord &module : modules)
{
add_dynamic(entries, kDynamicNeeded, module.soname);
add_dynamic(entries, kDynamicNeededModule,
module.module_name | (std::uint64_t{1} << 32) |
(static_cast<std::uint64_t>(module.id) << 48));
for (const LibraryRecord &library : libraries)
{
if (library.module_id != module.id)
continue;
add_dynamic(entries, kDynamicImportLibrary,
library.name | (std::uint64_t{1} << 32) |
(static_cast<std::uint64_t>(library.id) << 48));
add_dynamic(entries, kDynamicImportLibraryAttributes,
(static_cast<std::uint64_t>(library.id) << 48) | 9);
}
}
add_dynamic(entries, kDynamicModuleInfo, module_info_name | (std::uint64_t{1} << 32));
add_dynamic(entries, kDynamicModuleAttributes, 0);
add_dynamic(entries, kDynamicOriginalFilename, original_file_name);
add_dynamic(entries, kDynamicDebug, 0);
add_dynamic(entries, kDynamicRela, relocations);
add_dynamic(entries, kDynamicRelaSize, relocations_size);
add_dynamic(entries, kDynamicRelaEntry, 24);
add_dynamic(entries, kDynamicRelaCount, relative_count);
add_dynamic(entries, kDynamicJumpRela, jump_relocations);
add_dynamic(entries, kDynamicPltRelSize, jump_relocations_size);
add_dynamic(entries, kDynamicPltGot, got);
add_dynamic(entries, kDynamicPltRel, kDynamicRela);
add_dynamic(entries, kDynamicSymbolTable, symbol_table);
add_dynamic(entries, kDynamicSymbolEntry, 24);
add_dynamic(entries, kDynamicStringTable, string_table);
add_dynamic(entries, kDynamicStringSize, string_size);
add_dynamic(entries, kDynamicHash, hash_table);
add_dynamic(entries, kDynamicPreinitArray, preinit);
add_dynamic(entries, kDynamicPreinitArraySize, preinit_size);
add_dynamic(entries, kDynamicInitArray, init_array);
add_dynamic(entries, kDynamicInitArraySize, init_array_size);
add_dynamic(entries, kDynamicFiniArray, fini_array);
add_dynamic(entries, kDynamicFiniArraySize, fini_array_size);
add_dynamic(entries, kDynamicInit, init);
add_dynamic(entries, kDynamicFini, fini);
add_dynamic(entries, kDynamicSymbolTableSize, symbol_table_size);
add_dynamic(entries, kDynamicHashSize, hash_size);
add_dynamic(entries, 0, 0);
Bytes output(entries.size() * 16);
for (std::size_t i = 0; i < entries.size(); ++i)
{
write_u64(output, i * 16, static_cast<std::uint64_t>(entries[i].first));
write_u64(output, i * 16 + 8, entries[i].second);
}
return output;
}
struct ProgramHeader
{
std::uint32_t type{};
std::uint32_t flags{};
std::uint64_t offset{};
std::uint64_t address{};
std::uint64_t file_size{};
std::uint64_t memory_size{};
std::uint64_t alignment{};
};
void write_program_header(std::span<std::uint8_t> output, std::size_t index,
const ProgramHeader &header)
{
const std::size_t at = 0x40 + index * 0x38;
write_u32(output, at, header.type);
write_u32(output, at + 4, header.flags);
write_u64(output, at + 8, header.offset);
write_u64(output, at + 16, header.address);
write_u64(output, at + 24, header.address);
write_u64(output, at + 32, header.file_size);
write_u64(output, at + 40, header.memory_size);
write_u64(output, at + 48, header.alignment);
}
void write_elf_header(std::span<std::uint8_t> output, std::uint64_t entry)
{
require(output.size() >= kPage, "output cannot hold ELF header");
output[0] = 0x7f;
output[1] = 'E';
output[2] = 'L';
output[3] = 'F';
output[4] = 2;
output[5] = 1;
output[6] = 1;
output[7] = 9;
output[8] = 2;
write_u16(output, 0x10, kTypeDynamicExecutable);
write_u16(output, 0x12, 0x3e);
write_u32(output, 0x14, 1);
write_u64(output, 0x18, entry);
write_u64(output, 0x20, 0x40);
write_u64(output, 0x28, 0);
write_u32(output, 0x30, 0);
write_u16(output, 0x34, 0x40);
write_u16(output, 0x36, 0x38);
write_u16(output, 0x38, 14);
write_u16(output, 0x3a, 0x40);
write_u16(output, 0x3c, 0);
write_u16(output, 0x3e, 0);
}
} // namespace
Bytes write_executable(const Image &image, std::span<const Stub> stubs, const Options &options)
{
const Section &text = section(image, ".text");
const Section &dynamic_source = section(image, ".dynamic");
const Section &got = section(image, ".got");
const Section *eh_header = optional_section(image, ".eh_frame_hdr");
require(text.executable() && text.address == 0 && text.file_offset >= kPage,
"LLVM text layout is incompatible with the PS5 converter");
std::uint64_t copied_file_end = kPage;
for (const Section &input : image.sections)
{
if (!input.allocated() || input.no_bits())
continue;
copied_file_end = std::max(copied_file_end, input.file_offset + input.data.size());
}
std::uint64_t text_end = text.address + text.size;
std::uint64_t ro_start = std::numeric_limits<std::uint64_t>::max();
std::uint64_t ro_end = 0;
std::uint64_t relro_start = std::numeric_limits<std::uint64_t>::max();
std::uint64_t relro_content_end = 0;
std::uint64_t data_start = dynamic_source.address;
std::uint64_t data_end = data_start + 8;
std::uint64_t data_stored_end = data_start + 8;
std::uint64_t tls_start = 0;
std::uint64_t tls_file_end = 0;
std::uint64_t tls_memory_end = 0;
std::uint64_t tls_alignment = 0x20;
for (const Section &input : image.sections)
{
if (!input.allocated() || input.name == ".dynamic")
continue;
const std::uint64_t end = input.address + input.size;
if (input.executable())
{
text_end = std::max(text_end, end);
}
else if (input.tls())
{
if (tls_start == 0)
tls_start = input.address;
tls_start = std::min(tls_start, input.address);
tls_memory_end = std::max(tls_memory_end, end);
if (!input.no_bits())
tls_file_end = std::max(tls_file_end, end);
tls_alignment = std::max(tls_alignment, input.alignment);
}
else if (relro_section(input.name))
{
relro_start = std::min(relro_start, input.address);
relro_content_end = std::max(relro_content_end, end);
}
else if (input.writable() || input.no_bits())
{
data_start = std::min(data_start, input.address);
data_end = std::max(data_end, end);
if (!input.no_bits())
data_stored_end = std::max(data_stored_end, end);
}
else if (!metadata_section(input.name))
{
ro_start = std::min(ro_start, input.address);
ro_end = std::max(ro_end, end);
}
}
require(relro_start != std::numeric_limits<std::uint64_t>::max(),
"LLVM-linked image has no GOT/RELRO region");
if (ro_start == std::numeric_limits<std::uint64_t>::max())
{
ro_start = align_up(text_end, kPage);
ro_end = ro_start;
}
const Bytes process_parameters =
build_process_parameters(options.module_sdk, options.companion_sdk);
const ParameterBlocks blocks = build_parameter_blocks();
const std::uint64_t process_address =
align_up(std::max(relro_content_end, ro_end > relro_start ? ro_end : 0), 8);
const std::uint64_t blocks_address = align_up(process_address + process_parameters.size(), 8);
const std::uint64_t relro_end = blocks_address + blocks.data.size();
require(relro_end <= data_start, "LLVM layout leaves no room for PS5 process parameters");
std::vector<Import> imports;
std::vector<const Stub *> module_order;
for (const std::string &needed : image.needed)
{
const auto provider = std::find_if(stubs.begin(), stubs.end(), [&](const Stub &candidate)
{ return candidate.soname == needed; });
require(provider != stubs.end(), "public SDK stub directory lacks needed module " + needed);
module_order.push_back(&*provider);
}
for (std::size_t i = 1; i < image.dynamic_symbols.size(); ++i)
{
const elf::Symbol &symbol = image.dynamic_symbols[i];
require(symbol.undefined(), "native converter does not yet publish application exports");
const Stub *provider = nullptr;
for (const Stub *candidate : module_order)
{
if (std::find(candidate->exports.begin(), candidate->exports.end(), symbol.name) !=
candidate->exports.end())
{
provider = candidate;
break;
}
}
require(provider != nullptr, "no public SDK stub exports required symbol " + symbol.name);
imports.push_back({symbol.name, provider, 0, 0, static_cast<std::uint32_t>(i), {}});
}
std::vector<ModuleRecord> modules;
std::vector<LibraryRecord> libraries;
StringTable strings;
for (std::size_t i = 0; i < module_order.size(); ++i)
{
const Stub *provider = module_order[i];
modules.push_back({static_cast<int>(i + 1), strings.add(provider->soname),
strings.add(provider->module_name)});
libraries.push_back(
{static_cast<int>(i), static_cast<int>(i + 1), strings.add(provider->library_name)});
}
for (Import &import : imports)
{
const auto module = std::find(module_order.begin(), module_order.end(), import.provider);
require(module != module_order.end(), "internal import provider error");
const int index = static_cast<int>(module - module_order.begin());
import.module_id = index + 1;
import.library_id = index;
import.mangled = nid(import.plain) + "#" + encode_id(import.library_id) + "#" +
encode_id(import.module_id);
}
const std::uint32_t file_name_offset = strings.add(options.file_name);
std::string module_name = options.file_name;
if (const std::size_t dot = module_name.find_last_of('.'); dot != std::string::npos)
module_name.resize(dot);
const std::uint32_t module_name_offset = strings.add(module_name);
Bytes dynamic_symbols(image.dynamic_symbols.size() * 24);
std::vector<std::string> hash_names(image.dynamic_symbols.size());
for (const Import &import : imports)
{
const elf::Symbol &source = image.dynamic_symbols[import.dynamic_symbol];
const std::size_t at = import.dynamic_symbol * 24;
write_u32(dynamic_symbols, at, strings.add(import.mangled));
dynamic_symbols[at + 4] = source.info;
dynamic_symbols[at + 5] = source.other;
write_u16(dynamic_symbols, at + 6, source.section);
write_u64(dynamic_symbols, at + 8, source.value);
write_u64(dynamic_symbols, at + 16, source.size);
hash_names[import.dynamic_symbol] = nid(import.plain) + "#" +
import.provider->library_name + "#" +
import.provider->module_name;
}
const Bytes dynamic_strings = strings.data();
const Bytes hash = build_sysv_hash(hash_names);
std::vector<DynamicRelocation> source_relocations =
read_relocations(optional_section(image, ".rela.dyn"));
std::vector<DynamicRelocation> relative;
std::vector<DynamicRelocation> symbolic;
for (const DynamicRelocation &relocation : source_relocations)
{
if (static_cast<std::uint32_t>(relocation.info) == kRelRelative)
relative.push_back(relocation);
else
symbolic.push_back(relocation);
}
const std::array<std::pair<std::uint64_t, std::uint64_t>, 8> parameter_pointers = {{
{process_address + 0x38, blocks_address + blocks.offsets[0]},
{process_address + 0x40, blocks_address + blocks.offsets[1]},
{process_address + 0x48, blocks_address + blocks.offsets[2]},
{blocks_address + 0x30, blocks_address + blocks.offsets[3]},
{blocks_address + 0x38, blocks_address + blocks.offsets[4]},
{blocks_address + 0x60, blocks_address + blocks.offsets[5]},
{blocks_address + 0x10, blocks_address + blocks.heap_size},
{blocks_address + 0x20, blocks_address + blocks.heap_extended},
}};
std::vector<DynamicRelocation> relocations;
for (const auto &[offset, addend] : parameter_pointers)
relocations.push_back({offset, kRelRelative, addend});
relocations.insert(relocations.end(), relative.begin(), relative.end());
const std::size_t relative_count = relocations.size();
relocations.insert(relocations.end(), symbolic.begin(), symbolic.end());
const Bytes rela_dynamic = write_relocations(relocations);
const Section *source_plt = optional_section(image, ".rela.plt");
const Bytes rela_plt = source_plt == nullptr ? Bytes{} : source_plt->data;
const Bytes note = build_note();
data_end = std::max(data_end, data_start + 8);
const std::uint64_t dynamic_base = align_up(data_end, 16);
const std::uint64_t string_address = dynamic_base;
const std::uint64_t symbol_address = align_up(string_address + dynamic_strings.size(), 8);
const std::uint64_t jump_address = align_up(symbol_address + dynamic_symbols.size(), 8);
const std::uint64_t relocation_address = align_up(jump_address + rela_plt.size(), 8);
const std::uint64_t hash_address = align_up(relocation_address + rela_dynamic.size(), 8);
const std::uint64_t note_address = align_up(hash_address + hash.size(), 4);
const std::uint64_t dynamic_address = align_up(note_address + note.size(), 8);
const Bytes dynamic = build_dynamic(
modules, libraries, module_name_offset, file_name_offset, symbol_address, string_address,
dynamic_strings.size(), hash_address, hash.size(), jump_address, rela_plt.size(),
got.address, dynamic_symbols.size(), relocation_address, rela_dynamic.size(),
relative_count, dynamic_value(dynamic_source, kDynamicPreinitArray),
dynamic_value(dynamic_source, kDynamicPreinitArraySize),
dynamic_value(dynamic_source, kDynamicInitArray),
dynamic_value(dynamic_source, kDynamicInitArraySize),
dynamic_value(dynamic_source, kDynamicFiniArray),
dynamic_value(dynamic_source, kDynamicFiniArraySize),
dynamic_value(dynamic_source, kDynamicInit), dynamic_value(dynamic_source, kDynamicFini));
const std::uint64_t dynamic_end = dynamic_address + dynamic.size();
std::vector<std::string> components = options.version_components;
if (components.empty())
{
components.push_back("app_crt.o");
for (const Stub *provider : module_order)
components.push_back(provider->soname);
}
const Bytes comment = build_comment(options.file_name);
const Bytes version = build_version(components, options.module_sdk);
const Bytes tail_note = build_tail_note();
const std::uint64_t dynamic_file = congruent_offset(copied_file_end, dynamic_base);
const auto dynamic_file_at = [&](std::uint64_t address)
{ return dynamic_file + (address - dynamic_base); };
const std::uint64_t comment_file = align_up(dynamic_file_at(dynamic_end), 8);
const std::uint64_t version_file = align_up(comment_file + comment.size(), 4);
const std::uint64_t tail_file = align_up(version_file + version.size(), 4);
Bytes output(tail_file + tail_note.size());
for (const Section &input : image.sections)
{
if (!input.allocated() || input.no_bits() || metadata_section(input.name) ||
input.name == ".dynamic")
continue;
copy_bytes(output, input.file_offset, input.data);
}
// The RELRO segment starts at its lowest-addressed section. Sections are
// copied at their LLVM file offsets, which lld keeps page-congruent with
// their addresses, so the segment's file offset comes from that same
// section. Using the GOT's offset broke once .data.rel.ro (relocated
// constants) preceded the GOT: the segment became va 0x10000 at file
// 0x14090 and the PS5 loader rejected it as not page aligned.
const Section *relro_first = nullptr;
for (const Section &input : image.sections)
{
if (input.allocated() && relro_section(input.name) &&
(relro_first == nullptr || input.address < relro_first->address))
relro_first = &input;
}
require(relro_first != nullptr && relro_first->address == relro_start,
"RELRO start section does not match the RELRO region");
const std::uint64_t relro_file = relro_first->file_offset;
copy_bytes(output, relro_file + process_address - relro_start, process_parameters);
copy_bytes(output, relro_file + blocks_address - relro_start, blocks.data);
copy_bytes(output, dynamic_file_at(string_address), dynamic_strings);
copy_bytes(output, dynamic_file_at(symbol_address), dynamic_symbols);
copy_bytes(output, dynamic_file_at(jump_address), rela_plt);
copy_bytes(output, dynamic_file_at(relocation_address), rela_dynamic);
copy_bytes(output, dynamic_file_at(hash_address), hash);
copy_bytes(output, dynamic_file_at(note_address), note);
copy_bytes(output, dynamic_file_at(dynamic_address), dynamic);
copy_bytes(output, comment_file, comment);
copy_bytes(output, version_file, version);
copy_bytes(output, tail_file, tail_note);
write_elf_header(output, image.entry);
const std::uint64_t ro_file =
eh_header != nullptr ? eh_header->file_offset : section(image, ".eh_frame").file_offset;
const std::uint64_t data_file = dynamic_source.file_offset;
const std::uint64_t data_file_size = std::max<std::uint64_t>(8, data_stored_end - data_start);
const std::uint64_t tls_file = tls_start == 0 ? relro_file + (relro_end - relro_start)
: relro_file + (tls_start - relro_start);
const std::uint64_t eh_address = eh_header == nullptr ? ro_start : eh_header->address;
const std::uint64_t eh_file = eh_header == nullptr ? ro_file : eh_header->file_offset;
const std::uint64_t eh_size = eh_header == nullptr ? 0 : eh_header->size;
const std::array<ProgramHeader, 14> headers = {{
{kProgramLoad, kFlagExecute, text.file_offset, 0, text_end, text_end, kPage},
{kProgramLoad, kFlagRead, ro_file, ro_start, ro_end - ro_start, ro_end - ro_start, kPage},
{kProgramLoad, kFlagRead | kFlagWrite, relro_file, relro_start, relro_end - relro_start,
relro_end - relro_start, kPage},
{kProgramGnuRelro, kFlagRead, relro_file, relro_start, relro_end - relro_start,
align_up(relro_end - relro_start, kPage), 1},
{kProgramLoad, kFlagRead | kFlagWrite, data_file, data_start, data_file_size,
data_end - data_start, kPage},
{kProgramProcParam, kFlagRead, relro_file + process_address - relro_start, process_address,
process_parameters.size(), process_parameters.size(), 8},
{kProgramDynamic, kFlagRead | kFlagWrite, dynamic_file_at(dynamic_address), dynamic_address,
dynamic.size(), dynamic.size(), 8},
{kProgramTls, kFlagRead, tls_start == 0 ? relro_file : tls_file,
tls_start == 0 ? relro_start : tls_start, tls_start == 0 ? 0 : tls_file_end - tls_start,
tls_start == 0 ? 0 : tls_memory_end - tls_start, tls_start == 0 ? 1 : tls_alignment},
{kProgramGnuEhFrame, kFlagRead, eh_file, eh_address, eh_size, eh_size, 4},
{kProgramLoad, 0, dynamic_file, dynamic_base, dynamic_end - dynamic_base,
dynamic_end - dynamic_base, kPage},
{kProgramComment, 0, comment_file, 0, comment.size(), 0, 0x10},
{kProgramVersion, 0, version_file, 0, version.size(), version.size(), 1},
{kProgramNote, 0, dynamic_file_at(note_address), note_address, note.size(), note.size(), 4},
{kProgramNote, 0, tail_file, 0, tail_note.size(), 0, 4},
}};
// The PS5 loader rejects any PT_LOAD whose file offset and address differ
// modulo its alignment; fail the host build instead of the console launch.
for (const ProgramHeader &header : headers)
{
if (header.type == kProgramLoad && header.alignment > 1)
require(header.offset % header.alignment == header.address % header.alignment,
"PT_LOAD at address " + std::to_string(header.address) +
" is not page aligned with file offset " +
std::to_string(header.offset));
}
for (std::size_t i = 0; i < headers.size(); ++i)
write_program_header(output, i, headers[i]);
const auto build_id = crypto::sha1(output);
std::copy(build_id.begin(), build_id.begin() + 16,
output.begin() + dynamic_file_at(note_address) + 16);
std::copy(build_id.begin(), build_id.begin() + 8, output.begin() + tail_file + 16);
return output;
}
} // namespace ps5::module
+34
View File
@@ -0,0 +1,34 @@
/*
* ps5-native-app-boilerplate - Native PS5 dynamic-module writer interface.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Declares conversion of an ordinary LLVM-linked PIE into the PS5 application
* ELF layout consumed by the FSELF wrapper.
*/
#pragma once
#include "elf_object.hpp"
#include <cstdint>
#include <span>
#include <string>
#include <vector>
namespace ps5::module
{
struct Options
{
std::string entry = "_start";
std::string file_name = "eboot.elf";
std::uint32_t module_sdk = 0x02000009;
std::uint32_t companion_sdk = 0x08050001;
std::vector<std::string> version_components;
};
[[nodiscard]] elf::Bytes write_executable(const elf::Image &image, std::span<const elf::Stub> stubs,
const Options &options = {});
} // namespace ps5::module
+516
View File
@@ -0,0 +1,516 @@
/*
* ps5-native-app-boilerplate - Native FSELF container implementation.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Produces and reads deterministic plaintext signed-executable containers.
* The implementation follows the documented container geometry used by the
* project and contains no proprietary keys, signatures, or runtime code.
*/
#include "self_container.hpp"
#include "hash.hpp"
#include <algorithm>
#include <array>
#include <cstring>
#include <limits>
#include <stdexcept>
#include <string>
#include <utility>
#include <zlib.h>
namespace ps5::self
{
namespace
{
constexpr std::size_t kContainerHeaderSize = 0x20;
constexpr std::size_t kSegmentEntrySize = 0x20;
constexpr std::size_t kExtendedInfoSize = 0x40;
constexpr std::size_t kControlRegionSize = 0x30;
constexpr std::size_t kMetadataBlockSize = 0x50;
constexpr std::size_t kMetadataFooterSize = 0x50;
constexpr std::size_t kSignatureSize = 0x200;
constexpr std::size_t kAlternateSignatureSize = 0x100;
constexpr std::size_t kSegmentBlockSize = 0x4000;
constexpr std::size_t kDigestSlotSize = 0x20;
constexpr std::size_t kFooterMarkerOffset = 0x30;
constexpr std::uint32_t kDefaultProgramType = 0x101;
constexpr std::size_t kElfHeaderSize = 0x40;
constexpr std::size_t kElfProgramHeaderSize = 0x38;
constexpr std::size_t kOsAbiOffset = 0x07;
constexpr std::size_t kTypeOffset = 0x10;
constexpr std::size_t kMachineOffset = 0x12;
constexpr std::size_t kProgramHeaderOffset = 0x20;
constexpr std::size_t kProgramHeaderEntrySizeOffset = 0x36;
constexpr std::size_t kProgramHeaderCountOffset = 0x38;
constexpr std::uint32_t kPtLoad = 0x00000001;
constexpr std::uint32_t kPtDynamic = 0x00000002;
constexpr std::uint32_t kPtDynlibData = 0x61000000;
constexpr std::uint32_t kPtProcParam = 0x61000001;
constexpr std::uint32_t kPtRelro = 0x61000010;
constexpr std::uint32_t kPtComment = 0x6fffff00;
constexpr std::uint32_t kPtVersionRecords = 0x6fffff01;
void require(bool condition, const std::string &message)
{
if (!condition)
throw std::runtime_error(message);
}
bool range_in_bounds(std::uint64_t offset, std::uint64_t size, std::uint64_t length)
{
return offset <= length && size <= length - offset;
}
std::uint16_t read_u16(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 2 <= data.size(), "read_u16 outside input");
return static_cast<std::uint16_t>(data[at]) | static_cast<std::uint16_t>(data[at + 1]) << 8;
}
std::uint32_t read_u32(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 4 <= data.size(), "read_u32 outside input");
std::uint32_t value = 0;
for (std::size_t i = 0; i < 4; ++i)
value |= static_cast<std::uint32_t>(data[at + i]) << (i * 8);
return value;
}
std::uint64_t read_u64(std::span<const std::uint8_t> data, std::size_t at)
{
require(at + 8 <= data.size(), "read_u64 outside input");
std::uint64_t value = 0;
for (std::size_t i = 0; i < 8; ++i)
value |= static_cast<std::uint64_t>(data[at + i]) << (i * 8);
return value;
}
void write_u16(std::span<std::uint8_t> data, std::size_t at, std::uint16_t value)
{
require(at + 2 <= data.size(), "write_u16 outside output");
data[at] = static_cast<std::uint8_t>(value);
data[at + 1] = static_cast<std::uint8_t>(value >> 8);
}
void write_u32(std::span<std::uint8_t> data, std::size_t at, std::uint32_t value)
{
require(at + 4 <= data.size(), "write_u32 outside output");
for (std::size_t i = 0; i < 4; ++i)
data[at + i] = static_cast<std::uint8_t>(value >> (i * 8));
}
void write_u64(std::span<std::uint8_t> data, std::size_t at, std::uint64_t value)
{
require(at + 8 <= data.size(), "write_u64 outside output");
for (std::size_t i = 0; i < 8; ++i)
data[at + i] = static_cast<std::uint8_t>(value >> (i * 8));
}
void copy_bytes(std::span<std::uint8_t> target, std::size_t at,
std::span<const std::uint8_t> source)
{
require(at <= target.size() && source.size() <= target.size() - at, "copy outside output");
std::copy(source.begin(), source.end(), target.begin() + at);
}
std::size_t align_up(std::size_t value, std::size_t alignment)
{
return (value + alignment - 1) & ~(alignment - 1);
}
std::size_t metadata_size(std::size_t segment_count, std::uint32_t magic)
{
return segment_count * kMetadataBlockSize + kMetadataFooterSize +
(magic == kAlternateMagic ? kAlternateSignatureSize : kSignatureSize);
}
std::size_t digest_size(std::size_t data_size)
{
return ((data_size + kSegmentBlockSize - 1) / kSegmentBlockSize) * kDigestSlotSize;
}
struct FileRegion
{
std::size_t offset{};
std::size_t size{};
};
FileRegion find_version_records(std::span<const std::uint8_t> elf, std::size_t program_count,
std::uint64_t limit)
{
for (std::size_t i = 0; i < program_count; ++i)
{
const std::size_t header = kElfHeaderSize + i * kElfProgramHeaderSize;
if (read_u32(elf, header) != kPtVersionRecords)
continue;
const std::uint64_t offset = read_u64(elf, header + 0x08);
const std::uint64_t size = read_u64(elf, header + 0x20);
if (size == 0 || !range_in_bounds(offset, size, limit) ||
offset > std::numeric_limits<std::size_t>::max() ||
size > std::numeric_limits<std::size_t>::max())
return {};
return {static_cast<std::size_t>(offset), static_cast<std::size_t>(size)};
}
return {};
}
Bytes inflate_segment(std::span<const std::uint8_t> input, std::size_t expected, int window_bits)
{
Bytes output(expected);
z_stream stream{};
stream.next_in = const_cast<Bytef *>(reinterpret_cast<const Bytef *>(input.data()));
stream.avail_in = static_cast<uInt>(input.size());
stream.next_out = reinterpret_cast<Bytef *>(output.data());
stream.avail_out = static_cast<uInt>(output.size());
if (inflateInit2(&stream, window_bits) != Z_OK)
return {};
const int result = ::inflate(&stream, Z_FINISH);
const bool valid = result == Z_STREAM_END && stream.total_out == expected;
inflateEnd(&stream);
return valid ? output : Bytes{};
}
Bytes inflate_segment(std::span<const std::uint8_t> input, std::size_t expected)
{
if (expected == 0)
return {};
if (input.size() > std::numeric_limits<uInt>::max() ||
expected > std::numeric_limits<uInt>::max())
throw std::runtime_error("compressed segment is too large");
Bytes output = inflate_segment(input, expected, MAX_WBITS);
if (output.size() == expected)
return output;
output = inflate_segment(input, expected, -MAX_WBITS);
require(output.size() == expected, "compressed segment did not inflate to its recorded size");
return output;
}
void write_segment(std::span<std::uint8_t> output, std::size_t at, std::uint64_t flags,
std::uint64_t offset, std::uint64_t file_size, std::uint64_t memory_size)
{
write_u64(output, at, flags);
write_u64(output, at + 0x08, offset);
write_u64(output, at + 0x10, file_size);
write_u64(output, at + 0x18, memory_size);
}
struct SelectedSegment
{
std::size_t header_index{};
std::size_t file_offset{};
std::size_t file_size{};
};
std::vector<SelectedSegment> select_segments(std::span<const std::uint8_t> elf,
std::size_t header_offset, std::size_t header_count,
bool include_proc_param)
{
std::vector<SelectedSegment> selected;
for (std::size_t i = 0; i < header_count; ++i)
{
const std::size_t at = header_offset + i * kElfProgramHeaderSize;
const std::uint32_t type = read_u32(elf, at);
const std::uint32_t flags = read_u32(elf, at + 4);
const std::uint64_t offset = read_u64(elf, at + 0x08);
const std::uint64_t size = read_u64(elf, at + 0x20);
if (size == 0)
{
require(type != kPtLoad || flags == 0, "a mapped program header stores no bytes");
continue;
}
if (type != kPtLoad && type != kPtDynlibData && type != kPtRelro && type != kPtComment &&
(type != kPtProcParam || !include_proc_param))
continue;
require(range_in_bounds(offset, size, elf.size()),
"a container segment reaches past the input ELF");
require(offset <= std::numeric_limits<std::size_t>::max() &&
size <= std::numeric_limits<std::size_t>::max(),
"a container segment is too large for this host");
selected.push_back({i, static_cast<std::size_t>(offset), static_cast<std::size_t>(size)});
}
return selected;
}
void normalize_header(Bytes &elf)
{
write_u16(elf, kMachineOffset, 0x3e);
if (elf[kOsAbiOffset] == 0 || elf[kOsAbiOffset] == 3)
elf[kOsAbiOffset] = 9;
if (read_u16(elf, kTypeOffset) == 0)
write_u16(elf, kTypeOffset, 2);
}
} // namespace
bool is_elf(std::span<const std::uint8_t> data)
{
return data.size() >= kElfHeaderSize && read_u32(data, 0) == 0x464c457f;
}
bool is_self(std::span<const std::uint8_t> data)
{
if (data.size() < kContainerHeaderSize)
return false;
const std::uint32_t magic = read_u32(data, 0);
return magic == kMagic || magic == kAlternateMagic;
}
Image parse(std::span<const std::uint8_t> data)
{
require(is_self(data), "input is not a signed-executable container");
Image image;
image.program_type = read_u32(data, 0x08);
image.header_size = read_u16(data, 0x0c);
image.metadata_size = read_u16(data, 0x0e);
image.file_size = read_u64(data, 0x10);
const std::size_t segment_count = read_u16(data, 0x18);
const std::size_t table_end = kContainerHeaderSize + segment_count * kSegmentEntrySize;
require(table_end <= data.size(), "container segment table overruns input");
require(image.header_size <= data.size(), "container header overruns input");
image.segments.reserve(segment_count);
for (std::size_t i = 0; i < segment_count; ++i)
{
const std::size_t at = kContainerHeaderSize + i * kSegmentEntrySize;
image.segments.push_back({read_u64(data, at), read_u64(data, at + 0x08),
read_u64(data, at + 0x10), read_u64(data, at + 0x18)});
}
require(table_end + kElfHeaderSize <= data.size() && is_elf(data.subspan(table_end)),
"container has no readable ELF header");
const std::size_t program_count = read_u16(data, table_end + kProgramHeaderCountOffset);
const std::size_t elf_headers_size = kElfHeaderSize + program_count * kElfProgramHeaderSize;
require(table_end + elf_headers_size <= data.size(),
"container ELF header table overruns input");
image.elf_headers.assign(data.begin() + table_end, data.begin() + table_end + elf_headers_size);
const std::size_t extended_at = align_up(table_end + elf_headers_size, 0x10);
if (extended_at + kExtendedInfoSize <= image.header_size &&
extended_at + kExtendedInfoSize <= data.size())
{
image.has_extended_info = true;
image.authority = read_u64(data, extended_at);
image.extended_program_type = read_u64(data, extended_at + 0x08);
image.app_version = read_u64(data, extended_at + 0x10);
image.firmware_version = read_u64(data, extended_at + 0x18);
std::copy_n(data.begin() + extended_at + 0x20, image.digest.size(), image.digest.begin());
}
return image;
}
Bytes extract(std::span<const std::uint8_t> data)
{
const Image image = parse(data);
const std::span<const std::uint8_t> headers = image.elf_headers;
const std::size_t program_count = read_u16(headers, kProgramHeaderCountOffset);
const std::size_t program_table_end = kElfHeaderSize + program_count * kElfProgramHeaderSize;
require(program_table_end <= headers.size(), "stored ELF program-header table is incomplete");
std::uint64_t output_size = headers.size();
for (std::size_t i = 0; i < program_count; ++i)
{
const std::size_t at = kElfHeaderSize + i * kElfProgramHeaderSize;
const std::uint64_t offset = read_u64(headers, at + 0x08);
const std::uint64_t size = read_u64(headers, at + 0x20);
require(offset <= std::numeric_limits<std::size_t>::max() &&
size <= std::numeric_limits<std::size_t>::max() - offset,
"ELF program header is too large for this host");
output_size = std::max(output_size, offset + size);
}
require(output_size <= std::numeric_limits<std::size_t>::max(),
"reconstructed ELF is too large for this host");
Bytes output(static_cast<std::size_t>(output_size));
for (const Segment &segment : image.segments)
{
if (!segment.blocked())
continue;
require(!segment.encrypted(),
"encrypted retail segment cannot be extracted without its key");
if (segment.id() >= program_count)
continue;
const std::size_t header = kElfHeaderSize + segment.id() * kElfProgramHeaderSize;
const std::uint64_t output_offset = read_u64(headers, header + 0x08);
const std::uint64_t output_length = read_u64(headers, header + 0x20);
require(range_in_bounds(segment.file_offset, segment.file_size, data.size()),
"container segment overruns input");
const auto stored = data.subspan(static_cast<std::size_t>(segment.file_offset),
static_cast<std::size_t>(segment.file_size));
Bytes inflated;
std::span<const std::uint8_t> payload = stored;
if (segment.compressed())
{
inflated = inflate_segment(stored, static_cast<std::size_t>(output_length));
payload = inflated;
}
const std::size_t copy_size =
static_cast<std::size_t>(std::min<std::uint64_t>(payload.size(), output_length));
require(range_in_bounds(output_offset, copy_size, output.size()),
"container segment is outside reconstructed ELF");
copy_bytes(output, static_cast<std::size_t>(output_offset), payload.first(copy_size));
}
const FileRegion version = find_version_records(headers, program_count, output.size());
if (version.size != 0)
{
std::uint64_t tail = 0;
for (const Segment &segment : image.segments)
{
if (range_in_bounds(segment.file_offset, segment.file_size, data.size()))
tail = std::max(tail, segment.file_offset + segment.file_size);
}
if (range_in_bounds(tail, version.size, data.size()) &&
range_in_bounds(version.offset, version.size, output.size()))
copy_bytes(output, version.offset,
data.subspan(static_cast<std::size_t>(tail), version.size));
}
copy_bytes(output, 0, headers.first(program_table_end));
return output;
}
Bytes sign(std::span<const std::uint8_t> input, const SignOptions &options)
{
require(is_elf(input), "input is not an ELF file");
require(input[4] == 2, "only 64-bit ELF modules are supported");
require(options.magic == kMagic || options.magic == kAlternateMagic,
"unsupported signed-container magic");
require(options.auth_info.empty() || options.auth_info.size() == 0x88,
"authentication info must be exactly 0x88 bytes");
Bytes elf(input.begin(), input.end());
if (options.normalize_header)
normalize_header(elf);
const std::size_t program_offset = read_u64(elf, kProgramHeaderOffset);
const std::size_t program_entry_size = read_u16(elf, kProgramHeaderEntrySizeOffset);
const std::size_t program_count = read_u16(elf, kProgramHeaderCountOffset);
require(program_entry_size == kElfProgramHeaderSize,
"unexpected ELF program-header entry size");
require(program_offset == kElfHeaderSize,
"ELF program-header table must immediately follow the ELF header");
require(program_count <= (elf.size() - program_offset) / kElfProgramHeaderSize,
"ELF program-header table overruns input");
const auto selected =
select_segments(elf, program_offset, program_count, options.include_proc_param);
require(!selected.empty(), "ELF has no loadable segment content");
const std::size_t segment_count = selected.size() * 2;
const std::size_t after_segments = kContainerHeaderSize + segment_count * kSegmentEntrySize;
const std::size_t elf_headers_size = kElfHeaderSize + program_count * kElfProgramHeaderSize;
const std::size_t extended_at = align_up(after_segments + elf_headers_size, 0x10);
const std::size_t header_size = extended_at + kExtendedInfoSize + kControlRegionSize;
const std::size_t meta_size = metadata_size(segment_count, options.magic);
require(header_size <= std::numeric_limits<std::uint16_t>::max() &&
meta_size <= std::numeric_limits<std::uint16_t>::max(),
"container header or metadata exceeds its 16-bit field");
std::vector<std::size_t> offsets(segment_count);
std::vector<std::size_t> digest_sizes(selected.size());
std::size_t cursor = header_size + meta_size;
for (std::size_t i = 0; i < selected.size(); ++i)
{
digest_sizes[i] = digest_size(selected[i].file_size);
offsets[i * 2] = cursor;
cursor += digest_sizes[i];
offsets[i * 2 + 1] = cursor;
cursor = align_up(cursor + selected[i].file_size, 0x10);
}
const std::size_t declared_file_size = cursor;
const std::size_t version_start = offsets.back() + selected.back().file_size;
const FileRegion version = find_version_records(elf, program_count, elf.size());
Bytes output(std::max(declared_file_size, version_start + version.size));
write_u32(output, 0, options.magic);
output[0x04] = 0;
output[0x05] = 1;
output[0x06] = 1;
output[0x07] = 0x12;
write_u32(output, 0x08, kDefaultProgramType);
write_u16(output, 0x0c, static_cast<std::uint16_t>(header_size));
write_u16(output, 0x0e, static_cast<std::uint16_t>(meta_size));
write_u64(output, 0x10, declared_file_size);
write_u16(output, 0x18, static_cast<std::uint16_t>(segment_count));
write_u16(output, 0x1a, 0x22);
for (std::size_t i = 0; i < selected.size(); ++i)
{
const std::size_t digest_entry = kContainerHeaderSize + (i * 2) * kSegmentEntrySize;
const std::size_t data_entry = digest_entry + kSegmentEntrySize;
const std::uint64_t digest_flags = ((i * 2 + 1) << 20) | 0x10004;
const std::uint64_t data_flags = (selected[i].header_index << 20) | 0x2804;
write_segment(output, digest_entry, digest_flags, offsets[i * 2], digest_sizes[i],
digest_sizes[i]);
write_segment(output, data_entry, data_flags, offsets[i * 2 + 1], selected[i].file_size,
selected[i].file_size);
}
copy_bytes(output, after_segments, std::span<const std::uint8_t>{elf}.first(elf_headers_size));
write_u64(output, extended_at, options.authority);
write_u64(output, extended_at + 0x08, 1);
write_u64(output, extended_at + 0x10, options.app_version);
write_u64(output, extended_at + 0x18, options.firmware_version);
write_u64(output, extended_at + kExtendedInfoSize, 3);
const std::size_t footer = header_size + segment_count * kMetadataBlockSize;
write_u32(output, footer + kFooterMarkerOffset, 0x10000);
if (!options.auth_info.empty())
{
const std::size_t signature = footer + kMetadataFooterSize;
write_u64(output, signature, 0x88);
write_u64(output, signature + 0x08, options.authority);
copy_bytes(output, signature + 0x10,
std::span<const std::uint8_t>{options.auth_info}.subspan(0x08));
}
for (std::size_t i = 0; i < selected.size(); ++i)
copy_bytes(output, offsets[i * 2 + 1],
std::span<const std::uint8_t>{elf}.subspan(selected[i].file_offset,
selected[i].file_size));
if (version.size != 0)
copy_bytes(output, version_start,
std::span<const std::uint8_t>{elf}.subspan(version.offset, version.size));
const Bytes reconstructed = extract(output);
const auto digest = crypto::sha256(reconstructed);
copy_bytes(output, extended_at + 0x20, digest);
return output;
}
Bytes strip_sections(std::span<const std::uint8_t> elf)
{
require(is_elf(elf), "input is not an ELF file");
const std::uint64_t program_offset = read_u64(elf, kProgramHeaderOffset);
const std::size_t program_entry_size = read_u16(elf, kProgramHeaderEntrySizeOffset);
const std::size_t program_count = read_u16(elf, kProgramHeaderCountOffset);
require(program_entry_size >= kElfProgramHeaderSize, "ELF program-header entry is too small");
std::uint64_t extent = std::max<std::uint64_t>(
kElfHeaderSize, program_offset + program_count * program_entry_size);
bool has_dynamic = false;
for (std::size_t i = 0; i < program_count; ++i)
{
const std::uint64_t at = program_offset + i * program_entry_size;
if (!range_in_bounds(at, kElfProgramHeaderSize, elf.size()))
break;
const std::uint32_t type = read_u32(elf, static_cast<std::size_t>(at));
const std::uint64_t offset = read_u64(elf, static_cast<std::size_t>(at) + 0x08);
const std::uint64_t size = read_u64(elf, static_cast<std::size_t>(at) + 0x20);
has_dynamic |= type == kPtDynamic;
if (offset <= std::numeric_limits<std::uint64_t>::max() - size)
extent = std::max(extent, offset + size);
}
require(has_dynamic, "only an ELF with a dynamic segment can be stripped");
extent = std::min<std::uint64_t>(extent, elf.size());
Bytes output(elf.begin(), elf.begin() + static_cast<std::size_t>(extent));
write_u64(output, 0x28, 0);
write_u16(output, 0x3a, 0);
write_u16(output, 0x3c, 0);
write_u16(output, 0x3e, 0);
return output;
}
} // namespace ps5::self
+86
View File
@@ -0,0 +1,86 @@
/*
* ps5-native-app-boilerplate - Native FSELF container interface.
* Copyright (C) 2026 BlackBearReloaded
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Declares deterministic wrapping, extraction, inspection, and integrity
* helpers for plaintext PS5 signed-executable containers.
*/
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
namespace ps5::self
{
using Bytes = std::vector<std::uint8_t>;
inline constexpr std::uint32_t kMagic = 0xEEF51454;
inline constexpr std::uint32_t kAlternateMagic = 0x1D3D154F;
inline constexpr std::uint64_t kDeveloperAuthority = 0x3100000000000002;
struct SignOptions
{
std::uint32_t magic = kMagic;
bool include_proc_param = false;
bool normalize_header = true;
std::uint64_t app_version = 0;
std::uint64_t firmware_version = 0;
std::uint64_t authority = kDeveloperAuthority;
Bytes auth_info;
};
struct Segment
{
std::uint64_t flags{};
std::uint64_t file_offset{};
std::uint64_t file_size{};
std::uint64_t memory_size{};
[[nodiscard]] std::size_t id() const
{
return (flags >> 20) & 0xffff;
}
[[nodiscard]] bool encrypted() const
{
return (flags & 0x2) != 0;
}
[[nodiscard]] bool compressed() const
{
return (flags & 0x8) != 0;
}
[[nodiscard]] bool blocked() const
{
return (flags & 0x800) != 0;
}
};
struct Image
{
std::uint32_t program_type{};
std::uint16_t header_size{};
std::uint16_t metadata_size{};
std::uint64_t file_size{};
std::vector<Segment> segments;
Bytes elf_headers;
bool has_extended_info{};
std::uint64_t authority{};
std::uint64_t extended_program_type{};
std::uint64_t app_version{};
std::uint64_t firmware_version{};
std::array<std::uint8_t, 32> digest{};
};
[[nodiscard]] bool is_elf(std::span<const std::uint8_t> data);
[[nodiscard]] bool is_self(std::span<const std::uint8_t> data);
[[nodiscard]] Image parse(std::span<const std::uint8_t> data);
[[nodiscard]] Bytes extract(std::span<const std::uint8_t> data);
[[nodiscard]] Bytes sign(std::span<const std::uint8_t> elf, const SignOptions &options = {});
[[nodiscard]] Bytes strip_sections(std::span<const std::uint8_t> elf);
} // namespace ps5::self
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# ps5-native-app-boilerplate - PS5 Clang target wrapper.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Applies the repository's proven compiler target and selected SDK paths.
if [ -z "${PS5_PAYLOAD_SDK:-}" ]; then
echo "PS5_PAYLOAD_SDK is not set; run make deps or the dependency bootstrap." >&2
exit 2
fi
sdk_root=$PS5_PAYLOAD_SDK
compiler=${PS5_CLANG:-}
if [ -z "$compiler" ]; then
compiler=$(command -v clang-18 || true)
fi
if [ -z "$compiler" ]; then
echo "clang-18 was not found; set PS5_CLANG to the Clang 18 executable." >&2
exit 2
fi
exec "$compiler" \
-target x86_64-sie-ps5 \
-fvisibility-nodllstorageclass=default \
-isysroot "$sdk_root" \
-isystem "$sdk_root/target/include/c++/v1" \
-isystem "$sdk_root/target/include" \
-fno-stack-protector -fno-plt -femulated-tls \
"$@"
-88
View File
@@ -1,88 +0,0 @@
"""A minimal stand-in for the `jsonschema` package, for building third_party/mbedtls.
Why this file exists. mbedTLS regenerates two source files during its build by
running scripts/generate_driver_wrappers.py, which imports `jsonschema` and calls
`jsonschema.validate(...)` once to check its own driver JSON against a schema
before generating code. That import is the only use of the package, and it is not
installed on this machine.
Two honest ways to satisfy it: install python-jsonschema, or provide this. As
installed, the generated files are already present in the mbedTLS tree and the
generator is only re-run because a template is newer than them; the validation is
a development-time self-check of mbedTLS's own data files, not part of the
compiler path.
This module therefore does exactly what the caller needs and no more:
`validate()` checks the parts of JSON Schema the generator's schema uses, and
raises `exceptions.ValidationError` when a value does not match, so a genuinely
malformed data file still fails loudly. It is not a JSON Schema implementation and
does not pretend to be one.
Put it on the path with `PYTHONPATH=tooling/pystub` (tools/build-prospero.sh does
this). If the real package is installed, it takes precedence when this directory
is not on the path.
"""
from __future__ import annotations
from typing import Any
class exceptions: # noqa: N801 - mirrors the real package's namespace
class ValidationError(Exception):
"""Raised when a value does not match the schema."""
def __init__(self, message: str, path: str = "") -> None:
super().__init__(f"{message} (at {path})" if path else message)
self.message = message
self.path = path
_TYPES = {
"object": dict,
"array": list,
"string": str,
"boolean": bool,
"number": (int, float),
"integer": int,
"null": type(None),
}
def _check(value: Any, schema: Any, path: str) -> None:
if not isinstance(schema, dict):
return
expected = schema.get("type")
if expected is not None:
names = expected if isinstance(expected, list) else [expected]
allowed = tuple(_TYPES[name] for name in names if name in _TYPES)
# bool is a subclass of int; an integer field must not accept True.
if "integer" in names and isinstance(value, bool):
raise exceptions.ValidationError(f"expected {expected}, got boolean", path)
if allowed and not isinstance(value, allowed):
raise exceptions.ValidationError(f"expected {expected}, got {type(value).__name__}", path)
if "enum" in schema and value not in schema["enum"]:
raise exceptions.ValidationError(f"{value!r} is not one of {schema['enum']!r}", path)
if isinstance(value, dict):
for name in schema.get("required", []):
if name not in value:
raise exceptions.ValidationError(f"missing required property {name!r}", path)
properties = schema.get("properties", {})
for name, item in value.items():
if name in properties:
_check(item, properties[name], f"{path}/{name}")
elif isinstance(schema.get("additionalProperties"), dict):
_check(item, schema["additionalProperties"], f"{path}/{name}")
elif isinstance(value, list) and "items" in schema:
for index, item in enumerate(value):
_check(item, schema["items"], f"{path}/{index}")
def validate(instance: Any = None, schema: Any = None, **kwargs: Any) -> None:
"""Validate `instance` against `schema`, raising ValidationError on a mismatch."""
if schema is None:
return
_check(instance, schema, "#")
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Native Linux/WSL application build.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Compiles, links, signs, validates, and assembles the root skeleton app.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
format=${1:-Folder}
format=${format,,}
case "$format" in folder|ffpkg|ffpfsc|all) ;; *)
echo "usage: tools/build.sh [Folder|Ffpkg|Ffpfsc|All]" >&2
exit 2
esac
for command in python3 sha256sum; do
command -v "$command" >/dev/null || {
echo "missing required command: $command" >&2
exit 2
}
done
bash "$root/tools/setup-native-dependencies.sh" >/dev/null
param="$root/sce_sys/param.json"
title_id=$(python3 - "$param" <<'PY'
import json, re, sys
with open(sys.argv[1], encoding="utf-8") as source:
value = json.load(source)
title_id = value.get("titleId", "")
concept_id = value.get("conceptId", "")
content_id = value.get("contentId", "")
if not re.fullmatch(r"PPSA\d{5}", title_id):
raise SystemExit("param.json titleId must use PPSA followed by five digits")
if not re.fullmatch(r"\d{5}", concept_id):
raise SystemExit("param.json conceptId must contain five digits")
if (not re.fullmatch(r"[A-Z]{2}\d{4}-PPSA\d{5}_00-[A-Z0-9]{16}", content_id)
or title_id not in content_id):
raise SystemExit("param.json contentId must be valid and contain titleId")
if not re.fullmatch(r"\d{2}\.\d{3}\.\d{3}", value.get("contentVersion", "")):
raise SystemExit("param.json contentVersion must use NN.NNN.NNN")
if not re.fullmatch(r"\d{2}\.\d{2}", value.get("masterVersion", "")):
raise SystemExit("param.json masterVersion must use NN.NN")
size = value.get("downloadDataSize")
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
raise SystemExit("param.json downloadDataSize must be a non-negative integer")
category = value.get("applicationCategoryType")
badge = value.get("contentBadgeType")
if (category, badge) not in {(0, 1), (65536, 2)}:
raise SystemExit("param.json category and badge must describe a game or media app")
if category == 0:
intents = value.get("gameIntent", {}).get("permittedIntents", [])
if not any(item.get("intentType") == "launchActivity" for item in intents):
raise SystemExit("game param.json must permit the launchActivity intent")
elif "gameIntent" in value:
raise SystemExit("media param.json must not contain gameIntent")
localized = value.get("localizedParameters", {})
language = localized.get("defaultLanguage", "")
title = localized.get(language, {}).get("titleName", "")
if not isinstance(title, str) or not title.strip():
raise SystemExit("param.json default-language titleName cannot be empty")
print(title_id)
PY
)
# Loader/container constants validated on firmware 6.02 and 12.70. These are
# deliberately separate from the public application version in param.json.
module_sdk=0x02000009
companion_sdk=0x08050001
fself_magic=0x1D3D154F
bash "$root/tools/validate-assets.sh" "$root/sce_sys"
sdk_root="$root/.deps/native/ps5-payload-sdk"
zlib_root="$root/.deps/native/zlib/root"
zlib_archive=$(find "$zlib_root" -type f -name libz.a -print -quit)
cxx=${CXX:-}
if [[ -z $cxx ]]; then
cxx=$(command -v clang++-18 || command -v clang++)
fi
[[ -n $cxx ]] || { echo "Clang++ was not found" >&2; exit 2; }
build="$root/build"
dist="$root/dist"
native="$root/tooling/native"
tool="$build/host/ps5-native-tool"
mkdir -p "$build/host" "$build/obj" "$dist"
"$cxx" -std=c++20 -O2 -Wall -Wextra -Werror \
-I "$zlib_root/usr/include" \
"$native/native_app_builder.cpp" "$native/self_container.cpp" \
"$native/elf_object.cpp" "$native/sce_module_writer.cpp" \
"$zlib_archive" -o "$tool"
mapfile -d '' -t source_paths < <(
find "$root/src" -type f \( -name '*.c' -o -name '*.cc' -o -name '*.cpp' \) \
-print0 | sort -z
)
sources=()
for source in "${source_paths[@]}"; do
sources+=("${source#"$root/"}")
done
(( ${#sources[@]} > 0 )) || { echo "src/ has no C or C++ sources" >&2; exit 2; }
definitions=()
includes=()
archives=()
pacbrew_packages=()
pacbrew_includes=()
pacbrew_archives=()
[[ -z ${APP_DEFINITIONS:-} ]] || read -r -a definitions <<< "$APP_DEFINITIONS"
[[ -z ${APP_INCLUDE_PATHS:-} ]] || read -r -a includes <<< "$APP_INCLUDE_PATHS"
[[ -z ${APP_STATIC_ARCHIVES:-} ]] || read -r -a archives <<< "$APP_STATIC_ARCHIVES"
[[ -z ${PACBREW_PACKAGES:-} ]] || read -r -a pacbrew_packages <<< "$PACBREW_PACKAGES"
[[ -z ${PACBREW_INCLUDE_PATHS:-} ]] || read -r -a pacbrew_includes <<< "$PACBREW_INCLUDE_PATHS"
[[ -z ${PACBREW_STATIC_ARCHIVES:-} ]] || read -r -a pacbrew_archives <<< "$PACBREW_STATIC_ARCHIVES"
pacbrew_cflags=()
pacbrew_libs=()
if (( ${#pacbrew_packages[@]} > 0 || ${#pacbrew_includes[@]} > 0 || ${#pacbrew_archives[@]} > 0 )); then
pacbrew_resolution=$(bash "$root/tools/setup-pacbrew-dependencies.sh" \
--resolve "${pacbrew_packages[@]}")
mapfile -d '' -t pacbrew_cflags < <(python3 -c \
'import json,sys; [print(v, end="\0") for v in json.loads(sys.argv[1])["cflags"]]' \
"$pacbrew_resolution")
mapfile -d '' -t pacbrew_libs < <(python3 -c \
'import json,sys; [print(v, end="\0") for v in json.loads(sys.argv[1])["libs"]]' \
"$pacbrew_resolution")
pacbrew_root=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["root"])' \
"$pacbrew_resolution")
for include in "${pacbrew_includes[@]}"; do
[[ $include =~ ^[A-Za-z0-9_.+-]+(/[A-Za-z0-9_.+-]+)*$ &&
-d $pacbrew_root/user/homebrew/$include ]] || {
echo "invalid PacBrew include path: $include" >&2; exit 2;
}
pacbrew_cflags+=("-I$pacbrew_root/user/homebrew/$include")
done
for archive in "${pacbrew_archives[@]}"; do
[[ $archive =~ ^[A-Za-z0-9_.+-]+(/[A-Za-z0-9_.+-]+)*\.a$ &&
-f $pacbrew_root/user/homebrew/$archive ]] || {
echo "invalid PacBrew static archive: $archive" >&2; exit 2;
}
pacbrew_libs+=("$pacbrew_root/user/homebrew/$archive")
done
printf 'PacBrew dependencies: %s\n' "${pacbrew_packages[*]:-(manual archives)}"
fi
objects=()
for source in "${sources[@]}"; do
[[ $source =~ ^src/[A-Za-z0-9_./-]+\.(c|cc|cpp)$ && -f $root/$source ]] || {
echo "invalid source: $source" >&2; exit 2;
}
object="$build/obj/${source//\//_}.o"
if [[ $source == *.c ]]; then standard=-std=c11; else standard=-std=c++20; fi
args=("$standard" -O2 -Wall -Wextra -ffunction-sections -fdata-sections)
[[ $source == *.c ]] || args+=(-fno-exceptions -fno-rtti)
for definition in "${definitions[@]}"; do
[[ $definition =~ ^[A-Za-z_][A-Za-z0-9_]*(=[A-Za-z0-9_]+)?$ ]] || {
echo "invalid compile definition: $definition" >&2; exit 2;
}
args+=("-D$definition")
done
for include in "${includes[@]}"; do
[[ $include =~ ^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$ && -d $root/$include ]] || {
echo "invalid include path: $include" >&2; exit 2;
}
args+=("-I$root/$include")
done
args+=("${pacbrew_cflags[@]}")
PS5_PAYLOAD_SDK="$sdk_root" sh "$root/tooling/prospero-clang18" \
"${args[@]}" -c "$root/$source" -o "$object"
objects+=("$object")
done
PS5_PAYLOAD_SDK="$sdk_root" sh "$root/tooling/prospero-clang18" \
-std=c++20 -O2 -Wall -Wextra -fno-exceptions -fno-rtti \
-ffunction-sections -fdata-sections \
-c "$native/app_crt.cpp" -o "$build/obj/app_crt.o"
PS5_PAYLOAD_SDK="$sdk_root" sh "$root/tooling/prospero-clang18" \
-std=c++20 -O2 -Wall -Wextra -fno-exceptions -fno-rtti \
-ffunction-sections -fdata-sections \
-c "$native/app_cpp_runtime.cpp" -o "$build/obj/app_cpp_runtime.o"
# AGC is provided by PS5 system modules rather than the public libc stubs.
# These tiny host-link objects let the native converter record the imports;
# their bodies are never packaged or executed on the console.
build_system_link_stub() {
local library=$1
local source=$2
local object="$build/obj/${library}_link_stub.o"
local output="$build/stubs/${library}.so"
mkdir -p "${output%/*}"
PS5_PAYLOAD_SDK="$sdk_root" sh "$root/tooling/prospero-clang18" \
-std=c11 -O2 -fPIC -ffunction-sections -fdata-sections \
-c "$root/$source" -o "$object"
"$sdk_root/bin/prospero-lld" --shared -soname "${library}.prx" \
-o "$output" "$object"
printf '%s\n' "$output"
}
agc_stub=$(build_system_link_stub libSceAgc vendor/ps5/sdk/stubs/agc_link_stub.c)
agc_driver_stub=$(build_system_link_stub libSceAgcDriver vendor/ps5/sdk/stubs/agc_driver_link_stub.c)
link_inputs=("$build/obj/app_crt.o" "$build/obj/app_cpp_runtime.o" "${objects[@]}")
link_inputs+=("$agc_stub" "$agc_driver_stub")
for archive in "${archives[@]}"; do
[[ $archive =~ ^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*\.a$ && -f $root/$archive ]] || {
echo "invalid static archive: $archive" >&2; exit 2;
}
link_inputs+=("$root/$archive")
done
if (( ${#pacbrew_libs[@]} > 0 )); then
link_inputs+=(--start-group "${pacbrew_libs[@]}" --end-group)
fi
"$sdk_root/bin/prospero-lld" -T "$native/ps5-pie.ld" --eh-frame-hdr \
--version-script "$native/app-symbols.map" \
--exclude-libs=ALL -L "$build/obj" \
-e _start -o "$build/llvm-pie.elf" "${link_inputs[@]}" \
--as-needed "$sdk_root"/target/lib/*.so
"$tool" link --in "$build/llvm-pie.elf" --out "$build/eboot.elf" \
--stub-dir "$sdk_root/target/lib" --stub "$agc_stub" \
--stub "$agc_driver_stub" --module-sdk "$module_sdk" \
--companion-sdk "$companion_sdk" --file-name eboot.elf
app="$dist/$title_id"
rm -rf -- "$app"
mkdir -p "$app/sce_sys" "$app/sce_module"
"$tool" self --sign --in "$build/eboot.elf" --out "$app/eboot.bin" \
--magic "$fself_magic"
cp "$param" "$app/sce_sys/param.json"
for asset in icon0.png pic0.dds pic1.dds snd0.at9; do
[[ -f $root/sce_sys/$asset ]] && cp "$root/sce_sys/$asset" "$app/sce_sys/$asset"
done
[[ ! -d $root/assets ]] || cp -a "$root/assets" "$app/assets"
[[ -f $root/runtime/libc.prx ]] || bash "$root/tools/rebuild-libc.sh"
(cd "$root/runtime" && sha256sum --check --strict libc.prx.sha256)
runtime_modules=("$root/runtime/libc.prx")
additional_runtime=()
[[ -z ${APP_RUNTIME_MODULES:-} ]] || read -r -a additional_runtime <<< "$APP_RUNTIME_MODULES"
for source in "${additional_runtime[@]}"; do
[[ $source =~ ^\.local/runtime/[A-Za-z0-9._-]+\.prx$ && -f $root/$source ]] || {
echo "invalid runtime module: $source" >&2; exit 2;
}
runtime_modules+=("$root/$source")
done
declare -A runtime_names=()
for input in "${runtime_modules[@]}"; do
name=${input##*/}
[[ $name =~ ^[A-Za-z0-9._-]+\.prx$ && -z ${runtime_names[$name]+present} ]] || {
echo "invalid or duplicate runtime module name: $name" >&2; exit 2;
}
runtime_names[$name]=present
magic=$(python3 - "$input" <<'PY'
import struct, sys
with open(sys.argv[1], "rb") as stream:
print(f"{struct.unpack('<I', stream.read(4))[0]:08x}")
PY
)
if [[ $magic == 1d3d154f || $magic == eef51454 ]]; then
cp "$input" "$app/sce_module/$name"
else
"$tool" self --sign --in "$input" --out "$app/sce_module/$name"
fi
"$tool" self --inspect --file "$app/sce_module/$name"
done
"$tool" self --inspect --file "$app/eboot.bin"
if [[ $format == ffpkg || $format == all ]]; then
ufs2tool=$(bash "$root/tools/setup-packaging-dependencies.sh" ffpkg)
rm -f -- "$dist/$title_id.ffpkg"
"$ufs2tool" makefs -S 4096 -b 20% -t ffs \
-o version=2,bsize=32768,fsize=4096,minfree=0,softupdates=0,optimization=space \
"$dist/$title_id.ffpkg" "$app"
python3 - "$dist/$title_id.ffpkg" <<'PY'
import struct, sys
with open(sys.argv[1], "rb") as stream:
stream.seek(0x1055c)
if struct.unpack("<I", stream.read(4))[0] != 0x19540119:
raise SystemExit("FFPKG is missing the UFS2 superblock magic")
PY
fi
if [[ $format == ffpfsc || $format == all ]]; then
mkpfs=$(bash "$root/tools/setup-packaging-dependencies.sh" ffpfsc)
rm -f -- "$dist/$title_id.ffpfsc"
"$mkpfs" pack folder --no-adjust-output-file-extension \
--version PS5 --verify "$app" "$dist/$title_id.ffpfsc"
fi
printf 'Build complete.\nApp folder: %s\n' "$app"
[[ $format != ffpkg && $format != all ]] || printf 'FFPKG: %s\n' "$dist/$title_id.ffpkg"
[[ $format != ffpfsc && $format != all ]] || printf 'FFPFSC: %s\n' "$dist/$title_id.ffpfsc"
+56
View File
@@ -0,0 +1,56 @@
<#
ps5-native-app-boilerplate - Source attribution header audit.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Verifies attribution in every tracked, comment-capable code and tooling file.
#>
#requires -Version 5.1
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$patterns = @("*.c", "*.cc", "*.cpp", "*.h", "*.hpp", "*.ld", "*.py", "*.ps1", "*.yml", "*.yaml", "*.sh")
$files = @(& git -C $root ls-files --cached --others --exclude-standard -- @patterns |
Where-Object { Test-Path -LiteralPath (Join-Path $root $_) -PathType Leaf })
$files += @(
".editorconfig",
".clang-format",
".clang-tidy",
".gitattributes",
".gitignore",
".env.example",
"Makefile",
"tooling/prospero-clang18",
"tooling/native/runtime/api-surface.txt",
"tooling/native/runtime/imports.txt"
)
$files = @($files | Sort-Object -Unique)
$markers = @(
"ps5-native-app-boilerplate",
"Copyright (C) 2026 BlackBearReloaded",
"SPDX-License-Identifier: GPL-3.0-or-later"
)
$missing = @()
foreach ($file in $files) {
$path = Join-Path $root $file
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$missing += "$file (missing file)"
continue
}
$header = (Get-Content -LiteralPath $path -TotalCount 20) -join "`n"
foreach ($marker in $markers) {
if (-not $header.Contains($marker)) {
$missing += "$file (missing '$marker')"
}
}
}
if ($missing.Count -ne 0) {
$missing | ForEach-Object { Write-Error $_ }
throw "One or more source attribution headers are incomplete."
}
Write-Host "Verified attribution headers in $($files.Count) tracked code and tooling files."
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - FTP development deployment and cleanup.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Builds the selected output and publishes it below /data/homebrew, or removes
# only the current title's staged files. Folder files are uploaded under
# ignored temporary names and promoted individually.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
action=${1:-deploy}
format=${DEPLOY_FORMAT:-folder}
format=${format,,}
host=${PS5_HOST:-}
port=${FTP_PORT:-2121}
user=${PS5_FTP_USER:-anonymous}
password=${PS5_FTP_PASSWORD:-codex}
[[ $# -le 1 && ($action == deploy || $action == undeploy) ]] || {
echo "usage: tools/deploy.sh [undeploy]" >&2
exit 2
}
[[ $action == undeploy || $format == folder || $format == ffpfsc || $format == ffpkg ]] || {
echo "DEPLOY_FORMAT must be folder, ffpfsc, or ffpkg" >&2
exit 2
}
[[ $host =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || {
echo "PS5_HOST must be an IPv4 address or hostname" >&2
exit 2
}
[[ $port =~ ^[0-9]+$ ]] && (( 10#$port >= 1 && 10#$port <= 65535 )) || {
echo "FTP_PORT must be between 1 and 65535" >&2
exit 2
}
command -v make >/dev/null || { echo "missing required command: make" >&2; exit 2; }
command -v python3 >/dev/null || { echo "missing required command: python3" >&2; exit 2; }
title_id=$(python3 - "$root/sce_sys/param.json" <<'PY'
import json, re, sys
with open(sys.argv[1], encoding="utf-8") as source:
title_id = json.load(source)["titleId"]
if not re.fullmatch(r"PPSA\d{5}", title_id):
raise SystemExit("param.json contains an invalid titleId")
print(title_id)
PY
)
base_url="ftp://$host:$port/data/homebrew"
if [[ $action == undeploy ]]; then
printf '==> [undeploy] Title: %s\n' "$title_id"
printf '==> [undeploy] Folder: %s/%s/\n' "$base_url" "$title_id"
printf '==> [undeploy] Images: %s/%s.{ffpkg,ffpfsc}\n' "$base_url" "$title_id"
if [[ ${DEPLOY_DRY_RUN:-0} == 1 ]]; then
echo "==> [undeploy] Dry run complete; no network request was sent"
exit 0
fi
python3 - "$host" "$port" "$user" "$password" "$title_id" <<'PY'
from ftplib import FTP, error_perm
from posixpath import join
import sys
host, port, user, password, title_id = sys.argv[1:]
homebrew_root = "/data/homebrew"
def reply_code(error):
return str(error).split(maxsplit=1)[0]
def list_names(ftp, path):
previous = ftp.pwd()
ftp.cwd(path)
try:
return [name for name, _ in ftp.mlsd() if name not in {".", ".."}]
finally:
ftp.cwd(previous)
def validate_name(name):
if not name or name in {".", ".."} or "/" in name or "\r" in name or "\n" in name:
raise RuntimeError(f"unsafe FTP entry name: {name!r}")
def remove_entry(ftp, path):
try:
# ftpsrv implements DELE with POSIX remove(), which safely unlinks files,
# symlinks, and empty directories before recursion is considered.
ftp.sendcmd(f"DELE {path}")
print(f"Removed: {path}")
return True
except error_perm as error:
message = str(error)
if reply_code(error) == "550" and "No such file or directory" in message:
return False
if reply_code(error) != "550" or "Directory not empty" not in message:
raise
for name in list_names(ftp, path):
validate_name(name)
remove_entry(ftp, join(path, name))
ftp.rmd(path)
print(f"Removed directory: {path}")
return True
with FTP() as ftp:
ftp.connect(host, int(port), timeout=15)
ftp.login(user, password)
removed = remove_entry(ftp, join(homebrew_root, title_id))
for suffix in ("ffpkg", "ffpfsc"):
removed |= remove_entry(ftp, join(homebrew_root, f"{title_id}.{suffix}"))
removed |= remove_entry(ftp, join(homebrew_root, f".{title_id}.{suffix}.upload"))
try:
ftp.quit()
except (EOFError, OSError):
pass
if removed:
print(f"Undeployment complete: {title_id}")
else:
print(f"Nothing staged for {title_id}")
PY
exit 0
fi
build_target=$format
[[ $format == folder ]] && build_target=app
echo "==> [deploy] Building $format"
make -C "$root" --no-print-directory "$build_target"
if [[ $format == folder ]]; then
artifact="$root/dist/$title_id"
[[ -d $artifact ]] || {
echo "missing deployment folder: $artifact" >&2
exit 2
}
[[ -s $artifact/eboot.bin && -s $artifact/sce_sys/param.json ]] || {
echo "deployment folder is missing eboot.bin or sce_sys/param.json" >&2
exit 2
}
mapfile -d '' files < <(
find "$artifact" -type f \
! -path "$artifact/eboot.bin" \
! -path "$artifact/sce_sys/param.json" \
-print0 | sort -z
)
files+=("$artifact/eboot.bin" "$artifact/sce_sys/param.json")
total=${#files[@]}
printf '==> [deploy] Target: %s/%s/\n' "$base_url" "$title_id"
else
artifact="$root/dist/$title_id.$format"
[[ -s $artifact ]] || {
echo "missing deployment artifact: $artifact" >&2
exit 2
}
remote_name="$title_id.$format"
temporary_name=".$remote_name.upload"
printf '==> [deploy] Target: %s/%s\n' "$base_url" "$remote_name"
fi
if [[ ${DEPLOY_DRY_RUN:-0} == 1 ]]; then
if [[ $format == folder ]]; then
echo "==> [deploy] Would publish $total files; eboot.bin and param.json are last"
fi
echo "==> [deploy] Dry run complete; no network request was sent"
exit 0
fi
python3 - "$host" "$port" "$user" "$password" "$title_id" "$format" "$artifact" <<'PY'
from ftplib import FTP, error_perm
from pathlib import Path
from posixpath import dirname, join
import sys
host, port, user, password, title_id, format_name, artifact_name = sys.argv[1:]
homebrew_root = "/data/homebrew"
def reply_code(error):
return str(error).split(maxsplit=1)[0]
def list_names(ftp, path):
previous = ftp.pwd()
ftp.cwd(path)
try:
return {name for name, _ in ftp.mlsd() if name not in {".", ".."}}
finally:
ftp.cwd(previous)
def ensure_directory(ftp, path):
current = ""
for component in path.strip("/").split("/"):
current += f"/{component}"
try:
ftp.mkd(current)
except error_perm as error:
if reply_code(error) != "550":
raise
previous = ftp.pwd()
try:
ftp.cwd(current)
finally:
ftp.cwd(previous)
def remove_if_present(ftp, path):
try:
# ftpsrv returns 226 for a successful DELE. FTP.sendcmd accepts every
# valid 2xx completion while ftplib.FTP.delete only permits 200/250.
ftp.sendcmd(f"DELE {path}")
return True
except error_perm as error:
text = str(error).lower()
if reply_code(error) == "550" and ("no such" in text or "not found" in text):
return False
raise
def upload_atomic(ftp, local, remote):
directory = dirname(remote)
temporary = join(directory, f".{remote.rsplit('/', 1)[-1]}.upload")
ensure_directory(ftp, directory)
remove_if_present(ftp, temporary)
with local.open("rb") as source:
ftp.storbinary(f"STOR {temporary}", source, blocksize=256 * 1024)
remove_if_present(ftp, remote)
ftp.rename(temporary, remote)
with FTP() as ftp:
ftp.connect(host, int(port), timeout=15)
ftp.login(user, password)
if format_name == "folder":
artifact = Path(artifact_name)
critical = [artifact / "eboot.bin", artifact / "sce_sys/param.json"]
files = sorted(
path for path in artifact.rglob("*")
if path.is_file() and path not in critical
) + critical
print(f"==> [deploy] Publishing {len(files)} files; eboot.bin and param.json are last")
remote_root = join(homebrew_root, title_id)
for index, local in enumerate(files, 1):
relative = local.relative_to(artifact).as_posix()
if "\n" in relative or "\r" in relative:
raise RuntimeError(f"deployment paths cannot contain newlines: {relative!r}")
print(f"==> [deploy] [{index}/{len(files)}] {relative}")
upload_atomic(ftp, local, join(remote_root, relative))
if "eboot.bin" not in list_names(ftp, remote_root):
raise RuntimeError("FTP upload completed but eboot.bin is not listed")
if "param.json" not in list_names(ftp, join(remote_root, "sce_sys")):
raise RuntimeError("FTP upload completed but param.json is not listed")
print(f"Deployment complete: ftp://{host}:{port}{remote_root}/")
else:
artifact = Path(artifact_name)
remote_name = f"{title_id}.{format_name}"
temporary = join(homebrew_root, f".{remote_name}.upload")
ensure_directory(ftp, homebrew_root)
remove_if_present(ftp, temporary)
print("==> [deploy] Uploading complete image under a temporary name")
with artifact.open("rb") as source:
ftp.storbinary(f"STOR {temporary}", source, blocksize=256 * 1024)
for suffix in ("ffpfsc", "ffpkg"):
old_name = f"{title_id}.{suffix}"
if remove_if_present(ftp, join(homebrew_root, old_name)):
print(f"==> [deploy] Removed previous {old_name} image")
print("==> [deploy] Publishing the completed image")
ftp.rename(temporary, join(homebrew_root, remote_name))
if remote_name not in list_names(ftp, homebrew_root):
raise RuntimeError("FTP upload completed but the final image is not listed")
print(f"Deployment complete: ftp://{host}:{port}{homebrew_root}/{remote_name}")
try:
ftp.quit()
except (EOFError, OSError):
pass
PY
+34
View File
@@ -0,0 +1,34 @@
<#
ps5-native-app-boilerplate - Windows prerequisite-check frontend.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Runs the canonical read-only Linux/WSL doctor so both host entry points report
the same required and optional tools.
#>
#requires -Version 5.1
$ErrorActionPreference = "Stop"
function Fail([string]$Message) {
throw "ps5-native-app-boilerplate: $Message"
}
function Convert-ToWslPath([string]$Path) {
$absolute = [IO.Path]::GetFullPath($Path)
if ($absolute -notmatch '^([A-Za-z]):\\(.*)$') {
Fail "The repository must be on a Windows drive visible to WSL."
}
return "/mnt/$($Matches[1].ToLowerInvariant())/$($Matches[2].Replace('\', '/'))"
}
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Fail "WSL was not found. Install WSL and a Linux distribution first."
}
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$doctor = Convert-ToWslPath (Join-Path $root "tools/doctor.sh")
& wsl.exe --exec bash $doctor
if ($LASTEXITCODE -ne 0) {
Fail "One or more Linux/WSL prerequisites are missing."
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Portable Linux/WSL prerequisite checker.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Reports required and optional host tools without downloading or changing them.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
failed=0
required() {
local command=$1
local purpose=$2
if command -v "$command" >/dev/null; then
printf '[OK] %-18s %s\n' "$command" "$purpose"
else
printf '[MISSING] %-13s %s\n' "$command" "$purpose"
failed=1
fi
}
optional() {
local command=$1
local purpose=$2
if command -v "$command" >/dev/null; then
printf '[OK] %-18s %s\n' "$command" "$purpose"
else
printf '[OPTIONAL] %-12s %s\n' "$command" "$purpose"
fi
}
required_one_of() {
local label=$1
local purpose=$2
shift 2
local candidate
for candidate in "$@"; do
if command -v "$candidate" >/dev/null; then
printf '[OK] %-18s %s (%s)\n' "$label" "$purpose" "$candidate"
return
fi
done
printf '[MISSING] %-13s %s\n' "$label" "$purpose"
failed=1
}
printf 'Host: %s\n' "$(uname -srm)"
if grep -qi microsoft /proc/version 2>/dev/null; then
echo 'Environment: WSL'
else
echo 'Environment: Linux'
fi
required bash 'build orchestration'
required make 'primary build entry point'
required python3 'metadata, tests, and packaging helpers'
required_one_of clang 'native host and target compilation' clang-18 clang
required_one_of clang++ 'C++ host and target compilation' clang++-18 clang++
required_one_of clang-format 'format validation' clang-format-18 clang-format
required_one_of clang-tidy 'static analysis' clang-tidy-18 clang-tidy
required_one_of llvm-ar 'static archive creation' llvm-ar-18 llvm-ar ar
required_one_of llvm-ranlib 'static archive indexing' llvm-ranlib-18 llvm-ranlib ranlib
required wget 'pinned dependency downloads'
required unzip 'public SDK extraction'
required tar 'zlib source extraction'
required sha256sum 'download and runtime verification'
required git 'source and optional dependency checkout'
optional curl 'FTP deployment'
optional pkg-config 'PacBrew dependency resolution'
optional dotnet 'optional .ffpkg generation'
optional ffmpeg 'presentation-audio preparation'
if [[ -f $root/sce_sys/param.json ]]; then
printf '[OK] %-18s %s\n' 'param.json' 'application metadata found'
else
printf '[MISSING] %-13s %s\n' 'param.json' 'sce_sys/param.json was not found'
failed=1
fi
if [[ -x $root/.deps/native/ps5-payload-sdk/bin/prospero-lld ]]; then
printf '[CACHED] %-14s %s\n' 'PS5 SDK' '.deps/native/ps5-payload-sdk'
else
printf '[GENERATED] %-11s %s\n' 'PS5 SDK' 'make will fetch the pinned public SDK'
fi
if [[ -f $root/runtime/libc.prx ]]; then
printf '[CACHED] %-14s %s\n' 'libc.prx' 'generated runtime found'
else
printf '[GENERATED] %-11s %s\n' 'libc.prx' 'make will generate and verify it'
fi
if ((failed)); then
echo 'One or more required tools are missing. See docs/GETTING_STARTED.md.' >&2
exit 1
fi
echo 'Required Linux/WSL build prerequisites are available.'
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Application identity initializer.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Updates the coordinated identity and category fields in param.json.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
param=${1:-$root/sce_sys/param.json}
command -v python3 >/dev/null || {
echo "missing required command: python3" >&2
exit 2
}
python3 - "$param" <<'PY'
import json
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
path = Path(sys.argv[1])
title_id = os.environ.get("TITLE_ID", "")
app_name = os.environ.get("APP_NAME", "").strip()
category = os.environ.get("APP_CATEGORY", "game").lower()
suffix = os.environ.get("CONTENT_SUFFIX", "")
if not re.fullmatch(r"PPSA\d{5}", title_id):
raise SystemExit("TITLE_ID must use PPSA followed by five digits")
if not app_name or any(character in app_name for character in "\r\n"):
raise SystemExit("APP_NAME must be a non-empty, single-line title")
if category not in {"game", "media"}:
raise SystemExit("APP_CATEGORY must be game or media")
if not suffix:
suffix = re.sub(r"[^A-Z0-9]", "", app_name.upper())[:16].ljust(16, "0")
if not re.fullmatch(r"[A-Z0-9]{16}", suffix):
raise SystemExit("CONTENT_SUFFIX must contain exactly 16 uppercase letters or digits")
with path.open(encoding="utf-8") as source:
param = json.load(source)
original_mode = stat.S_IMODE(path.stat().st_mode)
content_id = param.get("contentId", "")
prefix = content_id.split("-", 1)[0]
if not re.fullmatch(r"[A-Z]{2}\d{4}", prefix):
prefix = "UP9000"
param["titleId"] = title_id
param["conceptId"] = title_id[4:]
param["contentId"] = f"{prefix}-{title_id}_00-{suffix}"
localized = param.setdefault("localizedParameters", {})
language = localized.setdefault("defaultLanguage", "en-US")
localized.setdefault(language, {})["titleName"] = app_name
if category == "game":
param["applicationCategoryType"] = 0
param["contentBadgeType"] = 1
param["gameIntent"] = {"permittedIntents": [{"intentType": "launchActivity"}]}
else:
param["applicationCategoryType"] = 65536
param["contentBadgeType"] = 2
param.pop("gameIntent", None)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
json.dump(param, output, indent=2, ensure_ascii=False)
output.write("\n")
os.chmod(temporary_name, original_mode)
os.replace(temporary_name, path)
except BaseException:
try:
os.unlink(temporary_name)
except FileNotFoundError:
pass
raise
print(f"Configured {title_id}: {app_name} ({category})")
print(f"Content ID: {param['contentId']}")
PY
+157
View File
@@ -0,0 +1,157 @@
<#
ps5-native-app-boilerplate - Static ELF/FSELF validator.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Checks the loader-visible structure of a generated native application.
#>
#requires -Version 5.1
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$File
)
$ErrorActionPreference = "Stop"
$path = (Resolve-Path -LiteralPath $File).Path
$data = [IO.File]::ReadAllBytes($path)
function U16([int]$offset) { [BitConverter]::ToUInt16($data, $offset) }
function U32([int]$offset) { [BitConverter]::ToUInt32($data, $offset) }
function U64([int]$offset) { [BitConverter]::ToUInt64($data, $offset) }
function Hex([uint64]$value) { "0x{0:x}" -f $value }
function Align16([uint64]$value) { ($value + 15) -band ([uint64]::MaxValue - 15) }
$container = $false
$elf = 0
$fselfCompatible = $null
$authInfoEmbedded = $null
if ($data.Length -ge 0x20 -and (U32 0) -in @([uint32]0x1D3D154F, [uint32]4009038932)) {
$container = $true
$entries = U16 0x18
$headerSize = U16 0x0c
$metaSize = U16 0x0e
$elf = 0x20 + 0x20 * $entries
if ($elf + 0x40 -gt $data.Length -or (U32 $elf) -ne 0x464c457f) {
throw "FSELF does not contain an ELF header at the segment-table boundary."
}
$phoff = U64 ($elf + 0x20)
$phnum = U16 ($elf + 0x38)
$ext = Align16 ($elf + $phoff + 0x38 * $phnum)
$extMarker = if ($ext + 16 -le $data.Length) { U64 ([int]$ext + 8) } else { 0 }
$fselfCompatible = $extMarker -eq 1
# This is where current kstuff-lite looks for an optional 0x88-byte auth record.
$authOffset = $ext + 0x40 + 0x30 + 0x50 * $entries + 0x50
$authInfoEmbedded = $authOffset + 8 -le $data.Length -and (U64 ([int]$authOffset)) -eq 0x88
[pscustomobject]@{
Layer = "FSELF"
SegmentEntries = $entries
HeaderSize = Hex $headerSize
MetaSize = Hex $metaSize
ElfOffset = Hex $elf
ExtendedInfoOffset = Hex $ext
ExtendedInfoMarker = Hex $extMarker
KstuffRecognized = $fselfCompatible
EmbeddedAuthInfo = $authInfoEmbedded
} | Format-List
}
if ($elf + 0x40 -gt $data.Length -or (U32 $elf) -ne 0x464c457f) {
throw "Input is neither a raw ELF nor a supported FSELF."
}
$osAbi = $data[$elf + 7]
$abiVersion = $data[$elf + 8]
$type = U16 ($elf + 0x10)
$entry = U64 ($elf + 0x18)
$phoff = U64 ($elf + 0x20)
$phentsize = U16 ($elf + 0x36)
$phnum = U16 ($elf + 0x38)
$types = @()
$mappedLoads = @()
$procParam = $null
$linkingLoad = $null
$programHeaders = @()
for ($i = 0; $i -lt $phnum; $i++) {
$p = $elf + [int]$phoff + $i * $phentsize
$pt = U32 $p
$flags = U32 ($p + 4)
$offset = U64 ($p + 8)
$vaddr = U64 ($p + 16)
$filesz = U64 ($p + 32)
$memsz = U64 ($p + 40)
$align = U64 ($p + 48)
$programHeaders += [pscustomobject]@{
Index=$i; Type=$pt; Flags=$flags; Offset=$offset; Address=$vaddr
FileSize=$filesz; MemorySize=$memsz; Align=$align
}
$types += $pt
if ($pt -eq 1) {
if ($flags -eq 0) { $linkingLoad = $i }
else { $mappedLoads += [pscustomobject]@{ Index=$i; Flags=$flags; Offset=$offset; Address=$vaddr; FileSize=$filesz; MemorySize=$memsz; Align=$align } }
}
if ($pt -eq 0x61000001) { $procParam = $offset }
}
$errors = [Collections.Generic.List[string]]::new()
if ($osAbi -ne 9) { $errors.Add("OS/ABI is not FreeBSD (9).") }
if ($abiVersion -ne 2) { $errors.Add("ABI version is not 2.") }
if ($type -ne 0xfe10) { $errors.Add("ELF type is not ET_SCE_EXEC_ASLR (0xfe10).") }
if ($phentsize -ne 0x38) { $errors.Add("Program-header size is not 0x38.") }
if ($null -eq $procParam) { $errors.Add("PT_SCE_PROCPARAM is missing.") }
if ($null -eq $linkingLoad) { $errors.Add("The flags-zero linking LOAD is missing.") }
foreach ($load in $mappedLoads) {
if ($load.Align -ne 0x4000) { $errors.Add("Mapped LOAD $($load.Index) is not 0x4000 aligned.") }
if ($load.MemorySize -lt $load.FileSize) { $errors.Add("LOAD $($load.Index) has memsz smaller than filesz.") }
if ($load.Flags -notin @(1, 4, 6)) { $errors.Add("LOAD $($load.Index) has unsupported flags $($load.Flags).") }
}
foreach ($header in $programHeaders) {
if ($header.Type -eq 0x6fffff00) {
if ($header.MemorySize -ne 0) {
$errors.Add("Comment header $($header.Index) must have zero memsz.")
}
if ($header.Align -ne 0x10) {
$errors.Add("Comment header $($header.Index) must use 0x10 alignment.")
}
}
if ($header.Type -eq 4 -and $header.Address -eq 0 -and
$header.MemorySize -ne 0) {
$errors.Add("Unmapped NOTE header $($header.Index) must have zero memsz.")
}
}
$companion = $null
$sdk = $null
$procMagic = $null
if (-not $container -and $null -ne $procParam -and $elf + $procParam + 0x60 -le $data.Length) {
$procMagic = [Text.Encoding]::ASCII.GetString($data, $elf + [int]$procParam + 8, 4)
$companion = U32 ($elf + [int]$procParam + 0x10)
$sdk = U32 ($elf + [int]$procParam + 0x14)
if ($procMagic -ne "ORBI") { $errors.Add("Process-parameter magic is not ORBI.") }
}
[pscustomobject]@{
Layer = "ELF"
Container = $container
OsAbi = $osAbi
AbiVersion = $abiVersion
Type = Hex $type
Entry = Hex $entry
ProgramHeaders = $phnum
MappedLoads = $mappedLoads.Count
LinkingLoadIndex = $linkingLoad
ProcessParamMagic = $procMagic
CompanionVersion = if ($null -ne $companion) { Hex $companion } else { $null }
SdkVersion = if ($null -ne $sdk) { Hex $sdk } else { $null }
StaticErrors = $errors.Count
} | Format-List
if ($errors.Count) {
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Linux/WSL source and metadata checks.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Runs the portable checks used by the Make workflow.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
bash tools/setup-native-dependencies.sh >/dev/null
bash tools/run_clang_format.sh --check
bash tools/run_clang_tidy.sh
mapfile -t repository_files < <(git ls-files --cached --others --exclude-standard)
checked=0
for file in "${repository_files[@]}"; do
[[ -f $file ]] || continue
case "$file" in
*.c|*.cc|*.cpp|*.h|*.hpp|*.ld|*.py|*.ps1|*.sh|*.yml|*.yaml|Makefile|.clang-format|.clang-tidy|.env.example)
header=$(head -n 20 "$file")
grep -Fq ps5-native-app-boilerplate <<<"$header"
grep -Fq 'Copyright (C) 2026 BlackBearReloaded' <<<"$header"
grep -Fq 'SPDX-License-Identifier: GPL-3.0-or-later' <<<"$header"
((checked += 1))
;;
*.cs|*.csproj)
echo "managed source is not allowed: $file" >&2
exit 2
;;
esac
done
while IFS= read -r script; do
bash -n "$script"
done < <(find tools -maxdepth 1 -type f -name '*.sh' -print)
bash tools/validate-assets.sh
python3 - <<'PY'
from pathlib import Path
import json, re, subprocess
for name in subprocess.check_output(["git", "ls-files", "*.json"], text=True).splitlines():
if Path(name).is_file():
with open(name, encoding="utf-8") as source:
json.load(source)
with open("sce_sys/param.json", encoding="utf-8") as source:
param = json.load(source)
title_id = param.get("titleId", "")
content_id = param.get("contentId", "")
if not re.fullmatch(r"PPSA\d{5}", title_id):
raise SystemExit("param.json titleId must use PPSA followed by five digits")
if not re.fullmatch(r"\d{5}", param.get("conceptId", "")):
raise SystemExit("param.json conceptId must contain five digits")
if (not re.fullmatch(r"[A-Z]{2}\d{4}-PPSA\d{5}_00-[A-Z0-9]{16}", content_id)
or title_id not in content_id):
raise SystemExit("param.json contentId must be valid and contain titleId")
if not re.fullmatch(r"\d{2}\.\d{3}\.\d{3}", param.get("contentVersion", "")):
raise SystemExit("param.json contentVersion must use NN.NNN.NNN")
if not re.fullmatch(r"\d{2}\.\d{2}", param.get("masterVersion", "")):
raise SystemExit("param.json masterVersion must use NN.NN")
size = param.get("downloadDataSize")
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
raise SystemExit("param.json downloadDataSize must be a non-negative integer")
category = param.get("applicationCategoryType")
if (category, param.get("contentBadgeType")) not in {(0, 1), (65536, 2)}:
raise SystemExit("param.json category and badge must describe a game or media app")
if category == 0:
intents = param.get("gameIntent", {}).get("permittedIntents", [])
if not any(item.get("intentType") == "launchActivity" for item in intents):
raise SystemExit("game param.json must permit the launchActivity intent")
elif "gameIntent" in param:
raise SystemExit("media param.json must not contain gameIntent")
localized = param.get("localizedParameters", {})
language = localized.get("defaultLanguage", "")
title = localized.get(language, {}).get("titleName", "")
if not isinstance(title, str) or not title.strip():
raise SystemExit("param.json default-language titleName cannot be empty")
PY
if git grep -n -E 'C:\\Users\\|/home/denis|/mnt/c/Users/denis|\bDenis\b' -- . \
':(exclude)tools/lint.sh'; then
echo "repository contains a local path or personal-name leak" >&2
exit 2
fi
git diff --check
printf 'Lint passed: %d attributed code and tooling files.\n' "$checked"
+375
View File
@@ -0,0 +1,375 @@
<#
ps5-native-app-boilerplate - Presentation asset preparation.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Converts developer-owned artwork and audio into the supported launcher
asset profile, or validates the current sce_sys presentation files.
#>
#requires -Version 5.1
[CmdletBinding()]
param(
[switch]$ValidateOnly,
[string]$Icon = "",
[string]$Background = "",
[string]$SelectionBackground = "",
[string]$LaunchBackground = "",
[string]$Audio = "",
[string]$Texconv = "",
[string]$Ffmpeg = "",
[string]$At9Tool = "",
[ValidateRange(0, 86400)]
[double]$AudioStart = 0,
[ValidateRange(0.1, 87.3)]
[double]$AudioDuration = 15,
[string]$OutputDirectory = ""
)
$ErrorActionPreference = "Stop"
$repo = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
if (-not $OutputDirectory) {
$OutputDirectory = Join-Path $repo "sce_sys"
}
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
function Fail([string]$Message) {
throw "prepare-assets: $Message"
}
function Read-U16([byte[]]$Bytes, [int]$Offset) {
if ($Offset -lt 0 -or $Offset + 2 -gt $Bytes.Length) {
Fail "Unexpected end of file."
}
return [BitConverter]::ToUInt16($Bytes, $Offset)
}
function Read-U32([byte[]]$Bytes, [int]$Offset) {
if ($Offset -lt 0 -or $Offset + 4 -gt $Bytes.Length) {
Fail "Unexpected end of file."
}
return [BitConverter]::ToUInt32($Bytes, $Offset)
}
function Read-U32BigEndian([byte[]]$Bytes, [int]$Offset) {
if ($Offset -lt 0 -or $Offset + 4 -gt $Bytes.Length) {
Fail "Unexpected end of file."
}
return ([uint32]$Bytes[$Offset] -shl 24) -bor
([uint32]$Bytes[$Offset + 1] -shl 16) -bor
([uint32]$Bytes[$Offset + 2] -shl 8) -bor
[uint32]$Bytes[$Offset + 3]
}
function Read-Ascii([byte[]]$Bytes, [int]$Offset, [int]$Count) {
if ($Offset -lt 0 -or $Offset + $Count -gt $Bytes.Length) {
Fail "Unexpected end of file."
}
return [Text.Encoding]::ASCII.GetString($Bytes, $Offset, $Count)
}
function Resolve-Input([string]$Path, [string]$Label) {
if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
Fail "$Label file not found: $Path"
}
return (Resolve-Path -LiteralPath $Path).Path
}
function Resolve-Executable([string]$Path, [string[]]$Names, [string]$Help) {
if ($Path) {
if (Test-Path -LiteralPath $Path -PathType Leaf) {
return (Resolve-Path -LiteralPath $Path).Path
}
$command = Get-Command $Path -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
Fail "Executable not found: $Path"
}
foreach ($name in $Names) {
$command = Get-Command $name -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
}
Fail $Help
}
function Invoke-Checked([string]$Executable, [string[]]$Arguments, [string]$Label) {
& $Executable @Arguments
if ($LASTEXITCODE -ne 0) {
Fail "$Label failed with exit code $LASTEXITCODE."
}
}
function Convert-BackgroundAsset(
[string]$Source,
[string]$Label,
[string]$Stem,
[string]$WorkDirectory,
[string]$Destination,
[string]$TexconvPath
) {
$resolvedSource = Resolve-Input $Source $Label
$assetWork = Join-Path $WorkDirectory $Stem
New-Item -ItemType Directory -Path $assetWork | Out-Null
Invoke-Checked $TexconvPath @("-nologo", "-y", "-w", "3840", "-h", "2160", "-m", "1",
"-f", "R8G8B8A8_UNORM", "-ft", "png", "-o", $assetWork, $resolvedSource) `
"$Label preview conversion"
$preview = Get-ChildItem -LiteralPath $assetWork -File |
Where-Object { $_.Extension -ieq ".png" } | Select-Object -First 1
if (-not $preview) {
Fail "texconv did not produce the $Label preview."
}
Invoke-Checked $TexconvPath @("-nologo", "-y", "-w", "3840", "-h", "2160", "-m", "1",
"-f", "BC7_UNORM", "-dx10", "-o", $assetWork, $preview.FullName) "$Label DDS conversion"
$dds = Get-ChildItem -LiteralPath $assetWork -File |
Where-Object { $_.Extension -ieq ".dds" } | Select-Object -First 1
if (-not $dds) {
Fail "texconv did not produce the $Label BC7 DDS."
}
[IO.File]::Copy($dds.FullName, (Join-Path $Destination "$Stem.dds"), $true)
return [PSCustomObject]@{ Preview = $preview.FullName; Dds = $dds.FullName }
}
function Test-Png([string]$Path, [int]$ExpectedWidth, [int]$ExpectedHeight) {
$bytes = [IO.File]::ReadAllBytes($Path)
$signature = [byte[]](137, 80, 78, 71, 13, 10, 26, 10)
if ($bytes.Length -lt 24) {
Fail "$Path is too small to be a PNG."
}
for ($i = 0; $i -lt $signature.Length; $i++) {
if ($bytes[$i] -ne $signature[$i]) {
Fail "$Path is not a PNG file."
}
}
$width = Read-U32BigEndian $bytes 16
$height = Read-U32BigEndian $bytes 20
if ($width -ne $ExpectedWidth -or $height -ne $ExpectedHeight) {
Fail "$Path must be ${ExpectedWidth}x${ExpectedHeight}; found ${width}x${height}."
}
}
function Test-Dds([string]$Path) {
$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 148 -or (Read-Ascii $bytes 0 4) -ne "DDS ") {
Fail "$Path is not a DDS file."
}
$headerSize = Read-U32 $bytes 4
$height = Read-U32 $bytes 12
$width = Read-U32 $bytes 16
$mipCount = Read-U32 $bytes 28
$fourCc = Read-Ascii $bytes 84 4
$format = Read-U32 $bytes 128
$dimension = Read-U32 $bytes 132
$arraySize = Read-U32 $bytes 140
$expectedSize = [long]148 + ([long]$width / 4) * ([long]$height / 4) * 16
if ($headerSize -ne 124 -or $width -ne 3840 -or $height -ne 2160 -or
$mipCount -ne 1 -or $fourCc -ne "DX10" -or $format -ne 98 -or
$dimension -ne 3 -or $arraySize -ne 1 -or $bytes.Length -ne $expectedSize) {
Fail "$Path must be a single 3840x2160 BC7_UNORM (98) DX10 2D DDS without mipmaps."
}
}
function Test-At9([string]$Path) {
$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 128 -or (Read-Ascii $bytes 0 4) -ne "RIFF" -or
(Read-Ascii $bytes 8 4) -ne "WAVE" -or (Read-U32 $bytes 4) + 8 -ne $bytes.Length) {
Fail "$Path is not a complete RIFF/WAVE file."
}
$chunks = @{}
$position = 12
while ($position + 8 -le $bytes.Length) {
$id = Read-Ascii $bytes $position 4
$size = [int](Read-U32 $bytes ($position + 4))
$payload = $position + 8
if ($size -lt 0 -or $payload + $size -gt $bytes.Length) {
Fail "$Path contains a truncated RIFF chunk."
}
$chunks[$id] = @($payload, $size)
$position = $payload + $size + ($size % 2)
}
foreach ($required in @("fmt ", "fact", "smpl", "data")) {
if (-not $chunks.ContainsKey($required)) {
Fail "$Path is missing the required RIFF '$required' chunk."
}
}
$fmt = [int]$chunks["fmt "][0]
$smpl = [int]$chunks["smpl"][0]
if ([int]$chunks["fmt "][1] -lt 40 -or [int]$chunks["smpl"][1] -lt 32) {
Fail "$Path contains undersized ATRAC9 format or loop metadata."
}
$formatGuid = "D242E147BA368D4D88FC61654F8C836C"
$actualGuid = -join ($bytes[($fmt + 24)..($fmt + 39)] | ForEach-Object { $_.ToString("X2") })
$channels = Read-U16 $bytes ($fmt + 2)
$byteRate = Read-U32 $bytes ($fmt + 8)
$loops = Read-U32 $bytes ($smpl + 28)
if ((Read-U16 $bytes $fmt) -ne 0xFFFE -or $channels -notin @(1, 2) -or
(Read-U32 $bytes ($fmt + 4)) -ne 48000 -or $byteRate -le 0 -or
$byteRate -gt ($channels * 12000) -or $actualGuid -ne $formatGuid -or
$loops -lt 1 -or $bytes.Length -gt 2097152) {
Fail "$Path must be looped 48 kHz ATRAC9 at no more than 96 kb/s mono or 192 kb/s stereo, and no larger than 2 MiB."
}
}
function Test-AssetSet([string]$Directory) {
$iconPath = Join-Path $Directory "icon0.png"
if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
Fail "Required launcher icon not found: $iconPath"
}
Test-Png $iconPath 512 512
$pic0 = Join-Path $Directory "pic0.dds"
$pic1 = Join-Path $Directory "pic1.dds"
$hasPic0 = Test-Path -LiteralPath $pic0 -PathType Leaf
$hasPic1 = Test-Path -LiteralPath $pic1 -PathType Leaf
if ($hasPic0 -xor $hasPic1) {
Fail "Supply both pic0.dds and pic1.dds, or neither."
}
if ($hasPic0) {
Test-Dds $pic0
Test-Dds $pic1
}
$sound = Join-Path $Directory "snd0.at9"
if (Test-Path -LiteralPath $sound -PathType Leaf) {
Test-At9 $sound
}
Write-Host "Presentation assets validated: $Directory"
}
if ($ValidateOnly) {
Test-AssetSet $OutputDirectory
return
}
if ($Background -and ($SelectionBackground -or $LaunchBackground)) {
Fail "Use -Background by itself, or use -SelectionBackground and -LaunchBackground for distinct artwork."
}
if (-not $Icon -and -not $Background -and -not $SelectionBackground -and
-not $LaunchBackground -and -not $Audio) {
Fail "Supply an icon, background, or audio input, or use -ValidateOnly."
}
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
Fail "Conversion uses Windows texconv and an optional Windows ATRAC9 encoder; run this command from Windows PowerShell."
}
$root = [IO.Path]::GetPathRoot($OutputDirectory)
if ($OutputDirectory.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) {
Fail "Refusing to use a filesystem root as the output directory."
}
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
$work = Join-Path ([IO.Path]::GetTempPath()) ("ps5-native-assets-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $work | Out-Null
try {
if ($Icon -or $Background -or $SelectionBackground -or $LaunchBackground) {
$texconvPath = Resolve-Executable $Texconv @("texconv.exe", "texconv") `
"texconv was not found. Install it with: winget install Microsoft.DirectXTex.Texconv"
}
if ($Icon) {
$iconSource = Resolve-Input $Icon "Icon"
$iconWork = Join-Path $work "icon"
New-Item -ItemType Directory -Path $iconWork | Out-Null
Invoke-Checked $texconvPath @("-nologo", "-y", "-w", "512", "-h", "512", "-m", "1",
"-f", "R8G8B8A8_UNORM", "-ft", "png", "-o", $iconWork, $iconSource) "Icon conversion"
$converted = Get-ChildItem -LiteralPath $iconWork -File |
Where-Object { $_.Extension -ieq ".png" } | Select-Object -First 1
if (-not $converted) {
Fail "texconv did not produce the launcher PNG."
}
[IO.File]::Copy($converted.FullName, (Join-Path $OutputDirectory "icon0.png"), $true)
}
if ($Background) {
$convertedBackground = Convert-BackgroundAsset $Background "Background" "pic0" $work `
$OutputDirectory $texconvPath
[IO.File]::Copy($convertedBackground.Dds, (Join-Path $OutputDirectory "pic1.dds"), $true)
[IO.File]::Copy($convertedBackground.Preview,
(Join-Path $OutputDirectory "background-source.png"), $true)
[IO.File]::Copy($convertedBackground.Preview,
(Join-Path $OutputDirectory "launch-background-source.png"), $true)
} else {
if ($SelectionBackground) {
$convertedSelection = Convert-BackgroundAsset $SelectionBackground `
"Selection background" "pic0" $work $OutputDirectory $texconvPath
[IO.File]::Copy($convertedSelection.Preview,
(Join-Path $OutputDirectory "background-source.png"), $true)
}
if ($LaunchBackground) {
$convertedLaunch = Convert-BackgroundAsset $LaunchBackground `
"Launch background" "pic1" $work $OutputDirectory $texconvPath
[IO.File]::Copy($convertedLaunch.Preview,
(Join-Path $OutputDirectory "launch-background-source.png"), $true)
}
}
if ($Audio) {
$audioSource = Resolve-Input $Audio "Audio"
$soundOutput = Join-Path $OutputDirectory "snd0.at9"
if ([IO.Path]::GetExtension($audioSource) -ieq ".at9") {
if ($audioSource -ne $soundOutput) {
[IO.File]::Copy($audioSource, $soundOutput, $true)
}
} else {
$at9ToolPath = Resolve-Executable $At9Tool @("ps4_at9tool.exe") `
"A compatible ATRAC9 encoder was not found. Supply your legally obtained ps4_at9tool.exe with -At9Tool."
$wav = Join-Path $work "selection.wav"
$startText = $AudioStart.ToString([Globalization.CultureInfo]::InvariantCulture)
$durationText = $AudioDuration.ToString([Globalization.CultureInfo]::InvariantCulture)
$ffmpegArgs = @("-hide_banner", "-loglevel", "error", "-y", "-ss", $startText, "-i", $audioSource,
"-t", $durationText, "-map_metadata", "-1", "-af", "loudnorm=I=-28:LRA=11:TP=-2",
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", $wav)
$ffmpegCommand = if ($Ffmpeg) {
Resolve-Executable $Ffmpeg @() "FFmpeg was not found."
} else {
$command = Get-Command ffmpeg.exe, ffmpeg -ErrorAction SilentlyContinue | Select-Object -First 1
if ($command) { $command.Source } else { "" }
}
if ($ffmpegCommand) {
Invoke-Checked $ffmpegCommand $ffmpegArgs "Audio preparation"
} elseif (Get-Command wsl.exe -ErrorAction SilentlyContinue) {
& wsl.exe --exec sh -lc "command -v ffmpeg >/dev/null 2>&1"
if ($LASTEXITCODE -ne 0) {
Fail "FFmpeg was not found on Windows or in WSL. Install it in WSL with: sudo apt install ffmpeg"
}
function To-WslPath([string]$Path) {
if ($Path -notmatch '^([A-Za-z]):\\(.*)$') {
Fail "WSL FFmpeg requires source files on a Windows drive: $Path"
}
return "/mnt/$($Matches[1].ToLowerInvariant())/$($Matches[2].Replace('\', '/'))"
}
$wslArgs = @($ffmpegArgs)
$wslArgs[7] = To-WslPath $audioSource
$wslArgs[$wslArgs.Count - 1] = To-WslPath $wav
Invoke-Checked "wsl.exe" (@("--exec", "ffmpeg") + $wslArgs) "WSL audio preparation"
} else {
Fail "FFmpeg was not found. Install it or pass its executable with -Ffmpeg."
}
$encoded = Join-Path $work "snd0.at9"
Invoke-Checked $at9ToolPath @("-e", "-br", "192", "-wholeloop", $wav, $encoded) `
"ATRAC9 encoding"
[IO.File]::Copy($encoded, $soundOutput, $true)
}
$paramPath = Join-Path $OutputDirectory "param.json"
if (Test-Path -LiteralPath $paramPath -PathType Leaf) {
$param = Get-Content -LiteralPath $paramPath -Raw | ConvertFrom-Json
$param.pubtools | Add-Member -NotePropertyName loudnessSnd0 -NotePropertyValue "-28.00" -Force
$json = $param | ConvertTo-Json -Depth 16
[IO.File]::WriteAllText($paramPath, $json + [Environment]::NewLine,
[Text.UTF8Encoding]::new($false))
}
}
Test-AssetSet $OutputDirectory
} finally {
$resolvedWork = [IO.Path]::GetFullPath($work)
$resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if ($resolvedWork.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -and
(Test-Path -LiteralPath $resolvedWork -PathType Container)) {
Remove-Item -LiteralPath $resolvedWork -Recurse -Force
}
}
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Bash presentation asset preparation frontend.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Provides Linux/WSL-style arguments for the Windows DirectXTex and ATRAC9
# conversion workflow while keeping validation available on native Linux.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
output_directory="$root/sce_sys"
validate_only=0
conversion_inputs=0
declare -a forwarded=()
usage() {
cat <<'EOF'
usage: tools/prepare-assets.sh [options]
--icon PATH Prepare sce_sys/icon0.png
--background PATH Use one image for pic0.dds and pic1.dds
--selection-background PATH Prepare pic0.dds from distinct artwork
--launch-background PATH Prepare pic1.dds from distinct artwork
--audio PATH Copy ready AT9 or prepare supported source audio
--audio-start SECONDS Start of the audio excerpt (default: 0)
--audio-duration SECONDS Excerpt duration (default: 15; maximum: 87.3)
--texconv COMMAND texconv.exe name or path
--ffmpeg COMMAND Windows FFmpeg name or path
--at9-tool COMMAND Legally obtained compatible ATRAC9 encoder
--output-directory PATH Destination (default: sce_sys)
--validate-only Validate current files on Linux or WSL
-h, --help Show this help
Conversion runs through Windows PowerShell and therefore requires WSL or a
Windows Bash environment. Validation is portable and requires only Python 3.
EOF
}
need_value() {
[[ $# -ge 2 && -n $2 ]] || {
echo "missing value for $1" >&2
exit 2
}
}
while (($#)); do
case "$1" in
--icon|--background|--selection-background|--launch-background|--audio)
need_value "$@"
forwarded+=("$1" "$2")
((conversion_inputs += 1))
shift 2
;;
--texconv|--ffmpeg|--at9-tool|--audio-start|--audio-duration)
need_value "$@"
forwarded+=("$1" "$2")
shift 2
;;
--output-directory)
need_value "$@"
output_directory=$2
shift 2
;;
--validate-only)
validate_only=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if ((validate_only)); then
((${#forwarded[@]} == 0)) || {
echo "--validate-only cannot be combined with conversion options" >&2
exit 2
}
exec bash "$root/tools/validate-assets.sh" "$output_directory"
fi
((conversion_inputs > 0)) || {
echo "supply an icon, background, or audio input, or use --validate-only" >&2
exit 2
}
if command -v powershell.exe >/dev/null 2>&1; then
powershell_command=$(command -v powershell.exe)
elif command -v powershell >/dev/null 2>&1; then
powershell_command=$(command -v powershell)
else
echo "conversion requires Windows PowerShell through WSL or Windows Bash" >&2
exit 2
fi
to_windows_path() {
local path=$1
case "$path" in
[A-Za-z]:\\*) printf '%s\n' "$path"; return ;;
esac
if command -v wslpath >/dev/null 2>&1; then
wslpath -w -- "$(realpath -m -- "$path")"
elif command -v cygpath >/dev/null 2>&1; then
cygpath -w -- "$path"
else
echo "cannot translate path for Windows: $path" >&2
exit 2
fi
}
to_windows_command() {
local command=$1
if [[ -e $command || $command == */* ]]; then
to_windows_path "$command"
else
printf '%s\n' "$command"
fi
}
script_path=$(to_windows_path "$root/tools/prepare-assets.ps1")
declare -a powershell_args=(-NoProfile -ExecutionPolicy Bypass -File "$script_path")
for ((index = 0; index < ${#forwarded[@]}; index += 2)); do
option=${forwarded[index]}
value=${forwarded[index + 1]}
case "$option" in
--icon) powershell_args+=(-Icon "$(to_windows_path "$value")") ;;
--background) powershell_args+=(-Background "$(to_windows_path "$value")") ;;
--selection-background) powershell_args+=(-SelectionBackground "$(to_windows_path "$value")") ;;
--launch-background) powershell_args+=(-LaunchBackground "$(to_windows_path "$value")") ;;
--audio) powershell_args+=(-Audio "$(to_windows_path "$value")") ;;
--texconv) powershell_args+=(-Texconv "$(to_windows_command "$value")") ;;
--ffmpeg) powershell_args+=(-Ffmpeg "$(to_windows_command "$value")") ;;
--at9-tool) powershell_args+=(-At9Tool "$(to_windows_command "$value")") ;;
--audio-start) powershell_args+=(-AudioStart "$value") ;;
--audio-duration) powershell_args+=(-AudioDuration "$value") ;;
esac
done
powershell_args+=(-OutputDirectory "$(to_windows_path "$output_directory")")
"$powershell_command" "${powershell_args[@]}"
+126
View File
@@ -0,0 +1,126 @@
<#
ps5-native-app-boilerplate - Clean-room runtime-shim reproducer.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Rebuilds twice with the native C++ tools and installs the shim only after
deterministic hash and attribution checks pass.
#>
#requires -Version 5.1
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$native = Join-Path $root "tooling/native"
$setupNativeDependencies = Join-Path $root "tools/setup-native-dependencies.ps1"
$apiManifest = Join-Path $root "tooling/native/runtime/api-surface.txt"
$importManifest = Join-Path $root "tooling/native/runtime/imports.txt"
$work = Join-Path $root "build/runtime-shim"
$rawA = Join-Path $work "libc-a.raw.elf"
$rawB = Join-Path $work "libc-b.raw.elf"
$signedA = Join-Path $work "libc-a.prx"
$signedB = Join-Path $work "libc-b.prx"
$builder = Join-Path $work "libc-builder"
$tool = Join-Path $work "ps5-native-tool"
$output = Join-Path $root "runtime/libc.prx"
$manifest = Join-Path $root "runtime/libc.prx.sha256"
$expectedRaw = "8EE6E124993E1AF26420CB455890FD002F5D6C7E78883C860CE45734E7D002BB"
$expectedSigned = "E6FF45D16ADF687855CC3B33B0C8A4132B6504360B221E0A34C7E99FB3BA0036"
function Fail([string]$Message) {
throw "ps5-native-app-boilerplate: $Message"
}
function WslPath([string]$Path) {
$absolute = [IO.Path]::GetFullPath($Path)
if ($absolute -notmatch '^([A-Za-z]):\\(.*)$') {
Fail "Path is not on a Windows drive visible to WSL: $absolute"
}
return "/mnt/$($Matches[1].ToLowerInvariant())/$($Matches[2].Replace('\', '/'))"
}
function Invoke-Wsl([string[]]$Arguments) {
& wsl.exe --exec @Arguments
if ($LASTEXITCODE -ne 0) { Fail "Native runtime build failed." }
}
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Fail "WSL was not found."
}
foreach ($required in @($apiManifest, $importManifest,
$manifest,
$setupNativeDependencies,
(Join-Path $native "libc_builder.cpp"),
(Join-Path $native "native_app_builder.cpp"))) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
Fail "Required source file not found: $required"
}
}
$recordedSigned = ((Get-Content -LiteralPath $manifest -Raw).Trim() -split '\s+')[0]
if ($recordedSigned -ne $expectedSigned) {
Fail "The runtime checksum manifest does not match the release digest."
}
New-Item -ItemType Directory -Path $work -Force | Out-Null
$dependencyJson = & $setupNativeDependencies -SkipSdk
$nativeDependencies = ($dependencyJson -join "`n") | ConvertFrom-Json
$zlibInclude = [string]$nativeDependencies.zlibInclude
$zlibArchive = [string]$nativeDependencies.zlibArchive
if (-not $zlibInclude -or -not $zlibArchive) {
Fail "Native dependency bootstrap returned incomplete zlib paths."
}
Invoke-Wsl @("clang++", "-std=c++20", "-O2", "-Wall", "-Wextra", "-Werror",
(WslPath (Join-Path $native "libc_builder.cpp")), "-o", (WslPath $builder))
Invoke-Wsl @("clang++", "-std=c++20", "-O2", "-Wall", "-Wextra", "-Werror",
"-I", $zlibInclude,
(WslPath (Join-Path $native "native_app_builder.cpp")),
(WslPath (Join-Path $native "self_container.cpp")),
(WslPath (Join-Path $native "elf_object.cpp")),
(WslPath (Join-Path $native "sce_module_writer.cpp")),
$zlibArchive, "-o", (WslPath $tool))
Invoke-Wsl @((WslPath $builder), (WslPath $apiManifest),
(WslPath $importManifest), (WslPath $rawA))
Invoke-Wsl @((WslPath $builder), (WslPath $apiManifest),
(WslPath $importManifest), (WslPath $rawB))
$rawHashA = (Get-FileHash -LiteralPath $rawA -Algorithm SHA256).Hash
$rawHashB = (Get-FileHash -LiteralPath $rawB -Algorithm SHA256).Hash
if ($rawHashA -ne $rawHashB -or $rawHashA -ne $expectedRaw) {
Fail "The raw module does not match the deterministic release artifact."
}
Invoke-Wsl @((WslPath $tool), "self", "--sign", "--in", (WslPath $rawA),
"--out", (WslPath $signedA))
Invoke-Wsl @((WslPath $tool), "self", "--sign", "--in", (WslPath $rawB),
"--out", (WslPath $signedB))
$signedHashA = (Get-FileHash -LiteralPath $signedA -Algorithm SHA256).Hash
$signedHashB = (Get-FileHash -LiteralPath $signedB -Algorithm SHA256).Hash
if ($signedHashA -ne $signedHashB -or $signedHashA -ne $expectedSigned) {
Fail "The signed module does not match the deterministic release artifact."
}
if ((Get-Item -LiteralPath $signedA).Length -ne 1284674) {
Fail "The signed module is not 1,284,674 bytes."
}
foreach ($artifact in @($rawA, $signedA)) {
$text = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($artifact))
if (-not $text.Contains("BlackBearReloaded")) {
Fail "Generated output is missing the attribution marker."
}
foreach ($forbidden in @("W:/Build", "J013", "Prospero_Release", "sys/internal")) {
if ($text.Contains($forbidden)) {
Fail "Generated output contains forbidden historical text: $forbidden"
}
}
}
[IO.File]::Copy($signedA, $output, $true)
$installedHash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash
if ($installedHash -ne $expectedSigned) {
Fail "The installed runtime does not match the release digest."
}
Write-Host "Rebuilt deterministic clean-room runtime module with native C++ tools."
Write-Host "Raw SHA-256: $rawHashA"
Write-Host "Signed SHA-256: $signedHashA"
Write-Host "Output: $output"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Linux/WSL clean-room runtime reproducer.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Rebuilds twice and installs only the byte-identical recorded artifact.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
work="$root/build/runtime-shim"
native="$root/tooling/native"
expected_raw="8ee6e124993e1af26420cb455890fd002f5d6c7e78883c860ce45734e7d002bb"
expected_signed="e6ff45d16adf687855cc3b33b0c8a4132b6504360b221e0a34c7e99fb3ba0036"
manifest="$root/runtime/libc.prx.sha256"
grep -Fxq "$expected_signed *libc.prx" "$manifest" || {
echo "runtime checksum manifest does not match the release digest" >&2
exit 2
}
bash "$root/tools/setup-native-dependencies.sh" --skip-sdk >/dev/null
zlib_root="$root/.deps/native/zlib/root"
zlib_archive=$(find "$zlib_root" -type f -name libz.a -print -quit)
cxx=${CXX:-}
if [[ -z $cxx ]]; then
cxx=$(command -v clang++-18 || command -v clang++)
fi
[[ -n $cxx ]] || { echo "Clang++ was not found" >&2; exit 2; }
mkdir -p "$work"
"$cxx" -std=c++20 -O2 -Wall -Wextra -Werror \
"$native/libc_builder.cpp" -o "$work/libc-builder"
"$cxx" -std=c++20 -O2 -Wall -Wextra -Werror \
-I "$zlib_root/usr/include" \
"$native/native_app_builder.cpp" "$native/self_container.cpp" \
"$native/elf_object.cpp" "$native/sce_module_writer.cpp" \
"$zlib_archive" -o "$work/ps5-native-tool"
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"
raw_hash=$(sha256sum "$work/libc-a.raw.elf" | cut -d ' ' -f 1)
[[ $raw_hash == "$expected_raw" ]] || {
echo "raw runtime hash mismatch: $raw_hash" >&2
exit 2
}
for copy in a b; do
"$work/ps5-native-tool" self --sign \
--in "$work/libc-$copy.raw.elf" --out "$work/libc-$copy.prx"
done
cmp --silent "$work/libc-a.prx" "$work/libc-b.prx"
signed_hash=$(sha256sum "$work/libc-a.prx" | cut -d ' ' -f 1)
[[ $signed_hash == "$expected_signed" ]] || {
echo "signed runtime hash mismatch: $signed_hash" >&2
exit 2
}
[[ $(stat -c %s "$work/libc-a.prx") == 1284674 ]] || {
echo "signed runtime size mismatch" >&2
exit 2
}
for artifact in "$work/libc-a.raw.elf" "$work/libc-a.prx"; do
grep -aFq BlackBearReloaded "$artifact" || {
echo "generated runtime is missing its attribution marker" >&2
exit 2
}
for forbidden in W:/Build J013 Prospero_Release sys/internal; do
if grep -aFq "$forbidden" "$artifact"; then
echo "generated runtime contains forbidden text: $forbidden" >&2
exit 2
fi
done
done
cp "$work/libc-a.prx" "$root/runtime/libc.prx"
(cd "$root/runtime" && sha256sum -c libc.prx.sha256)
printf 'Rebuilt clean-room runtime.\nRaw SHA-256: %s\nSigned SHA-256: %s\n' \
"$raw_hash" "$signed_hash"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Clang formatter driver.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Applies the formatting policy shared with the CPython PS5 project.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
formatter=${CLANG_FORMAT:-}
if [[ -z $formatter ]]; then
formatter=$(command -v clang-format-18 || command -v clang-format || true)
fi
[[ -n $formatter ]] || { echo "clang-format is required" >&2; exit 2; }
mapfile -d '' sources < <(find "$root/src" "$root/tooling/native" "$root/tests" -type f \
\( -name '*.c' -o -name '*.cc' -o -name '*.cpp' -o -name '*.h' -o -name '*.hpp' \) \
-print0)
if [[ ${1:-} == --check ]]; then
"$formatter" --dry-run --Werror "${sources[@]}"
elif [[ $# -eq 0 ]]; then
"$formatter" -i "${sources[@]}"
else
echo "usage: tools/run_clang_format.sh [--check]" >&2
exit 2
fi
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Clang static-analysis driver.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Runs the analyzer profile shared with the CPython PS5 project.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
tidy=${CLANG_TIDY:-}
if [[ -z $tidy ]]; then
tidy=$(command -v clang-tidy-18 || command -v clang-tidy || true)
fi
[[ -n $tidy ]] || { echo "clang-tidy is required" >&2; exit 2; }
bash "$root/tools/setup-native-dependencies.sh" >/dev/null
sdk="$root/.deps/native/ps5-payload-sdk"
zlib="$root/.deps/native/zlib/root/usr/include"
mapfile -d '' host_sources < <(find "$root/tooling/native" -maxdepth 1 \
-type f -name '*.cpp' ! -name 'app_crt.cpp' ! -name 'app_cpp_runtime.cpp' -print0)
"$tidy" "${host_sources[@]}" --quiet --warnings-as-errors='*' -- \
-std=c++20 -I"$zlib"
gtest=$(bash "$root/tools/setup-test-dependencies.sh")
mapfile -d '' test_sources < <(find "$root/tests" -maxdepth 1 -type f -name '*.cpp' -print0)
if (( ${#test_sources[@]} )); then
"$tidy" "${test_sources[@]}" --quiet --warnings-as-errors='*' -- \
-std=c++20 -I"$root/src" -isystem "$gtest/googletest/include"
fi
mapfile -d '' app_c_sources < <(find "$root/src" -type f -name '*.c' -print0)
if (( ${#app_c_sources[@]} )); then
"$tidy" "${app_c_sources[@]}" --quiet --warnings-as-errors='*' -- \
-std=c11 -isystem "$sdk/target/include"
fi
mapfile -d '' app_cpp_sources < <(find "$root/src" -type f \
\( -name '*.cc' -o -name '*.cpp' \) -print0)
app_cpp_sources+=("$root/tooling/native/app_crt.cpp" "$root/tooling/native/app_cpp_runtime.cpp")
if (( ${#app_cpp_sources[@]} )); then
"$tidy" "${app_cpp_sources[@]}" --quiet --warnings-as-errors='*' -- \
-std=c++20 -fno-exceptions -fno-rtti --target=x86_64-sie-ps5 \
-isystem "$sdk/target/include/c++/v1" -isystem "$sdk/target/include"
fi
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# PS5 RetroArch - build this project's title-folder scaffold.
#
# This project is the native pipeline's scaffolding plus RetroArch's sources, so
# that RetroArch is *compiled for* the title pipeline instead of converted into it
# afterwards (docs/PHASE_LOG.md, "The route changes").
#
# tools/scaffold-native.sh create or refresh the scaffold
# tools/scaffold-native.sh --check report what is where, change nothing
#
# What is taken from the pipeline (../ps5-native-app-boilerplate-main):
# tooling/ the native converter, the FSELF writer, the CRT and the PIE layout
# runtime/ the loader-visible compatibility module, with its digest manifest
# Makefile the entry point
# tools/ its build, deploy, packaging and asset scripts — merged into this
# repository's tools/, never replacing it
#
# What is this repository's own:
# tools/ the project's own scripts, which live beside the pipeline's
# sce_sys/ the title's identity and launcher assets
# src/ the integration: the entry point and the driver
# vendor/ RetroArch's sources, fetched and never edited
#
# A note on tools/. An earlier version of this script replaced tools/ wholesale
# and deleted this project's own scripts. It now copies the pipeline's scripts in
# without overwriting anything, so re-running is safe.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
parent="$root/.."
donor="$parent/ps5-native-app-boilerplate-main"
mode=${1:-scaffold}
say() { printf '==> [scaffold] %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
[[ -d $donor ]] || die "the native pipeline is missing: $donor"
if [[ $mode == --check ]]; then
for item in tooling runtime Makefile sce_sys src vendor; do
[[ -e $root/$item ]] && printf ' %-10s present\n' "$item" || printf ' %-10s MISSING\n' "$item"
done
printf ' %-10s %s scripts\n' tools "$(ls "$root/tools" | wc -l)"
exit 0
fi
say "copying the pipeline's scaffolding from ${donor##*/}"
for item in tooling runtime Makefile; do
rm -rf -- "$root/$item"
cp -a -- "$donor/$item" "$root/$item"
done
mkdir -p -- "$root/tools"
cp -an -- "$donor/tools/." "$root/tools/" # -n: never clobber this project's scripts
mkdir -p -- "$root/sce_sys" "$root/src"
if [[ -f $root/runtime/libc.prx ]]; then
(cd "$root/runtime" && sha256sum -c libc.prx.sha256 >/dev/null 2>&1) \
&& say "runtime verified against its manifest" \
|| die "the copied runtime does not match runtime/libc.prx.sha256"
else
die "no runtime/libc.prx was copied; run the donor's own make libc first"
fi
cat > "$root/sce_sys/param.json" <<'JSON'
{
"ageLevel": {"default": 0},
"applicationCategoryType": 0,
"applicationDrmType": "free",
"attribute": 0,
"attribute2": 0,
"attribute3": 0,
"conceptId": "99169",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA99169_00-RETROARCH0000001",
"contentVersion": "01.000.000",
"downloadDataSize": 256,
"gameIntent": {"permittedIntents": [{"intentType": "launchActivity"}]},
"localizedParameters": {
"defaultLanguage": "en-US",
"en-US": {"titleName": "PS5 RetroArch"}
},
"masterVersion": "01.00",
"pubtools": {
"creationDate": "2026-09-18 00:00:00",
"loudnessSnd0": "-28.00",
"toolVersion": "2.00"
},
"requiredSystemSoftwareVersion": "0x0000000000000000",
"sdkVersion": "0x0000000000000000",
"titleId": "PPSA99169",
"versionFileUri": ""
}
JSON
say "wrote sce_sys/param.json for PPSA99169"
[[ -f $root/title/assets/retroarch.png ]] \
|| die "the launcher icon source is missing: title/assets/retroarch.png"
cp -a -- "$root/title/assets/retroarch.png" "$root/sce_sys/icon0.png"
for asset in pic0.dds pic1.dds snd0.at9; do
[[ -f $donor/sce_sys/$asset ]] && cp -a -- "$donor/sce_sys/$asset" "$root/sce_sys/$asset"
done
say "sce_sys holds param.json, the icon and the presentation assets"
[[ -d $root/vendor/retroarch/.git ]] || die \
"vendor/retroarch is not a checkout; run tools/fetch-retroarch.sh first"
ln -sfn ../vendor/retroarch "$root/src/retroarch"
say "src/retroarch -> vendor/retroarch"
say "scaffold complete"
+43
View File
@@ -0,0 +1,43 @@
<#
ps5-native-app-boilerplate - Optional UFS2Tool bootstrapper.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Delegates to the shared WSL bootstrapper, which fetches and builds a pinned
UFS2Tool checkout inside the ignored dependency cache.
#>
#requires -Version 5.1
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$setup = Join-Path $root "tools/setup-packaging-dependencies.sh"
function Fail([string]$Message) {
throw "ps5-native-app-boilerplate: $Message"
}
function WslPath([string]$Path) {
$absolute = [IO.Path]::GetFullPath($Path)
if ($absolute -notmatch '^([A-Za-z]):\\(.*)$') {
Fail "Path is not on a Windows drive visible to WSL: $absolute"
}
return "/mnt/$($Matches[1].ToLowerInvariant())/$($Matches[2].Replace('\', '/'))"
}
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Fail "WSL was not found."
}
if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) {
Fail "Shared packaging bootstrapper was not found: $setup"
}
$binary = & wsl.exe --exec bash (WslPath $setup) ffpkg
if ($LASTEXITCODE -ne 0) {
Fail "Unable to fetch and build UFS2Tool."
}
if ($binary -is [array]) { $binary = $binary[-1] }
if (-not $binary) {
Fail "UFS2Tool bootstrapper did not return its runner path."
}
Write-Output $binary.Trim()
+106
View File
@@ -0,0 +1,106 @@
<#
ps5-native-app-boilerplate - Optional MkPFS packaging bootstrapper.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Fetches a pinned MkPFS checkout and installs it in an ignored virtual env.
#>
#requires -Version 5.1
param(
[string]$Python = ""
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$dependencyRoot = Join-Path $root ".deps"
$checkout = Join-Path $dependencyRoot "MkPFS"
$repository = "https://github.com/PSBrew/MkPFS.git"
$revision = "6cb8313dfe0c988ac52617794553f343243d3a56"
function Fail([string]$Message) {
throw "ps5-native-app-boilerplate: $Message"
}
function Invoke-Git([string[]]$Arguments) {
& git @Arguments
if ($LASTEXITCODE -ne 0) {
Fail "Git command failed: git $($Arguments -join ' ')"
}
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Fail "Git was not found. Install Git for Windows and retry."
}
if (-not $Python) {
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
} else {
$pythonCommand = Get-Command $Python -ErrorAction SilentlyContinue
}
if (-not $pythonCommand) {
Fail "Python 3.9 or newer was not found. Install Python or pass -Python C:\path\to\python.exe."
}
$Python = $pythonCommand.Source
$pythonVersion = (& $Python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')").Trim()
if ($LASTEXITCODE -ne 0 -or [version]$pythonVersion -lt [version]"3.9") {
Fail "MkPFS requires Python 3.9 or newer; found $pythonVersion."
}
if (-not (Test-Path -LiteralPath $checkout)) {
New-Item -ItemType Directory -Path $dependencyRoot -Force | Out-Null
New-Item -ItemType Directory -Path $checkout | Out-Null
Invoke-Git @("-C", $checkout, "init", "--quiet")
Invoke-Git @("-C", $checkout, "config", "core.autocrlf", "false")
Invoke-Git @("-C", $checkout, "remote", "add", "origin", $repository)
Invoke-Git @("-C", $checkout, "fetch", "--quiet", "--depth", "1", "origin", $revision)
Invoke-Git @("-C", $checkout, "checkout", "--quiet", "--detach", "FETCH_HEAD")
} elseif (-not (Test-Path -LiteralPath (Join-Path $checkout ".git") -PathType Container)) {
Fail "$checkout exists but is not a managed Git checkout."
}
$actualRevision = (& git -C $checkout rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $actualRevision -ne $revision) {
Fail "MkPFS cache is at $actualRevision; expected $revision. Remove .deps/MkPFS and retry."
}
& git -C $checkout diff --quiet
if ($LASTEXITCODE -ne 0) {
Fail "MkPFS cache has local changes. Remove .deps/MkPFS and retry."
}
foreach ($required in @("pyproject.toml", "LICENSE", "mkpfs/__main__.py")) {
$requiredPath = Join-Path $checkout $required
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
Fail "Required MkPFS file not found: $requiredPath"
}
}
$venv = Join-Path $checkout ".venv"
$venvPython = Join-Path $venv "Scripts/python.exe"
$stamp = Join-Path $venv ".boilerplate-revision"
if (-not (Test-Path -LiteralPath $venvPython -PathType Leaf)) {
& $Python -m venv $venv
if ($LASTEXITCODE -ne 0) {
Fail "Unable to create the MkPFS virtual environment."
}
}
$installedRevision = if (Test-Path -LiteralPath $stamp -PathType Leaf) {
(Get-Content -LiteralPath $stamp -Raw).Trim()
} else {
""
}
if ($installedRevision -ne $revision) {
& $venvPython -m pip install --disable-pip-version-check $checkout | Out-Host
if ($LASTEXITCODE -ne 0) {
Fail "MkPFS dependency installation failed."
}
Set-Content -LiteralPath $stamp -Value $revision -Encoding ascii
}
& $venvPython -m mkpfs --help *> $null
if ($LASTEXITCODE -ne 0) {
Fail "The cached MkPFS command failed its startup check."
}
Write-Host "MkPFS ready at revision $revision."
Write-Output $venvPython
+63
View File
@@ -0,0 +1,63 @@
<#
ps5-native-app-boilerplate - Native dependency bootstrapper.
Copyright (C) 2026 BlackBearReloaded
SPDX-License-Identifier: GPL-3.0-or-later
Runs the canonical Linux/WSL dependency bootstrap and returns its paths as
JSON for PowerShell callers.
#>
#requires -Version 5.1
param(
[switch]$SkipSdk
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
function Fail([string]$Message) {
throw "ps5-native-app-boilerplate: $Message"
}
function Convert-ToWslPath([string]$Path) {
$absolute = [IO.Path]::GetFullPath($Path)
if ($absolute -notmatch '^([A-Za-z]):\\(.*)$') {
Fail "The repository must be on a Windows drive visible to WSL."
}
return "/mnt/$($Matches[1].ToLowerInvariant())/$($Matches[2].Replace('\', '/'))"
}
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Fail "WSL was not found."
}
$script = Convert-ToWslPath (Join-Path $root "tools/setup-native-dependencies.sh")
$arguments = @("--exec", "bash", $script)
if ($SkipSdk) {
$arguments += "--skip-sdk"
}
$lines = @(& wsl.exe @arguments)
if ($LASTEXITCODE -ne 0) {
Fail "Native dependency bootstrap failed."
}
$values = @{}
foreach ($line in $lines) {
if ($line -match '^([A-Z_]+)=(.*)$') {
$values[$Matches[1]] = $Matches[2]
}
}
foreach ($name in @("ZLIB_INCLUDE", "ZLIB_ARCHIVE")) {
if (-not $values.ContainsKey($name)) {
Fail "Native dependency bootstrap did not report $name."
}
}
if (-not $SkipSdk -and -not $values.ContainsKey("SDK_ROOT")) {
Fail "Native dependency bootstrap did not report SDK_ROOT."
}
[pscustomobject]@{
sdkRoot = if ($SkipSdk) { "" } else { $values["SDK_ROOT"] }
zlibInclude = $values["ZLIB_INCLUDE"]
zlibArchive = $values["ZLIB_ARCHIVE"]
} | ConvertTo-Json -Compress
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Linux/WSL native dependency bootstrapper.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Fetches the public PS5 payload SDK and static zlib into the ignored cache.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cache="$root/.deps/native"
sdk="$cache/ps5-payload-sdk"
zlib_directory="$cache/zlib"
zlib_root="$zlib_directory/root"
zlib_version="1.3.2"
zlib_source="$zlib_directory/zlib-$zlib_version"
zlib_archive="$zlib_directory/zlib-$zlib_version.tar.gz"
zlib_stamp="$zlib_root/.source-version"
sdk_url="https://github.com/ps5-payload-dev/sdk/releases/download/v0.42/ps5-payload-sdk.zip"
sdk_hash="8cfbc7cd5811e719eb4f0c47eea668d3dc7b40bc8ab11c4a5031d40c23ec02da"
zlib_url="https://zlib.net/fossils/zlib-$zlib_version.tar.gz"
zlib_hash="bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16"
skip_sdk=false
if [[ ${1:-} == "--skip-sdk" ]]; then
skip_sdk=true
elif [[ $# -ne 0 ]]; then
echo "usage: tools/setup-native-dependencies.sh [--skip-sdk]" >&2
exit 2
fi
for command in wget unzip sha256sum tar make; do
command -v "$command" >/dev/null || {
echo "missing required command: $command" >&2
exit 2
}
done
compiler=$(command -v clang-18 || command -v clang || true)
archiver=$(command -v llvm-ar-18 || command -v llvm-ar || command -v ar || true)
ranlib=$(command -v llvm-ranlib-18 || command -v llvm-ranlib || command -v ranlib || true)
[[ -n $compiler && -n $archiver && -n $ranlib ]] || {
echo "clang, an archive tool, and ranlib are required to build zlib" >&2
exit 2
}
mkdir -p "$cache"
if ! $skip_sdk && [[ ! -x "$sdk/bin/prospero-lld" ]]; then
archive="$cache/ps5-payload-sdk.zip"
temporary="$archive.download"
wget -q "$sdk_url" -O "$temporary"
printf '%s %s\n' "$sdk_hash" "$temporary" | sha256sum --check --strict
mv "$temporary" "$archive"
unzip -q -o "$archive" -d "$cache"
fi
if ! $skip_sdk && [[ ! -x "$sdk/bin/prospero-lld" || ! -d "$sdk/target/include" ]]; then
echo "the pinned PS5 payload SDK is incomplete" >&2
exit 2
fi
zlib_library=$(find "$zlib_root" -type f -name libz.a -print -quit 2>/dev/null || true)
if [[ -z $zlib_library || ! -f $zlib_root/usr/include/zlib.h ||
! -f $zlib_stamp || $(<"$zlib_stamp") != "$zlib_version" ]]; then
echo "==> [deps] Building pinned zlib $zlib_version from source" >&2
mkdir -p "$zlib_directory"
if [[ -f $zlib_archive ]] &&
! printf '%s %s\n' "$zlib_hash" "$zlib_archive" |
sha256sum --check --strict >/dev/null 2>&1; then
rm -f -- "$zlib_archive"
fi
if [[ ! -f $zlib_archive ]]; then
wget -q "$zlib_url" -O "$zlib_archive.download"
mv "$zlib_archive.download" "$zlib_archive"
fi
printf '%s %s\n' "$zlib_hash" "$zlib_archive" | sha256sum --check --strict >/dev/null
rm -rf -- "$zlib_source" "$zlib_root"
tar -xzf "$zlib_archive" -C "$zlib_directory"
mkdir -p "$zlib_root"
jobs=${BUILD_JOBS:-$(nproc 2>/dev/null || printf '2')}
(
cd "$zlib_source"
CC="$compiler" AR="$archiver" RANLIB="$ranlib" ./configure --static --prefix=/usr
make -j "$jobs" CC="$compiler" AR="$archiver" RANLIB="$ranlib"
make DESTDIR="$zlib_root" install
) >"$zlib_directory/build.log"
printf '%s\n' "$zlib_version" >"$zlib_stamp"
zlib_library=$(find "$zlib_root" -type f -name libz.a -print -quit 2>/dev/null || true)
fi
if [[ -z $zlib_library ]]; then
echo "the pinned native zlib archive was not found after compilation" >&2
exit 2
fi
printf 'SDK_ROOT=%s\nZLIB_INCLUDE=%s\nZLIB_ARCHIVE=%s\n' \
"$sdk" "$zlib_root/usr/include" "$zlib_library"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - PacBrew dependency bootstrapper.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Installs the pinned PacBrew ports prefix into the ignored repository cache
# and resolves selected pkg-config modules without modifying the host SDK.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cache="$root/.deps/pacbrew"
version=v0.40.2
archive="$cache/ps5-payload-dev-$version.tar.gz"
release="$cache/$version"
sysroot="$release/sysroot"
url="https://github.com/ps5-payload-dev/pacbrew-repo/releases/download/$version/ps5-payload-dev.tar.gz"
hash=a85f65de418a8e6a898c6c3e3c870d50fff7618a200e4dd59ea9692af6ecec4d
mode=${1:-}
[[ -n $mode ]] && shift
case "$mode" in
--all|--list|--environment|--resolve) ;;
*)
echo "usage: tools/setup-pacbrew-dependencies.sh --all|--list|--environment|--resolve MODULE..." >&2
exit 2
;;
esac
command -v python3 >/dev/null || {
echo "missing required command for PacBrew: python3" >&2
exit 2
}
modules=("$@")
if [[ $mode == --environment ]]; then
modules=()
if [[ -n ${PACBREW_PACKAGES:-} ]]; then
read -r -a modules <<< "$PACBREW_PACKAGES"
fi
if [[ -z ${PACBREW_PACKAGES:-}${PACBREW_INCLUDE_PATHS:-}${PACBREW_STATIC_ARCHIVES:-} ]]; then
echo "==> [pacbrew] No dependencies selected through Make variables" >&2
exit 0
fi
fi
for command in pkg-config sha256sum tar wget; do
command -v "$command" >/dev/null || {
echo "missing required command for PacBrew: $command" >&2
exit 2
}
done
for module in "${modules[@]}"; do
[[ $module =~ ^[A-Za-z0-9_.+-]+$ ]] || {
echo "invalid PacBrew pkg-config module: $module" >&2
exit 2
}
done
mkdir -p "$cache"
marker="$release/.complete"
expected_marker="$version $hash"
actual_marker=""
[[ ! -f $marker ]] || actual_marker=$(<"$marker")
if [[ $actual_marker != "$expected_marker" || ! -d $sysroot/user/homebrew/lib ]]; then
if [[ ! -f $archive ]] || ! printf '%s %s\n' "$hash" "$archive" | sha256sum --check --strict >/dev/null 2>&1; then
temporary="$archive.download"
echo "==> [pacbrew] Downloading $version prebuilt ports (about 346 MB)" >&2
wget -q "$url" -O "$temporary"
printf '%s %s\n' "$hash" "$temporary" | sha256sum --check --strict >&2
mv "$temporary" "$archive"
fi
temporary_root=$(mktemp -d "$cache/.extract.XXXXXX")
trap 'rm -rf -- "$temporary_root"' EXIT
mkdir -p "$temporary_root/sysroot"
echo "==> [pacbrew] Extracting the isolated ports prefix" >&2
tar -xzf "$archive" -C "$temporary_root/sysroot" --strip-components=3 \
opt/ps5-payload-sdk/target/user/homebrew
[[ -d $temporary_root/sysroot/user/homebrew/include &&
-d $temporary_root/sysroot/user/homebrew/lib ]] || {
echo "PacBrew release has an unexpected sysroot layout" >&2
exit 2
}
printf '%s\n' "$expected_marker" > "$temporary_root/.complete"
[[ $release == "$cache/$version" ]] || exit 2
rm -rf -- "$release"
mv "$temporary_root" "$release"
trap - EXIT
fi
export PKG_CONFIG_DIR=
export PKG_CONFIG_SYSROOT_DIR="$sysroot"
export PKG_CONFIG_LIBDIR="$sysroot/user/homebrew/lib/pkgconfig"
export PKG_CONFIG_PATH="$sysroot/user/homebrew/libdata/pkgconfig"
case "$mode" in
--all)
printf '%s\n' "$sysroot"
;;
--list)
pkg-config --list-all | LC_ALL=C sort
;;
--environment)
if (( ${#modules[@]} > 0 )); then
pkg-config --static --exists "${modules[@]}" || {
echo "PacBrew does not provide every selected pkg-config module: ${modules[*]}" >&2
exit 2
}
fi
echo "==> [pacbrew] Declared dependencies are cached" >&2
;;
--resolve)
python3 - "$sysroot" "${modules[@]}" <<'PY'
import json, os, shlex, subprocess, sys
sysroot, *modules = sys.argv[1:]
try:
if modules:
subprocess.run(["pkg-config", "--static", "--exists", *modules], check=True)
cflags = shlex.split(subprocess.check_output(
["pkg-config", "--static", "--cflags", *modules], text=True))
raw_libs = shlex.split(subprocess.check_output(
["pkg-config", "--static", "--libs", *modules], text=True))
else:
cflags, raw_libs = [], []
except subprocess.CalledProcessError:
raise SystemExit(f"PacBrew does not provide every selected pkg-config module: {' '.join(modules)}")
libs = []
for flag in raw_libs:
if flag == "-pthread":
if flag not in cflags:
cflags.append(flag)
elif flag.startswith("-Wl,"):
libs.extend(part for part in flag[4:].split(",") if part)
else:
libs.append(flag)
print(json.dumps({"version": "v0.40.2", "root": sysroot,
"packages": modules, "cflags": cflags, "libs": libs}))
PY
;;
esac
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Linux/WSL package-tool bootstrapper.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Fetches UFS2Tool or MkPFS into the ignored cache without a global install.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
kind=${1:-}
case "$kind" in
ffpkg)
for command in dotnet git; do
command -v "$command" >/dev/null || {
echo "missing required command: $command" >&2
exit 2
}
done
dotnet_major=$(dotnet --version | cut -d. -f1)
[[ $dotnet_major =~ ^[0-9]+$ && $dotnet_major -ge 8 ]] || {
echo "UFS2Tool requires the .NET SDK 8 or newer" >&2
exit 2
}
checkout="$root/.deps/UFS2Tool"
revision="b5307a60d5b4e3a68ba680e0e33cfadf05017c77"
if [[ ! -d $checkout/.git ]]; then
mkdir -p "$checkout"
git -C "$checkout" init --quiet
git -C "$checkout" remote add origin https://github.com/SvenGDK/UFS2Tool.git
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
git -C "$checkout" checkout --quiet --detach FETCH_HEAD
fi
actual=$(git -C "$checkout" rev-parse HEAD)
[[ $actual == "$revision" ]] || {
echo "UFS2Tool cache is at $actual; expected $revision" >&2
exit 2
}
git -C "$checkout" diff --quiet || {
echo "UFS2Tool cache has local changes" >&2
exit 2
}
output="$checkout/.build-linux"
binary="$output/UFS2Tool.dll"
stamp="$output/.boilerplate-revision"
if [[ ! -f $binary || ! -f $stamp || $(<"$stamp") != "$revision" ]]; then
dotnet build "$checkout/UFS2Tool.csproj" --configuration Release \
--output "$output" --nologo --verbosity quiet >&2
printf '%s\n' "$revision" > "$stamp"
fi
runner="$checkout/.ufs2tool-run-linux"
printf '#!/bin/sh\nDOTNET_ROLL_FORWARD=Major exec dotnet "%s" "$@"\n' \
"$binary" > "$runner"
chmod +x "$runner"
"$runner" --help >/dev/null || [[ $? -eq 1 ]]
printf '%s\n' "$runner"
;;
ffpfsc)
for command in git python3; do
command -v "$command" >/dev/null || {
echo "missing required command: $command" >&2
exit 2
}
done
checkout="$root/.deps/MkPFS"
revision="6cb8313dfe0c988ac52617794553f343243d3a56"
if [[ ! -d $checkout/.git ]]; then
mkdir -p "$checkout"
git -C "$checkout" init --quiet
git -C "$checkout" remote add origin https://github.com/PSBrew/MkPFS.git
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
git -C "$checkout" checkout --quiet --detach FETCH_HEAD
fi
actual=$(git -C "$checkout" rev-parse HEAD)
[[ $actual == "$revision" ]] || {
echo "MkPFS cache is at $actual; expected $revision" >&2
exit 2
}
git -C "$checkout" diff --quiet || {
echo "MkPFS cache has local changes" >&2
exit 2
}
venv="$checkout/.venv-linux"
python="$venv/bin/python"
stamp="$venv/.boilerplate-revision"
if [[ ! -x $python ]]; then
rm -rf -- "$venv"
python3 -m venv "$venv" || {
echo "Python venv support is required (install python3-venv)" >&2
exit 2
}
fi
if ! "$python" -m pip --version >/dev/null 2>&1; then
python3 -m pip --python "$python" install \
--disable-pip-version-check --quiet pip >&2 || {
echo "Unable to seed pip in the MkPFS virtual environment" >&2
exit 2
}
fi
if [[ ! -f $stamp || $(<"$stamp") != "$revision" ]]; then
"$python" -m pip install --disable-pip-version-check --quiet \
"$checkout" >&2
printf '%s\n' "$revision" > "$stamp"
fi
runner="$checkout/.mkpfs-run-linux"
printf '#!/bin/sh\nexec "%s" -m mkpfs "$@"\n' "$python" > "$runner"
chmod +x "$runner"
"$runner" --help >/dev/null
printf '%s\n' "$runner"
;;
*)
echo "usage: tools/setup-packaging-dependencies.sh <ffpkg|ffpfsc>" >&2
exit 2
;;
esac
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Host test dependency bootstrapper.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Fetches a verified GoogleTest source release into the ignored dependency cache.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
version="1.17.0"
directory="$root/.deps/test/googletest"
source="$directory/googletest-$version"
archive="$directory/googletest-$version.tar.gz"
stamp="$directory/.source-sha256"
url="https://github.com/google/googletest/archive/refs/tags/v$version.tar.gz"
hash="65fab701d9829d38cb77c14acdc431d2108bfdbf8979e40eb8ae567edf10b27c"
for command in wget sha256sum tar; do
command -v "$command" >/dev/null || {
echo "missing required command: $command" >&2
exit 2
}
done
mkdir -p "$directory"
if [[ -f $archive ]] &&
! printf '%s %s\n' "$hash" "$archive" | sha256sum --check --strict >/dev/null 2>&1; then
rm -f -- "$archive"
fi
if [[ ! -f $archive ]]; then
temporary="$archive.download"
rm -f -- "$temporary"
wget -q "$url" -O "$temporary"
printf '%s %s\n' "$hash" "$temporary" | sha256sum --check --strict >/dev/null
mv "$temporary" "$archive"
fi
if [[ ! -f $source/googletest/include/gtest/gtest.h ||
! -f $stamp || $(<"$stamp") != "$hash" ]]; then
echo "==> [test-deps] Extracting GoogleTest $version" >&2
rm -rf -- "$source"
tar -xzf "$archive" -C "$directory"
printf '%s\n' "$hash" >"$stamp"
fi
[[ -f $source/googletest/src/gtest-all.cc &&
-f $source/googletest/src/gtest_main.cc ]] || {
echo "the pinned GoogleTest source tree is incomplete" >&2
exit 2
}
printf '%s\n' "$source"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# ps5-native-app-boilerplate - Portable presentation asset validation.
# Copyright (C) 2026 BlackBearReloaded
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Validates the launcher icon, optional BC7 backgrounds, and optional ATRAC9
# selection audio without requiring Windows conversion tools.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
asset_directory=${1:-"$root/sce_sys"}
[[ -d $asset_directory ]] || {
echo "presentation asset directory not found: $asset_directory" >&2
exit 2
}
command -v python3 >/dev/null || {
echo "missing required command: python3" >&2
exit 2
}
python3 - "$asset_directory" <<'PY'
from pathlib import Path
import struct
import sys
root = Path(sys.argv[1])
icon_path = root / "icon0.png"
if not icon_path.is_file():
raise SystemExit(f"required launcher icon not found: {icon_path}")
icon = icon_path.read_bytes()
if (len(icon) < 24 or icon[:8] != b"\x89PNG\r\n\x1a\n"
or struct.unpack(">II", icon[16:24]) != (512, 512)):
raise SystemExit(f"{icon_path} must be a 512x512 PNG")
pics = [root / "pic0.dds", root / "pic1.dds"]
if pics[0].exists() != pics[1].exists():
raise SystemExit("supply both pic0.dds and pic1.dds, or neither")
for path in pics if pics[0].exists() else []:
data = path.read_bytes()
if len(data) < 148 or data[:4] != b"DDS ":
raise SystemExit(f"{path} is not a DDS file")
header_size = struct.unpack_from("<I", data, 4)[0]
height, width = struct.unpack_from("<II", data, 12)
mipmaps = struct.unpack_from("<I", data, 28)[0]
format_id, dimension = struct.unpack_from("<II", data, 128)
array_size = struct.unpack_from("<I", data, 140)[0]
expected = 148 + width // 4 * (height // 4) * 16
profile = (header_size, width, height, mipmaps, data[84:88], format_id,
dimension, array_size, len(data))
expected_profile = (124, 3840, 2160, 1, b"DX10", 98, 3, 1, expected)
if profile != expected_profile:
raise SystemExit(
f"{path} must be a single 3840x2160 BC7_UNORM DX10 2D DDS without mipmaps"
)
sound = root / "snd0.at9"
if sound.exists():
data = sound.read_bytes()
if (len(data) < 128 or data[:4] != b"RIFF" or data[8:12] != b"WAVE"
or struct.unpack_from("<I", data, 4)[0] + 8 != len(data)
or len(data) > 2_097_152):
raise SystemExit(f"{sound} is not a complete supported RIFF/WAVE file")
chunks = {}
position = 12
while position + 8 <= len(data):
chunk_id = data[position:position + 4]
size = struct.unpack_from("<I", data, position + 4)[0]
payload = position + 8
if payload + size > len(data):
raise SystemExit(f"{sound} contains a truncated RIFF chunk")
chunks[chunk_id] = (payload, size)
position = payload + size + size % 2
required = (b"fmt ", b"fact", b"smpl", b"data")
if not all(chunk in chunks for chunk in required):
raise SystemExit(f"{sound} is missing required ATRAC9 RIFF chunks")
fmt = chunks[b"fmt "][0]
smpl = chunks[b"smpl"][0]
if chunks[b"fmt "][1] < 40 or chunks[b"smpl"][1] < 32:
raise SystemExit(f"{sound} contains undersized format or loop metadata")
channels = struct.unpack_from("<H", data, fmt + 2)[0]
byte_rate = struct.unpack_from("<I", data, fmt + 8)[0]
atrac9_guid = bytes.fromhex("D242E147BA368D4D88FC61654F8C836C")
if (struct.unpack_from("<H", data, fmt)[0] != 0xFFFE
or channels not in (1, 2)
or struct.unpack_from("<I", data, fmt + 4)[0] != 48_000
or not 0 < byte_rate <= channels * 12_000
or data[fmt + 24:fmt + 40] != atrac9_guid
or struct.unpack_from("<I", data, smpl + 28)[0] < 1):
raise SystemExit(
f"{sound} must be looped 48 kHz ATRAC9 within the supported bitrate"
)
print(f"Presentation assets validated: {root}")
PY