Bundle the engine

This commit is contained in:
unicxrn committed 2026-10-10 21:59:13 +02:00
1 parent 6667b7fb1a
commit dd6d795e35
13 files changed
+295 -69

No files matched your search

+2
View File
@@ -3,3 +3,5 @@ dist/
src-tauri/target/
src-tauri/gen/
public/mock/
src-tauri/binaries/
target-engine/
+52
View File
@@ -0,0 +1,52 @@
"""Freeze the engine into src-tauri/binaries/prospack-engine-<target-triple>[.exe].
Tauri's sidecar convention needs the Rust target triple in the file name.
Run with the Python that has engine/requirements-build.txt installed.
"""
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENGINE = ROOT / "engine"
OUT = ROOT / "src-tauri" / "binaries"
def host_triple() -> str:
out = subprocess.run(["rustc", "-vV"], check=True, capture_output=True, text=True).stdout
return next(l.split(": ", 1)[1] for l in out.splitlines() if l.startswith("host: "))
def main() -> int:
work = ROOT / "target-engine"
subprocess.run(
[
sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile",
"--name", "prospack-engine",
"--collect-all", "mkpfs",
"--collect-all", "zlib_ng",
"--hidden-import", "deep_verify",
"--paths", str(ENGINE),
"--distpath", str(work / "dist"),
"--workpath", str(work / "build"),
"--specpath", str(work),
str(ENGINE / "prospack_engine.py"),
],
check=True,
)
exe = ".exe" if platform.system() == "Windows" else ""
OUT.mkdir(parents=True, exist_ok=True)
dest = OUT / f"prospack-engine-{host_triple()}{exe}"
shutil.copy2(work / "dist" / f"prospack-engine{exe}", dest)
if not exe:
os.chmod(dest, 0o755)
print(dest)
return 0
if __name__ == "__main__":
sys.exit(main())
File renamed without changes.
+55
View File
@@ -0,0 +1,55 @@
"""Write a small, reproducible fake PS5 dump for tests and CI (no real game data).
python engine/make_fake_dump.py OUT_DIR/PPSA00001-app
"""
import json
import os
import random
import struct
import sys
import zlib
from pathlib import Path
def png(path: Path, w: int, h: int, rgb: tuple[int, int, int]) -> None:
def chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
rows = b"".join(b"\0" + bytes(rgb) * w for _ in range(h))
path.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(rows, 9))
+ chunk(b"IEND", b"")
)
def main() -> int:
root = Path(sys.argv[1])
title = root.name.split("-")[0] if root.name[:4].isalpha() else "PPSA00001"
rnd = random.Random(1234)
(root / "sce_sys").mkdir(parents=True, exist_ok=True)
(root / "fakelib").mkdir(exist_ok=True)
(root / "data/prein/bg").mkdir(parents=True, exist_ok=True)
(root / "sce_sys/param.json").write_text(json.dumps({
"titleId": title,
"contentVersion": "01.000.000",
"localizedParameters": {"defaultLanguage": "en-US", "en-US": {"titleName": "TEST GAME"}},
}))
png(root / "sce_sys/icon0.png", 512, 512, (40, 90, 220))
png(root / "sce_sys/pic0.png", 1280, 720, (30, 30, 60))
(root / "eboot.bin").write_bytes(rnd.randbytes(2 * 1024 * 1024))
(root / "fakelib/libSceAmpr.sprx").write_bytes(rnd.randbytes(64 * 1024))
for i in range(300):
(root / f"data/prein/bg/small_{i}.txt").write_bytes(b"a" * rnd.randrange(1, 90000))
with open(root / "data/prein/bg/big_text.bin", "wb") as f:
line = b"level data compressible\n"
f.write(line * (40 * 1024 * 1024 // len(line)))
(root / "data/prein/bg/big_random.bin").write_bytes(rnd.randbytes(8 * 1024 * 1024))
print(root)
return 0
if __name__ == "__main__":
sys.exit(main())
+39
View File
@@ -0,0 +1,39 @@
"""Prospack engine: the compression and verification half of Prospack.
Frozen into a single executable (see build.py) and shipped next to the app, so users
don't need Python.
prospack-engine pack folder [mkpfs options] SOURCE IMAGE
prospack-engine verify IMAGE SOURCE [WORKERS]
prospack-engine --version
"""
import multiprocessing
import sys
def main() -> int:
# Must run first: MkPFS and the verifier start worker processes, which re-enter this
# executable when frozen.
multiprocessing.freeze_support()
if len(sys.argv) < 2 or sys.argv[1] not in ("pack", "verify", "--version"):
print(__doc__.strip(), file=sys.stderr)
return 2
cmd = sys.argv[1]
if cmd == "--version":
import importlib.metadata as md
print(f"mkpfs {md.version('mkpfs')}")
return 0
if cmd == "pack":
from mkpfs.cli import cli_mkpfs_main
return cli_mkpfs_main(sys.argv[1:])
import deep_verify
sys.argv = [sys.argv[0], *sys.argv[2:]]
return deep_verify.main()
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pyinstaller>=6.10
+2
View File
@@ -0,0 +1,2 @@
mkpfs==1.1.0
zlib-ng>=1.0
+92 -43
View File
@@ -1,4 +1,8 @@
//! The compression engine: a private Python venv with MkPFS, plus the deep verifier.
//! The compression engine: MkPFS plus the deep verifier, as one `prospack-engine` program.
//!
//! Release builds ship it frozen as a Tauri sidecar next to the app. Developers can point
//! `PROSPACK_ENGINE` at a frozen build, or let the app run `engine/prospack_engine.py` in a
//! private venv ("Install MkPFS" in the UI).
use serde::Serialize;
use std::io::Read;
@@ -9,7 +13,9 @@ use std::sync::Arc;
/// Pinned so a new MkPFS release can't silently change the image format.
pub const MKPFS_VERSION: &str = "1.1.0";
const VERIFIER: &str = include_str!("../resources/deep_verify.py");
const EXE: &str = if cfg!(windows) { ".exe" } else { "" };
/// The repo's engine sources, for the developer fallback.
const DEV_ENGINE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../engine");
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -17,9 +23,19 @@ pub struct EngineStatus {
pub python: Option<String>,
pub mkpfs: Option<String>,
pub ready: bool,
/// True when the frozen engine shipped with the app is in use.
pub bundled: bool,
/// Whether "Install MkPFS" (developer venv) is possible on this machine.
pub can_install: bool,
pub dir: String,
}
#[derive(Debug, Clone, PartialEq)]
enum Runner {
Frozen(PathBuf),
Script { python: PathBuf, script: PathBuf },
}
pub struct Engine {
root: PathBuf,
}
@@ -32,65 +48,97 @@ impl Engine {
fn venv(&self) -> PathBuf {
self.root.join("venv")
}
fn mkpfs_bin(&self) -> PathBuf {
self.venv().join("bin/mkpfs")
}
fn python_bin(&self) -> PathBuf {
self.venv().join("bin/python")
if cfg!(windows) {
self.venv().join("Scripts/python.exe")
} else {
self.venv().join("bin/python")
}
}
fn verifier(&self) -> PathBuf {
self.root.join("deep_verify.py")
fn dev_script() -> Option<PathBuf> {
std::env::var_os("PROSPACK_ENGINE_SCRIPT")
.map(PathBuf::from)
.or_else(|| Some(PathBuf::from(DEV_ENGINE_DIR).join("prospack_engine.py")))
.filter(|p| p.is_file())
}
/// Sidecar next to the app, then $PROSPACK_ENGINE, then the developer venv.
fn runner(&self) -> Option<Runner> {
let sidecar = std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|d| d.join(format!("prospack-engine{EXE}"))))
.filter(|p| p.is_file());
if let Some(p) = sidecar {
return Some(Runner::Frozen(p));
}
if let Some(p) = std::env::var_os("PROSPACK_ENGINE").map(PathBuf::from).filter(|p| p.is_file()) {
return Some(Runner::Frozen(p));
}
let python = self.python_bin();
match (python.is_file(), Self::dev_script()) {
(true, Some(script)) => Some(Runner::Script { python, script }),
_ => None,
}
}
fn command(&self) -> Result<Command, String> {
match self.runner() {
Some(Runner::Frozen(p)) => Ok(quiet(p)),
Some(Runner::Script { python, script }) => {
let mut c = quiet(python);
c.arg(script);
Ok(c)
}
None => Err("The compression engine is missing. Reinstall Prospack.".into()),
}
}
pub fn status(&self) -> EngineStatus {
let python = quiet("python3")
.arg("--version")
.output()
let runner = self.runner();
let mkpfs = self
.command()
.ok()
.and_then(|mut c| c.arg("--version").output().ok())
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().replace("Python ", ""));
let mkpfs = if self.mkpfs_bin().is_file() {
quiet(self.python_bin())
.args(["-c", "import importlib.metadata as m; print(m.version('mkpfs'))"])
.map(|o| String::from_utf8_lossy(&o.stdout).trim().trim_start_matches("mkpfs ").to_string());
let can_install = Self::dev_script().is_some();
let python = if matches!(runner, Some(Runner::Frozen(_))) || !can_install {
None
} else {
quiet(if cfg!(windows) { "python" } else { "python3" })
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
.map(|o| String::from_utf8_lossy(&o.stdout).trim().replace("Python ", ""))
};
let ready = mkpfs.is_some();
EngineStatus { python, mkpfs, ready, dir: self.root.to_string_lossy().into_owned() }
EngineStatus {
python,
ready: mkpfs.is_some(),
mkpfs,
bundled: matches!(runner, Some(Runner::Frozen(_))),
can_install,
dir: self.root.to_string_lossy().into_owned(),
}
}
/// Creates the venv and installs the pinned MkPFS. Streams pip's output to `log`.
/// Developer setup: a venv with the engine's requirements. Release builds never need this.
pub fn install(&self, log: &mut dyn FnMut(String)) -> Result<(), String> {
let script = Self::dev_script()
.ok_or("The compression engine ships with Prospack. Reinstall Prospack to restore it.")?;
let reqs = script.with_file_name("requirements.txt");
std::fs::create_dir_all(&self.root).map_err(|e| e.to_string())?;
if !self.python_bin().is_file() {
log("Creating Python environment".into());
run_logged(quiet("python3").arg("-m").arg("venv").arg(self.venv()), log)?;
run_logged(quiet(if cfg!(windows) { "python" } else { "python3" }).arg("-m").arg("venv").arg(self.venv()), log)?;
}
log(format!("Installing MkPFS {MKPFS_VERSION}"));
run_logged(
quiet(self.python_bin()).args([
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--upgrade",
&format!("mkpfs=={MKPFS_VERSION}"),
]),
quiet(self.python_bin())
.args(["-m", "pip", "install", "--disable-pip-version-check", "-r"])
.arg(reqs),
log,
)?;
self.write_verifier()?;
Ok(())
}
pub fn write_verifier(&self) -> Result<(), String> {
std::fs::create_dir_all(&self.root).map_err(|e| e.to_string())?;
if std::fs::read_to_string(self.verifier()).ok().as_deref() != Some(VERIFIER) {
std::fs::write(self.verifier(), VERIFIER).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -107,7 +155,7 @@ impl Engine {
) -> Result<(), String> {
let tmp_dir = out.parent().unwrap().join("tmp");
std::fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?;
let mut cmd = quiet(self.mkpfs_bin());
let mut cmd = self.command()?;
cmd.args(["pack", "folder", "--no-adjust-output-file-extension", "--ampr-skip-regen-if-exists"])
.arg("--compression-level")
.arg(level.min(9).to_string())
@@ -141,14 +189,13 @@ impl Engine {
cancel: &Arc<AtomicBool>,
on: &mut dyn FnMut(VerifyEvent),
) -> Result<bool, String> {
self.write_verifier()?;
let workers = if workers == 0 {
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2) / 2).clamp(1, 8) as u32
} else {
workers
};
let mut cmd = quiet(self.python_bin());
cmd.arg(self.verifier()).arg(image).arg(src).arg(workers.to_string());
let mut cmd = self.command()?;
cmd.arg("verify").arg(image).arg(src).arg(workers.to_string());
let mut buf = String::new();
let mut verdict: Option<bool> = None;
run_streaming(cmd, cancel, &mut |chunk| {
@@ -464,6 +511,7 @@ mod cancel_test {
/// PACKER_E2E_SCRATCH=<dir with mkvenv> cargo test cancel -- --ignored
#[test]
#[ignore]
#[cfg(unix)]
fn cancel_kills_mkpfs_promptly() {
let scratch = PathBuf::from(std::env::var("PACKER_E2E_SCRATCH").unwrap());
let tmp = tempfile::tempdir().unwrap();
@@ -483,6 +531,7 @@ mod cancel_test {
let out_dir = tmp.path().join("out");
std::fs::create_dir_all(&out_dir).unwrap();
let engine = Engine { root };
assert!(engine.runner().is_some(), "no engine: set PROSPACK_ENGINE or provide the venv");
let cancel = Arc::new(AtomicBool::new(false));
let c2 = cancel.clone();
let t = std::time::Instant::now();
-1
View File
@@ -120,7 +120,6 @@ pub fn run() {
let _ = handle.emit(event, payload);
});
let engine = Arc::new(engine::Engine::new(&settings::data_dir()));
let _ = engine.write_verifier();
let queue = pipeline::Queue::new(emitter, engine.clone());
// Folders passed on the command line (or via "Open with") are queued straight away.
for arg in std::env::args().skip(1).filter(|a| !a.starts_with('-')) {
+24 -9
View File
@@ -876,6 +876,18 @@ mod tests {
mod e2e {
use super::*;
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
copy_dir(&p, &to.join(e.file_name()));
} else {
std::fs::copy(&p, to.join(e.file_name())).unwrap();
}
}
}
fn wait_until(q: &Queue, id: u64, secs: u64) -> Job {
let t = Instant::now();
loop {
@@ -895,17 +907,19 @@ mod e2e {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("PROSPACK_CONFIG_DIR", tmp.path().join("config"));
std::env::set_var("PROSPACK_DATA_DIR", tmp.path().join("data"));
let engine_dir = tmp.path().join("data/engine");
std::fs::create_dir_all(&engine_dir).unwrap();
std::os::unix::fs::symlink(scratch.join("mkvenv"), engine_dir.join("venv")).unwrap();
// Engine: $PROSPACK_ENGINE (frozen build) if set, else the scratch venv as the dev venv.
if std::env::var_os("PROSPACK_ENGINE").is_none() {
let engine_dir = tmp.path().join("data/engine");
std::fs::create_dir_all(&engine_dir).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(scratch.join("mkvenv"), engine_dir.join("venv")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(scratch.join("mkvenv"), engine_dir.join("venv")).unwrap();
}
// Fresh copy of the fake dump so MkPFS's ampr_emu.index write can't leak between runs.
let dump = tmp.path().join("PPSA00001-app");
assert!(std::process::Command::new("cp")
.args(["-r", scratch.join("fakedump/PPSA00001-app").to_str().unwrap(), dump.to_str().unwrap()])
.status()
.unwrap()
.success());
copy_dir(&scratch.join("fakedump/PPSA00001-app"), &dump);
let _ = std::fs::remove_file(dump.join("ampr_emu.index"));
let ps5_root = tmp.path().join("ps5");
@@ -917,7 +931,8 @@ mod e2e {
let _ = self.0.wait();
}
}
let _server = KillOnDrop(std::process::Command::new(scratch.join("mkvenv/bin/python"))
let py = if cfg!(windows) { scratch.join("mkvenv/Scripts/python.exe") } else { scratch.join("mkvenv/bin/python") };
let _server = KillOnDrop(std::process::Command::new(py)
.arg(scratch.join("fake_ps5.py"))
.arg(&ps5_root)
.arg(port.to_string())
+3 -1
View File
@@ -40,7 +40,9 @@
"icons/128x128@2x.png",
"icons/icon.png"
],
"resources": []
"externalBin": [
"binaries/prospack-engine"
]
},
"mainBinaryName": "prospack"
}
+23 -14
View File
@@ -11,21 +11,30 @@
<div class="empty">
{#if engine && !engine.ready}
<section class="setup reveal">
<div class="kicker">One-time setup</div>
<h2>Install the compression engine</h2>
<p>
Prospack uses <b>MkPFS 1.1.0</b>, the same format Game Compressor builds on. It goes into a private Python
environment in <span class="mono">{engine.dir}</span> and doesn't touch your system Python.
</p>
{#if !engine.python}
<p class="err">Python 3 wasn't found. Install it with your package manager, then restart Prospack.</p>
{#if engine.canInstall}
<div class="kicker">Developer setup</div>
<h2>Install the compression engine</h2>
<p>
This is a source checkout without the bundled engine. Prospack can set up <b>MkPFS {"1.1.0"}</b> in a private
Python environment in <span class="mono">{engine.dir}</span>, without touching your system Python.
</p>
{#if !engine.python}
<p class="err">Python 3 wasn't found. Install it, then restart Prospack.</p>
{:else}
<button class="btn primary" disabled={installing} onclick={onInstall}>
{installing ? "Installing…" : "Install MkPFS"}
</button>
{/if}
{#if installLog.length}
<pre class="mono">{installLog.slice(-6).join("\n")}</pre>
{/if}
{:else}
<button class="btn primary" disabled={installing} onclick={onInstall}>
{installing ? "Installing…" : "Install MkPFS"}
</button>
{/if}
{#if installLog.length}
<pre class="mono">{installLog.slice(-6).join("\n")}</pre>
<div class="kicker err">Something's missing</div>
<h2>The compression engine isn't there</h2>
<p>
Prospack ships with its engine (<span class="mono">prospack-engine</span>), but it can't be found or started.
Antivirus software sometimes quarantines it. Reinstall Prospack, and allow it in your antivirus if needed.
</p>
{/if}
</section>
{/if}
+1 -1
View File
@@ -113,7 +113,7 @@ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
export const mock = {
getSettings: async () => structuredClone(settings),
saveSettings: async (v) => { settings = v; },
engineStatus: async () => ({ python: "3.14.7", mkpfs: "1.1.0", ready: true, dir: "~/.local/share/ps5-packer/engine" }),
engineStatus: async () => ({ python: null, mkpfs: "1.1.0", ready: true, bundled: true, canInstall: false, dir: "~/.local/share/prospack/engine" }),
installEngine: async () => { for (const l of ["Creating Python environment", "Installing MkPFS 1.1.0"]) { emit("engine-log", l); await delay(500); } return mock.engineStatus(); },
inspectDump: async () => { throw new Error("preview"); },
addJob: async () => { throw "Adding dumps needs the desktop app (this is a browser preview)"; },