Toolbox for analyzing and editing pkg application files for psp,ps3, ps4 and ps5, includes the most useful functions you might need.
| 1 | import argparse |
| 2 | import json |
| 3 | import os |
| 4 | import sys |
| 5 | from typing import Dict, Any |
| 6 | |
| 7 | # Ensure project root (PkgToolBox) is on sys.path when running as a script |
| 8 | THIS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| 9 | PROJECT_ROOT = os.path.abspath(os.path.join(THIS_DIR, os.pardir)) |
| 10 | if PROJECT_ROOT not in sys.path: |
| 11 | sys.path.insert(0, PROJECT_ROOT) |
| 12 | |
| 13 | from packages import PackagePS4, PackagePS5 |
| 14 | from tools.utils import Logger |
| 15 | |
| 16 | IMPORTANT_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 |
| 40 | IMPORTANT_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 | |
| 59 | def 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 | |
| 72 | def 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 | |
| 80 | def 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 | |
| 110 | def 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 | |
| 132 | def _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 | |
| 146 | def 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 | |
| 247 | def 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 | |
| 271 | if __name__ == "__main__": |
| 272 | main() |
| 273 |