Compare commits

...
5 Commits
Author SHA1 Message Date
Knutwurst 04b63eb800 Rebuild when a header changes 2026-10-20 08:23:48 +02:00
Knutwurst e853bbb0b4 Bump version to 0.3.3 2026-10-20 08:22:16 +02:00
Knutwurst e6e7054e95 Measure the game's drive through the system root
Game paths (/user/app/<id>) don't resolve in PatchDL's own root view, so
the install-space checks must go through the system root the scanner
keeps, or they silently skipped. Whether the download folder and the
game share a drive is decided by the file system id or, failing that,
its exact size. A full drive (0 bytes free) now counts as full instead
of as not measurable. The download-time check moved into
download_and_install_fit().
2026-10-20 08:12:03 +02:00
Knutwurst 687966bb8e Check room for the install before a download starts
A download that fits can still leave too little room to install it: the
installer needs about the package's size again on the game's drive. When
the download starts and the manifest gives the size, PatchDL now checks
both. On the same drive the download and the install have to fit
together; on different drives each one on its own. If not, the download
doesn't start and the card says how much is needed and suggests freeing
space or another download drive.
2026-10-20 08:01:23 +02:00
Knutwurst 4d44427737 Warn before an install the game's drive has no room for
The console's installer writes the update next to the game. With less
free space there than the package needs, it reads the header and stops
without saying why (#20). Before starting, PatchDL compares the free
space on the game's drive with the package size plus 1 GB and, if it's
short, asks whether to install anyway. The margin is an estimate; the
installer's exact need isn't known.
2026-10-20 07:41:41 +02:00
7 changed files with 208 additions and 29 deletions

No files matched your search

+2 -1
View File
@@ -63,7 +63,8 @@ gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
# Headers too: a changed header (the version string, say) must rebuild.
$(BIN): $(SRCS) $(wildcard src/*.h) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
test: $(BIN)
+12
View File
@@ -22,6 +22,7 @@
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h>
#define MAX_TITLES 512
@@ -683,6 +684,17 @@ read_title_file(const char *const *bases, const char *title_id, const char *name
return -1;
}
int
patchdl_scan_sys_statvfs(const char *path, struct statvfs *out) {
int fd, rc;
if (g_sysroot_fd < 0 || !path || path[0] != '/') return -1;
fd = openat(g_sysroot_fd, path[1] ? path + 1 : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd < 0) return -1;
rc = fstatvfs(fd, out);
close(fd);
return rc;
}
int
patchdl_scan_read_app_file(const char *title_id, const char *name,
char *buf, size_t sz) {
+6
View File
@@ -84,6 +84,12 @@ const char *patchdl_scan_step(void);
/* Read <title dir>/<name> of an installed title (e.g. app.json) through the
kept system root handle; thread-safe, no working-directory change.
0 on success with a NUL-terminated buf. */
/* statvfs of a path as the system sees it (e.g. a game's /user/app/<id>),
through the system root kept by the first scan: the process's own root
doesn't show those paths. 0 on success. */
struct statvfs;
int patchdl_scan_sys_statvfs(const char *path, struct statvfs *out);
int patchdl_scan_read_app_file(const char *title_id, const char *name,
char *buf, size_t sz);
/* Same for an installed PS4 patch: /user/patch/<id>/<name>. */
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.3.2"
#define PATCHDL_VERSION "0.3.3"
+135 -20
View File
@@ -250,7 +250,8 @@ typedef struct dl_job {
int verifying; /* admit is hashing a package found on disk */
int in_admit; /* admit_next holds this job with the lock dropped */
long long verify_bytes; /* bytes hashed so far */
long long free_bytes; /* rc -3: bytes free on /data at the time */
long long free_bytes; /* rc -3: bytes free on that drive at the time */
int space_install; /* rc -3: short because the install needs room too */
int last_curl_rc; /* CURLcode from the most recent piece */
char fail_step[24]; /* why admit failed (manifest fetch), or "" */
long last_http_code; /* HTTP status from the most recent piece */
@@ -954,20 +955,48 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
/* ---------- status JSON ------------------------------------------------- */
/* Free space on the drive holding `path`, in MB. A folder that doesn't
exist yet counts as its closest existing parent. 0 if unavailable. */
/* statvfs of the drive holding `path` (in this process's view). A folder
that doesn't exist yet counts as its closest existing parent. */
static int
statvfs_up(const char *path, struct statvfs *vfs) {
char p[288], *slash;
snprintf(p, sizeof p, "%s", path);
for (;;) {
if (statvfs(p, vfs) == 0) return 0;
if (!(slash = strrchr(p, '/')) || slash == p) return -1;
*slash = '\0';
}
}
/* Free space on the drive holding `path`, in MB. 0 if unavailable. */
static long long
drive_free_mb(const char *path) {
struct statvfs vfs;
char p[288], *slash;
snprintf(p, sizeof p, "%s", path);
for (;;) {
if (statvfs(p, &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
if (!(slash = strrchr(p, '/')) || slash == p) return 0;
*slash = '\0';
}
if (statvfs_up(path, &vfs)) return 0;
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
}
/* Free bytes on a game's drive, -1 if it can't be measured. Game paths
(/user/app/<id>, /mnt/extN/user/app/<id>) only resolve through the
system root the scanner keeps. */
static long long
game_free_bytes(const char *game_dir) {
struct statvfs vfs;
if (!game_dir[0] || patchdl_scan_sys_statvfs(game_dir, &vfs)) return -1;
return (long long)((unsigned long long)vfs.f_bavail * (unsigned long long)vfs.f_frsize);
}
/* The download folder and the game sit on the same drive. The two paths
live in different views of the filesystem, so compare the file system
id, and failing that its exact size. */
static int
same_drive(const char *dl_dir, const char *game_dir) {
struct statvfs a, b;
if (!game_dir[0] || statvfs_up(dl_dir, &a) || patchdl_scan_sys_statvfs(game_dir, &b))
return 0;
if (a.f_fsid && a.f_fsid == b.f_fsid) return 1;
return a.f_blocks == b.f_blocks && a.f_frsize == b.f_frsize;
}
/* Free space where new downloads go, in MB. */
@@ -983,6 +1012,10 @@ data_free_mb(void) {
internal storage to the last byte. */
#define PATCHDL_SPACE_MARGIN (2LL * 1024 * 1024 * 1024)
/* Extra room asked for on the game's drive before an install, on top of the
package size. A guess: the installer's exact need isn't known. */
#define INSTALL_SPACE_MARGIN (1LL * 1024 * 1024 * 1024)
/* Bytes still to be written for a package of `total` bytes at `path`: a
partial from an earlier run already occupies part of it. */
static long long
@@ -1002,6 +1035,40 @@ space_ok(const char *dir, long long need, long long *free_out) {
return need + PATCHDL_SPACE_MARGIN <= free_b;
}
/* Is there room to download what's missing of a package and then install
it? The install needs about the package's size again on the game's drive
(game_dir); on the drive of the download folder (dl_dir) the two share
the free space. 1 if it fits. Otherwise *need_out / *free_out describe
the drive that is short and *for_install says whether the install part
made it short. */
static int
download_and_install_fit(const char *dl_dir, const char *game_dir,
long long remaining, long long total,
long long *need_out, long long *free_out, int *for_install) {
long long inst = total + INSTALL_SPACE_MARGIN, free_b = 0;
int same = same_drive(dl_dir, game_dir);
*for_install = 0;
if (!space_ok(dl_dir, remaining + (same ? inst : 0), &free_b)) {
*need_out = remaining + (same ? inst : 0);
*free_out = free_b;
*for_install = same;
return 0;
}
if (game_dir[0] && !same) {
long long gfree = game_free_bytes(game_dir);
if (gfree >= 0 && gfree < inst) {
*need_out = inst;
*free_out = gfree;
*for_install = 1;
return 0;
}
}
*need_out = remaining;
*free_out = free_b;
return 1;
}
/* Built fresh per request so free space stays live (a 60+ GB download moves
it a lot). g_fw is read-only after startup, so this is thread-safe. */
/* The console's LAN address, so a page opened on the PS5 itself (127.0.0.1)
@@ -1457,8 +1524,9 @@ build_downloads_json(void) {
jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld",
job->rc, job->last_curl_rc, job->last_http_code);
if (job->rc == -3)
jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld",
job->need_bytes, job->free_bytes);
jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld%s",
job->need_bytes, job->free_bytes,
job->space_install ? ",\"space_install\":true" : "");
if (job->fail_step[0]) {
jbuf_append(&j, ",\"fail_step\":");
jbuf_append_str(&j, job->fail_step);
@@ -2178,7 +2246,8 @@ admit_next(void) {
dl_job_t *q = NULL;
patchdl_manifest_t mf;
char murl[768], dest[320], dir[288];
int is_manifest, fd = -1, ok = 0, no_space = 0;
int is_manifest, fd = -1, ok = 0, no_space = 0, space_inst = 0;
char q_title[32];
unsigned char *pre_done = NULL; /* pieces proven good by hash */
char fstep[24] = "";
int fcurl = 0;
@@ -2201,6 +2270,7 @@ admit_next(void) {
snprintf(murl, sizeof murl, "%s", q->manifest_url);
snprintf(dest, sizeof dest, "%s", q->dest);
snprintf(dir, sizeof dir, "%s", q->dir);
snprintf(q_title, sizeof q_title, "%s", q->title_id);
pthread_mutex_unlock(&g_pool.mtx);
/* ---- network + disk, NO pool lock ---- */
@@ -2214,8 +2284,24 @@ admit_next(void) {
total = mf.total;
adopted = adopt_by_size(dir, dest, total);
need = space_needed(dest, total);
if (!space_ok(dir, need, &free_b)) no_space = 1;
else fd = open(dest, O_RDWR | O_CREAT, 0666);
/* The install needs about the package's size again on the
game's drive. Check that now, before downloading something
that can't be installed: on the same drive the download and
the install share the free space. */
{
char gdir[256] = "";
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, q_title)) {
snprintf(gdir, sizeof gdir, "%s", g_titles[i].source_path);
break;
}
pthread_mutex_unlock(&g_mutex);
if (!download_and_install_fit(dir, gdir, need, total,
&need, &free_b, &space_inst))
no_space = 1;
}
if (!no_space) fd = open(dest, O_RDWR | O_CREAT, 0666);
if (fd >= 0) {
struct stat st;
if (fstat(fd, &st) == 0) old_size = (long long)st.st_size;
@@ -2289,6 +2375,7 @@ admit_next(void) {
q->rc = no_space ? -3 : -1;
q->need_bytes = need;
q->free_bytes = free_b;
q->space_install = space_inst;
/* the manifest fetch failed: say where, so a report can tell a
blocked host from a TLS or HTTP problem */
snprintf(q->fail_step, sizeof q->fail_step, "%s", fstep);
@@ -3055,7 +3142,7 @@ static enum MHD_Result
do_install(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url, const char *delta_url,
const char *patch_title_id, const char *patch_storage_title_id,
const char *content_id, const char *version, int enabled) {
const char *content_id, const char *version, int enabled, int force) {
char dest[320], msg[256], resp[720];
int rc;
install_stream_t *st = NULL;
@@ -3088,6 +3175,32 @@ do_install(struct MHD_Connection *conn, const char *title_id,
: "{\"ok\":false,\"reason\":\"download_incomplete\"}");
}
/* The console writes the update next to the game and needs about the
package's size free there; with less, its installer reads the header
and quietly stops. Ask before starting (force = the user said go). */
if (!force) {
char gdir[256] = "";
struct stat pst;
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, title_id)) {
snprintf(gdir, sizeof gdir, "%s", g_titles[i].source_path);
break;
}
pthread_mutex_unlock(&g_mutex);
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
if (gdir[0] && stat(dest, &pst) == 0) {
long long need = (long long)pst.st_size + INSTALL_SPACE_MARGIN;
long long free_b = game_free_bytes(gdir);
if (free_b >= 0 && free_b < need) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"install_space\","
"\"need_bytes\":%lld,\"free_bytes\":%lld}", need, free_b);
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
}
}
}
/* GUARD (app layer): the version.xml target title id must match the game. */
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
snprintf(resp, sizeof(resp),
@@ -3447,10 +3560,12 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
if (!strcmp(action, "install"))
if (!strcmp(action, "install")) {
const char *f = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "force");
return do_install(conn, title_id, src, patch_url, delta_url,
patch_title_id, patch_storage_title_id,
content_id, version, enabled);
content_id, version, enabled, f && !strcmp(f, "1"));
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
+22 -7
View File
@@ -340,7 +340,7 @@ async function openDiag(titleId) {
diagRow(Boolean(mf && mf.ok), t("diag.manifest"), mfDetail),
];
if (job) rows.push(diagRow(job.state !== "error", t("diag.lastDownload"),
job.state === "error" ? (Number(job.rc) === -3 ? noSpaceText(job.need_bytes, job.free_bytes)
job.state === "error" ? (Number(job.rc) === -3 ? noSpaceText(job.need_bytes, job.free_bytes, job.space_install)
: job.fail_step ? failStepText(job) : t("diag.failedPieces"))
: tCode("diag.jobState.", job.state, job.state)));
@@ -922,7 +922,7 @@ function cardNotesHtml(game) {
const notes = [];
const job = state.downloads.find((d) => d.title_id === game.title_id);
if (job && job.state === "error" && Number(job.rc) === -3)
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(noSpaceText(job.need_bytes, job.free_bytes))}</span></div>`);
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(noSpaceText(job.need_bytes, job.free_bytes, job.space_install))}</span></div>`);
else if (job && job.state === "error")
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(t("card.downloadFailed"))}${job.fail_step ? " " + escapeHtml(failStepText(job)) : ""}</span>
<button type="button" class="link-button" data-diag="${escapeHtml(game.title_id)}">${t("card.details")}</button></div>`);
@@ -1079,7 +1079,7 @@ function reconcileFromJobs(jobs) {
// red Cancel stays available to delete the kept partial.
const bytes = Number(j.bytes) || 0;
if (Number(j.rc) === -3) {
if (!g._spaceWarned) showToast(t("toast.nameMessage", { name: g.name, message: noSpaceText(j.need_bytes, j.free_bytes) }));
if (!g._spaceWarned) showToast(t("toast.nameMessage", { name: g.name, message: noSpaceText(j.need_bytes, j.free_bytes, j.space_install) }));
g._spaceWarned = true;
} else if (g._wasActive) {
const why = j.fail_step ? ` ${failStepText(j)}` : "";
@@ -1547,7 +1547,7 @@ async function doDownload(game) {
}
}
async function doInstall(game) {
async function doInstall(game, force = false) {
const wasDownloaded = game.downloaded;
game.installing = true;
game.installStatus = "starting";
@@ -1555,9 +1555,22 @@ async function doInstall(game) {
game.downloaded = false; // the package is being consumed by the install
renderGames();
try {
await postJson(API.action(game.title_id, "install"), {});
await postJson(API.action(game.title_id, "install") + (force ? "?force=1" : ""), {});
} catch (error) {
game.downloaded = wasDownloaded; // refused: the package is untouched
const b = (error && error.body) || {};
if (b.reason === "install_space" && !force) {
// Little room on the game's drive: the console would likely stop
// right after reading the header. Let the user decide.
game.installing = false;
renderGames();
if (window.confirm(t("install.lowSpace", {
name: game.name, need: formatBytes(b.need_bytes), free: formatBytes(b.free_bytes) })))
return doInstall(game, true);
addLog("log.installBlocked", { id: game.title_id, why: t("reason.install_space") });
renderLogs();
return false;
}
if (error.body && error.body.reason === "install_running") {
// Another tab or device started it: follow that install here.
startInstallPolling();
@@ -1681,8 +1694,10 @@ async function postJson(url, body) {
// Server refusal reasons are translated as reason.<code> in i18n.js.
// The server keeps 2 GB free on /data on top of the package itself.
function noSpaceText(need, free) {
return t("reason.noSpace", { need: formatBytes(need), free: formatBytes(free) });
// install: the download fit, but installing it afterwards wouldn't.
function noSpaceText(need, free, install) {
return t(install ? "reason.noSpaceInstall" : "reason.noSpace",
{ need: formatBytes(need), free: formatBytes(free) });
}
function reasonText(error) {
+30
View File
@@ -103,6 +103,8 @@ const I18N = {
"progress.queued": "Queued — waiting for a free slot",
"progress.verifying": "Checking the package already on disk",
"progress.retrying": "Connection interrupted, trying again…",
"install.lowSpace": "{name}: the console probably needs about {need} free on the game's drive to install this update, but only {free} is free there. The install may stop right at the start. Install anyway?",
"reason.install_space": "Not enough free space to install",
"progress.eta": "ETA <b>{eta}</b>",
"progress.fetchingManifest": "Fetching manifest…",
"install.phase.waiting": "Waiting for the console",
@@ -324,6 +326,7 @@ const I18N = {
"reason.fallback": "failed",
"reason.notOrbisLink": "That isn't a link from Orbis Patches. Press “Copy all links!” there and paste what it copies.",
"reason.noSpace": "Not enough space on the PS5: needs {need} plus 2 GB headroom, {free} free.",
"reason.noSpaceInstall": "Not enough space to download and install this update: it needs about {need}, {free} is free. Free up space, or pick another download drive in Settings.",
// ---- older patch guide ----
"guide.eyebrowOther": "Other version",
@@ -453,6 +456,8 @@ I18N["de"] = {
"progress.queued": "In Warteschlange — wartet auf einen freien Platz",
"progress.verifying": "Vorhandenes Paket auf der Konsole wird geprüft",
"progress.retrying": "Verbindung unterbrochen, neuer Versuch…",
"install.lowSpace": "{name}: Zum Installieren braucht die Konsole auf dem Laufwerk des Spiels wahrscheinlich etwa {need} frei, dort sind aber nur {free} frei. Die Installation könnte gleich am Anfang abbrechen. Trotzdem installieren?",
"reason.install_space": "Nicht genug Platz zum Installieren",
"progress.eta": "Restzeit <b>{eta}</b>",
"progress.fetchingManifest": "Paketliste wird geladen…",
"install.phase.waiting": "Warten auf die Konsole",
@@ -653,6 +658,7 @@ I18N["de"] = {
"reason.source_not_supported": "Downloads werden für diese Quelle nicht unterstützt.",
"reason.fallback": "fehlgeschlagen",
"reason.noSpace": "Nicht genug Speicher auf der PS5: benötigt {need} plus 2 GB Reserve, {free} frei.",
"reason.noSpaceInstall": "Nicht genug Platz, um dieses Update herunterzuladen und zu installieren: Es braucht etwa {need}, frei sind {free}. Schaffe Platz oder wähle in den Einstellungen ein anderes Download-Laufwerk.",
"guide.eyebrowOther": "Andere Version",
"guide.eyebrowOld": "Patch für alte FW",
"guide.title": "{version} herunterladen",
@@ -776,6 +782,8 @@ I18N["fr"] = {
"progress.queued": "En file d'attente — en attente d'un emplacement libre",
"progress.verifying": "Vérification du paquet déjà présent sur la console",
"progress.retrying": "Connexion interrompue, nouvel essai…",
"install.lowSpace": "{name} : pour installer cette mise à jour, la console a probablement besoin d'environ {need} libres sur le disque du jeu, mais il n'y a que {free} de libre. L'installation risque de s'arrêter dès le début. Installer quand même ?",
"reason.install_space": "Pas assez d'espace libre pour installer",
"progress.eta": "Temps restant <b>{eta}</b>",
"progress.fetchingManifest": "Récupération de la liste des paquets…",
"install.phase.waiting": "En attente de la console",
@@ -976,6 +984,7 @@ I18N["fr"] = {
"reason.source_not_supported": "Les téléchargements ne sont pas pris en charge pour cette source.",
"reason.fallback": "échec",
"reason.noSpace": "Pas assez d'espace sur la PS5 : il faut {need} plus 2 Go de marge, {free} libres.",
"reason.noSpaceInstall": "Pas assez d'espace pour télécharger et installer cette mise à jour : il faut environ {need}, {free} sont libres. Libérez de l'espace ou choisissez un autre disque de téléchargement dans les paramètres.",
"guide.eyebrowOther": "Autre version",
"guide.eyebrowOld": "Patch pour ancien FW",
"guide.title": "Télécharger {version}",
@@ -1099,6 +1108,8 @@ I18N["es"] = {
"progress.queued": "En cola — esperando un hueco libre",
"progress.verifying": "Comprobando el paquete que ya está en la consola",
"progress.retrying": "Conexión interrumpida, reintentando…",
"install.lowSpace": "{name}: para instalar esta actualización, la consola probablemente necesita unos {need} libres en la unidad del juego, pero solo hay {free} libres. La instalación podría detenerse al principio. ¿Instalar de todos modos?",
"reason.install_space": "No hay espacio suficiente para instalar",
"progress.eta": "Tiempo restante <b>{eta}</b>",
"progress.fetchingManifest": "Obteniendo la lista de paquetes…",
"install.phase.waiting": "Esperando a la consola",
@@ -1299,6 +1310,7 @@ I18N["es"] = {
"reason.source_not_supported": "No se admiten descargas para esta fuente.",
"reason.fallback": "error",
"reason.noSpace": "No hay suficiente espacio en la PS5: se necesitan {need} más 2 GB de margen, {free} libres.",
"reason.noSpaceInstall": "No hay espacio suficiente para descargar e instalar esta actualización: necesita unos {need} y hay {free} libres. Libera espacio o elige otra unidad de descarga en Ajustes.",
"guide.eyebrowOther": "Otra versión",
"guide.eyebrowOld": "Parche para FW antiguo",
"guide.title": "Descargar {version}",
@@ -1422,6 +1434,8 @@ I18N["it"] = {
"progress.queued": "In coda — in attesa di uno slot libero",
"progress.verifying": "Verifica del pacchetto già presente sul disco",
"progress.retrying": "Connessione interrotta, nuovo tentativo…",
"install.lowSpace": "{name}: per installare questo aggiornamento la console probabilmente ha bisogno di circa {need} liberi sull'unità del gioco, ma ce ne sono solo {free}. L'installazione potrebbe fermarsi subito. Installare comunque?",
"reason.install_space": "Spazio insufficiente per l'installazione",
"progress.eta": "Fine tra <b>{eta}</b>",
"progress.fetchingManifest": "Recupero elenco pacchetti…",
"install.phase.waiting": "In attesa della console",
@@ -1622,6 +1636,7 @@ I18N["it"] = {
"reason.source_not_supported": "I download non sono supportati per questa origine.",
"reason.fallback": "non riuscito",
"reason.noSpace": "Spazio insufficiente sulla PS5: servono {need} più 2 GB di margine, {free} liberi.",
"reason.noSpaceInstall": "Spazio insufficiente per scaricare e installare questo aggiornamento: servono circa {need}, ne sono liberi {free}. Libera spazio o scegli un'altra unità di download nelle Impostazioni.",
"guide.eyebrowOther": "Altra versione",
"guide.eyebrowOld": "Patch per FW precedente",
"guide.title": "Scarica {version}",
@@ -1745,6 +1760,8 @@ I18N["pt-BR"] = {
"progress.queued": "Na fila — aguardando uma vaga livre",
"progress.verifying": "Verificando o pacote que já está no disco",
"progress.retrying": "Conexão interrompida, tentando de novo…",
"install.lowSpace": "{name}: para instalar esta atualização, o console provavelmente precisa de cerca de {need} livres na unidade do jogo, mas só há {free} livres. A instalação pode parar logo no início. Instalar mesmo assim?",
"reason.install_space": "Espaço insuficiente para instalar",
"progress.eta": "Faltam <b>{eta}</b>",
"progress.fetchingManifest": "Obtendo lista de pacotes…",
"install.phase.waiting": "Aguardando o console",
@@ -1945,6 +1962,7 @@ I18N["pt-BR"] = {
"reason.source_not_supported": "Downloads não são suportados para esta origem.",
"reason.fallback": "falhou",
"reason.noSpace": "Espaço insuficiente no PS5: são necessários {need} mais 2 GB de folga, {free} livres.",
"reason.noSpaceInstall": "Espaço insuficiente para baixar e instalar esta atualização: ela precisa de cerca de {need} e há {free} livres. Libere espaço ou escolha outra unidade de download em Configurações.",
"guide.eyebrowOther": "Outra versão",
"guide.eyebrowOld": "Patch de FW antigo",
"guide.title": "Baixar {version}",
@@ -2068,6 +2086,8 @@ I18N["ja"] = {
"progress.queued": "待機中 — 空きスロットを待っています",
"progress.verifying": "ディスク上の既存パッケージを確認しています",
"progress.retrying": "接続が中断されました。再試行しています…",
"install.lowSpace": "{name}: このアップデートのインストールには、ゲームのドライブに約 {need} の空き容量が必要と思われますが、空きは {free} しかありません。インストールが最初に止まる可能性があります。それでもインストールしますか?",
"reason.install_space": "インストールに必要な空き容量が足りません",
"progress.eta": "残り <b>{eta}</b>",
"progress.fetchingManifest": "パッケージリストを取得中…",
"install.phase.waiting": "本体を待っています",
@@ -2268,6 +2288,7 @@ I18N["ja"] = {
"reason.source_not_supported": "このソースのダウンロードには対応していません。",
"reason.fallback": "失敗",
"reason.noSpace": "PS5 の空き容量が不足しています: {need} と予備の 2 GB が必要ですが、空きは {free} です。",
"reason.noSpaceInstall": "このアップデートをダウンロードしてインストールするには空き容量が足りません。約 {need} 必要ですが、空きは {free} です。空き容量を増やすか、設定で別のダウンロード先ドライブを選んでください。",
"guide.eyebrowOther": "他のバージョン",
"guide.eyebrowOld": "旧 FW 用パッチ",
"guide.title": "{version} をダウンロード",
@@ -2391,6 +2412,8 @@ I18N["zh-Hans"] = {
"progress.queued": "排队中 — 正在等待空闲位置",
"progress.verifying": "正在检查磁盘上已有的安装包",
"progress.retrying": "连接中断,正在重试…",
"install.lowSpace": "{name}:安装此更新时,主机可能需要游戏所在驱动器上约 {need} 的可用空间,但那里只有 {free} 可用。安装可能会在一开始就停止。仍要安装吗?",
"reason.install_space": "可用空间不足,无法安装",
"progress.eta": "剩余 <b>{eta}</b>",
"progress.fetchingManifest": "正在获取包列表…",
"install.phase.waiting": "正在等待主机",
@@ -2591,6 +2614,7 @@ I18N["zh-Hans"] = {
"reason.source_not_supported": "此来源不支持下载。",
"reason.fallback": "失败",
"reason.noSpace": "PS5 空间不足:需要 {need} 外加 2 GB 余量,当前可用 {free}。",
"reason.noSpaceInstall": "空间不足,无法下载并安装此更新:大约需要 {need},当前可用 {free}。请释放空间,或在设置中选择其他下载驱动器。",
"guide.eyebrowOther": "其他版本",
"guide.eyebrowOld": "旧 FW 补丁",
"guide.title": "下载 {version}",
@@ -2714,6 +2738,8 @@ I18N["ko"] = {
"progress.queued": "대기 중 — 빈 슬롯을 기다리는 중",
"progress.verifying": "디스크에 이미 있는 패키지를 확인하는 중",
"progress.retrying": "연결이 끊겼습니다. 다시 시도하는 중…",
"install.lowSpace": "{name}: 이 업데이트를 설치하려면 게임이 있는 드라이브에 약 {need}의 여유 공간이 필요할 것으로 보이지만, 여유 공간은 {free}뿐입니다. 설치가 시작하자마자 멈출 수 있습니다. 그래도 설치하시겠습니까?",
"reason.install_space": "설치할 여유 공간이 부족합니다",
"progress.eta": "남은 시간 <b>{eta}</b>",
"progress.fetchingManifest": "패키지 목록을 가져오는 중…",
"install.phase.waiting": "본체를 기다리는 중",
@@ -2914,6 +2940,7 @@ I18N["ko"] = {
"reason.source_not_supported": "이 소스는 다운로드가 지원되지 않습니다.",
"reason.fallback": "실패",
"reason.noSpace": "PS5의 공간이 부족합니다: {need}에 여유분 2 GB가 더 필요하지만, 여유 공간은 {free}입니다.",
"reason.noSpaceInstall": "이 업데이트를 다운로드하고 설치할 공간이 부족합니다. 약 {need}이(가) 필요하지만 여유 공간은 {free}입니다. 공간을 확보하거나 설정에서 다른 다운로드 드라이브를 선택하세요.",
"guide.eyebrowOther": "다른 버전",
"guide.eyebrowOld": "이전 FW용 패치",
"guide.title": "{version} 다운로드",
@@ -3037,6 +3064,8 @@ I18N["ru"] = {
"progress.queued": "В очереди — ожидание свободного слота",
"progress.verifying": "Проверка пакета, который уже есть на диске",
"progress.retrying": "Соединение прервано, повторная попытка…",
"install.lowSpace": "{name}: для установки этого обновления консоли, вероятно, нужно около {need} свободного места на диске с игрой, а там свободно только {free}. Установка может остановиться в самом начале. Всё равно установить?",
"reason.install_space": "Недостаточно места для установки",
"progress.eta": "Осталось <b>{eta}</b>",
"progress.fetchingManifest": "Получение списка пакетов…",
"install.phase.waiting": "Ожидание консоли",
@@ -3237,6 +3266,7 @@ I18N["ru"] = {
"reason.source_not_supported": "Для этого источника загрузки не поддерживаются.",
"reason.fallback": "ошибка",
"reason.noSpace": "Недостаточно места на PS5: нужно {need} плюс 2 ГБ запаса, свободно {free}.",
"reason.noSpaceInstall": "Недостаточно места, чтобы загрузить и установить это обновление: нужно около {need}, свободно {free}. Освободите место или выберите другой диск для загрузок в настройках.",
"guide.eyebrowOther": "Другая версия",
"guide.eyebrowOld": "Патч для старой FW",
"guide.title": "Скачать {version}",