diff --git a/client/src/components/PackagePanel.test.tsx b/client/src/components/PackagePanel.test.tsx
index 2cbbddae..a0ff456d 100644
--- a/client/src/components/PackagePanel.test.tsx
+++ b/client/src/components/PackagePanel.test.tsx
@@ -99,6 +99,22 @@ describe("PackagePanelView", () => {
expect(html).toContain("encrypted or missing");
});
+ it("names a package's entries and a PS4 parental level for what they are", () => {
+ const html = view();
+ expect(html).toContain("Entries");
+ expect(html).toContain("Parental level");
+ const folder = view({
+ inspection: {
+ ...g,
+ source: { ...g.source, format: "folder", parts: [] },
+ identity: { ...g.identity, platform: "ps5" },
+ },
+ });
+ expect(folder).toContain("Files");
+ expect(folder).toContain("Age rating");
+ expect(folder).not.toContain("Parental level");
+ });
+
it("details lists PARAM keys behind Show all", () => {
const html = view({ tab: "details" });
expect(html).toContain("TITLE");
diff --git a/client/src/components/PackagePanel.tsx b/client/src/components/PackagePanel.tsx
index 9951475f..34c947d8 100644
--- a/client/src/components/PackagePanel.tsx
+++ b/client/src/components/PackagePanel.tsx
@@ -26,6 +26,7 @@ import {
} from "../lib/packageChecks";
import { useConnectionStore } from "../state/connection";
import { useTr } from "../state/lang";
+import { writeClipboard } from "../lib/clipboard";
export interface PanelAction {
label: string;
@@ -183,10 +184,22 @@ export function PackagePanelView({
[tr("viewer_field_sdk", undefined, "SDK"), g.specs.sdk_ver],
[tr("viewer_field_built", undefined, "Built"), g.specs.build_date],
[tr("viewer_field_drm", undefined, "DRM"), g.specs.drm],
- [tr("viewer_field_age", undefined, "Age rating"), g.specs.age_rating],
+ // PS4's PARENTAL_LEVEL is a 1–11 level, not an age.
+ [
+ g.identity.platform === "ps4"
+ ? tr("viewer_field_parental", undefined, "Parental level")
+ : tr("viewer_field_age", undefined, "Age rating"),
+ g.specs.age_rating,
+ ],
[tr("viewer_field_languages", undefined, "Languages"), g.specs.languages.length ? g.specs.languages.join(", ") : null],
[tr("viewer_field_size", undefined, "Size"), formatBytes(g.source.size)],
- [tr("viewer_field_files", undefined, "Files"), g.specs.file_count != null ? String(g.specs.file_count) : null],
+ [
+ // A package counts its entry table, not files.
+ g.source.format === "pkg" || g.source.format === "split-pkg"
+ ? tr("viewer_field_entries", undefined, "Entries")
+ : tr("viewer_field_files", undefined, "Files"),
+ g.specs.file_count != null ? String(g.specs.file_count) : null,
+ ],
];
const params = showAll ? g.params : g.params.filter((p) => CURATED_KEYS.has(p.key));
@@ -317,7 +330,7 @@ export function PackagePanelView({
size="sm"
variant="ghost"
leftIcon={}
- onClick={() => void navigator.clipboard?.writeText(copyText).catch(() => {})}
+ onClick={() => void writeClipboard(copyText)}
>
{tr("viewer_copy", undefined, "Copy details")}
@@ -356,13 +369,15 @@ export function PackagePanel({
const [tab, setTab] = useState("overview");
const [installed, setInstalled] = useState(null);
const [free, setFree] = useState(null);
- const latest = useRef(null);
+ // Bumped per load: a reply for an earlier path or console is ignored.
+ const request = useRef(0);
const kernel = useConnectionStore((s) =>
host ? (s.runtimeByHost[hostOf(host)]?.ps5Kernel ?? null) : null,
);
useEffect(() => {
- latest.current = path;
+ const req = ++request.current;
+ const current = () => request.current === req;
setData(null);
setError(null);
setCover(null);
@@ -373,18 +388,18 @@ export function PackagePanel({
if (!path) return;
void gameInspect(path)
.then(async (r) => {
- if (latest.current !== path) return;
+ if (!current()) return;
setData(r);
const names = new Set(r.inspection.images.map((i) => i.name));
if (names.has("icon0.png")) {
void gameInspectImageUrl(r.token, "icon0.png")
- .then((u) => latest.current === path && setCover(u))
+ .then((u) => current() && setCover(u))
.catch(() => {});
}
const bg = names.has("pic0.png") ? "pic0.png" : names.has("pic1.png") ? "pic1.png" : null;
if (bg) {
void gameInspectImageUrl(r.token, bg)
- .then((u) => latest.current === path && setBackdrop(u))
+ .then((u) => current() && setBackdrop(u))
.catch(() => {});
}
if (host) {
@@ -394,18 +409,18 @@ export function PackagePanel({
packageType: pkgTypeForCategory(g.identity.category, g.identity.platform),
})
.then((pre) => {
- if (latest.current !== path) return;
+ if (!current()) return;
setInstalled(installedFromPreflight(g.specs.app_ver, pre));
})
.catch(() => {});
}
void listVolumes(transferAddr(host))
- .then((vols) => latest.current === path && setFree(largestFreeBytes(vols)))
+ .then((vols) => current() && setFree(largestFreeBytes(vols)))
.catch(() => {});
}
})
.catch((e) => {
- if (latest.current === path) setError(e instanceof Error ? e.message : String(e));
+ if (current()) setError(e instanceof Error ? e.message : String(e));
});
}, [path, host]);
diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts
index 2dad65a1..27f70b6b 100644
--- a/client/src/i18n/locales/ar.ts
+++ b/client/src/i18n/locales/ar.ts
@@ -342,6 +342,8 @@ queue_title: "قائمة الانتظار",
"viewer_field_languages": "اللغات",
"viewer_field_size": "الحجم",
"viewer_field_files": "الملفات",
+"viewer_field_entries": "الإدخالات",
+"viewer_field_parental": "مستوى الرقابة الأبوية",
"viewer_field_parts": "الأجزاء",
"queue_install_from_pc": "تثبيت بالبث من هذا الكمبيوتر",
"queue_install_from_server": "تثبيت بالبث من خادم محفوظ",
diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts
index 41c84f36..0d6a5533 100644
--- a/client/src/i18n/locales/bn.ts
+++ b/client/src/i18n/locales/bn.ts
@@ -342,6 +342,8 @@ queue_title: "সারি",
"viewer_field_languages": "ভাষা",
"viewer_field_size": "আকার",
"viewer_field_files": "ফাইল",
+"viewer_field_entries": "এন্ট্রি",
+"viewer_field_parental": "প্যারেন্টাল স্তর",
"viewer_field_parts": "অংশ",
"queue_install_from_pc": "এই কম্পিউটার থেকে স্ট্রিম ইনস্টল",
"queue_install_from_server": "সংরক্ষিত সার্ভার থেকে স্ট্রিম ইনস্টল",
diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts
index b17cac73..e4e2d68d 100644
--- a/client/src/i18n/locales/de.ts
+++ b/client/src/i18n/locales/de.ts
@@ -343,6 +343,8 @@ queue_title: "Warteschlange",
"viewer_field_languages": "Sprachen",
"viewer_field_size": "Größe",
"viewer_field_files": "Dateien",
+"viewer_field_entries": "Einträge",
+"viewer_field_parental": "Jugendschutzstufe",
"viewer_field_parts": "Teile",
"queue_install_from_pc": "Stream-Installation von diesem Computer",
"queue_install_from_server": "Stream-Installation von einem gespeicherten Server",
diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts
index 4bddea8b..a620e8af 100644
--- a/client/src/i18n/locales/en.ts
+++ b/client/src/i18n/locales/en.ts
@@ -604,6 +604,8 @@ queue_title: "Queue",
"viewer_field_languages": "Languages",
"viewer_field_size": "Size",
"viewer_field_files": "Files",
+"viewer_field_entries": "Entries",
+"viewer_field_parental": "Parental level",
"viewer_field_parts": "Parts",
"queue_install_from_pc": "Stream install from this computer",
"queue_install_from_server": "Stream install from a saved server",
diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts
index 7a5a63d9..7b7150b7 100644
--- a/client/src/i18n/locales/es.ts
+++ b/client/src/i18n/locales/es.ts
@@ -342,6 +342,8 @@ queue_title: "Cola",
"viewer_field_languages": "Idiomas",
"viewer_field_size": "Tamaño",
"viewer_field_files": "Archivos",
+"viewer_field_entries": "Entradas",
+"viewer_field_parental": "Nivel de control parental",
"viewer_field_parts": "Partes",
"queue_install_from_pc": "Instalación en streaming desde este equipo",
"queue_install_from_server": "Instalación en streaming desde un servidor guardado",
diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts
index e67f7c0d..0dbbc4fa 100644
--- a/client/src/i18n/locales/fr.ts
+++ b/client/src/i18n/locales/fr.ts
@@ -342,6 +342,8 @@ queue_title: "File d’attente",
"viewer_field_languages": "Langues",
"viewer_field_size": "Taille",
"viewer_field_files": "Fichiers",
+"viewer_field_entries": "Entrées",
+"viewer_field_parental": "Niveau de contrôle parental",
"viewer_field_parts": "Parties",
"queue_install_from_pc": "Installation en streaming depuis cet ordinateur",
"queue_install_from_server": "Installation en streaming depuis un serveur enregistré",
diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts
index 90405664..a2c5558c 100644
--- a/client/src/i18n/locales/hi.ts
+++ b/client/src/i18n/locales/hi.ts
@@ -342,6 +342,8 @@ queue_title: "कतार",
"viewer_field_languages": "भाषाएँ",
"viewer_field_size": "आकार",
"viewer_field_files": "फ़ाइलें",
+"viewer_field_entries": "प्रविष्टियाँ",
+"viewer_field_parental": "पैरेंटल स्तर",
"viewer_field_parts": "भाग",
"queue_install_from_pc": "इस कंप्यूटर से स्ट्रीम इंस्टॉल",
"queue_install_from_server": "सहेजे गए सर्वर से स्ट्रीम इंस्टॉल",
diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts
index cc1fbecf..c3107a05 100644
--- a/client/src/i18n/locales/hu.ts
+++ b/client/src/i18n/locales/hu.ts
@@ -601,6 +601,8 @@ queue_title: "Sor",
"viewer_field_languages": "Nyelvek",
"viewer_field_size": "Méret",
"viewer_field_files": "Fájlok",
+"viewer_field_entries": "Bejegyzések",
+"viewer_field_parental": "Szülői felügyeleti szint",
"viewer_field_parts": "Részek",
"queue_install_from_pc": "Streamelt telepítés erről a számítógépről",
"queue_install_from_server": "Streamelt telepítés mentett szerverről",
diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts
index 27d28275..bab79f27 100644
--- a/client/src/i18n/locales/id.ts
+++ b/client/src/i18n/locales/id.ts
@@ -342,6 +342,8 @@ queue_title: "Antrean",
"viewer_field_languages": "Bahasa",
"viewer_field_size": "Ukuran",
"viewer_field_files": "File",
+"viewer_field_entries": "Entri",
+"viewer_field_parental": "Tingkat kontrol orang tua",
"viewer_field_parts": "Bagian",
"queue_install_from_pc": "Instal stream dari komputer ini",
"queue_install_from_server": "Instal stream dari server tersimpan",
diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts
index 4c494053..e2cdc53f 100644
--- a/client/src/i18n/locales/it.ts
+++ b/client/src/i18n/locales/it.ts
@@ -342,6 +342,8 @@ queue_title: "Coda",
"viewer_field_languages": "Lingue",
"viewer_field_size": "Dimensione",
"viewer_field_files": "File",
+"viewer_field_entries": "Voci",
+"viewer_field_parental": "Livello di controllo genitori",
"viewer_field_parts": "Parti",
"queue_install_from_pc": "Installazione in streaming da questo computer",
"queue_install_from_server": "Installazione in streaming da un server salvato",
diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts
index 2de85eeb..9748aac7 100644
--- a/client/src/i18n/locales/ja.ts
+++ b/client/src/i18n/locales/ja.ts
@@ -343,6 +343,8 @@ queue_title: "キュー",
"viewer_field_languages": "言語",
"viewer_field_size": "サイズ",
"viewer_field_files": "ファイル",
+"viewer_field_entries": "エントリ",
+"viewer_field_parental": "ペアレンタルレベル",
"viewer_field_parts": "パーツ",
"queue_install_from_pc": "このコンピューターからストリームインストール",
"queue_install_from_server": "保存済みサーバーからストリームインストール",
diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts
index 22ed551f..3ada6d5a 100644
--- a/client/src/i18n/locales/ko.ts
+++ b/client/src/i18n/locales/ko.ts
@@ -343,6 +343,8 @@ queue_title: "대기열",
"viewer_field_languages": "언어",
"viewer_field_size": "크기",
"viewer_field_files": "파일",
+"viewer_field_entries": "항목",
+"viewer_field_parental": "자녀 보호 등급",
"viewer_field_parts": "파트",
"queue_install_from_pc": "이 컴퓨터에서 스트림 설치",
"queue_install_from_server": "저장된 서버에서 스트림 설치",
diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts
index 565e196a..8f64500e 100644
--- a/client/src/i18n/locales/pl.ts
+++ b/client/src/i18n/locales/pl.ts
@@ -582,6 +582,8 @@ queue_title: "Kolejka",
"viewer_field_languages": "Języki",
"viewer_field_size": "Rozmiar",
"viewer_field_files": "Pliki",
+"viewer_field_entries": "Wpisy",
+"viewer_field_parental": "Poziom kontroli rodzicielskiej",
"viewer_field_parts": "Części",
"queue_install_from_pc": "Instalacja strumieniowa z tego komputera",
"queue_install_from_server": "Instalacja strumieniowa z zapisanego serwera",
diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts
index 8392bbbd..3e75e2ef 100644
--- a/client/src/i18n/locales/pt-BR.ts
+++ b/client/src/i18n/locales/pt-BR.ts
@@ -342,6 +342,8 @@ queue_title: "Fila",
"viewer_field_languages": "Idiomas",
"viewer_field_size": "Tamanho",
"viewer_field_files": "Arquivos",
+"viewer_field_entries": "Entradas",
+"viewer_field_parental": "Nível de controle parental",
"viewer_field_parts": "Partes",
"queue_install_from_pc": "Instalação por stream deste computador",
"queue_install_from_server": "Instalação por stream de um servidor salvo",
diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts
index c68b6c04..e0bdf7d4 100644
--- a/client/src/i18n/locales/ru.ts
+++ b/client/src/i18n/locales/ru.ts
@@ -342,6 +342,8 @@ queue_title: "Очередь",
"viewer_field_languages": "Языки",
"viewer_field_size": "Размер",
"viewer_field_files": "Файлы",
+"viewer_field_entries": "Записи",
+"viewer_field_parental": "Уровень родительского контроля",
"viewer_field_parts": "Части",
"queue_install_from_pc": "Потоковая установка с этого компьютера",
"queue_install_from_server": "Потоковая установка с сохранённого сервера",
diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts
index c8879576..ac6252b9 100644
--- a/client/src/i18n/locales/th.ts
+++ b/client/src/i18n/locales/th.ts
@@ -342,6 +342,8 @@ queue_title: "คิว",
"viewer_field_languages": "ภาษา",
"viewer_field_size": "ขนาด",
"viewer_field_files": "ไฟล์",
+"viewer_field_entries": "รายการ",
+"viewer_field_parental": "ระดับการควบคุมโดยผู้ปกครอง",
"viewer_field_parts": "ส่วน",
"queue_install_from_pc": "ติดตั้งแบบสตรีมจากคอมพิวเตอร์เครื่องนี้",
"queue_install_from_server": "ติดตั้งแบบสตรีมจากเซิร์ฟเวอร์ที่บันทึกไว้",
diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts
index 676353d4..d84eed31 100644
--- a/client/src/i18n/locales/tr.ts
+++ b/client/src/i18n/locales/tr.ts
@@ -342,6 +342,8 @@ queue_title: "Kuyruk",
"viewer_field_languages": "Diller",
"viewer_field_size": "Boyut",
"viewer_field_files": "Dosyalar",
+"viewer_field_entries": "Girdiler",
+"viewer_field_parental": "Ebeveyn denetimi düzeyi",
"viewer_field_parts": "Parçalar",
"queue_install_from_pc": "Bu bilgisayardan akışla kurulum",
"queue_install_from_server": "Kayıtlı bir sunucudan akışla kurulum",
diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts
index 4f19b5aa..ff8aae0b 100644
--- a/client/src/i18n/locales/vi.ts
+++ b/client/src/i18n/locales/vi.ts
@@ -343,6 +343,8 @@ queue_title: "Hàng đợi",
"viewer_field_languages": "Ngôn ngữ",
"viewer_field_size": "Kích thước",
"viewer_field_files": "Tệp",
+"viewer_field_entries": "Mục",
+"viewer_field_parental": "Mức kiểm soát của phụ huynh",
"viewer_field_parts": "Phần",
"queue_install_from_pc": "Cài đặt truyền trực tiếp từ máy tính này",
"queue_install_from_server": "Cài đặt truyền trực tiếp từ máy chủ đã lưu",
diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts
index 108a0123..f09b3dac 100644
--- a/client/src/i18n/locales/zh-CN.ts
+++ b/client/src/i18n/locales/zh-CN.ts
@@ -349,6 +349,8 @@ queue_title: "队列",
"viewer_field_languages": "语言",
"viewer_field_size": "大小",
"viewer_field_files": "文件",
+"viewer_field_entries": "条目",
+"viewer_field_parental": "家长控制等级",
"viewer_field_parts": "分卷",
"queue_install_from_pc": "从这台电脑流式安装",
"queue_install_from_server": "从已保存的服务器流式安装",
diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts
index 552e0a52..983b1a7f 100644
--- a/client/src/i18n/locales/zh-TW.ts
+++ b/client/src/i18n/locales/zh-TW.ts
@@ -343,6 +343,8 @@ queue_title: "佇列",
"viewer_field_languages": "語言",
"viewer_field_size": "大小",
"viewer_field_files": "檔案",
+"viewer_field_entries": "項目",
+"viewer_field_parental": "家長監護等級",
"viewer_field_parts": "分卷",
"queue_install_from_pc": "從這台電腦串流安裝",
"queue_install_from_server": "從已儲存的伺服器串流安裝",
diff --git a/engine/crates/ps5upload-engine/src/inspect.rs b/engine/crates/ps5upload-engine/src/inspect.rs
index 616f8aa3..c4f0665a 100644
--- a/engine/crates/ps5upload-engine/src/inspect.rs
+++ b/engine/crates/ps5upload-engine/src/inspect.rs
@@ -180,7 +180,11 @@ fn apply_sfo(g: &mut GameInspection, params: &[(String, SfoValue)]) {
for (key, value) in params {
let shown = match value {
SfoValue::Text(t) => t.clone(),
- SfoValue::Int(i) => format!("0x{i:08X}"),
+ // Flags and BCD versions read best in hex; counts and levels don't.
+ SfoValue::Int(i) if key.starts_with("ATTRIBUTE") || key.ends_with("_VER") => {
+ format!("0x{i:08X}")
+ }
+ SfoValue::Int(i) => i.to_string(),
};
g.params.push(ParamField {
key: key.clone(),
@@ -287,8 +291,11 @@ fn apply_param_json(g: &mut GameInspection, v: &serde_json::Value) {
}
fn finish(g: &mut GameInspection) {
- if g.identity.title_id.is_empty() && g.identity.content_id.len() >= 16 {
- g.identity.title_id = g.identity.content_id[7..16].to_string();
+ if g.identity.title_id.is_empty() {
+ // `get`, not slicing: a malformed id may split a multi-byte character.
+ if let Some(t) = g.identity.content_id.get(7..16) {
+ g.identity.title_id = t.to_string();
+ }
}
if g.identity.platform.is_empty() {
if let Some(p) = platform_from_title_id(&g.identity.title_id) {
@@ -422,6 +429,17 @@ fn inspect_tree(path: &Path, format: &str) -> Result {
let mut tree = ps5upload_fpkg::source::open(path)?;
let files = tree.files().to_vec();
let has = |p: &str| files.iter().any(|f| f.path == p);
+ let mut too_large = Vec::new();
+ // A text file this viewer reads whole, or None when it is absent, too
+ // large (a damaged image can claim any size) or unreadable.
+ let mut read_text = |tree: &mut Box, p: &str| {
+ let f = files.iter().find(|f| f.path == p)?;
+ if f.size > u64::from(MAX_TEXT_BYTES) {
+ too_large.push(p.to_string());
+ return None;
+ }
+ tree.read(p).ok()
+ };
let mut g = GameInspection {
authenticity: "none".to_string(),
..Default::default()
@@ -439,7 +457,7 @@ fn inspect_tree(path: &Path, format: &str) -> Result {
};
let mut have_param = false;
if has("sce_sys/param.json") {
- if let Ok(bytes) = tree.read("sce_sys/param.json") {
+ if let Some(bytes) = read_text(&mut tree, "sce_sys/param.json") {
let end = bytes.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
if let Ok(v) = serde_json::from_slice::(&bytes[..end]) {
apply_param_json(&mut g, &v);
@@ -448,7 +466,7 @@ fn inspect_tree(path: &Path, format: &str) -> Result {
}
}
if !have_param && has("sce_sys/param.sfo") {
- if let Ok(bytes) = tree.read("sce_sys/param.sfo") {
+ if let Some(bytes) = read_text(&mut tree, "sce_sys/param.sfo") {
if let Ok(params) = sfo_params(&bytes) {
apply_sfo(&mut g, ¶ms);
have_param = true;
@@ -470,10 +488,11 @@ fn inspect_tree(path: &Path, format: &str) -> Result {
}
}
let change = format!("sce_sys/{CHANGEINFO}");
- if has(&change) {
- if let Ok(bytes) = tree.read(&change) {
- g.change_notes = Some(String::from_utf8_lossy(&bytes).to_string());
- }
+ if let Some(bytes) = read_text(&mut tree, &change) {
+ g.change_notes = Some(String::from_utf8_lossy(&bytes).to_string());
+ }
+ for p in too_large {
+ g.warnings.push(format!("{p} is too large to show"));
}
g.specs.file_count = Some(files.len() as u64);
finish(&mut g);
@@ -493,18 +512,24 @@ pub fn read_image(path: &Path, format: &str, name: &str) -> Result> {
return Ok(read_pkg_entry(path, e, MAX_IMAGE_BYTES)?);
}
let mut tree = ps5upload_fpkg::source::open(path)?;
- Ok(tree.read(&format!("sce_sys/{name}"))?)
+ let p = format!("sce_sys/{name}");
+ match tree.files().iter().find(|f| f.path == p) {
+ None => bail!("no {name} in this game"),
+ Some(f) if f.size > u64::from(MAX_IMAGE_BYTES) => bail!("{name} is too large to show"),
+ Some(_) => Ok(tree.read(&p)?),
+ }
}
/// Inspections by token, so the image requests that follow reuse one parse.
-/// Keyed by path + size + mtime: a changed file is parsed again.
+/// Keyed by path plus the size and mtime of every file the inspection depends
+/// on: a changed file, split part or folder PARAM is parsed again.
pub struct InspectCache {
cap: usize,
inner: std::sync::Mutex>,
}
struct CacheEntry {
- key: (PathBuf, u64, std::time::SystemTime),
+ key: (PathBuf, Vec<(u64, std::time::SystemTime)>),
token: String,
at: std::time::Instant,
inspection: GameInspection,
@@ -523,21 +548,18 @@ impl InspectCache {
/// The cached token and inspection for this exact file, or a fresh one.
pub fn inspect(&self, path: &Path) -> Result<(String, GameInspection)> {
- let meta = std::fs::metadata(path)?;
- let key = (
- path.to_path_buf(),
- meta.len(),
- meta.modified().unwrap_or(std::time::UNIX_EPOCH),
- );
+ let key = (path.to_path_buf(), fingerprint(path)?);
{
let mut entries = self.inner.lock().unwrap_or_else(|e| e.into_inner());
entries.retain(|e| e.at.elapsed() < CACHE_TTL);
- if let Some(e) = entries.iter().find(|e| e.key == key) {
+ if let Some(e) = entries.iter_mut().find(|e| e.key == key) {
+ // In use: keep its token alive for the image requests to come.
+ e.at = std::time::Instant::now();
return Ok((e.token.clone(), e.inspection.clone()));
}
}
let inspection = inspect_local(path)?;
- let token = new_token();
+ let token = new_token()?;
let mut entries = self.inner.lock().unwrap_or_else(|e| e.into_inner());
entries.push(CacheEntry {
key,
@@ -560,11 +582,43 @@ impl InspectCache {
}
}
-fn new_token() -> String {
+/// Size and mtime of `path` and what else its inspection reads: a folder's
+/// PARAM files (editing them leaves the folder's own mtime alone) and a
+/// package's split parts.
+fn fingerprint(path: &Path) -> Result> {
+ let stamp = |p: &Path| {
+ std::fs::metadata(p)
+ .ok()
+ .map(|m| (m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)))
+ };
+ let Some(own) = stamp(path) else {
+ bail!("cannot read {}", path.display());
+ };
+ let mut out = vec![own];
+ if path.is_dir() {
+ for f in ["sce_sys/param.json", "sce_sys/param.sfo"] {
+ out.extend(stamp(&path.join(f)));
+ }
+ } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
+ let dir = path.parent().unwrap_or(Path::new("."));
+ // Same walk as the split-set parser: .0, .1, ...
+ for i in 0..=1024u32 {
+ match stamp(&dir.join(format!("{name}.{i}"))) {
+ Some(s) => out.push(s),
+ None => break,
+ }
+ }
+ }
+ Ok(out)
+}
+
+fn new_token() -> Result {
let mut b = [0u8; 16];
// The OS generator; never a Unix-only path (the engine also runs on Windows).
- let _ = getrandom::fill(&mut b);
- b.iter().map(|x| format!("{x:02x}")).collect()
+ // A token is what lets a request read files, so never fall back to a
+ // guessable one.
+ getrandom::fill(&mut b).map_err(|e| anyhow::anyhow!("no secure random source: {e}"))?;
+ Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}
static CACHE: std::sync::LazyLock =
@@ -894,4 +948,70 @@ mod tests {
assert_eq!(g.identity.title, "Fixture");
assert_eq!(g.identity.content_type, "game");
}
+
+ #[test]
+ fn the_cache_sees_a_folder_param_edit_and_a_split_part_change() {
+ let dir = test_fixtures::scratch();
+ let sys = dir.join("g/sce_sys");
+ std::fs::create_dir_all(&sys).unwrap();
+ std::fs::write(sys.join("param.json"), br#"{"titleId":"PPSA00001"}"#).unwrap();
+ let cache = InspectCache::new(32);
+ let (t1, g1) = cache.inspect(&dir.join("g")).unwrap();
+ assert_eq!(g1.identity.title_id, "PPSA00001");
+ std::thread::sleep(std::time::Duration::from_millis(1100));
+ // Editing a file inside sce_sys leaves the folder's own mtime alone.
+ std::fs::write(sys.join("param.json"), br#"{"titleId":"PPSA00002"}"#).unwrap();
+ let (t2, g2) = cache.inspect(&dir.join("g")).unwrap();
+ assert_ne!(t1, t2);
+ assert_eq!(g2.identity.title_id, "PPSA00002");
+
+ let lead = test_fixtures::write_cnt_pkg(dir.join("s.pkg"), "gd");
+ std::fs::write(dir.join("s.pkg.0"), vec![0u8; 10]).unwrap();
+ let (t3, g3) = cache.inspect(&lead).unwrap();
+ std::fs::write(dir.join("s.pkg.0"), vec![0u8; 20]).unwrap();
+ let (t4, g4) = cache.inspect(&lead).unwrap();
+ assert_ne!(t3, t4);
+ assert_eq!(g4.source.size, g3.source.size + 10);
+ }
+
+ #[test]
+ fn a_non_ascii_content_id_does_not_panic() {
+ let mut g = GameInspection::default();
+ g.identity.content_id = "EP9000-PPSA0123é_00-X".to_string();
+ finish(&mut g);
+ assert!(g.identity.title_id.is_empty() || g.identity.title_id.is_char_boundary(0));
+ }
+
+ #[test]
+ fn oversized_text_in_a_folder_is_not_read_whole() {
+ let dir = test_fixtures::scratch();
+ let sys = dir.join("sce_sys");
+ std::fs::create_dir_all(sys.join("changeinfo")).unwrap();
+ std::fs::write(sys.join("param.json"), br#"{"titleId":"PPSA00001"}"#).unwrap();
+ std::fs::write(
+ sys.join("changeinfo/changeinfo.xml"),
+ vec![b'a'; MAX_TEXT_BYTES as usize + 1],
+ )
+ .unwrap();
+ let g = inspect_local(&dir).unwrap();
+ assert!(g.change_notes.is_none());
+ assert!(g.warnings.iter().any(|w| w.contains("changeinfo")));
+ }
+
+ #[test]
+ fn sfo_numbers_read_as_numbers_and_flags_as_hex() {
+ let mut g = GameInspection::default();
+ apply_sfo(
+ &mut g,
+ &[
+ ("PARENTAL_LEVEL".to_string(), SfoValue::Int(5)),
+ ("ATTRIBUTE".to_string(), SfoValue::Int(0x10)),
+ ("SYSTEM_VER".to_string(), SfoValue::Int(0x0505_0000)),
+ ],
+ );
+ let v = |k: &str| g.params.iter().find(|p| p.key == k).unwrap().value.clone();
+ assert_eq!(v("PARENTAL_LEVEL"), "5");
+ assert_eq!(v("ATTRIBUTE"), "0x00000010");
+ assert_eq!(v("SYSTEM_VER"), "0x05050000");
+ }
}