Merge pull request #5114 from boykopovar/feat/web-relinker-ui

feat(pages): add web relinker ui
This commit is contained in:
boykopovar 2026-10-13 15:16:10 +03:00 committed by GitHub
commit 8485298e46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1113 additions and 1 deletions

View File

@ -10,6 +10,19 @@ endfunction()
option(BUILD_TESTING "Build and register relinker tests" OFF)
option(ANYPS5_RELINKER_ONLY "Build the relinker without system libraries or third-party dependencies" OFF)
option(ANYPS5_BUILD_WASM "Build the browser relinker" OFF)
if(ANYPS5_BUILD_WASM)
if(NOT EMSCRIPTEN)
message(FATAL_ERROR "ANYPS5_BUILD_WASM requires emcmake and Emscripten")
endif()
if(BUILD_TESTING)
message(FATAL_ERROR "Use the native build for C++ tests and the browser tests for WASM")
endif()
set(ANYPS5_RELINKER_CLI OFF CACHE BOOL "Build the native relinker command line" FORCE)
add_subdirectory(core/relinker)
return()
endif()
if(BUILD_TESTING)
enable_testing()
if(MINGW)

View File

@ -2,6 +2,10 @@
Tool for automatic executables porting to Linux and Windows.
[**Online converter**](https://boykopovar.github.io/AnyPS5/) - files stay on your device.
[Discord](https://discord.gg/BHFztBPUe)
Includes a [relinker](core/relinker) that converts executable to the target system's native format and implementations of [system prx libraries](core/libs/prx) suitable for dynamic linking. No emulation or separate runtime process.
[Usage](docs/user/USAGE.md), [Build instructions](docs/dev/BUILD.md), [Architecture](docs/dev/ARCHITECTURE.md), [Technical debt of the project](docs/dev/TechnicalDebt.md), [code style conventions](docs/dev/CONVENTIONS.md), [contributing](CONTRIBUTING.md)
@ -12,7 +16,7 @@ Includes a [relinker](core/relinker) that converts executable to the target syst
[![progress map](https://boykopovar.github.io/AnyPS5/progress.svg)](https://boykopovar.github.io/AnyPS5/)
[![progress history](https://boykopovar.github.io/AnyPS5/history.svg)](https://boykopovar.github.io/AnyPS5/history.html)
[Progress history](https://boykopovar.github.io/AnyPS5/history.html)
<sub>* System libraries: percentage of the functions known to the project so far (declared in [core/libs/prx](core/libs/prx)), not of every PS5 system function. The total grows as more functions are declared.</sub>

View File

@ -87,6 +87,11 @@ if(BUILD_TESTING)
set_tests_properties(relinker_conversion PROPERTIES TIMEOUT 20)
endif()
if(ANYPS5_BUILD_WASM)
target_compile_options(relinker_core PRIVATE -fexceptions)
add_subdirectory(wasm)
endif()
if(NOT ANYPS5_RELINKER_CLI)
return()
endif()

View File

@ -0,0 +1,99 @@
#include <relinker/pipeline/Conversion.hpp>
#include <io/NativePath.hpp>
#include <emscripten/bind.h>
#include <stdexcept>
namespace {
class BrowserProgress final : public Domain::IProgress {
public:
explicit BrowserProgress(emscripten::val callback) : callback(std::move(callback)) {}
void Report(const Domain::ProgressEvent& event) override {
auto report = emscripten::val::object();
report.set("stage", static_cast<unsigned>(event.Stage));
report.set("input", event.Input);
report.set("completed", static_cast<double>(event.Completed));
report.set("total", static_cast<double>(event.Total));
callback(report);
}
void Message(std::string_view message) override {
auto report = emscripten::val::object();
report.set("message", std::string(message));
callback(report);
}
private:
emscripten::val callback;
};
std::vector<std::uint8_t> ReadBytes(const emscripten::val& bytes, std::size_t& remaining) {
if (!bytes.instanceof(emscripten::val::global("Uint8Array"))) throw std::invalid_argument("Expected Uint8Array input");
const auto length = bytes["length"].as<std::size_t>();
if (length > remaining) throw std::invalid_argument("Executable, modules and icon exceed the 256 MiB conversion limit");
remaining -= length;
std::vector<std::uint8_t> result(length);
if (length != 0) emscripten::val(emscripten::typed_memory_view(length, result.data())).call<void>("set", bytes);
return result;
}
emscripten::val CopyBytes(const std::vector<std::uint8_t>& bytes) {
return emscripten::val::global("Uint8Array").new_(emscripten::val(emscripten::typed_memory_view(bytes.size(), bytes.data())));
}
emscripten::val Convert(const emscripten::val& request, const emscripten::val& callback) {
auto result = emscripten::val::object();
try {
const auto target = request["target"].as<std::string>();
if (target != "windows" && target != "linux") throw std::invalid_argument("Unknown target system");
const auto name = request["name"].as<std::string>();
if (name.empty() || name.find_first_of("/\\") != std::string::npos || name == "." || name == "..") throw std::invalid_argument("Invalid executable name");
std::size_t remaining = 256 * 1024 * 1024;
Relinker::ConversionInput input;
input.InputPath = Io::NativePath("/input/" + name);
input.OutputPath = Io::NativePath("/output/" + request["outputName"].as<std::string>());
if (input.OutputPath.parent_path() != "/output") throw std::invalid_argument("Invalid output name");
input.ModuleRoot = "/input";
input.Executable = ReadBytes(request["executable"], remaining);
const auto modules = request["modules"];
const auto count = modules["length"].as<unsigned>();
if (count > 4096) throw std::invalid_argument("Too many modules");
for (unsigned index = 0; index < count; ++index) {
const auto module = modules[index];
const auto path = Io::NativePath(module["name"].as<std::string>());
if (path.is_absolute() || path.lexically_normal() != path || path.begin() == path.end() || *path.begin() != "sce_module") throw std::invalid_argument("Invalid module path");
input.Modules.push_back({Io::NativePath("/input") / path, ReadBytes(module["bytes"], remaining)});
}
input.RuntimeDirectories.push_back("/input/sce_module");
if (!request["icon"].isNull()) input.Icon = ReadBytes(request["icon"], remaining);
Relinker::ConversionOptions options;
options.Target = target == "windows" ? Relinker::TargetSystem::Windows : Relinker::TargetSystem::Linux;
options.WindowsGui = false;
options.WindowsDiagnostics = target == "windows";
options.ToIntel = request["toIntel"].as<bool>();
BrowserProgress progress(callback);
auto output = Relinker::Conversion(progress).Convert(std::move(input), options);
result.set("executable", CopyBytes(output.Executable));
result.set("diagnostics", output.WindowsDiagnostics);
auto artifacts = emscripten::val::array();
for (const auto& module : output.Modules) {
auto artifact = emscripten::val::object();
artifact.set("name", Io::Utf8Path(module.Path.lexically_relative("/output")));
artifact.set("bytes", CopyBytes(module.Bytes));
artifacts.call<void>("push", artifact);
}
result.set("modules", artifacts);
result.set("ok", true);
} catch (const std::exception& error) {
result.set("ok", false);
result.set("error", std::string(error.what()));
}
return result;
}
}
EMSCRIPTEN_BINDINGS(relinker) {
emscripten::function("Convert", &Convert);
}

View File

@ -0,0 +1,28 @@
add_executable(relinker_wasm Bindings.cpp)
target_link_libraries(relinker_wasm PRIVATE relinker_core)
target_compile_options(relinker_wasm PRIVATE -fexceptions)
target_link_options(relinker_wasm PRIVATE
--bind
--no-entry
-fexceptions
-sMODULARIZE=1
-sEXPORT_ES6=1
-sEXPORT_NAME=createRelinker
-sALLOW_MEMORY_GROWTH=1
-sMAXIMUM_MEMORY=2147483648
-sSTACK_SIZE=1048576
-sFILESYSTEM=0
-sENVIRONMENT=web,worker,node
)
set_target_properties(relinker_wasm PROPERTIES
SUFFIX ".mjs"
CXX_EXTENSIONS OFF
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/web/native"
)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_custom_target(relinker_web
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/build_web.py" --build "${CMAKE_BINARY_DIR}"
DEPENDS relinker_wasm
VERBATIM
)

6
demo/web/app.css Normal file

File diff suppressed because one or more lines are too long

235
demo/web/app.mjs Normal file
View File

@ -0,0 +1,235 @@
const $ = id => document.getElementById(id);
const stages = ["Converting executable", "Converting PRX modules", "Scanning syscalls", "Scanning instructions", "Converting instructions", "Analysing imports", "Building output image", "Writing output"];
let worker;
let file;
let summary;
let working = false;
let sink;
let outputDirectory;
let downloadUrl;
let logs = [];
let release;
const canSaveZip = typeof window.showSaveFilePicker === "function" && window.isSecureContext;
const canStream = typeof window.showDirectoryPicker === "function" && window.isSecureContext;
const supported = window.isSecureContext && window.Worker && window.WebAssembly && window.ReadableStream && window.WritableStream && window.crypto?.subtle;
$("save").querySelector('[value="disk"]').disabled = !canStream;
$("save").querySelector('[value="zip"]').disabled = !canSaveZip;
$("save").value = canSaveZip ? "zip" : "memory";
function bytes(value) {
if (value < 1024) return `${value} B`;
const unit = value >= 1024 ** 3 ? 3 : value >= 1024 ** 2 ? 2 : 1;
return `${(value / 1024 ** unit).toFixed(1)} ${["B", "KiB", "MiB", "GiB"][unit]}`;
}
function update() {
const target = document.querySelector('input[name="target"]:checked').value;
const folderBlocked = target === "windows" && $("save").value === "disk";
const folderOption = $("save").querySelector('[value="disk"]');
folderOption.disabled = !canStream || target === "windows";
folderOption.textContent = target === "windows" ? "Save to folder · unavailable for Windows (DLL restrictions)" : "Save to folder · uncompressed";
const libraryBytes = release?.platforms[target]?.expandedBytes;
const estimate = summary && libraryBytes !== undefined ? Math.ceil(summary.totalBytes * 1.02) + summary.selectedBytes * 4 + libraryBytes + summary.files * 1024 + 8 * 1024 * 1024 : null;
const tooLarge = estimate !== null && estimate > 256 * 1024 * 1024;
$("save").querySelector('[value="memory"]').disabled = Boolean(tooLarge);
const memoryBlocked = $("save").value === "memory" && tooLarge;
$("save-note").textContent = $("save").value === "disk" ? "Creates a subfolder with the executable, libs and app0. No ZIP compression. Browsers may block DLL and other restricted files; use ZIP for those apps." : $("save").value === "zip" ? "Writes a compressed ZIP directly to disk without holding the whole archive in memory." : "Up to 256 MiB including expanded files and output overhead.";
$("browser-note").hidden = supported && canStream && canSaveZip && !memoryBlocked && !folderBlocked;
$("browser-note").textContent = !supported ? "This browser or connection does not support the converter. Use a current browser over HTTPS, or use the native relinker." : folderBlocked ? "Browsers block direct DLL creation. Windows output requires ZIP. Choose Save ZIP to disk or Download in memory." : memoryBlocked ? "This archive exceeds the memory-download limit. Choose Save ZIP to disk. If that option is unavailable, open this page in desktop Chrome or Edge." : "Some direct disk saving options are unavailable in this browser or context. Small apps can still be downloaded in memory. For larger apps, use desktop Chrome or Edge over HTTPS.";
$("convert").disabled = !supported || working || !summary || !release || memoryBlocked || folderBlocked || (summary.warnings.length > 0 && !$("acknowledge").checked);
$("ready-note").textContent = summary ? (folderBlocked ? "Choose a ZIP save mode for Windows." : memoryBlocked ? "A direct disk save is required for this archive." : release ? `Libraries: ${release.tag} · ${$("save").value === "disk" ? "Uncompressed folder" : "ZIP compression level 9"}` : "Loading release information…") : "Choose a ZIP or folder to get started.";
}
function lock(value) {
for (const id of ["choose", "choose-folder", "change", "target", "save", "intel"]) $(id).disabled = value || !supported || (id === "choose-folder" && !("webkitdirectory" in $("folder")));
}
async function fail(message) {
worker?.terminate();
worker = null;
working = false;
summary = null;
if (sink) {
const active = sink;
sink = null;
try { await active.abort(); } catch (error) { message += `\nCould not discard the output file: ${error.message}`; }
}
if (outputDirectory) { message += `\nIncomplete files remain in ${outputDirectory.name}/.`; outputDirectory = null; }
$("error").textContent = message;
$("error").hidden = false;
$("status").hidden = true;
$("cancel").hidden = true;
$("drop").hidden = false;
$("file").value = "";
lock(false);
update();
}
async function onMessage({ data }) {
try {
if (data.type === "progress") {
if (data.message) {
logs.push(data.message.trim());
logs = logs.slice(-150);
$("log").textContent = logs.join("\n");
return;
}
$("phase").textContent = data.phase ?? stages[data.stage];
$("current-file").textContent = data.input ?? "";
if (Number.isFinite(data.total) && data.total > 0) {
const percent = Math.min(100, Math.floor(data.completed / data.total * 100));
$("progress").value = percent;
$("percent").textContent = `${percent}%`;
} else {
$("progress").removeAttribute("value");
$("percent").textContent = "";
}
} else if (data.type === "inspected") {
summary = data.summary;
working = false;
$("selection").hidden = false;
$("drop").hidden = true;
$("archive-name").textContent = file.name;
$("archive-info").textContent = `${summary.files} files · ${bytes(summary.totalBytes)} expanded · ${summary.modules} modules · ${bytes(summary.selectedBytes)} conversion input` + (summary.root ? ` · Root: ${summary.root}` : "");
$("warning-list").replaceChildren(...summary.warnings.map(message => { const item = document.createElement("li"); item.textContent = message; return item; }));
$("warnings").hidden = !summary.warnings.length;
$("status").hidden = true;
$("cancel").hidden = true;
lock(false);
update();
} else if (data.type === "directory" || data.type === "open-file") {
if (!outputDirectory) throw new Error("No output folder is open");
const parts = data.name.replace(/\/$/u, "").split("/");
if (parts.some(part => !part || part === "." || part === ".." || part.includes("\\"))) throw new Error("Invalid output path");
const filename = data.type === "open-file" ? parts.pop() : null;
let parent = outputDirectory;
for (const part of parts) parent = await parent.getDirectoryHandle(part, { create: true });
if (filename) {
try {
const handle = await parent.getFileHandle(filename, { create: true });
sink = await handle.createWritable();
} catch (error) {
throw new Error(`Cannot write ${data.name}: ${error.message}. Choose ZIP output if the browser blocks this file type.`);
}
}
worker?.postMessage({ type: "written" });
} else if (data.type === "close-file") {
if (!sink) throw new Error("No output file is open");
await sink.close();
sink = null;
worker?.postMessage({ type: "written" });
} else if (data.type === "write") {
if (!sink) throw new Error("No output file is open");
await sink.write(data.bytes);
worker?.postMessage({ type: "written" });
} else if (data.type === "complete") {
if (sink) { await sink.close(); sink = null; }
working = false;
$("done").hidden = false;
$("status").hidden = true;
$("cancel").hidden = true;
$("result-info").textContent = `${bytes(data.size)} ${data.mode === "disk" ? "files" : "ZIP"} · ${data.release} system libraries · ${data.outputName}`;
if (data.blob) {
downloadUrl = URL.createObjectURL(data.blob);
$("download").href = downloadUrl;
$("download").download = `${data.outputName}.zip`;
$("download").hidden = false;
} else if (data.mode === "zip") {
$("result-info").textContent += " · ZIP saved to disk";
} else {
$("result-info").textContent += ` · Saved in ${outputDirectory.name}/`;
if (document.querySelector('input[name="target"]:checked').value === "linux") $("result-info").textContent += ` · On Linux, run chmod +x "${data.outputName}" before launching.`;
}
$("result-note").hidden = data.mode === "disk";
outputDirectory = null;
summary = null;
worker.terminate();
worker = null;
lock(false);
update();
} else if (data.type === "error") await fail(data.message);
} catch (error) { await fail(error.message); }
}
async function selectFile(selected, files) {
if (working || !supported || !selected) return;
if (!files && !selected.name.toLowerCase().endsWith(".zip")) { await fail("Choose a ZIP archive."); return; }
worker?.terminate();
if (downloadUrl) { URL.revokeObjectURL(downloadUrl); downloadUrl = null; }
summary = null;
file = selected;
working = true;
logs = [];
$("log").textContent = "";
for (const id of ["error", "done", "download", "warnings", "selection"]) $(id).hidden = true;
$("acknowledge").checked = false;
$("status").hidden = false;
$("cancel").hidden = false;
$("phase").textContent = "Inspecting files";
$("current-file").textContent = file.name;
$("percent").textContent = "";
$("progress").removeAttribute("value");
lock(true);
update();
worker = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
worker.onmessage = onMessage;
worker.onerror = event => fail(event.message || "The converter worker stopped. Try a smaller app or use the native relinker.");
worker.postMessage(files ? { type: "inspect", files } : { type: "inspect", file });
}
$("choose").onclick = () => $("file").click();
$("change").onclick = () => { $("drop").hidden = false; };
$("choose-folder").onclick = () => { $("folder").value = ""; $("folder").click(); };
$("folder").onchange = () => {
const files = [...$("folder").files].map(file => ({ path: file.webkitRelativePath, file }));
if (files.length) selectFile({ name: files[0].path.split("/")[0] }, files);
};
$("file").onchange = () => selectFile($("file").files[0]);
for (const type of ["dragenter", "dragover"]) $("drop").addEventListener(type, event => { event.preventDefault(); if (!working) $("drop").classList.add("dragging"); });
$("drop").ondragleave = () => $("drop").classList.remove("dragging");
$("drop").ondrop = event => { event.preventDefault(); $("drop").classList.remove("dragging"); if (working) return; if (event.dataTransfer.files.length !== 1) { fail("Drop one ZIP archive at a time."); return; } selectFile(event.dataTransfer.files[0]); };
for (const id of ["save", "target", "acknowledge"]) $(id).addEventListener("change", update);
$("cancel").onclick = () => fail("Cancelled. Choose the ZIP or folder again to restart.");
$("convert").onclick = async () => {
if ($("convert").disabled) return;
working = true;
lock(true);
update();
try {
if ($("save").value === "zip") {
const handle = await window.showSaveFilePicker({ suggestedName: file.name.replace(/\.zip$/iu, "") + "-" + document.querySelector('input[name="target"]:checked').value + ".zip", types: [{ description: "ZIP archive", accept: { "application/zip": [".zip"] } }] });
sink = await handle.createWritable();
} else if ($("save").value === "disk") {
const parent = await window.showDirectoryPicker({ mode: "readwrite" });
const name = file.name.replace(/\.zip$/iu, "") + "-" + document.querySelector('input[name="target"]:checked').value;
let exists = false;
try { await parent.getDirectoryHandle(name); exists = true; }
catch (error) { if (error.name !== "NotFoundError") throw error; }
if (exists) throw new Error(`The folder ${name} already exists. Choose another destination or rename the existing folder.`);
outputDirectory = await parent.getDirectoryHandle(name, { create: true });
}
$("status").hidden = false;
$("cancel").hidden = false;
$("error").hidden = true;
worker.postMessage({ type: "convert", target: document.querySelector('input[name="target"]:checked').value, toIntel: $("intel").checked, mode: $("save").value });
} catch (error) {
if (error.name === "AbortError") { working = false; lock(false); update(); }
else await fail(error.message);
}
};
$("theme").onclick = () => {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme = dark ? "light" : "dark";
};
update();
lock(false);
if (supported) fetch("./release.json", { cache: "no-cache" }).then(response => { if (!response.ok) throw new Error(`Cannot load release information (${response.status})`); return response.json(); }).then(value => {
if (!value.tag || !["windows", "linux"].every(target => Number.isSafeInteger(value.platforms?.[target]?.expandedBytes))) throw new Error("Invalid release information");
release = value;
update();
}).catch(error => fail(error.message));
if (!("webkitdirectory" in $("folder"))) {
$("choose-folder").disabled = true;
$("choose-folder").title = "Folder selection is unavailable in this browser. Choose a ZIP instead.";
}

295
demo/web/archive.mjs Normal file
View File

@ -0,0 +1,295 @@
import { BlobReader, Uint8ArrayReader, ZipReader, ZipWriter, configure } from "https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/lib/zip-no-worker.js/+esm";
configure({ useWebWorkers: false, useCompressionStream: false, chunkSize: 65536 });
export const MEMORY_LIMIT = 256 * 1024 * 1024;
export const CONVERSION_LIMIT = 256 * 1024 * 1024;
export const ICON_LIMIT = 16 * 1024 * 1024;
const MAX_ENTRIES = 100000;
class BoundedBlobReader extends BlobReader {
async readUint8Array(offset, length) {
if (length > 16 * 1024 * 1024) throw new Error("ZIP directory metadata exceeds 16 MiB");
return super.readUint8Array(offset, length);
}
}
export function validPath(name) {
if (!name || name.includes("\\") || /[\x00-\x1f<>:"|?*]/u.test(name)) throw new Error(`Unsafe ZIP path: ${name}`);
const parts = name.replace(/\/$/u, "").split("/");
if (parts.some(part => !part || part === "." || part === ".." || /[. ]$/u.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))) throw new Error(`Unsafe ZIP path: ${name}`);
return name;
}
function safeSize(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error("ZIP contains an unsupported file size");
return value;
}
async function signature(entry) {
if (entry.uncompressedSize < 4) return false;
const stop = new AbortController();
const header = new Uint8Array(4);
let filled = 0;
try {
await entry.getData(new WritableStream({
write(chunk) {
const length = Math.min(4 - filled, chunk.length);
header.set(chunk.subarray(0, length), filled);
filled += length;
if (filled === 4) stop.abort();
}
}), { signal: stop.signal, checkSignature: false });
} catch (error) {
if (!stop.signal.aborted || filled !== 4) throw error;
}
return header[0] === 0x7f && header[1] === 69 && header[2] === 76 && header[3] === 70;
}
async function boundedBytes(entry, limit) {
if (entry.uncompressedSize > limit) throw new Error(`${entry.filename} exceeds the conversion memory limit`);
let size = 0;
const chunks = [];
await entry.getData(new WritableStream({
write(chunk) {
size += chunk.byteLength;
if (size > limit || size > entry.uncompressedSize) throw new Error(`Unexpected expanded size: ${entry.filename}`);
chunks.push(chunk);
}
}), { checkSignature: true });
if (size !== entry.uncompressedSize) throw new Error(`Incorrect expanded size: ${entry.filename}`);
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
return bytes;
}
class FolderReader {
constructor(files) {
this.files = files;
}
async *getEntriesGenerator() {
for (const { path, file } of this.files) {
if (!(file instanceof Blob)) throw new Error(`Invalid folder file: ${path}`);
yield {
filename: path, uncompressedSize: file.size, directory: false, compressionMethod: 0,
externalFileAttributes: (0o100644 << 16) >>> 0,
getData(destination, options = {}) {
return file.stream().pipeTo(destination, { signal: options.signal });
}
};
}
}
async close() {}
}
export class Archive {
constructor(file) {
this.reader = Array.isArray(file) ? new FolderReader(file) : new ZipReader(new BoundedBlobReader(file));
this.entries = [];
}
async inspect(report) {
const paths = new Map();
let total = 0;
for await (const entry of this.reader.getEntriesGenerator()) {
if (this.entries.length >= MAX_ENTRIES) throw new Error("Input contains more than 100,000 entries");
validPath(entry.filename);
const key = entry.filename.replace(/\/$/u, "").toLowerCase();
if (paths.has(key)) throw new Error(`Duplicate ZIP path: ${entry.filename}`);
if (entry.encrypted) throw new Error(`Encrypted ZIP entries are not supported: ${entry.filename}`);
if (!entry.directory && entry.compressionMethod !== 0 && entry.compressionMethod !== 8) throw new Error(`Unsupported ZIP compression: ${entry.filename}`);
const mode = (entry.externalFileAttributes >>> 16) & 0xf000;
if (mode && mode !== 0x8000 && mode !== 0x4000) throw new Error(`Links and special files are not supported: ${entry.filename}`);
paths.set(key, entry.directory);
total = safeSize(total + safeSize(entry.uncompressedSize));
this.entries.push(entry);
}
if (!this.entries.some(entry => !entry.directory)) throw new Error("No files were selected");
for (const entry of this.entries) {
const parts = entry.filename.split("/");
for (let index = 1; index < parts.length; index++) {
if (paths.get(parts.slice(0, index).join("/").toLowerCase()) === false) throw new Error(`A file is also used as a folder: ${entry.filename}`);
}
}
let prefix = "";
const files = this.entries.filter(entry => !entry.directory);
while (files.every(entry => entry.filename.slice(prefix.length).includes("/"))) {
const first = files[0].filename.slice(prefix.length).split("/")[0];
if (["sce_sys", "sce_module"].includes(first) || !this.entries.every(entry => entry.filename === prefix || entry.filename.startsWith(prefix + first + "/"))) break;
prefix += first + "/";
}
this.prefix = prefix;
this.files = this.entries.filter(entry => entry.filename.startsWith(prefix) && entry.filename !== prefix).map(entry => ({ entry, name: entry.filename.slice(prefix.length) }));
const executables = [];
this.modules = [];
for (let index = 0; index < this.files.length; index++) {
const item = this.files[index];
report({ phase: "Inspecting files", input: item.name, completed: index, total: this.files.length });
if (item.entry.directory || item.name.startsWith("sce_sys/")) continue;
if (!item.name.startsWith("sce_module/") && item.name.includes("/")) continue;
if (item.name.startsWith("sce_module/") && item.name.endsWith(".guest.prx")) continue;
const elf = await signature(item.entry);
if (item.name.startsWith("sce_module/")) {
if (elf) this.modules.push(item);
} else if (elf) executables.push(item);
}
if (executables.length !== 1) throw new Error(`Expected exactly one executable ELF in the app root; found ${executables.length}`);
this.executable = executables[0];
if (this.executable.name.includes("/")) throw new Error("Place the executable next to sce_sys and sce_module, not inside another folder");
this.icon = this.files.find(item => item.name === "sce_sys/icon0.png" && !item.entry.directory);
if (this.icon && this.icon.entry.uncompressedSize > ICON_LIMIT) throw new Error("sce_sys/icon0.png exceeds 16 MiB");
this.selectedBytes = [this.executable, ...this.modules, ...(this.icon ? [this.icon] : [])].reduce((sum, item) => sum + item.entry.uncompressedSize, 0);
if (this.selectedBytes > CONVERSION_LIMIT) throw new Error("Executable, modules and icon exceed the 256 MiB conversion limit. Use the native relinker for this app.");
const warnings = [];
if (!this.files.some(item => item.name.startsWith("sce_sys/"))) warnings.push("sce_sys is missing. The Windows executable will have no app icon.");
else if (!this.icon) warnings.push("sce_sys/icon0.png is missing. The Windows executable will have no app icon.");
if (!this.files.some(item => item.name.startsWith("sce_module/"))) warnings.push("sce_module is missing. If the original app included this folder, leaving it out can prevent the app from starting.");
this.totalBytes = total;
return { name: this.executable.name, files: files.length, modules: this.modules.length, totalBytes: total, selectedBytes: this.selectedBytes, root: prefix, warnings };
}
async conversionInput(target, toIntel, report) {
let remaining = CONVERSION_LIMIT;
const read = async item => {
report({ phase: "Reading conversion input", input: item.name });
const bytes = await boundedBytes(item.entry, remaining);
remaining -= bytes.length;
return bytes;
};
const name = this.executable.name;
const stem = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
const outputName = (stem || "app") + (target === "windows" ? ".exe" : ".elf");
const input = { name, outputName, target, toIntel, executable: await read(this.executable), modules: [], icon: null };
for (const item of this.modules) input.modules.push({ name: item.name, bytes: await read(item) });
if (this.icon && target === "windows") input.icon = await read(this.icon);
return input;
}
async close() { await this.reader.close(); }
}
export function memoryEstimate(archiveBytes, selectedBytes, libraryBytes, entries) {
return safeSize(Math.ceil(archiveBytes * 1.02) + selectedBytes * 4 + libraryBytes + entries * 1024 + 8 * 1024 * 1024);
}
async function appendEntry(writer, name, entry, report) {
const stream = new TransformStream();
let produced = 0;
const limit = new TransformStream({ transform(chunk, controller) {
produced += chunk.length;
if (produced > entry.uncompressedSize) throw new Error(`Unexpected expanded size: ${name}`);
controller.enqueue(chunk);
}, flush() {
if (produced !== entry.uncompressedSize) throw new Error(`Incorrect expanded size: ${name}`);
} });
await Promise.all([
entry.getData(stream.writable, { checkSignature: true }),
writer.add(name, stream.readable.pipeThrough(limit), {
versionMadeBy: 0x31e, externalFileAttributes: entry.externalFileAttributes,
onprogress: completed => report({ phase: writer instanceof DirectoryWriter ? "Writing files" : "Compressing ZIP", input: name, completed, total: entry.uncompressedSize })
})
]);
}
class DirectoryWriter {
constructor(destination) {
this.destination = destination;
}
async add(name, source, options = {}) {
if (options.directory) {
await this.destination.directory(name);
return;
}
const output = await this.destination.file(name);
if (source instanceof ReadableStream) {
let completed = 0;
const reader = source.getReader();
const writer = output.getWriter();
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (let offset = 0; offset < value.length; offset += 65536) {
const chunk = value.subarray(offset, offset + 65536);
await writer.write(chunk);
completed += chunk.length;
options.onprogress?.(completed);
}
}
await writer.close();
} catch (error) {
await reader.cancel(error);
await writer.abort(error);
throw error;
} finally {
reader.releaseLock();
writer.releaseLock();
}
} else {
await source.init();
const writer = output.getWriter();
try {
for (let offset = 0; offset < source.size; offset += 65536) {
await writer.write(await source.readUint8Array(offset, Math.min(65536, source.size - offset)));
}
await writer.close();
} catch (error) {
await writer.abort(error);
throw error;
} finally {
writer.releaseLock();
}
}
}
async close() {}
}
export async function packageArchive(archive, converted, outputName, libraryFile, destination, report, directory = false) {
const libraries = new ZipReader(new BoundedBlobReader(libraryFile));
const writer = directory ? new DirectoryWriter(destination) : new ZipWriter(destination, { level: 9, zip64: true, bufferedWrite: false, useWebWorkers: false, useCompressionStream: false, preventClose: true });
const names = new Set();
const claim = name => {
validPath(name);
const key = name.replace(/\/$/u, "").toLowerCase();
if (names.has(key)) throw new Error(`Output path collision: ${name}`);
names.add(key);
};
try {
const libraryEntries = await libraries.getEntries();
if (!libraryEntries.length || libraryEntries.length > 4096) throw new Error("Invalid library archive");
for (const entry of libraryEntries) {
if (!entry.filename.startsWith("libs/") || entry.encrypted || ((entry.externalFileAttributes >>> 16) & 0xf000) === 0xa000) throw new Error("Invalid library archive layout");
claim(entry.filename);
}
const replaced = new Set(converted.modules.map(module => module.name));
const originalFiles = archive.files.filter(item => !replaced.has("app0/" + item.name));
for (const item of originalFiles) claim("app0/" + item.name);
claim(outputName);
if (converted.diagnostics) claim("windows-diagnostics-imports.txt");
for (const module of converted.modules) {
if (!module.name.startsWith("app0/sce_module/")) throw new Error("Invalid converted module path");
claim(module.name);
}
const binary = { versionMadeBy: 0x31e, externalFileAttributes: (0o100755 << 16) >>> 0 };
await writer.add(outputName, new Uint8ArrayReader(converted.executable), binary);
if (converted.diagnostics) await writer.add("windows-diagnostics-imports.txt", new Uint8ArrayReader(new TextEncoder().encode(converted.diagnostics)));
for (const module of converted.modules) await writer.add(module.name, new Uint8ArrayReader(module.bytes), binary);
for (const entry of libraryEntries) {
if (entry.directory) await writer.add(entry.filename, undefined, { directory: true });
else await appendEntry(writer, entry.filename, entry, report);
}
for (const { entry, name } of originalFiles) {
if (entry.directory) await writer.add("app0/" + name, undefined, { directory: true });
else await appendEntry(writer, "app0/" + name, entry, report);
}
await writer.close();
} finally {
await libraries.close();
}
}

29
demo/web/index.html Normal file
View File

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark">
<meta name="description" content="Convert a decrypted PS5 app for Windows or Linux, locally in your browser.">
<title>AnyPS5 · Converter</title><link rel="stylesheet" href="app.css"><script type="module" src="app.mjs"></script>
</head>
<body>
<header><div class="bar"><a class="brand" href="./"><span class="mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>AnyPS5 <span>/ Converter</span></a><nav aria-label="Main navigation"><a href="https://alxcbtw.github.io/anyps5-roadmap/">Compatibility ↗</a><a href="https://github.com/boykopovar/AnyPS5">GitHub</a><a href="https://discord.gg/BHFztBPUe">Discord</a><button id="theme" class="plain" aria-label="Switch color theme">◐</button></nav></div></header>
<main>
<div class="intro"><p class="eyebrow">LOCAL CONVERSION</p><h1>PS5 apps to Windows/Linux converter</h1></div>
<div id="browser-note" class="notice" role="status" hidden></div>
<section class="converter" aria-label="App converter">
<div class="panel-heading"><h2>Convert an app</h2><span class="private"><b aria-hidden="true">●</b> Files stay on your device</span></div>
<div id="drop" class="drop"><span class="archive-icon" aria-hidden="true">↓</span><h3>Choose a folder or drop a ZIP</h3><p>One executable ELF, <code>sce_sys/</code> and <code>sce_module/</code>.<br>Other app files can stay in the archive.</p><div class="input-actions"><button id="choose" class="button">Choose ZIP file</button><button id="choose-folder" class="button">Choose folder</button></div><input id="folder" type="file" webkitdirectory multiple hidden><input id="file" type="file" accept=".zip,application/zip" hidden><p class="fine">ZIP &amp; ZIP64 · Decrypted ELF files only</p></div>
<div id="selection" class="selection" hidden><div><p class="eyebrow">SELECTED INPUT</p><h3 id="archive-name"></h3><p id="archive-info" class="muted"></p></div><button id="change" class="plain">Change input</button></div>
<div id="warnings" class="warnings" hidden><ul id="warning-list"></ul><label><input id="acknowledge" type="checkbox"> I understand and want to continue.</label></div>
<div class="settings"><fieldset id="target"><legend>Convert for</legend><div class="choices"><label><input type="radio" name="target" value="windows" checked> Windows <span>.exe</span></label><label><input type="radio" name="target" value="linux"> Linux <span>.elf</span></label></div></fieldset><div class="save-settings"><label for="save">Save the result</label><select id="save"><option value="memory">Download in memory · small apps</option><option value="zip">Save ZIP to disk · large apps</option><option value="disk">Save to folder · uncompressed</option></select><p id="save-note" class="fine"></p></div></div>
<div class="options"><label><input type="checkbox" id="intel"> Convert AMD-specific instructions for Intel CPUs</label></div>
<div class="action-row"><p id="ready-note" class="muted">Choose a ZIP or folder to get started.</p><button id="convert" class="button" disabled>Convert &amp; save →</button><button id="cancel" class="plain" hidden>Cancel</button></div>
<div id="status" class="status" hidden><div class="status-line"><strong id="phase" role="status"></strong><span id="percent"></span></div><progress id="progress" max="100"></progress><p id="current-file" class="fine"></p><details><summary>Conversion log</summary><pre id="log"></pre></details></div>
<div id="error" class="error" role="alert" hidden></div>
<div id="done" class="done" hidden><h3>Ready to run.</h3><p id="result-info"></p><a id="download" class="button" hidden>Download ZIP</a><p id="result-note" class="fine">Extract the entire ZIP before launching the executable.</p></div>
</section>
<p class="limits">Memory downloads have a 256 MiB budget, checked against expanded files and output overhead. Larger results require desktop Chrome or Edge with direct disk saving. Executable, modules and icon are limited to 256 MiB combined; use the native relinker for larger conversion inputs.</p>
<p class="compatibility">Conversion does not guarantee that an app runs. Check the <a href="https://alxcbtw.github.io/anyps5-roadmap/">community compatibility list</a> for known blockers and reports.</p>
</main><footer><span>AnyPS5</span><div><a href="progress.html">Implementation progress</a><a href="history.html">Progress history</a><a href="https://github.com/boykopovar/AnyPS5/releases/latest">Latest release</a></div></footer>
<noscript><p class="notice">JavaScript is required. You can also use the native relinker from GitHub Releases.</p></noscript>
</body></html>

View File

@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { register } from "node:module";
register("./cdn-loader.mjs", import.meta.url, { data: { url: process.env.ANYPS5_TEST_ZIP_URL, path: process.env.ANYPS5_TEST_ZIP_PATH } });
const site = name => pathToFileURL(path.join(process.argv[2], name)).href;
const { Archive, memoryEstimate, MEMORY_LIMIT, packageArchive } = await import(site("archive.mjs"));
const { BlobReader, BlobWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } = await import(process.env.ANYPS5_TEST_ZIP_URL);
const { default: createRelinker } = await import(site("native/relinker_wasm.mjs"));
const native = await createRelinker();
const executable = new Uint8Array(await readFile(path.join(process.argv[3], "main.elf")));
const module = new Uint8Array(await readFile(path.join(process.argv[3], "module.elf")));
const resource = new Uint8Array(2 * 1024 * 1024).fill(65);
async function zip(entries) {
const output = new BlobWriter("application/zip");
const writer = new ZipWriter(output, { zip64: true, level: 9, useWebWorkers: false, useCompressionStream: false });
for (const [name, bytes, options] of entries) await writer.add(name, bytes ? new Uint8ArrayReader(bytes) : undefined, options);
return writer.close();
}
const source = await zip([["title/", null, { directory: true }], ["title/program.dat", executable], ["title/sce_module/library.prx.esbak", module], ["title/sce_module/library.prx", new Uint8Array([79, 21, 61, 29])], ["title/sce_module/library.prx.esbak.guest.prx", new Uint8Array([77, 90, 0, 0])], ["title/sce_sys/param.json", new TextEncoder().encode("{}")], ["title/shaders/example.frag", executable], ["title/content/resource.bin", resource]]);
const folder = [
{ path: "title/program.dat", file: new Blob([executable]) },
{ path: "title/sce_module/library.prx.esbak", file: new Blob([module]) },
{ path: "title/sce_module/library.prx", file: new Blob([new Uint8Array([79, 21, 61, 29])]) },
{ path: "title/sce_module/library.prx.esbak.guest.prx", file: new Blob([new Uint8Array([77, 90, 0, 0])]) },
{ path: "title/sce_sys/param.json", file: new Blob(["{}"]) },
{ path: "title/shaders/example.frag", file: new Blob([executable]) },
{ path: "title/content/resource.bin", file: new Blob([resource]) }
];
for (const [kind, selected] of [["ZIP", source], ["folder", folder]]) {
for (const target of ["windows", "linux"]) {
const archive = new Archive(selected);
const summary = await archive.inspect(() => {});
assert.equal(summary.name, "program.dat");
assert.equal(summary.root, "title/");
assert.equal(summary.modules, 1);
assert.equal(summary.selectedBytes, executable.length + module.length);
const input = await archive.conversionInput(target, false, () => {});
assert.equal(input.modules.length, 1);
assert.deepEqual(input.executable, executable);
assert.equal(input.modules[0].name, "sce_module/library.prx.esbak");
const events = [];
const converted = native.Convert(input, event => events.push(event));
assert.equal(converted.ok, true, converted.error);
assert.equal(converted.executable[0], target === "windows" ? 77 : 127);
if (target === "windows") {
const image = new DataView(converted.executable.buffer, converted.executable.byteOffset, converted.executable.byteLength);
assert.equal(image.getUint16(image.getUint32(60, true) + 24 + 68, true), 3);
}
assert(events.some(event => event.stage === 6 && event.completed > 0));
const libraries = await zip([["libs/system.prx", new Uint8Array([1, 2, 3])]]);
const chunks = [];
let maxChunk = 0;
const destination = new WritableStream({ async write(chunk) {
maxChunk = Math.max(maxChunk, chunk.length);
chunks.push(chunk.slice());
await new Promise(resolve => setTimeout(resolve, 1));
} });
await packageArchive(archive, converted, input.outputName, libraries, destination, () => {});
await destination.close();
const reader = new ZipReader(new BlobReader(new Blob(chunks)));
const entries = await reader.getEntries();
const byName = new Map(entries.map(entry => [entry.filename, entry]));
assert(byName.has(input.outputName));
assert(byName.has("libs/system.prx"));
assert.deepEqual(await byName.get("app0/shaders/example.frag").getData(new Uint8ArrayWriter(), { checkSignature: true }), executable);
assert.deepEqual(await byName.get("app0/sce_module/library.prx.esbak.guest.prx").getData(new Uint8ArrayWriter(), { checkSignature: true }), converted.modules[0].bytes);
assert.deepEqual(await byName.get("app0/sce_module/library.prx").getData(new Uint8ArrayWriter(), { checkSignature: true }), new Uint8Array([79, 21, 61, 29]));
assert.deepEqual(await byName.get("app0/program.dat").getData(new Uint8ArrayWriter(), { checkSignature: true }), executable);
assert.deepEqual(await byName.get("app0/content/resource.bin").getData(new Uint8ArrayWriter(), { checkSignature: true }), resource);
assert.equal((byName.get(input.outputName).externalFileAttributes >>> 16) & 0o777, 0o755);
assert(maxChunk < resource.length);
const saved = new Map();
let active = false;
let directoryChunk = 0;
await packageArchive(archive, converted, input.outputName, libraries, {
async directory(name) { saved.set(name, null); },
async file(name) {
assert.equal(active, false);
active = true;
const pieces = [];
return new WritableStream({
async write(chunk) {
directoryChunk = Math.max(directoryChunk, chunk.length);
pieces.push(chunk.slice());
await new Promise(resolve => setTimeout(resolve, 1));
},
async close() {
saved.set(name, new Uint8Array(await new Blob(pieces).arrayBuffer()));
active = false;
}
});
}
}, () => {}, true);
assert.equal(active, false);
assert(directoryChunk < resource.length);
assert.equal(saved.size, entries.length);
for (const entry of entries) {
assert(saved.has(entry.filename));
if (!entry.directory) assert.deepEqual(saved.get(entry.filename), await entry.getData(new Uint8ArrayWriter(), { checkSignature: true }));
}
await reader.close();
await archive.close();
console.log(`${kind}, ${target}: conversion, original files, modules, permissions, streamed ZIP and directory output verified`);
}
}
for (const [entries, error] of [
[[["one", executable], ["two", executable]], /exactly one/u],
[[["readme", new Uint8Array([1])]], /exactly one/u],
[[["app", executable], ["..\/escape", resource]], /Unsafe ZIP/u],
[[["app", executable], ["APP", resource]], /Duplicate ZIP/u],
[[["app", executable], ["folder", resource], ["folder/file", resource]], /also used as a folder/u],
[[["app", executable], ["link", resource, { versionMadeBy: 0x31e, externalFileAttributes: (0o120777 << 16) >>> 0 }]], /Links and special/u]
]) {
const archive = new Archive(await zip(entries));
await assert.rejects(archive.inspect(() => {}), error);
await archive.close();
}
const missing = new Archive(await zip([["any-name", executable]]));
assert.equal((await missing.inspect(() => {})).warnings.length, 2);
await missing.close();
assert(memoryEstimate(200 * 1024 ** 3, 1024, 1024, 100) > MEMORY_LIMIT);
const invalid = native.Convert({ target: "unsupported" }, () => {});
assert.equal(invalid.ok, false);
console.log("Archive validation, missing-folder warnings and large-download budget verified");
const workerMessages = [];
globalThis.self = { postMessage: message => workerMessages.push(message) };
await import(site("worker.mjs"));
await self.onmessage({ data: { type: "convert", target: "windows", mode: "disk" } });
assert.equal(workerMessages.length, 1);
assert.equal(workerMessages[0].type, "error");
assert.match(workerMessages[0].message, /browsers block DLL creation/u);
delete globalThis.self;
console.log("Windows folder output rejected before conversion or file writes");

View File

@ -0,0 +1,9 @@
let library;
export function initialize(data) {
library = data;
}
export function resolve(specifier, context, nextResolve) {
return nextResolve(specifier === library.url ? library.path : specifier, context);
}

115
demo/web/worker.mjs Normal file
View File

@ -0,0 +1,115 @@
import { Archive, MEMORY_LIMIT, memoryEstimate, packageArchive } from "./archive.mjs";
let archive;
let busy = false;
let pendingWrite;
let lastReport = 0;
let lastPhase = "";
function report(event) {
const phase = event.phase ?? String(event.stage);
const now = performance.now();
if (!event.message && phase === lastPhase && event.completed !== event.total && now - lastReport < 80) return;
lastReport = now;
lastPhase = phase;
self.postMessage({ type: "progress", ...event });
}
async function downloadLibraries(target) {
report({ phase: "Downloading system libraries" });
const response = await fetch("./release.json", { cache: "no-cache" });
if (!response.ok) throw new Error(`Cannot load release manifest (${response.status})`);
const release = await response.json();
const asset = release.platforms[target];
if (!asset || !/^libraries\/(windows|linux)-[a-f0-9]+\.zip$/u.test(asset.path) || !/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error("Invalid release manifest");
const latestResponse = await fetch("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest", { headers: { Accept: "application/vnd.github+json" } });
if (!latestResponse.ok) throw new Error(`Cannot verify the latest release (${latestResponse.status}). Try again later.`);
const latest = await latestResponse.json();
if (latest.tag_name !== release.tag) throw new Error("A new release is being published to this site. Try again after the Pages build finishes.");
const download = await fetch(asset.path);
if (!download.ok) throw new Error(`Cannot download system libraries (${download.status})`);
const bytes = await download.arrayBuffer();
if (bytes.byteLength !== asset.size) throw new Error("System library download is incomplete");
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("");
if (digest !== asset.sha256) throw new Error("System library checksum mismatch");
return { file: new Blob([bytes]), release, expandedBytes: asset.expandedBytes };
}
self.onmessage = async ({ data }) => {
if (data.type === "written") {
if (!pendingWrite) return;
const resolve = pendingWrite;
pendingWrite = null;
resolve();
return;
}
if (busy) return;
busy = true;
try {
if (data.type === "inspect") {
if (archive) await archive.close();
archive = new Archive(data.files ?? data.file);
const summary = await archive.inspect(report);
self.postMessage({ type: "inspected", summary });
} else if (data.type === "convert") {
if (data.target === "windows" && data.mode === "disk") throw new Error("Windows folder output is unavailable because browsers block DLL creation. Choose a ZIP save mode.");
if (!archive) throw new Error("Select a ZIP or folder first");
const libraries = await downloadLibraries(data.target);
const estimate = memoryEstimate(archive.totalBytes, archive.selectedBytes, libraries.expandedBytes, archive.entries.length);
if (data.mode === "memory" && estimate > MEMORY_LIMIT) throw new Error("This result exceeds the 256 MiB memory-download budget. Use Save ZIP to disk in desktop Chrome or Edge.");
report({ phase: "Loading converter" });
const { default: createRelinker } = await import("./native/relinker_wasm.mjs");
const native = await createRelinker();
let input = await archive.conversionInput(data.target, data.toIntel, report);
const outputName = input.outputName;
const converted = native.Convert(input, report);
input = null;
if (!converted.ok) throw new Error(converted.error);
const chunks = [];
let size = 0;
const send = (event, transfer = []) => new Promise(resolve => {
pendingWrite = resolve;
self.postMessage(event, transfer);
});
if (data.mode === "disk") {
const destination = {
directory: name => send({ type: "directory", name }),
async file(name) {
await send({ type: "open-file", name });
return new WritableStream({
async write(chunk) {
size += chunk.byteLength;
const bytes = chunk.slice();
await send({ type: "write", bytes }, [bytes.buffer]);
},
close: () => send({ type: "close-file" })
});
}
};
await packageArchive(archive, converted, outputName, libraries.file, destination, report, true);
} else if (data.mode === "memory" || data.mode === "zip") {
const destination = new WritableStream({
async write(chunk) {
size += chunk.byteLength;
if (data.mode === "zip") {
const bytes = chunk.slice();
await send({ type: "write", bytes }, [bytes.buffer]);
return;
}
if (size > MEMORY_LIMIT) throw new Error("The compressed ZIP exceeded 256 MiB. Use Save ZIP to disk.");
chunks.push(chunk.slice());
}
});
await packageArchive(archive, converted, outputName, libraries.file, destination, report);
await destination.close();
} else throw new Error("Unknown save mode");
self.postMessage({ type: "complete", mode: data.mode, blob: data.mode === "memory" ? new Blob(chunks, { type: "application/zip" }) : null, outputName, size, release: libraries.release.tag });
await archive.close();
archive = null;
} else throw new Error("Unknown worker request");
} catch (error) {
self.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) });
} finally {
busy = false;
}
};

View File

@ -1,5 +1,7 @@
# Relinker usage
[**Online relinker**](https://boykopovar.github.io/AnyPS5/) - files stay on your device.
## Input and conversion
Use a clean ELF executable. Place its bundled ELF modules in `sce_module/`, `sce_modules/`, or `prx/` beside the input executable, or under the parent directory selected with `--sce-module-path <path>`. The option accepts absolute paths and paths relative to the current working directory; do not include the `sce_module` directory itself. Only files directly inside the module directories are processed. A bundled module in a SELF container is an error, as the executable is. `prx/` can coexist with either `sce_module/` or `sce_modules/`. Both `sce_module/` and `sce_modules/` present, or all three absent, is an error.

97
tools/build_web.py Normal file
View File

@ -0,0 +1,97 @@
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parent.parent
def copy_sources(output):
source = ROOT / "demo/web"
for file in source.iterdir():
if file.is_file():
shutil.copy2(file, output / file.name)
(output / ".nojekyll").touch()
def request(url):
headers = {"User-Agent": "AnyPS5-Pages"}
token = os.environ.get("GITHUB_TOKEN")
if token and url.startswith("https://api.github.com/"):
headers["Authorization"] = f"Bearer {token}"
return urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60)
def publish_libraries(output):
with request("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest") as response:
release = json.load(response)
tag = release["tag_name"]
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", tag):
raise ValueError("Invalid release tag")
manifest = {"tag": tag, "url": release["html_url"], "platforms": {}}
destination = output / "libraries"
destination.mkdir(exist_ok=True)
for platform in ("windows", "linux"):
name = f"prx-{platform}-{tag}.zip"
matches = [asset for asset in release["assets"] if asset["name"] == name]
if len(matches) != 1:
raise ValueError(f"Missing or ambiguous release asset: {name}")
asset = matches[0]
temporary = destination / f"{platform}.download"
try:
digest = hashlib.sha256()
size = 0
with request(asset["browser_download_url"]) as response, temporary.open("wb") as target:
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > asset["size"] or size > 512 * 1024 * 1024:
raise ValueError("Release archive exceeds expected size")
digest.update(chunk)
target.write(chunk)
if size != asset["size"]:
raise ValueError("Incomplete release archive")
checksum = digest.hexdigest()
if asset.get("digest") and asset["digest"] != "sha256:" + checksum:
raise ValueError("Release archive checksum mismatch")
with zipfile.ZipFile(temporary) as archive:
files = archive.infolist()
if not files or len(files) > 4096:
raise ValueError("Invalid library archive")
for file in files:
if not file.filename.startswith("libs/") or any(part in ("..", ".", "") for part in file.filename.rstrip("/").split("/")) or "\\" in file.filename:
raise ValueError("Invalid library archive layout")
corrupt = archive.testzip()
if corrupt:
raise ValueError(f"Corrupt release entry: {corrupt}")
expanded = sum(file.file_size for file in files)
path = f"libraries/{platform}-{checksum}.zip"
temporary.replace(output / path)
manifest["platforms"][platform] = {"path": path, "size": size, "expandedBytes": expanded, "sha256": checksum}
finally:
temporary.unlink(missing_ok=True)
(output / "release.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(f"Published library assets from {tag}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--build", type=Path, required=True)
parser.add_argument("--libraries", action="store_true")
args = parser.parse_args()
output = args.build.resolve() / "web"
for name in ("relinker_wasm.mjs", "relinker_wasm.wasm"):
if not (output / "native" / name).is_file():
raise ValueError(f"Build relinker_wasm first: missing {name}")
copy_sources(output)
if args.libraries:
publish_libraries(output)
print(output)
if __name__ == "__main__":
main()

34
tools/test_web.py Normal file
View File

@ -0,0 +1,34 @@
import argparse
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import urllib.request
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "core/relinker/relinker/tests"))
from test_guest_intel_trampolines import main_fixture
from test_guest_module_directories import module_with_symbol
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--build", type=Path, required=True)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="anyps5-web-") as directory:
fixture = Path(directory)
(fixture / "main.elf").write_bytes(main_fixture())
(fixture / "module.elf").write_bytes(module_with_symbol(True))
source = (args.build / "web/archive.mjs").read_text(encoding="utf-8")
url = re.search(r'from "(https://cdn.jsdelivr.net/[^"\n]+)"', source).group(1)
library = fixture / "zip.mjs"
with urllib.request.urlopen(url, timeout=30) as response:
library.write_bytes(response.read())
environment = dict(os.environ, ANYPS5_TEST_ZIP_URL=url, ANYPS5_TEST_ZIP_PATH=library.as_uri())
subprocess.run(["node", str(ROOT / "demo/web/tests/archive.test.mjs"), str(args.build.resolve() / "web"), str(fixture)], env=environment, check=True)
if __name__ == "__main__":
main()