diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a3297f..f8a8de5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ What's new in ps5upload, written for humans. --- +## 3.3.20 + +- **Installs wait for the PS5 to be ready — fewer "couldn't be applied" errors.** + Right after an install the PS5 has a brief recovery moment (the screen-black + blink); starting the next install during it was getting rejected with a + transient error, so you'd have to wait and retry by hand. ps5upload now checks + the console is settled before it installs, and if the PS5 says it's busy it + waits and retries automatically instead of failing — so back-to-back updates + and DLC just work. (Shows "Waiting for the PS5 to be ready…" while it waits.) + ## 3.3.19 - **"Installed" no longer leaks between consoles.** If you staged the same .pkg diff --git a/VERSION b/VERSION index d62498e6..3df908d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.19 +3.3.20 diff --git a/client/package-lock.json b/client/package-lock.json index ca125cd8..1506b2a6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ps5upload-client", - "version": "3.3.19", + "version": "3.3.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ps5upload-client", - "version": "3.3.19", + "version": "3.3.20", "dependencies": { "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/client/package.json b/client/package.json index 2c4d7db7..76114f66 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "ps5upload-client", "private": true, - "version": "3.3.19", + "version": "3.3.20", "description": "The all-in-one PS5 companion app.", "homepage": "https://github.com/phantomptr/ps5upload", "author": "PhantomPtr ", diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index f49f6a63..19831e08 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "ftx2-proto" -version = "3.3.19" +version = "3.3.20" dependencies = [ "serde", "thiserror 2.0.18", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "ps5upload-core" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "base64 0.22.1", @@ -3336,7 +3336,7 @@ dependencies = [ [[package]] name = "ps5upload-desktop" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "base64 0.22.1", @@ -3369,7 +3369,7 @@ dependencies = [ [[package]] name = "ps5upload-engine" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "axum", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "ps5upload-pkg" -version = "3.3.19" +version = "3.3.20" dependencies = [ "serde", "thiserror 2.0.18", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index bb66fda9..2ab57353 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ps5upload-desktop" -version = "3.3.19" +version = "3.3.20" description = "The all-in-one PS5 companion app." edition = "2021" rust-version = "1.77" diff --git a/client/src-tauri/src/commands/ps5_engine.rs b/client/src-tauri/src/commands/ps5_engine.rs index 8ca0b16b..2a281f65 100644 --- a/client/src-tauri/src/commands/ps5_engine.rs +++ b/client/src-tauri/src/commands/ps5_engine.rs @@ -207,6 +207,20 @@ pub async fn ps5_apps_installed(addr: Option) -> Result) -> Result { + let base = engine::url(); + let url = match addr { + Some(a) => format!("{base}/api/ps5/readiness?addr={}", urlencoding(&a)), + None => format!("{base}/api/ps5/readiness"), + }; + get_json(&url).await +} + #[tauri::command] pub async fn ps5_list_dir( path: String, diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 57cf14d0..0a8ca97c 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -172,6 +172,7 @@ pub fn run() { commands::pkg_metadata_console, commands::ps5_list_dir, commands::ps5_apps_installed, + commands::ps5_readiness, commands::transfer_file, commands::transfer_dir, commands::transfer_zip, diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 933bbe01..242164b8 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PS5Upload", - "version": "3.3.19", + "version": "3.3.20", "identifier": "com.phantomptr.ps5upload", "build": { "beforeDevCommand": "npm run dev:vite", diff --git a/client/src/api/ps5.ts b/client/src/api/ps5.ts index c3c4ed07..9c83a966 100644 --- a/client/src/api/ps5.ts +++ b/client/src/api/ps5.ts @@ -2942,6 +2942,22 @@ export async function appsInstalled( return { titles, registeredUnavailable: !!res?.registered_unavailable }; } +/** Whether the console is settled enough to take a .pkg install — the engine + * round-trips the AppListRegistered frame, which goes unanswered while the + * console is recovering from a prior install (the post-install SceShellUI + * black-screen blip). Returns false on any error (treat "can't tell" as + * "not ready" so the caller waits rather than firing into the blip). */ +export async function consoleReadiness(host: string): Promise { + try { + const res = await invoke<{ ready?: boolean }>("ps5_readiness", { + addr: toMgmtAddr(host), + }); + return !!res?.ready; + } catch { + return false; + } +} + /** One `.pkg` found on a connected external/USB drive. */ export interface ExternalPkg { /** Absolute on-console path, e.g. `/mnt/usb0/games/foo.pkg`. */ diff --git a/client/src/state/pkgLibrary.test.ts b/client/src/state/pkgLibrary.test.ts index 84ab5fca..a7dcc517 100644 --- a/client/src/state/pkgLibrary.test.ts +++ b/client/src/state/pkgLibrary.test.ts @@ -20,12 +20,21 @@ vi.mock("../api/ps5", () => ({ // Pre-install space check; default to "plenty free" so store tests aren't // blocked. (null would also be fine — it means "couldn't read, don't block".) installFreeBytes: vi.fn(async () => 1_000_000_000_000), + // Console-readiness probe; default to "ready" so install tests proceed past + // the pre-install gate immediately. Readiness-specific tests override it. + consoleReadiness: vi.fn(async () => true), })); // No active transfer in tests → installs proceed immediately. vi.mock("../lib/ps5Transfers", () => ({ transferScreenBusy: () => false })); import { invoke } from "@tauri-apps/api/core"; -import { fsDelete, fsListDir, fsCopy, pkgMetadataConsole } from "../api/ps5"; +import { + fsDelete, + fsListDir, + fsCopy, + pkgMetadataConsole, + consoleReadiness, +} from "../api/ps5"; import { titleIdFromContentId, platformFromTitleId, @@ -38,6 +47,7 @@ import { installSpaceWarning, installedLastResult, runPkgInstall, + waitForConsoleReady, recordPkgInstalled, isPkgInstalledHere, PKG_MAY_NOT_LAUNCH_MESSAGE, @@ -232,6 +242,49 @@ describe("pkgRowInstalled (installed/Reinstall badge)", () => { }); }); +describe("waitForConsoleReady (install readiness gate)", () => { + const mockedReady = vi.mocked(consoleReadiness); + beforeEach(() => { + mockedReady.mockReset(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + // Restore the module default ("ready") so later install suites sail through + // the pre-install gate instead of inheriting this suite's not-ready stub. + mockedReady.mockReset(); + mockedReady.mockResolvedValue(true); + }); + + it("returns true immediately when the console is already ready (no wait)", async () => { + mockedReady.mockResolvedValue(true); + const p = waitForConsoleReady("192.168.1.10"); + await expect(p).resolves.toBe(true); + expect(mockedReady).toHaveBeenCalledTimes(1); // first probe, no delay + }); + + it("polls until the console becomes ready", async () => { + // Not ready twice, then ready. + mockedReady + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + const p = waitForConsoleReady("192.168.1.10"); + // Let the polling timers + awaits flush. + await vi.advanceTimersByTimeAsync(5_000); + await expect(p).resolves.toBe(true); + expect(mockedReady.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + it("gives up (false) after the timeout when never ready", async () => { + mockedReady.mockResolvedValue(false); + const p = waitForConsoleReady("192.168.1.10", { timeoutMs: 4_500 }); + await vi.advanceTimersByTimeAsync(10_000); + // timeoutMs/POLL(1500) = 3 attempts, then false. + await expect(p).resolves.toBe(false); + }); +}); + describe("recordPkgInstalled / isPkgInstalledHere (per-console isolation)", () => { // The reported bug: the same .pkg staged on multiple consoles lands at an // identical path, and installing on ONE console used to mark it installed on diff --git a/client/src/state/pkgLibrary.ts b/client/src/state/pkgLibrary.ts index f78bebc3..0906ae58 100644 --- a/client/src/state/pkgLibrary.ts +++ b/client/src/state/pkgLibrary.ts @@ -11,6 +11,7 @@ import { pkgMetadataConsole, toastPush, installFreeBytes, + consoleReadiness, } from "../api/ps5"; import type { ExternalPkg } from "../api/ps5"; import { formatBytes } from "../lib/format"; @@ -536,6 +537,52 @@ const PKG_VERIFY_POLL_MS = 2_500; * assume success — `completed` stays false so the pkg is KEPT (never delete on * uncertainty). */ const PKG_VERIFY_MAX_POLL_ERRORS = 5; + +// ── Console-readiness gate ─────────────────────────────────────────────────── +// A console goes unresponsive on the AppListRegistered frame while it recovers +// from a prior install (the post-install SceShellUI black-screen blip). Firing +// an install into that window is what produces the transient rejections seen on +// hardware — DPI rc=0x80020002 and appinst 0x80b21106 — which clear once the +// console settles (the user's logs: rejected twice, then ok minutes later after +// they waited). We gate installs on a readiness probe and retry the transient. +const READY_POLL_MS = 1_500; +/** Cap on each readiness wait. We never hard-block forever — if the probe can't + * clear (e.g. an older payload that can't report registered apps), attempting + * the install is better than refusing to try, and the DPI transient-retry is + * the real safety net for a genuinely-busy console. Kept modest (30s) so a + * console whose probe never clears adds a bounded delay, not a 90s stall; the + * real post-install blip settles well within this. */ +const READY_WAIT_TIMEOUT_MS = 30_000; +/** The DPI daemon's "console not in an installable state" rejection — transient + * (clears on settle). Retried with a readiness gate rather than failed outright. */ +const DPI_TRANSIENT_BUSY_RC = 0x80020002; +/** How many times to (re)attempt a DPI install that keeps hitting the transient + * busy rc, each gated on the console becoming ready again. */ +const DPI_MAX_ATTEMPTS = 4; + +/** + * Poll the console-readiness probe (the AppListRegistered round-trip) until it + * reports ready, or `timeoutMs` elapses. Returns true once ready, false on + * timeout. The first probe runs with no delay — a settled console proceeds + * instantly. `onWait` fires before each subsequent poll so the caller can + * surface a "waiting for the PS5…" notice. Exported for the queue's pre-install + * gate and for tests. + */ +export async function waitForConsoleReady( + host: string, + opts: { timeoutMs?: number; onWait?: (elapsedMs: number) => void } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? READY_WAIT_TIMEOUT_MS; + const maxAttempts = Math.max(1, Math.ceil(timeoutMs / READY_POLL_MS)); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (await consoleReadiness(host)) return true; + if (attempt < maxAttempts - 1) { + opts.onWait?.(attempt * READY_POLL_MS); + await sleep(READY_POLL_MS); + } + } + return false; +} /** Absolute backstop so a pathological engine that keeps returning "install" * forever can't block the queue indefinitely. Generously past the engine's * own 2h session GC — under normal operation the engine reaches a terminal @@ -690,7 +737,8 @@ async function verifyInstallCompleted( async function runDpiInstall( host: string, localPs5Path: string, -): Promise<{ ok: boolean; errMessage: string; daemonFailed: boolean }> { + onStatus?: (msg: string) => void, +): Promise<{ ok: boolean; errMessage: string; daemonFailed: boolean; rc: number }> { const ip = hostOf(host); const ens = (await invoke("dpi_ensure", { ip })) as { ok?: boolean; @@ -700,17 +748,34 @@ async function runDpiInstall( return { ok: false, daemonFailed: true, + rc: 0, errMessage: ens.error || "the DPI daemon didn't come up on :9040", }; } + // Send the install, retrying the transient "console busy" rejection + // (0x80020002) — it clears once the console settles, so we gate each retry on + // the readiness probe instead of failing the way a single attempt used to. let resp: { ok?: boolean; rc?: number; err_message?: string } = {}; - try { - resp = (await invoke("pkg_dpi_install", { - ps5Addr: mgmtAddr(host), - localPs5Path, - })) as typeof resp; - } catch (e) { - resp = { ok: false, rc: 0, err_message: pkgError(e) }; + for (let attempt = 1; attempt <= DPI_MAX_ATTEMPTS; attempt++) { + try { + resp = (await invoke("pkg_dpi_install", { + ps5Addr: mgmtAddr(host), + localPs5Path, + })) as typeof resp; + } catch (e) { + resp = { ok: false, rc: 0, err_message: pkgError(e) }; + } + const rcNow = (resp.rc ?? 0) >>> 0; + if (resp.ok || rcNow !== DPI_TRANSIENT_BUSY_RC || attempt === DPI_MAX_ATTEMPTS) { + break; + } + // Transient busy → wait for the console to settle, then retry. + onStatus?.( + `PS5 is busy finishing the last install — waiting for it to be ready (attempt ${attempt}/${DPI_MAX_ATTEMPTS})…`, + ); + await waitForConsoleReady(host, { + onWait: () => onStatus?.("Waiting for the PS5 to be ready…"), + }); } // Restore our main payload — the daemon replaced it. Best-effort. try { @@ -729,6 +794,7 @@ async function runDpiInstall( return { ok, daemonFailed: false, + rc, errMessage: ok ? "" : resp.err_message || @@ -796,7 +862,20 @@ export async function runPkgInstall( // can render a real % for large titles (Sony's BGFT progress isn't // meaningful on the file:// staging path). Optional — no-op if omitted. onProgress?: (installedBytes: number, total: number) => void, + // Called with a human-readable status line while the install waits on the + // console-readiness gate (pre-install + DPI transient retry). Lets the caller + // surface "Waiting for the PS5 to be ready…" instead of a frozen UI. + onStatus?: (msg: string) => void, ): Promise { + // PRE-INSTALL GATE: don't fire an install into the post-install SceShellUI + // recovery window — that's what produces the transient rejections. Wait for + // the console to answer the readiness probe cleanly first. Best-effort: on + // timeout we proceed anyway (an older payload may never report ready, and the + // transient-retry below still rescues a genuinely-busy console). + await waitForConsoleReady(host, { + onWait: () => onStatus?.("Waiting for the PS5 to be ready…"), + }); + let installed = false; // True when the install accepted but stalled (flatlined) before completing — // the pkg is KEPT and the caller shows a retry message, not a hard failure. @@ -878,7 +957,7 @@ export async function runPkgInstall( // it's safe for patches. HW-proven on the Phat — a Jak X v01.04 patch landed // in /user/patch/CUSA07842/patch.pkg with the 3.8 GB base in // /user/app/CUSA07842/app.pkg fully intact. - const dpi = await runDpiInstall(host, localPs5Path); + const dpi = await runDpiInstall(host, localPs5Path, onStatus); if (dpi.daemonFailed) { // DPI couldn't even come up. For a patch, give update-specific guidance // (base is safe; try the PS5's Package Installer) rather than a raw @@ -1359,6 +1438,8 @@ const makePkgLibraryStore = () => set({ busyNotice: `Installing on the PS5… ${pct}%` }); } }, + // Readiness-gate status (pre-install wait / DPI transient retry). + (msg) => set({ busyNotice: msg }), ); useActivityHistoryStore .getState() diff --git a/client/src/state/uploadQueue.ts b/client/src/state/uploadQueue.ts index 51333c9f..b2c38755 100644 --- a/client/src/state/uploadQueue.ts +++ b/client/src/state/uploadQueue.ts @@ -37,15 +37,18 @@ import { type RateSample, } from "../lib/rollingRate"; import { archiveFormat, type SourceKind } from "./upload"; -import { runPkgInstall, pkgLibraryStore, recordPkgInstalled } from "./pkgLibrary"; +import { + runPkgInstall, + pkgLibraryStore, + recordPkgInstalled, + waitForConsoleReady, +} from "./pkgLibrary"; import type { UploadStrategy } from "./transfer"; import { useUploadSettingsStore } from "./uploadSettings"; import { useRecentHostMetricsStore } from "./recentHostMetrics"; import { pushNotification } from "./notifications"; import { withConsolePrefix } from "./roster"; import { hostOf, mgmtAddr } from "../lib/addr"; -import { useConnectionStore } from "./connection"; -import { parsePS5Firmware } from "../lib/ps5Firmware"; import { log } from "./logs"; import { ensurePayloadCurrent } from "../lib/ensurePayloadCurrent"; import { effectiveUploadStreams } from "../lib/uploadStreams"; @@ -699,6 +702,8 @@ export const useUploadQueueStore = create((set, get) => { }); } }, + // Readiness-gate status (pre-install wait / DPI transient retry). + (msg) => pkgStore.setState({ busyNotice: msg }), ); if (r.installed) { installPhase = r.mayNotLaunch ? "warn" : "done"; @@ -1344,16 +1349,20 @@ export const useUploadQueueStore = create((set, get) => { }; }); -/** Extra post-install settle on FW12+, where the main-payload install briefly - * destabilises SceShellUI (the screen-black blip) and the connection recovers - * a beat later. Without it, the next queued item's upload can start before the - * payload is back and stall the queue (reported on multi small DLC/updates). - * No-op below FW12, where the blip isn't observed. Env-free, host-scoped. */ +/** Post-install settle. A main-payload install briefly destabilises SceShellUI + * (the screen-black blip) and the connection recovers a beat later; starting + * the next queued item's upload/install before the console is back stalls the + * queue (reported on multi small DLC/updates) or draws a transient install + * rejection. Rather than a blind sleep, ACTIVELY wait for the console to answer + * the readiness probe again — adaptive (returns the moment it's ready) and + * applies on every firmware, not just FW12. Bounded so a console that never + * clears the probe doesn't wedge the queue; a short floor sleep still covers + * the case where the probe can't report readiness at all. */ async function fw12InstallSettle(host: string): Promise { - const rt = useConnectionStore.getState().runtimeByHost[host] ?? null; - const fw = parsePS5Firmware(rt?.ps5Kernel ?? null); - const major = fw ? parseFloat(fw) : 0; - if (major >= 12) await sleep(3000); + const ready = await waitForConsoleReady(mgmtAddr(host), { timeoutMs: 30_000 }); + // If the probe never reported ready (older payload), fall back to the old + // fixed settle so we don't barrel straight into the recovery window. + if (!ready) await sleep(3000); } function sleep(ms: number): Promise { diff --git a/engine/Cargo.lock b/engine/Cargo.lock index c6547f86..2befc5d3 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -494,7 +494,7 @@ dependencies = [ [[package]] name = "ftx2-proto" -version = "3.3.19" +version = "3.3.20" dependencies = [ "serde", "thiserror", @@ -954,7 +954,7 @@ dependencies = [ [[package]] name = "ps5upload-bench" -version = "3.3.19" +version = "3.3.20" dependencies = [ "criterion", "ftx2-proto", @@ -964,7 +964,7 @@ dependencies = [ [[package]] name = "ps5upload-core" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "base64", @@ -981,7 +981,7 @@ dependencies = [ [[package]] name = "ps5upload-engine" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "axum", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "ps5upload-lab" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "ftx2-proto", @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "ps5upload-pkg" -version = "3.3.19" +version = "3.3.20" dependencies = [ "serde", "serde_json", @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "ps5upload-tests" -version = "3.3.19" +version = "3.3.20" dependencies = [ "anyhow", "ftx2-proto", diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 75106617..c0075de0 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" [workspace.package] edition = "2021" license = "GPL-3.0-or-later" -version = "3.3.19" +version = "3.3.20" [workspace.dependencies] anyhow = "1.0" diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs index 4fea88a7..c75eb010 100644 --- a/engine/crates/ps5upload-engine/src/lib.rs +++ b/engine/crates/ps5upload-engine/src/lib.rs @@ -2738,6 +2738,34 @@ async fn ps5_status( } } +/// GET /api/ps5/readiness?addr=IP:MGMT_PORT +/// +/// Lightweight "is the console in a stable state to install a .pkg" probe. It +/// round-trips the AppListRegistered frame — the exact request that goes +/// unanswered ("read frame header: failed to fill whole buffer" / ECONNRESET) +/// while the console is recovering from a prior install (the post-install +/// SceShellUI black-screen blip). A clean response ⇒ the console is settled and +/// ready to take another install; an error ⇒ it's still busy. Always returns +/// 200 with `{ ready, detail }` so the client reads `ready` directly instead of +/// having to treat a transient "busy" as an HTTP failure. +async fn ps5_readiness( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let result = tokio::task::spawn_blocking(move || app_list_registered(&addr)).await; + let (ready, detail) = match result { + Ok(Ok(_)) => (true, String::new()), + Ok(Err(e)) => (false, format!("{e:#}")), + Err(e) => (false, format!("{e:#}")), + }; + ( + StatusCode::OK, + Json(serde_json::json!({ "ready": ready, "detail": detail })), + ) + .into_response() +} + /// POST /api/transfer/file async fn transfer_file_handler( State(state): State, @@ -5761,6 +5789,7 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> { let app = Router::new() .route("/", get(ui_handler)) .route("/api/ps5/status", get(ps5_status)) + .route("/api/ps5/readiness", get(ps5_readiness)) .route("/api/ps5/cleanup", post(ps5_cleanup)) .route("/api/ps5/volumes", get(ps5_volumes)) .route("/api/ps5/pkg/scan-external", get(ps5_pkg_scan_external)) diff --git a/payload/include/config.h b/payload/include/config.h index 48d4d39c..3cc2bdd8 100644 --- a/payload/include/config.h +++ b/payload/include/config.h @@ -5,7 +5,7 @@ * UI tell apart an old payload still running from a build that includes * a particular fix, without having to boot the console. Keep in sync * with the desktop app's package.json during releases. */ -#define PS5UPLOAD2_VERSION "3.3.19" +#define PS5UPLOAD2_VERSION "3.3.20" /* Author credit — embedded in the startup toast so anyone looking at * the console screen knows who wrote the software that just loaded. * Kept separate from VERSION so release scripts can bump the version