Seregon/PkgToolBox

Toolbox for analyzing and editing pkg application files for psp,ps3, ps4 and ps5, includes the most useful functions you might need.

Python/57.3 KB/No license
tools/ps5_pkg_mapper.py
PkgToolBox / tools / ps5_pkg_mapper.py
1import argparse
2import json
3import os
4import sys
5from typing import Dict, Any
6 
7# Ensure project root (PkgToolBox) is on sys.path when running as a script
8THIS_DIR = os.path.dirname(os.path.abspath(__file__))
9PROJECT_ROOT = os.path.abspath(os.path.join(THIS_DIR, os.pardir))
10if PROJECT_ROOT not in sys.path:
11 sys.path.insert(0, PROJECT_ROOT)
12 
13from packages import PackagePS4, PackagePS5
14from tools.utils import Logger
15 
16IMPORTANT_SUFFIXES_PS5 = [
17 "eboot.bin",
18 "sce_sys/param.json",
19 "sce_sys/icon0.png",
20 "sce_sys/icon0.dds",
21 "sce_sys/pic0.png",
22 "sce_sys/pic0.dds",
23 "sce_sys/pic1.png",
24 "sce_sys/pic1.dds",
25 "sce_sys/pic2.png",
26 "sce_sys/pic2.dds",
27 "sce_sys/playgo-chunk.dat",
28 "sce_sys/playgo-ficm.dat",
29 "sce_sys/playgo-hash-table.dat",
30 "sce_sys/playgo-scenario.json",
31 "sce_sys/trophy/trophy00.ucp",
32 "sce_sys/trophy/trophy00.trp",
33 "sce_sys/trophy2/trophy00.ucp",
34 "sce_sys/nptitle.dat",
35 "sce_sys/target-param.json",
36 "sce_sys/origin-param.json",
37]
38 
39# PS4 commonly observed filenames/paths
40IMPORTANT_SUFFIXES_PS4 = [
41 "eboot.bin",
42 "param.sfo",
43 "icon0.png",
44 "icon0.dds",
45 "pic0.png",
46 "pic0.dds",
47 "pic1.png",
48 "pic1.dds",
49 "pic2.png",
50 "pic2.dds",
51 "playgo-chunk.dat",
52 "playgo-chunk.sha",
53 "playgo-manifest.xml",
54 "app/playgo-chunk.dat",
55 "changeinfo/changeinfo.xml",
56]
57 
58 
59def norm_path(p: str) -> str:
60 if not p:
61 return ""
62 p = p.replace("\\", "/")
63 while p.startswith("./"):
64 p = p[2:]
65 if p.startswith("/"):
66 p = p[1:]
67 # Heuristic: fix inverted dir
68 p = p.replace("sys_sce/", "sce_sys/").replace("/sys_sce/", "/sce_sys/")
69 return p
70 
71 
72def classify_name(name: str, suffixes) -> str:
73 n = norm_path(name).lower()
74 for suf in suffixes:
75 if n.endswith(suf):
76 return suf
77 return "other"
78 
79 
80def sniff_type(pkg_path: str, offset: int, size: int) -> Dict[str, Any]:
81 """Read a small header at offset to guess file type/extension."""
82 result = {"type": "unknown", "ext": None, "details": None}
83 try:
84 with open(pkg_path, "rb") as fp:
85 fp.seek(int(offset))
86 header = fp.read(min(64, max(0, int(size))))
87 # Signatures
88 if header.startswith(b"\x89PNG\r\n\x1a\n"):
89 return {"type": "image/png", "ext": ".png", "details": "PNG"}
90 if header.startswith(b"DDS "):
91 return {"type": "image/dds", "ext": ".dds", "details": "DDS"}
92 if header.startswith(b"\xFF\xD8\xFF"):
93 return {"type": "image/jpeg", "ext": ".jpg", "details": "JPEG"}
94 if header.startswith(b"BM"):
95 return {"type": "image/bmp", "ext": ".bmp", "details": "BMP"}
96 if header.startswith(b"{") or header.startswith(b"["):
97 return {"type": "application/json", "ext": ".json", "details": "JSON-like"}
98 if header[:5].lower().startswith(b"<?xml") or header[:1] == b"<":
99 return {"type": "text/xml", "ext": ".xml", "details": "XML-like"}
100 if header.startswith(b"\x00PSF"):
101 return {"type": "application/x-param-sfo", "ext": ".sfo", "details": "PARAM.SFO"}
102 # Fallback: RIFF may indicate audio/container
103 if header.startswith(b"RIFF"):
104 return {"type": "audio/riff", "ext": ".riff", "details": "RIFF"}
105 except Exception as e:
106 result["details"] = f"sniff_error: {e}"
107 return result
108 
109 
110def maybe_export(pkg_path: str, entry: Dict[str, Any], export_dir: str) -> str:
111 """Export entry bytes if sniffed type is an image. Returns output path or empty string."""
112 if not export_dir:
113 return ""
114 sniff = entry.get("sniff", {})
115 mime = sniff.get("type")
116 ext = sniff.get("ext") or ".bin"
117 if not mime or not mime.startswith("image/"):
118 return ""
119 try:
120 os.makedirs(export_dir, exist_ok=True)
121 out_name = f"{entry['id']}_{os.path.basename(entry.get('name') or '') or 'unnamed'}{ext}"
122 out_name = out_name.replace('/', '_')
123 out_path = os.path.join(export_dir, out_name)
124 with open(pkg_path, "rb") as fp, open(out_path, "wb") as out:
125 fp.seek(int(entry["offset"]))
126 out.write(fp.read(int(entry["size"])))
127 return out_path
128 except Exception:
129 return ""
130 
131 
132def _detect_pkg_class(pkg_path: str):
133 with open(pkg_path, "rb") as fp:
134 magic_bytes = fp.read(4)
135 magic = int.from_bytes(magic_bytes, byteorder="big", signed=False)
136 Logger.log_information(f"Read magic number: {magic:08X}")
137 # PS4 CNT magic: 0x7F434E54 ('\x7F' 'C' 'N' 'T')
138 # PS5 magic observed: 0x7F464948 ('\x7F' 'F' 'I' 'H') and sometimes 0x7F504B47 ('\x7F' 'P' 'K' 'G')
139 if magic == 0x7F434E54:
140 return PackagePS4
141 if magic in (0x7F504B47, 0x7F464948):
142 return PackagePS5
143 raise ValueError(f"Unknown PKG format: {magic:08X}")
144 
145 
146def build_mapping(pkg_path: str, export_dir: str = "") -> Dict[str, Any]:
147 PkgCls = _detect_pkg_class(pkg_path)
148 pkg = PkgCls(pkg_path)
149 suffixes = IMPORTANT_SUFFIXES_PS5 if isinstance(pkg, PackagePS5) else IMPORTANT_SUFFIXES_PS4
150 
151 mapping = {
152 "package": os.path.basename(pkg_path),
153 "size": os.path.getsize(pkg_path),
154 "content_id": pkg.content_id,
155 "title_id": getattr(pkg, "title_id", None),
156 "platform": "PS5" if isinstance(pkg, PackagePS5) else "PS4",
157 "entries": [],
158 "important": {},
159 }
160 
161 # PS5: annotate PFS region if available
162 pfs_offset = getattr(pkg, "pfs_offset", None)
163 pfs_size = getattr(pkg, "pfs_size", None)
164 pfs_end = (pfs_offset + pfs_size) if isinstance(pfs_offset, int) and isinstance(pfs_size, int) else None
165 
166 # For PS5, also expose layout/digests to help diagnose encryption state
167 if isinstance(pkg, PackagePS5):
168 mapping["layout"] = {
169 "fih_offset": getattr(pkg, "fih_offset", None),
170 "fih_size": getattr(pkg, "fih_size", None),
171 "pfs_offset": pfs_offset,
172 "pfs_size": pfs_size,
173 "sc_offset": getattr(pkg, "sc_offset", None),
174 "sc_size": getattr(pkg, "sc_size", None),
175 "si_offset": getattr(pkg, "si_offset", None),
176 "si_size": getattr(pkg, "si_size", None),
177 "package_digest": getattr(pkg, "package_digest", None),
178 "pfs_area_digest": getattr(pkg, "pfs_area_digest", None),
179 }
180 
181 files = getattr(pkg, "files", {}) or {}
182 for file_id, info in files.items():
183 name = norm_path(info.get("name", f"file_{file_id}"))
184 cls = classify_name(name, suffixes)
185 entry = {
186 "id": file_id,
187 "name": name,
188 "offset": info.get("offset"),
189 "size": info.get("size"),
190 "encrypted": bool(info.get("encrypted", False)),
191 "class": cls,
192 }
193 # Mark if the entry lies within PS5 PFS region
194 if isinstance(entry["offset"], int) and isinstance(entry["size"], int) and pfs_end is not None:
195 off = entry["offset"]
196 entry["inside_pfs"] = (off >= pfs_offset and off < pfs_end)
197 else:
198 entry["inside_pfs"] = False
199 # Content sniff to help classify unnamed entries (icons/images/json/xml)
200 if entry["offset"] is not None and entry["size"]:
201 entry["sniff"] = sniff_type(pkg_path, entry["offset"], entry["size"])
202 # If still 'other' but we sniffed an image/json/xml, use ext as class hint
203 if cls == "other" and entry["sniff"]["ext"]:
204 entry["class"] = entry["sniff"]["ext"].lstrip('.')
205 # Heuristic score for ranking candidates
206 score = 0
207 sz = int(entry["size"]) if entry["size"] else 0
208 mime = entry.get("sniff", {}).get("type") if entry.get("sniff") else None
209 if entry["class"] != "other":
210 score += 2
211 if mime and mime.startswith("image/"):
212 score += 3
213 if 4 * 1024 <= sz <= 8 * 1024 * 1024:
214 score += 1
215 if entry["encrypted"]:
216 score -= 2
217 if entry["inside_pfs"]:
218 score -= 1
219 entry["score"] = score
220 # Optional export of images
221 exported_to = maybe_export(pkg_path, entry, export_dir)
222 if exported_to:
223 entry["exported_to"] = exported_to
224 mapping["entries"].append(entry)
225 if cls != "other" and cls not in mapping["important"]:
226 mapping["important"][cls] = entry
227 
228 mapping["summary"] = {
229 "total_entries": len(mapping["entries"]),
230 "important_found": sorted(list(mapping["important"].keys())),
231 "missing_important": sorted([s for s in suffixes if s not in mapping["important"]]),
232 "top_candidates": sorted(
233 [
234 {"id": e["id"], "name": e["name"], "class": e["class"], "score": e["score"], "sniff": e.get("sniff", {}), "inside_pfs": e.get("inside_pfs", False)}
235 for e in mapping["entries"]
236 ], key=lambda x: x["score"], reverse=True
237 )[:10],
238 }
239 
240 # If PS5 and zero entries, hint that it's likely encrypted/unsupported
241 if isinstance(pkg, PackagePS5) and not mapping["entries"]:
242 mapping["summary"]["note"] = "No entries available; package likely encrypted or unsupported without keys (entry table and layout are zero)."
243 
244 return mapping
245 
246 
247def main():
248 parser = argparse.ArgumentParser(description="Map PS5 PKG file entries and offsets into JSON report")
249 parser.add_argument("pkg", help="Path to PS5 PKG file")
250 parser.add_argument("-o", "--output", help="Output JSON path (default: <pkg>.map.json)")
251 parser.add_argument("--export-dir", help="Directory to export detected images (PNG/DDS/JPEG/BMP)")
252 args = parser.parse_args()
253 
254 pkg_path = os.path.abspath(args.pkg)
255 if not os.path.isfile(pkg_path):
256 raise SystemExit(f"File not found: {pkg_path}")
257 
258 try:
259 Logger.log_information(f"Building mapping for: {pkg_path}")
260 mapping = build_mapping(pkg_path, export_dir=args.export_dir or "")
261 out_path = args.output or (pkg_path + ".map.json")
262 with open(out_path, "w", encoding="utf-8") as f:
263 json.dump(mapping, f, indent=2, ensure_ascii=False)
264 Logger.log_information(f"Mapping written: {out_path}")
265 print(out_path)
266 except Exception as e:
267 Logger.log_error(f"Mapping failed: {e}")
268 raise
269 
270 
271if __name__ == "__main__":
272 main()
273