Remove unused repo files and stale FTX2 doc notes
Deletes tests/install-fallback-hw.mjs, the mobile audit probe and its allowlist, scripts/requirements.txt, tests/lab/verify-pending.sh, bench/baselines/ and client/icons/ (nothing references them; the bundle icons live in client/src-tauri/icons). audit-scripts lists fpkg-diff.py as a manual research tool and no longer counts a script naming itself as a reference. SPEC.md no longer says zip/7z/RAR sources fall back to FTX2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
No files matched your search
@@ -266,7 +266,7 @@ jobs:
|
||||
- name: C frame AEAD (RFC 8439 + Monocypher differential, AVX2 and portable paths)
|
||||
run: scripts/ava1-aead-test.sh
|
||||
|
||||
- name: No FTX2 or ports 9113/9114 outside the payload
|
||||
- name: No FTX2 or ports 9113/9114 in the repository
|
||||
run: make check-no-ftx2
|
||||
|
||||
- name: Fuzz C decoders + data-plane parsers (60 s each)
|
||||
|
||||
@@ -73,7 +73,6 @@ tests/lab/elev_probe/*.elf
|
||||
# ─── Bench artefacts (generated on demand) ───────────────────────────────
|
||||
bench/fixtures/
|
||||
bench/reports/
|
||||
bench/baselines/*.tmp
|
||||
|
||||
# ─── Logs / debug dumps ──────────────────────────────────────────────────
|
||||
# Anchor `logs/` and `debug/` to the repo root so the patterns don't
|
||||
@@ -160,7 +159,7 @@ test-results/
|
||||
#
|
||||
# So ignore image/video files AT THE ROOT by default and name the
|
||||
# exceptions. Anchored with a leading slash, so real assets under
|
||||
# client/icons/, client/public/ and client/src-tauri/icons/ are untouched.
|
||||
# client/public/ and client/src-tauri/icons/ are untouched.
|
||||
/*.png
|
||||
/*.jpg
|
||||
/*.jpeg
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 426 B |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 963 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 430 B |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 430 B |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 430 B |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -404,8 +404,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `/api/ps5/status` is the legacy JSON rebuilt from `NodeStatus`: the keys that scripts and
|
||||
/// the client read are all there (`bench/resume-test.mjs` and `tests/install-fallback-hw.mjs`
|
||||
/// read `version` and `command_count`), and the six old transaction fields are not.
|
||||
/// the client read are all there (`bench/resume-test.mjs` reads `version` and
|
||||
/// `command_count`), and the six old transaction fields are not.
|
||||
#[test]
|
||||
fn the_status_json_keeps_the_keys_callers_read_and_drops_the_transaction_ones() {
|
||||
let v = node_status_json(&NodeStatus::default());
|
||||
|
||||
@@ -374,7 +374,7 @@ normative table; the second column names the constant in the generated code.
|
||||
| 19 | `ERR_PAIRING_CODE` | a client-side refusal of the pairing: the console refused the typed code, or could not prove it knows it (§5.5) |
|
||||
|
||||
7.3 Management methods (the console operations FTX2 carried on :9114). Numbers are assigned by
|
||||
block; the tracked list, one row per FTX2 frame with its payload handler and engine caller, is
|
||||
block; the tracked list, one row per method with its payload handler and engine caller, is
|
||||
`MGMT_METHODS.md`. The generated constants `METHOD_*` in `schema/ava1.toml` are normative.
|
||||
|
||||
| numbers | block | bodies |
|
||||
@@ -605,11 +605,6 @@ Not in version 1, each with its reason:
|
||||
- Engine ↔ engine sharing: `host::FolderHost` exists as the receiving half, the sharing
|
||||
policy and the feature are deferred.
|
||||
- Zstd bundles and small-file deduplication: ruled out of project 2.
|
||||
- Zip entries larger than 256 MiB (`ZIP_MAX_ENTRY`) as AVA1 sources: entries are inflated
|
||||
on demand and there is no streaming entry reader yet, so an archive with a larger entry stays
|
||||
on FTX2.
|
||||
- 7z and RAR sources: their decoders are forward-only, so there is no random-access `Source`
|
||||
for them; they stay on FTX2 until project 3.
|
||||
- Full re-verification of durable groups on resume: §13.4 re-hashes only the last durable
|
||||
batch of each partial file and trusts older groups to the journal.
|
||||
- Sources of unknown length: every file's size must be known when the manifest is built.
|
||||
@@ -1177,9 +1172,8 @@ non-Unix hosts carry none). 0 means the archive has none. Directory mtimes are 0
|
||||
and `0755` for directories: archives' Unix permission bits are not carried (zip's `unix_mode` excepted).
|
||||
|
||||
17.6 Refusals. A duplicate name, a path that is both a file and a directory (and, for RAR, two names that differ
|
||||
only in case) fail the job terminally (`ava1_7z_unsupported`, `ava1_rar_unsupported`); only an unsupported 7z
|
||||
coder method falls back to FTX2, since FTX2 has the same problem with the others (it writes both duplicates, or
|
||||
hits the same decoder memory limit). A RAR's listing order is compared with its extraction order only when a
|
||||
only in case) fail the job terminally (`ava1_7z_unsupported`, `ava1_rar_unsupported`), and so does an
|
||||
unsupported 7z coder method (`7z_unsupported`). A RAR's listing order is compared with its extraction order only when a
|
||||
non-solid resume skips entries by position; any other pass binds entries by path.
|
||||
|
||||
## 18. Console to console
|
||||
|
||||
@@ -9,6 +9,8 @@ const strict = process.argv.includes("--strict");
|
||||
const manualEntrypoints = new Set([
|
||||
"scripts/bundle-fonts.py",
|
||||
"scripts/find-orphan-i18n.mjs",
|
||||
// Research tool for comparing a built package against a reference one by hand.
|
||||
"scripts/fpkg-diff.py",
|
||||
"scripts/hw-test-loop.sh",
|
||||
"scripts/i18n-fill-missing.mjs",
|
||||
"scripts/i18n-prune-unused.mjs",
|
||||
@@ -30,7 +32,8 @@ function countRefs(needle, haystackFiles) {
|
||||
const base = path.basename(needle);
|
||||
let refs = 0;
|
||||
for (const file of haystackFiles) {
|
||||
if (file === "scripts/audit-scripts.mjs") continue;
|
||||
// A script naming itself (usage text, a docstring) is not a reference.
|
||||
if (file === "scripts/audit-scripts.mjs" || file === needle) continue;
|
||||
try {
|
||||
const text = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
if (text.includes(base)) refs += 1;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"_comment": "Accepted exceptions for scripts/mobile-audit-probe.mjs, keyed by route then element key. Mirrors scripts/i18n-known-missing.json. Every entry needs a reason in _reasons — an unexplained allowlist entry is how a real defect gets buried.",
|
||||
"routes": {},
|
||||
"_reasons": {}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Mobile sizing audit — measurement logic.
|
||||
*
|
||||
* Walks the app at Android's viewport and reports layout defects that
|
||||
* are invisible in code review but obvious on a phone:
|
||||
*
|
||||
* 1. Interactive elements below the 44x44px touch floor
|
||||
* (docs/v5-mobile-design.md §4.1).
|
||||
* 2. Nested scroll containers — a scrollable element inside another.
|
||||
* This is the defect class that made the old More sheet unusable.
|
||||
* 3. Horizontal overflow of the document.
|
||||
*
|
||||
* DRIVER-AGNOSTIC BY DESIGN. This module holds the probe and the report
|
||||
* formatting; it does NOT depend on Playwright, because the repo's root
|
||||
* package.json deliberately carries zero devDependencies and a browser
|
||||
* driver would add a ~500MB download to every dev machine and CI runner.
|
||||
*
|
||||
* Two ways to run it:
|
||||
*
|
||||
* a) Inject `PROBE_SOURCE` into any browser automation you already
|
||||
* have (the probe is self-contained — no closures, no imports) and
|
||||
* feed the results to `summarize()`.
|
||||
*
|
||||
* b) Install Playwright and run this file directly:
|
||||
* npm i -D playwright && npx playwright install chromium
|
||||
* node scripts/mobile-audit-probe.mjs
|
||||
* It exits 0 when clean, 1 when there are findings.
|
||||
*
|
||||
* LIMITATION: this measures the BROWSER build at Android's viewport. It
|
||||
* catches layout, sizing and overflow. It does NOT catch WebView-specific
|
||||
* rendering, real touch behaviour, or device-reported safe-area insets.
|
||||
* Confirm on hardware with `adb exec-out screencap -p`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** Pixel 9 Pro XL: 1344x2992 physical at density 480 → 448x997 CSS px. */
|
||||
export const VIEWPORT = { width: 448, height: 997 };
|
||||
|
||||
/** WCAG 2.5.5 / mobile-design §4.1. */
|
||||
export const MIN_TARGET = 44;
|
||||
|
||||
/** Every route reachable from the nav catalogue, plus the tab roots. */
|
||||
export const ROUTES = [
|
||||
"/home", "/connection", "/whats-new", "/dashboard", "/more", "/payloads",
|
||||
"/upload", "/install-package", "/saves", "/screenshots", "/videos",
|
||||
"/games", "/installed", "/files", "/search", "/volumes", "/disk-usage",
|
||||
"/console", "/processes", "/profile", "/fan-curve", "/remote-play",
|
||||
"/notifications", "/cheats", "/game-activity", "/sdk-changer", "/tmdb",
|
||||
"/fw-spoof", "/ftp-server", "/smb-browser", "/backup", "/nanodns",
|
||||
"/shell", "/tasks", "/stats", "/logs", "/audit-log", "/bug-report",
|
||||
"/faq", "/settings", "/about",
|
||||
];
|
||||
|
||||
/**
|
||||
* Runs INSIDE the page. Self-contained on purpose so it can be injected
|
||||
* as a string by any driver — do not reference module scope from here.
|
||||
*
|
||||
* Scoped to <main> so the persistent chrome (bottom nav, top bar) is
|
||||
* measured once on its own rather than re-reported on all 41 routes.
|
||||
*/
|
||||
export function probe(minTarget) {
|
||||
const SEL =
|
||||
'a, button, [role="button"], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const isVisible = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return false;
|
||||
const s = getComputedStyle(el);
|
||||
return (
|
||||
s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0"
|
||||
);
|
||||
};
|
||||
|
||||
const labelOf = (el) =>
|
||||
(
|
||||
el.getAttribute("aria-label") ||
|
||||
el.textContent ||
|
||||
el.getAttribute("placeholder") ||
|
||||
""
|
||||
)
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 40);
|
||||
|
||||
/** Short, reasonably stable key for allowlisting. */
|
||||
const keyOf = (el) => {
|
||||
const cls = (el.getAttribute("class") || "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join(".");
|
||||
return el.tagName.toLowerCase() + (cls ? "." + cls : "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Effective tap rect.
|
||||
*
|
||||
* A checkbox or radio is drawn at ~20px on purpose — drawing it at
|
||||
* 44px looks wrong. What the user actually taps is the control PLUS
|
||||
* its associated <label for>, because clicking the label toggles the
|
||||
* control. Measuring the bare input would report a false failure on a
|
||||
* correctly-built row, so union the two.
|
||||
*/
|
||||
const tapRect = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const type = (el.getAttribute("type") || "").toLowerCase();
|
||||
if (el.tagName !== "INPUT" || (type !== "checkbox" && type !== "radio")) {
|
||||
return r;
|
||||
}
|
||||
const id = el.getAttribute("id");
|
||||
const lbl = id
|
||||
? document.querySelector(`label[for="${CSS.escape(id)}"]`)
|
||||
: el.closest("label");
|
||||
if (!lbl) return r;
|
||||
const l = lbl.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(r.right, l.right) - Math.min(r.left, l.left),
|
||||
height: Math.max(r.bottom, l.bottom) - Math.min(r.top, l.top),
|
||||
};
|
||||
};
|
||||
|
||||
const root = document.querySelector("main") || document.body;
|
||||
|
||||
const smallTargets = [...root.querySelectorAll(SEL)]
|
||||
.filter(isVisible)
|
||||
.map((el) => {
|
||||
const r = tapRect(el);
|
||||
return {
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
label: labelOf(el),
|
||||
key: keyOf(el),
|
||||
};
|
||||
})
|
||||
.filter((e) => e.h < minTarget || e.w < minTarget);
|
||||
|
||||
const scrollers = [...root.querySelectorAll("*")].filter((el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return (
|
||||
["auto", "scroll"].includes(s.overflowY) &&
|
||||
el.scrollHeight > el.clientHeight
|
||||
);
|
||||
});
|
||||
const nestedScrollers = [
|
||||
...new Set(
|
||||
scrollers
|
||||
.filter((el) => scrollers.some((o) => o !== el && o.contains(el)))
|
||||
.map(keyOf),
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
smallTargets,
|
||||
nestedScrollers,
|
||||
hOverflow:
|
||||
document.documentElement.scrollWidth >
|
||||
document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/** The probe as an injectable string, for drivers that take source. */
|
||||
export const PROBE_SOURCE = probe.toString();
|
||||
|
||||
export function loadAllowlist(path = "scripts/mobile-audit-allowlist.json") {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return { routes: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop allowlisted findings and keep only routes that still have any.
|
||||
* `results` is `[{ route, ...probeResult }]`.
|
||||
*/
|
||||
export function summarize(results, allowlist = { routes: {} }) {
|
||||
const report = [];
|
||||
for (const r of results) {
|
||||
if (r.error) {
|
||||
report.push({ route: r.route, error: r.error });
|
||||
continue;
|
||||
}
|
||||
const allowed = new Set(allowlist.routes?.[r.route] || []);
|
||||
const smallTargets = (r.smallTargets || []).filter(
|
||||
(s) => !allowed.has(s.key),
|
||||
);
|
||||
const nestedScrollers = (r.nestedScrollers || []).filter(
|
||||
(k) => !allowed.has(k),
|
||||
);
|
||||
if (smallTargets.length || nestedScrollers.length || r.hOverflow) {
|
||||
report.push({
|
||||
route: r.route,
|
||||
smallTargets,
|
||||
nestedScrollers,
|
||||
hOverflow: !!r.hOverflow,
|
||||
});
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function formatReport(report, routeCount) {
|
||||
if (report.length === 0) {
|
||||
return `[mobile-audit] ok — ${routeCount} routes clean at ${VIEWPORT.width}x${VIEWPORT.height}`;
|
||||
}
|
||||
const lines = [`[mobile-audit] ${report.length} route(s) with findings:`, ""];
|
||||
for (const r of report) {
|
||||
lines.push(` ${r.route}`);
|
||||
if (r.error) {
|
||||
lines.push(` ! ${r.error}`);
|
||||
continue;
|
||||
}
|
||||
if (r.hOverflow) lines.push(" ! horizontal overflow");
|
||||
for (const n of r.nestedScrollers)
|
||||
lines.push(` ! nested scroller: ${n}`);
|
||||
for (const s of r.smallTargets)
|
||||
lines.push(` ! ${s.w}x${s.h}px "${s.label}" (${s.key})`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
" fix the above, or allowlist the key in scripts/mobile-audit-allowlist.json",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/* ── Optional Playwright runner ───────────────────────────────────────
|
||||
* Only used when this file is executed directly AND playwright resolves.
|
||||
* Kept opt-in so the module stays dependency-free for injection use. */
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const base = process.env.AUDIT_BASE || "http://127.0.0.1:1420";
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import("playwright"));
|
||||
} catch {
|
||||
console.error(
|
||||
"[mobile-audit] playwright is not installed, so this file cannot drive a\n" +
|
||||
"browser itself. Either:\n" +
|
||||
" npm i -D playwright && npx playwright install chromium\n" +
|
||||
"or inject PROBE_SOURCE via your own automation and pass the results\n" +
|
||||
"to summarize() + formatReport().",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: VIEWPORT });
|
||||
const results = [];
|
||||
for (const route of ROUTES) {
|
||||
try {
|
||||
await page.goto(base + route, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 20000,
|
||||
});
|
||||
await page.waitForTimeout(400); // let lazy chunks paint
|
||||
results.push({ route, ...(await page.evaluate(probe, MIN_TARGET)) });
|
||||
} catch {
|
||||
results.push({ route, error: "navigation failed" });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
const report = summarize(results, loadAllowlist());
|
||||
console.log(formatReport(report, ROUTES.length));
|
||||
process.exit(report.length === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# No external dependencies, uses standard library
|
||||
@@ -1,211 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tests/install-fallback-hw.mjs
|
||||
*
|
||||
* Real-hardware test for the PKG-install fallback chain (the 2.27.x
|
||||
* FW-12 reorder in payload/src/bgft.c). It stages a DUMMY (invalid) .pkg
|
||||
* on the console and kicks off an install, forcing every tier to fail in
|
||||
* order:
|
||||
*
|
||||
* Tier 1 in-process sceAppInstUtilInstallByPackage(local) → reject
|
||||
* Tier 2 ShellUI-RPC InstallByPackage → reject
|
||||
* Tier 3 legacy BGFT IntDebug register → reject
|
||||
* LAST sceAppInstUtilAppInstallPkg (the unlaunchable one) → reject
|
||||
*
|
||||
* The point is NOT a successful install (we have no valid fakepkg) — it's
|
||||
* to prove the reordered chain:
|
||||
* 1. does NOT crash or wedge the payload (mgmt port answers afterwards),
|
||||
* 2. returns a structured error with a sane register_path / via, and
|
||||
* 3. never reports a clean success for the dummy.
|
||||
*
|
||||
* NOTE: this ptraces SceShellUI on the console, which can cause a brief
|
||||
* black flash / ShellUI respawn on the TV. It is recoverable. Opt-in:
|
||||
* run it explicitly, it is not part of `npm test`.
|
||||
*
|
||||
* Usage:
|
||||
* node tests/install-fallback-hw.mjs --ps5=192.168.86.99 [--engine=URL]
|
||||
*
|
||||
* Exit 0 = payload survived + returned structured diagnostics.
|
||||
* Exit 1 = payload crashed, wedged, or falsely reported success.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function arg(name, def) {
|
||||
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
|
||||
return hit ? hit.slice(name.length + 3) : def;
|
||||
}
|
||||
|
||||
const PS5 = arg('ps5', '192.168.86.99');
|
||||
const ENGINE = arg('engine', 'http://127.0.0.1:19113');
|
||||
const MGMT = PS5;
|
||||
const DATA = PS5;
|
||||
const STAGE = '/data/ps5upload/tests/install-fallback/dummy.pkg';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`${url} -> HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
async function postJson(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { _raw: text };
|
||||
}
|
||||
return { ok: r.ok, status: r.status, json };
|
||||
}
|
||||
async function waitHttp(url, ms) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.ok) return true;
|
||||
} catch {
|
||||
/* keep waiting */
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let engineChild = null;
|
||||
async function maybeSpawnEngine() {
|
||||
if (await waitHttp(`${ENGINE}/api/jobs`, 1000)) return; // already up
|
||||
const bin = path.join(repoRoot, 'engine', 'target', 'release', 'ps5upload-engine');
|
||||
engineChild = spawn(bin, [], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PS5UPLOAD_PS5_ADDR: DATA },
|
||||
});
|
||||
if (!(await waitHttp(`${ENGINE}/api/jobs`, 20_000))) {
|
||||
throw new Error('engine did not come up');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let failures = 0;
|
||||
const note = (ok, label, extra) => {
|
||||
if (!ok) failures++;
|
||||
process.stdout.write(` ${ok ? '✓ PASS' : '✗ FAIL'} ${label}${extra ? ` — ${extra}` : ''}\n`);
|
||||
};
|
||||
|
||||
await maybeSpawnEngine();
|
||||
process.stdout.write(`[install-fallback] ps5=${PS5} engine=${ENGINE}\n`);
|
||||
|
||||
// 0. Payload alive before.
|
||||
const before = await getJson(`${ENGINE}/api/ps5/status?addr=${encodeURIComponent(MGMT)}`);
|
||||
note(!!before, 'payload alive before', before?.version ? `v${before.version} ${before.ps5_kernel ?? ''}` : '');
|
||||
|
||||
// 1. Stage a dummy (invalid) .pkg on the console (1 KiB of zeros).
|
||||
const stage = await postJson(`${ENGINE}/api/transfer/bytes-b64`, {
|
||||
addr: DATA,
|
||||
dest: STAGE,
|
||||
// 1 KiB of 0x00 — definitely not a valid PKG header.
|
||||
data_b64: Buffer.alloc(1024, 0).toString('base64'),
|
||||
}).catch((e) => ({ ok: false, json: { error: String(e) } }));
|
||||
// The bytes endpoint may not exist on older engines; fall back to a file transfer.
|
||||
if (!stage.ok) {
|
||||
// Fallback: write a temp file and use /api/transfer/file.
|
||||
const fs = await import('node:fs/promises');
|
||||
const os = await import('node:os');
|
||||
const tmp = path.join(os.tmpdir(), `iflbk-${Date.now()}.pkg`);
|
||||
await fs.writeFile(tmp, Buffer.alloc(1024, 0));
|
||||
const j = await postJson(`${ENGINE}/api/transfer/file`, { addr: DATA, dest: STAGE, src: tmp });
|
||||
const jobId = j.json?.job_id;
|
||||
if (jobId) {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const job = await getJson(`${ENGINE}/api/jobs/${jobId}`);
|
||||
if (job.status === 'done' || job.status === 'error') break;
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
await fs.rm(tmp, { force: true });
|
||||
}
|
||||
|
||||
// 2. Kick the install with the staged dummy as a LOCAL path → forces the
|
||||
// full reordered fallback chain.
|
||||
const res = await postJson(`${ENGINE}/api/pkg/install/start`, {
|
||||
ps5_addr: MGMT,
|
||||
path: null,
|
||||
split_root: null,
|
||||
package_type_override: null,
|
||||
local_ps5_path: STAGE,
|
||||
content_id: 'IV0000-NPXX99999_00-DUMMY00000000000',
|
||||
});
|
||||
process.stdout.write(` install/start -> HTTP ${res.status}: ${JSON.stringify(res.json)}\n`);
|
||||
|
||||
// 3. Sony's InstallByPackage is ASYNC-ACCEPT: rc=0 means "queued", not
|
||||
// "valid pkg" — a dummy can legitimately be accepted by a launchable
|
||||
// tier (in-proc appinst or shellui-rpc) and only fail later, invisibly
|
||||
// (those tiers take the synthetic-DONE status bypass). So we do NOT
|
||||
// assert "rejected". We assert the INVARIANTS the 2.27.x change adds:
|
||||
const rc = (res.json?.err_code ?? 0) >>> 0;
|
||||
const regPath = res.json?.register_path ?? '';
|
||||
const via = res.json?.via ?? '';
|
||||
const mnl = res.json?.may_not_launch ?? false;
|
||||
const KNOWN = ['', 'none', 'appinst', 'appinst-local', 'shellui-rpc', 'intdebug', 'regular', 'tier0-worker'];
|
||||
process.stdout.write(
|
||||
` outcome: err=0x${rc.toString(16)} register_path=${regPath || '∅'} via=${via || '∅'} may_not_launch=${mnl}\n`,
|
||||
);
|
||||
// (a) register_path is a value the host knows how to render.
|
||||
note(KNOWN.includes(regPath), 'register_path is a known value', regPath || '∅');
|
||||
// (b) THE core invariant: may_not_launch is true IFF the unlaunchable
|
||||
// last-resort path (appinst-local) accepted. Every other accepted
|
||||
// tier is launchable and must be may_not_launch=false.
|
||||
note(
|
||||
mnl === (regPath === 'appinst-local'),
|
||||
'may_not_launch === (register_path === "appinst-local")',
|
||||
`may_not_launch=${mnl} register_path=${regPath || '∅'}`,
|
||||
);
|
||||
// (c) Reorder property: a launchable tier (appinst/shellui-rpc/intdebug/
|
||||
// regular) accepting means the unlaunchable AppInstallPkg was NOT
|
||||
// reached first — i.e. an accepted launchable tier is never flagged.
|
||||
if (rc === 0 && regPath !== 'appinst-local') {
|
||||
note(mnl === false, 'accepted launchable tier is not flagged unlaunchable', `via=${via}`);
|
||||
}
|
||||
|
||||
// 4. Poll status briefly — must not 5xx / wedge.
|
||||
if (res.json?.session_id) {
|
||||
const st = await getJson(
|
||||
`${ENGINE}/api/pkg/install/status?session=${encodeURIComponent(res.json.session_id)}`,
|
||||
).catch((e) => ({ _err: String(e) }));
|
||||
note(!st._err, 'status poll responded', st._err ? st._err : `phase=${st.phase} may_not_launch=${st.may_not_launch}`);
|
||||
}
|
||||
|
||||
// 5. THE KEY ASSERTION: payload survived the whole fallback chain.
|
||||
await sleep(1500);
|
||||
let alive = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
alive = await getJson(`${ENGINE}/api/ps5/status?addr=${encodeURIComponent(MGMT)}`).catch(() => null);
|
||||
if (alive) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
note(!!alive, 'payload SURVIVED the full fallback chain (mgmt port answers)',
|
||||
alive ? `v${alive.version} commands=${alive.command_count}` : 'mgmt port did not answer');
|
||||
|
||||
// Cleanup staged dummy (best-effort).
|
||||
await postJson(`${ENGINE}/api/ps5/fs/delete`, { addr: MGMT, path: STAGE }).catch(() => {});
|
||||
|
||||
process.stdout.write(`\ninstall-fallback result: ${failures === 0 ? 'all clear' : `${failures} FAILED`}\n`);
|
||||
if (engineChild) engineChild.kill();
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
process.stderr.write(`fatal: ${e.stack || e}\n`);
|
||||
if (engineChild) engineChild.kill();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Turnkey hardware check for the browser/Docker stream install with a REAL
|
||||
# package, through the unified install endpoint. Run it against a console that
|
||||
# already has the loader's prerequisite payloads running.
|
||||
#
|
||||
# PS5=192.168.86.99 ENGINE=http://127.0.0.1:19113 tests/lab/verify-pending.sh stream /path/to/real-game.pkg
|
||||
set -euo pipefail
|
||||
PS5="${PS5:-192.168.86.99}"
|
||||
ENGINE="${ENGINE:-http://127.0.0.1:19113}"
|
||||
MODE="${1:-help}"
|
||||
|
||||
need_up() {
|
||||
ping -c1 -W2000 "$PS5" >/dev/null 2>&1 || { echo "ABORT: $PS5 is not reachable — wake the console / re-run your loader."; exit 1; }
|
||||
nc -z -G3 "$PS5" 9120 2>/dev/null || { echo "ABORT: payload port :9120 closed — load ps5upload first (make send-payload PS5_HOST=$PS5)."; exit 1; }
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
stream)
|
||||
# Replicates handleBrowserStreamFile: upload the pkg to the engine host, then
|
||||
# one POST /api/pkg/install with a host_file source. The engine creates the
|
||||
# pkg-host session, brings up the installer daemon, hands it the URL, waits
|
||||
# until the console has pulled the whole package, and verifies.
|
||||
PKG="${2:?usage: verify-pending.sh stream /path/to/game.pkg}"
|
||||
[ -f "$PKG" ] || { echo "no such pkg: $PKG"; exit 1; }
|
||||
MAGIC=$(xxd -l4 -p "$PKG"); [ "$MAGIC" = "7f434e54" ] || { echo "ABORT: $PKG magic=$MAGIC, not a PS4/PS5 package (7f434e54)."; exit 1; }
|
||||
need_up
|
||||
ADDR="$PS5"
|
||||
echo "1. upload"; UP=$(curl -s -X POST "$ENGINE/api/pkg/upload" -F "f=@$PKG"); PATHV=$(jq -r .path <<<"$UP"); UPID=$(jq -r .upload_id <<<"$UP"); echo " -> $PATHV"
|
||||
HEAD=$(curl -s -X POST "$ENGINE/api/pkg/parse" -H 'content-type: application/json' -d "{\"path\":\"$PATHV\"}")
|
||||
CID=$(jq -r '.head.content_id // .content_id' <<<"$HEAD"); TID=$(jq -r '.head.title_id // .title_id' <<<"$HEAD"); CAT=$(jq -r '.head.package_type // .package_type' <<<"$HEAD")
|
||||
echo "2. install (content_id=$CID)"
|
||||
JOB=$(curl -s -X POST "$ENGINE/api/pkg/install" -H 'content-type: application/json' \
|
||||
-d "{\"ps5_addr\":\"$ADDR\",\"source\":{\"host_file\":\"$PATHV\"},\"content_id\":\"$CID\",\"title_id\":\"$TID\",\"category\":\"$CAT\",\"options\":{\"allow_destructive_reinstall\":true}}" | jq -r .job)
|
||||
echo " -> job $JOB"
|
||||
echo "3. status"
|
||||
while :; do
|
||||
S=$(curl -s "$ENGINE/api/pkg/install/status?job=$JOB")
|
||||
echo " $(jq -c '{phase,verdict,served:.metrics.served_bytes,total:.metrics.total_bytes}' <<<"$S")"
|
||||
case "$(jq -r .phase <<<"$S")" in done|failed) break;; esac
|
||||
sleep 3
|
||||
done
|
||||
echo "4. cleanup"; curl -s -X DELETE "$ENGINE/api/pkg/upload/$UPID" >/dev/null; echo " done"
|
||||
echo "PASS if the verdict is installed and the title launches on the PS5."
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "usage: verify-pending.sh stream <game.pkg>"
|
||||
;;
|
||||
esac
|
||||