diff --git a/.gitignore b/.gitignore
index 2930595..68fa54d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,3 +107,7 @@ _UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
*.vshost.* #ignore vshost files...
+
+# Python (tools/)
+__pycache__/
+*.pyc
diff --git a/Latest Compiled Version/PS3DumpChecker.exe b/Latest Compiled Version/PS3DumpChecker.exe
index 72932e8..638d2d6 100644
Binary files a/Latest Compiled Version/PS3DumpChecker.exe and b/Latest Compiled Version/PS3DumpChecker.exe differ
diff --git a/Latest Compiled Version/PS3DumpChecker.exe.md5 b/Latest Compiled Version/PS3DumpChecker.exe.md5
index 29c210c..8eb1084 100644
--- a/Latest Compiled Version/PS3DumpChecker.exe.md5
+++ b/Latest Compiled Version/PS3DumpChecker.exe.md5
@@ -1 +1 @@
-0CAC8E2164D7AF81CEBB66ECEEBFB697
\ No newline at end of file
+486A0D9A65E3E02409300E2DC2FDA409
\ No newline at end of file
diff --git a/Latest Compiled Version/PS3DumpChecker.exe.version b/Latest Compiled Version/PS3DumpChecker.exe.version
index 4e7095f..e28fc92 100644
--- a/Latest Compiled Version/PS3DumpChecker.exe.version
+++ b/Latest Compiled Version/PS3DumpChecker.exe.version
@@ -1 +1 @@
-1.0.505.0
+1.0.506.0
diff --git a/Latest Compiled Version/changelog b/Latest Compiled Version/changelog
index 23ea453..4cacd48 100644
--- a/Latest Compiled Version/changelog
+++ b/Latest Compiled Version/changelog
@@ -1,3 +1,7 @@
+Build 506 2026-08-27:
+ - Changed: The embedded 4.92 patch replaced by a 4.93 patch built from Evilnat's 4.93 custom firmware CoreOS.
+ - Added: OFW and Patched 4.93 ROS hashes.
+ - Added: tools/ to decrypt CoreOS from a PUP, build ROS hashes, and validate the data files.
Build 505 2025-03-07:
- Changed: The embedded 4.91 patch replaced by a 4.92 patch built from Evilnat's 4.92 custom firmware CoreOS.
- Added: OFW and Patched 4.92 ROS hashes.
diff --git a/Latest Compiled Version/default.hashlist b/Latest Compiled Version/default.hashlist
index fd792e0..6878817 100644
--- a/Latest Compiled Version/default.hashlist
+++ b/Latest Compiled Version/default.hashlist
@@ -9,6 +9,12 @@
7C0020
+
+ 505788E2430D1573D925E713EA58CA23
+
+
+ 7C54192073537CD1C448971547086423
+
36BD44795F06B59EECBDAAD6982BE426
diff --git a/Latest Compiled Version/default.hashlist.md5 b/Latest Compiled Version/default.hashlist.md5
index cddb75d..fdcf250 100644
--- a/Latest Compiled Version/default.hashlist.md5
+++ b/Latest Compiled Version/default.hashlist.md5
@@ -1 +1 @@
-81CD7ECFF0F7AF98158F1BD2CE61C8DD
\ No newline at end of file
+3DB50FC7A2EE7F244F429A09212B158C
\ No newline at end of file
diff --git a/src/PS3DumpChecker/Patches/patch.bin b/src/PS3DumpChecker/Patches/patch.bin
index b10d98e..af37dab 100644
Binary files a/src/PS3DumpChecker/Patches/patch.bin and b/src/PS3DumpChecker/Patches/patch.bin differ
diff --git a/src/PS3DumpChecker/Patches/patch_info.txt b/src/PS3DumpChecker/Patches/patch_info.txt
index 48bdb0c..6644675 100644
--- a/src/PS3DumpChecker/Patches/patch_info.txt
+++ b/src/PS3DumpChecker/Patches/patch_info.txt
@@ -1 +1 @@
-noFSM 4.92 (Evilnat based)
\ No newline at end of file
+noFSM 4.93 (Evilnat based)
\ No newline at end of file
diff --git a/src/PS3DumpChecker/Properties/AssemblyInfo.cs b/src/PS3DumpChecker/Properties/AssemblyInfo.cs
index e5ebf7a..f41c4b6 100644
--- a/src/PS3DumpChecker/Properties/AssemblyInfo.cs
+++ b/src/PS3DumpChecker/Properties/AssemblyInfo.cs
@@ -35,5 +35,5 @@
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.505.0")]
-[assembly: AssemblyFileVersion("1.0.505.0")]
\ No newline at end of file
+[assembly: AssemblyVersion("1.0.506.0")]
+[assembly: AssemblyFileVersion("1.0.506.0")]
\ No newline at end of file
diff --git a/src/PS3DumpChecker/hashlist.xml b/src/PS3DumpChecker/hashlist.xml
index c280e5b..2c82a74 100644
--- a/src/PS3DumpChecker/hashlist.xml
+++ b/src/PS3DumpChecker/hashlist.xml
@@ -9,6 +9,12 @@
7C0020
+
+ 505788E2430D1573D925E713EA58CA23
+
+
+ 7C54192073537CD1C448971547086423
+
36BD44795F06B59EECBDAAD6982BE426
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..8331c0c
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,103 @@
+# tools
+
+Helpers for adding support for a new PS3 firmware release.
+
+Adding a firmware has always been a manual process: unpack two PUPs, decrypt
+the CoreOS out of each, MD5 the ROS region, hand-edit `hashlist.xml`, and drop
+the new payload in as `patch.bin`. These scripts cover the parts that can be
+checked by a machine, so a typo in a hex digit fails here instead of on a
+user's dump.
+
+Python 3. No third-party packages.
+
+## ros_hash.py
+
+Computes the ROS MD5 that `hashlist.xml` stores, and prints the XML entries
+ready to paste.
+
+```
+python tools/ros_hash.py ofw_content cfw_content --version 4.93
+python tools/ros_hash.py content --name "4.93 CEX"
+python tools/ros_hash.py content --name "4.93 CEX Patched (Evilnat based)" --patched
+```
+
+Input is a *decrypted* CoreOS payload -- the `content` file from an unpacked
+`CORE_OS_PACKAGE.pkg`, not the `.pkg` itself and not the PUP. It is the same
+7,340,000-byte blob that ships as `Patches/patch.bin`.
+
+The hash is the MD5 of the first `0x6FFFE0` bytes, matching
+`HashCheck.CheckHash`. The byte-swapped MD5 is printed alongside it: NOR dumps
+store the region swapped and NAND dumps do not, so seeing both makes a
+mismatch obvious rather than silent.
+
+Sanity check -- this reproduces the 4.92 entry already in `hashlist.xml`:
+
+```
+$ python tools/ros_hash.py src/PS3DumpChecker/Patches/patch.bin \
+ --name "4.92 CEX Patched (Evilnat based)" --patched
+ MD5 : 36BD44795F06B59EECBDAAD6982BE426
+```
+
+## validate_data.py
+
+Checks `hashlist.xml` and `config.xml` before they ship. Run it after editing
+either file.
+
+```
+python tools/validate_data.py
+```
+
+Catches duplicate MD5s, duplicate entry names, malformed hashes, non-hex size
+and offset attributes, bad `patched` values, offsets missing attributes, and
+hashes referencing a type that was never declared. Exits non-zero on error.
+
+## coreos_decrypt.py
+
+Decrypts `CORE_OS_PACKAGE.pkg` out of a PUP and writes the `content` blob --
+the 7,340,000-byte ROS payload `ros_hash.py` needs.
+
+```
+python tools/coreos_decrypt.py PS3UPDAT.PUP out_dir
+python tools/coreos_decrypt.py --extract-selfs PS3UPDAT.PUP out_dir
+```
+
+Requires `pycryptodome` (`python -m pip install --user pycryptodome`). No other
+external tools -- reimplements `pupunpack`, `unpkg`, and `cosunpkg` from
+fail0verflow/ps3tools in Python, using the public retail PKG keys embedded in
+the script.
+
+`--extract-selfs` writes each SELF from the decrypted content next to
+`content`. Useful for sanity-checking the extraction (each file should start
+with the `SCE\0` magic).
+
+## Getting the decrypted CoreOS
+
+1. Get both PUPs and verify them:
+ - official `PS3UPDAT.PUP` for the firmware, from Sony's update CDN
+ - the matching **CEX** Evilnat CFW -- not `noBD`, `noBT` or `noBD+noBT`,
+ which drop modules and therefore hash differently
+2. `python tools/coreos_decrypt.py PS3UPDAT.PUP out_dir` on each. The
+ `out_dir/content` file is what `ros_hash.py` consumes.
+3. `python tools/ros_hash.py ofw_out/content cfw_out/content --version X.XX`.
+
+`pup_info.py` prints a PUP's entry table if you want to confirm the layout
+before decrypting.
+
+## Adding a firmware, end to end
+
+1. Verify both PUP downloads against their published checksums. The Evilnat
+ `.rar` ships an `md5.txt`; the official PUP has a SHA-256 published
+ alongside it.
+2. `python tools/coreos_decrypt.py OFW.PUP ofw_out` and again for the CFW.
+3. `python tools/ros_hash.py ofw_out/content cfw_out/content --version X.XX`
+4. Paste both entries at the top of `` in
+ `src/PS3DumpChecker/hashlist.xml`, newest first.
+5. Copy the CFW `content` to `src/PS3DumpChecker/Patches/patch.bin` and update
+ `Patches/patch_info.txt` to `noFSM X.XX (Evilnat based)`.
+6. `python tools/validate_data.py`
+7. Bump `AssemblyVersion` and `AssemblyFileVersion` in
+ `Properties/AssemblyInfo.cs`, and add a changelog entry.
+8. Build Release_Embedded. The post-build step refreshes
+ `Latest Compiled Version/`, which is what the in-app updater serves --
+ `default.cfg`, `default.hashlist` and their `.md5` files must be regenerated
+ or existing installs will keep fetching the old data.
diff --git a/tools/coreos_decrypt.py b/tools/coreos_decrypt.py
new file mode 100644
index 0000000..65f6f85
--- /dev/null
+++ b/tools/coreos_decrypt.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""
+coreos_decrypt.py - Decrypt CORE_OS_PACKAGE.pkg from a PS3 PUP.
+
+Reimplements the SCE PKG decrypt pipeline from fail0verflow/ps3tools (unpkg,
+cosunpkg) in pure Python. Ported from wargio/ps3tools tools.c, GPLv2.
+
+Input: PS3UPDAT.PUP (retail or Evilnat CFW).
+Output: `content` file (decrypted CoreOS layout) + individual SELFs of the
+CoreOS package alongside it.
+
+The `content` file is the 7,340,000-byte ROS payload the game hashes and
+patches. That is what ros_hash.py needs and what ships as patch.bin.
+
+Usage:
+ python coreos_decrypt.py PS3UPDAT.PUP out_dir
+ python coreos_decrypt.py --extract-selfs PS3UPDAT.PUP out_dir
+
+Exit codes: 0 ok, 1 error.
+"""
+
+import argparse
+import os
+import struct
+import sys
+import zlib
+
+try:
+ from Crypto.Cipher import AES
+except ImportError:
+ print("error: pycryptodome missing. run: python -m pip install --user pycryptodome",
+ file=sys.stderr)
+ sys.exit(1)
+
+
+# PKG keys from public scetool keys.conf. Two revisions cover every retail PUP
+# that ships CORE_OS_PACKAGE; sce_decrypt_header brute-forces the list until
+# the meta header decrypts to the expected zero pattern.
+PKG_KEYS = [
+ {
+ "revision": 0x00,
+ "erk": bytes.fromhex("A97818BD193A67A16FE83A855E1BE9FB5640938D4DBCB2CB52C5A2F8B02B1031"),
+ "riv": bytes.fromhex("4ACEF01224FBEEDF8245F8FF10211E6E"),
+ },
+ {
+ "revision": 0x01,
+ "erk": bytes.fromhex("F8F99006F1C007D5D0B1909E9566E0E70B569399FC3394A811809FDB5CAE92CD"),
+ "riv": bytes.fromhex("59D28DB4ADDFB40B7D768BC9667C67B1"),
+ },
+]
+
+
+def be(data, offset, length):
+ return int.from_bytes(data[offset:offset + length], "big")
+
+
+def be16(d, o): return be(d, o, 2)
+def be32(d, o): return be(d, o, 4)
+def be64(d, o): return be(d, o, 8)
+
+
+# -------- PUP layer (pupunpack) --------
+
+PUP_MAGIC = b"SCEUF"
+PUP_HEADER = 0x30
+PUP_ENTRY = 0x20
+
+PUP_ENTRY_NAMES = {
+ 0x100: "version.txt",
+ 0x101: "license.xml",
+ 0x102: "promo_flags.txt",
+ 0x103: "update_flags.txt",
+ 0x104: "patch_build.txt",
+ 0x200: "ps3swu.self",
+ 0x201: "vsh.tar",
+ 0x202: "dots.txt",
+ 0x203: "patch_data.pkg",
+ 0x300: "update_files.tar",
+ 0x501: "spkg_hdr.tar",
+ 0x601: "ps3swu2.self",
+}
+
+
+def parse_pup(path):
+ """Return (data, entries[]) where each entry is dict(id, offset, length)."""
+ with open(path, "rb") as fh:
+ data = fh.read()
+
+ if not data.startswith(PUP_MAGIC):
+ raise ValueError("not a PUP (magic mismatch)")
+ if len(data) < PUP_HEADER:
+ raise ValueError("truncated PUP header")
+ n = be64(data, 0x18)
+ table_end = PUP_HEADER + PUP_ENTRY * n
+ if n > 0x1000 or table_end > len(data):
+ raise ValueError(
+ "PUP claims %d entries, table would need 0x%X bytes of a %d-byte file"
+ % (n, table_end, len(data)))
+ entries = []
+ for i in range(n):
+ base = PUP_HEADER + PUP_ENTRY * i
+ entries.append({
+ "id": be64(data, base),
+ "offset": be64(data, base + 0x08),
+ "length": be64(data, base + 0x10),
+ })
+ return data, entries
+
+
+def find_entry(entries, entry_id):
+ for e in entries:
+ if e["id"] == entry_id:
+ return e
+ return None
+
+
+# -------- SPKG layer (unpkg) --------
+
+def aes256cbc_decrypt(key, iv, data):
+ return AES.new(key, AES.MODE_CBC, iv).decrypt(data)
+
+
+def aes128ctr(key, iv, data):
+ """AES-128-CTR matching fail0verflow's aes128ctr (be64 nonce increment)."""
+ out = bytearray(len(data))
+ counter = bytearray(iv)
+ ecb = AES.new(key, AES.MODE_ECB)
+ keystream = b""
+ for i, b in enumerate(data):
+ if (i & 0xF) == 0:
+ keystream = ecb.encrypt(bytes(counter))
+ lo = int.from_bytes(counter[8:16], "big") + 1
+ if lo > 0xFFFFFFFFFFFFFFFF:
+ lo = 0
+ hi = int.from_bytes(counter[0:8], "big") + 1
+ counter[0:8] = hi.to_bytes(8, "big")
+ counter[8:16] = lo.to_bytes(8, "big")
+ out[i] = b ^ keystream[i & 0xF]
+ return bytes(out)
+
+
+def sce_decrypt_header(pkg):
+ """Decrypt SCE meta header in place. Returns index of matching key or -1.
+
+ Ported from tools.c sce_decrypt_header. For each candidate key, AES-256-CBC
+ the 0x40 bytes at meta_offset+0x20; a matching key leaves bytes
+ [0x10..0x20) and [0x30..0x40) all zero.
+ """
+ meta_offset = be32(pkg, 0x0c)
+ header_len = be64(pkg, 0x10)
+
+ matched = -1
+ for idx, k in enumerate(PKG_KEYS):
+ tmp = aes256cbc_decrypt(k["erk"], k["riv"],
+ bytes(pkg[meta_offset + 0x20:meta_offset + 0x60]))
+ if all(b == 0 for b in tmp[0x10:0x20]) and all(b == 0 for b in tmp[0x30:0x40]):
+ pkg[meta_offset + 0x20:meta_offset + 0x60] = tmp
+ matched = idx
+ break
+ if matched < 0:
+ return -1
+
+ # tools.c does two aes128ctr calls sharing the same iv buffer, whose
+ # counter mutates in place -> effectively one continuous CTR stream over
+ # [+0x60, +header_len). Match that with a single call here.
+ key128 = bytes(pkg[meta_offset + 0x20:meta_offset + 0x30])
+ iv = bytes(pkg[meta_offset + 0x40:meta_offset + 0x50])
+
+ meta_len = header_len - meta_offset
+ start = meta_offset + 0x60
+ end = meta_offset + meta_len
+ pkg[start:end] = aes128ctr(key128, iv, bytes(pkg[start:end]))
+ return matched
+
+
+def sce_decrypt_data(pkg):
+ """Decrypt every data segment in place using the section table."""
+ meta_offset = be32(pkg, 0x0c)
+ n_hdr = be32(pkg, meta_offset + 0x60 + 0x0c)
+ keytable_base = meta_offset + 0x80 + 0x30 * n_hdr
+
+ for i in range(n_hdr):
+ entry = meta_offset + 0x80 + 0x30 * i
+ offset = be64(pkg, entry)
+ size = be64(pkg, entry + 8)
+ keyid = be32(pkg, entry + 0x24)
+ ivid = be32(pkg, entry + 0x28)
+ if keyid == 0xFFFFFFFF or ivid == 0xFFFFFFFF:
+ continue
+ key = bytes(pkg[keytable_base + keyid * 0x10:keytable_base + keyid * 0x10 + 0x10])
+ iv = bytes(pkg[keytable_base + ivid * 0x10:keytable_base + ivid * 0x10 + 0x10])
+ pkg[offset:offset + size] = aes128ctr(key, iv, bytes(pkg[offset:offset + size]))
+
+
+def unpkg_content(pkg):
+ """After decrypt, return the `content` blob (decompressed if needed)."""
+ meta_offset = be32(pkg, 0x0c)
+ dec_size = be64(pkg, 0x18)
+
+ tmp = meta_offset + 0x80 + 0x30 * 2
+ offset = be64(pkg, tmp)
+ size = be64(pkg, tmp + 8)
+ flag = be32(pkg, tmp + 0x2c)
+ size_real = dec_size - 0x80
+
+ raw = bytes(pkg[offset:offset + size])
+ if flag == 0x2:
+ return zlib.decompress(raw)
+ return raw
+
+
+# -------- CoreOS layer (cosunpkg) --------
+
+def cosunpkg_files(content):
+ """Iterate (name, data) tuples from a decrypted CoreOS content blob."""
+ n = be32(content, 4)
+ for i in range(n):
+ entry = 0x10 + 0x30 * i
+ offset = be64(content, entry)
+ size = be64(content, entry + 8)
+ name = content[entry + 0x10:entry + 0x30].rstrip(b"\x00").decode("ascii", "replace")
+ yield name, content[offset:offset + size]
+
+
+# -------- driver --------
+
+def extract_core_os_from_pup(pup_path):
+ """Return raw CORE_OS_PACKAGE.pkg bytes from a PUP.
+
+ In retail PUPs the CoreOS package is inside update_files.tar, itself in
+ entry 0x300. `unpkg` runs after untarring update_files.tar to get
+ CORE_OS_PACKAGE.pkg. For simplicity we do the whole thing inline.
+ """
+ data, entries = parse_pup(pup_path)
+ up = find_entry(entries, 0x300)
+ if up is None:
+ raise ValueError("PUP has no update_files.tar (entry 0x300)")
+ tar = data[up["offset"]:up["offset"] + up["length"]]
+
+ # Minimal tar walker: 512-byte header blocks, name at 0, size octal at 124.
+ pos = 0
+ while pos + 512 <= len(tar):
+ header = tar[pos:pos + 512]
+ if header == b"\x00" * 512:
+ break
+ name = header[0:100].split(b"\x00", 1)[0].decode("ascii", "replace")
+ size_field = header[124:136].split(b"\x00", 1)[0].strip()
+ size = int(size_field, 8) if size_field else 0
+ pos += 512
+ if name.endswith("CORE_OS_PACKAGE.pkg"):
+ return tar[pos:pos + size]
+ pos += (size + 511) & ~511
+ raise ValueError("CORE_OS_PACKAGE.pkg not found in update_files.tar")
+
+
+def decrypt_core_os_pkg(pkg_bytes):
+ """Given raw CORE_OS_PACKAGE.pkg, return content blob and matched key rev."""
+ pkg = bytearray(pkg_bytes)
+
+ if be16(pkg, 0x0a) != 3:
+ raise ValueError("not an SCE PKG (type != 3)")
+
+ rev = sce_decrypt_header(pkg)
+ if rev < 0:
+ raise ValueError("header decrypt failed: no PKG key matches")
+ sce_decrypt_data(pkg)
+ return unpkg_content(pkg), PKG_KEYS[rev]["revision"]
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description="Decrypt CORE_OS_PACKAGE.pkg from a PS3 PUP.")
+ ap.add_argument("pup")
+ ap.add_argument("out")
+ ap.add_argument("--extract-selfs", action="store_true",
+ help="also write each SELF from the content blob")
+ args = ap.parse_args(argv)
+
+ try:
+ print("[1/3] parsing PUP...")
+ pkg_bytes = extract_core_os_from_pup(args.pup)
+ print(" CORE_OS_PACKAGE.pkg extracted (%d bytes)" % len(pkg_bytes))
+
+ print("[2/3] decrypting SCE PKG...")
+ content, rev = decrypt_core_os_pkg(pkg_bytes)
+ print(" decrypted with pkg key revision %02x (%d bytes)" % (rev, len(content)))
+ except (OSError, ValueError, KeyError, struct.error) as exc:
+ print("error: %s" % exc, file=sys.stderr)
+ return 1
+
+ print("[3/3] writing outputs...")
+ os.makedirs(args.out, exist_ok=True)
+ content_path = os.path.join(args.out, "content")
+ with open(content_path, "wb") as fh:
+ fh.write(content)
+ print(" wrote %s (%d bytes)" % (content_path, len(content)))
+
+ if args.extract_selfs:
+ out_root = os.path.realpath(args.out)
+ for name, blob in cosunpkg_files(content):
+ if not name:
+ continue
+ # Reject path traversal in archive-supplied names: no absolute
+ # paths, no drive letters, no separators. CoreOS entries are flat
+ # basenames like "lv0" or "lv2_kernel.self".
+ if (os.path.isabs(name) or "/" in name or "\\" in name
+ or name in (".", "..") or ":" in name):
+ print(" skip %r (unsafe path)" % name, file=sys.stderr)
+ continue
+ out = os.path.join(args.out, name)
+ if not os.path.realpath(out).startswith(out_root + os.sep) \
+ and os.path.realpath(out) != out_root:
+ print(" skip %r (escapes out dir)" % name, file=sys.stderr)
+ continue
+ with open(out, "wb") as fh:
+ fh.write(blob)
+ print(" wrote %s (%d bytes)" % (out, len(blob)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/pup_info.py b/tools/pup_info.py
new file mode 100644
index 0000000..fed008a
--- /dev/null
+++ b/tools/pup_info.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""
+pup_info.py - Print the header and entry table of a PS3UPDAT.PUP.
+
+Useful for confirming a PUP is what you think it is before decrypting it, and
+for spotting layout changes. Optionally extracts one entry raw.
+
+Usage:
+ python pup_info.py PS3UPDAT.PUP
+ python pup_info.py PS3UPDAT.PUP --extract 768 --out update_files.tar
+
+Note: extracting an entry yields the *encrypted* SCE package. For the ROS
+`content` blob PS3DumpChecker consumes, use `coreos_decrypt.py` instead, which
+walks the PUP and decrypts the CoreOS package end-to-end.
+
+The CoreOS package appears in two places in a modern retail PUP:
+ - top-level entry 0x200, and
+ - inside `update_files.tar` (entry 0x300).
+The two blobs differ in wrapping but decrypt to the same 7,340,000-byte ROS
+payload. `coreos_decrypt.py` walks the tar, matching the console's install
+path.
+
+Exit codes: 0 ok, 1 error.
+"""
+
+import argparse
+import struct
+import sys
+
+MAGIC = b"SCEUF"
+HEADER_SIZE = 0x30
+ENTRY_SIZE = 0x20
+
+# Entry ids seen in retail PUPs.
+KNOWN_ENTRIES = {
+ 0x100: "version.txt",
+ 0x101: "license.xml",
+ 0x103: "promo_flags.txt",
+ 0x200: "CORE_OS_PACKAGE.pkg (top-level)",
+ 0x201: "UPDATE_FILES.pkg",
+ 0x202: "spkg_hdr.tar",
+ 0x300: "update_files.tar (contains CORE_OS_PACKAGE.pkg)",
+ 0x501: "CORE_OS_PACKAGE.pkg digests",
+ 0x601: "CORE_OS_PACKAGE.pkg signature",
+}
+
+
+def read_header(fh):
+ fh.seek(0)
+ raw = fh.read(HEADER_SIZE)
+ if len(raw) != HEADER_SIZE:
+ raise ValueError("file is too small to be a PUP")
+ if not raw.startswith(MAGIC):
+ raise ValueError("not a PUP (magic is %r, expected %r)" % (raw[:5], MAGIC))
+ pkg_ver, img_ver, count, hdr_len, data_len = struct.unpack(">QQQQQ", raw[8:HEADER_SIZE])
+ return {
+ "package_version": pkg_ver,
+ "image_version": img_ver,
+ "file_count": count,
+ "header_length": hdr_len,
+ "data_length": data_len,
+ }
+
+
+def read_entries(fh, count):
+ fh.seek(HEADER_SIZE)
+ raw = fh.read(ENTRY_SIZE * count)
+ if len(raw) != ENTRY_SIZE * count:
+ raise ValueError("truncated entry table")
+ entries = []
+ for i in range(count):
+ entry_id, offset, length, _pad = struct.unpack(
+ ">QQQQ", raw[i * ENTRY_SIZE:(i + 1) * ENTRY_SIZE])
+ entries.append({"id": entry_id, "offset": offset, "length": length})
+ return entries
+
+
+def extract(fh, entry, out_path):
+ fh.seek(entry["offset"])
+ remaining = entry["length"]
+ with open(out_path, "wb") as out:
+ while remaining:
+ chunk = fh.read(min(1 << 20, remaining))
+ if not chunk:
+ raise ValueError("unexpected EOF while extracting")
+ out.write(chunk)
+ remaining -= len(chunk)
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description="Inspect a PS3 PUP.")
+ ap.add_argument("pup")
+ ap.add_argument("--extract", type=lambda s: int(s, 0), metavar="ID",
+ help="entry id to extract, e.g. 512 or 0x200")
+ ap.add_argument("--out", help="output path for --extract")
+ args = ap.parse_args(argv)
+
+ if args.extract is not None and not args.out:
+ ap.error("--out is required with --extract")
+
+ try:
+ with open(args.pup, "rb") as fh:
+ hdr = read_header(fh)
+ entries = read_entries(fh, hdr["file_count"])
+
+ print("file : %s" % args.pup)
+ print("package version : %d" % hdr["package_version"])
+ print("image version : %d" % hdr["image_version"])
+ print("file count : %d" % hdr["file_count"])
+ print("header length : 0x%X" % hdr["header_length"])
+ print("data length : 0x%X" % hdr["data_length"])
+ print()
+ print("%-8s %-14s %-14s %s" % ("ID", "OFFSET", "LENGTH", "LIKELY CONTENT"))
+ for entry in entries:
+ print("%-8d %-14d %-14d %s"
+ % (entry["id"], entry["offset"], entry["length"],
+ KNOWN_ENTRIES.get(entry["id"], "")))
+
+ if args.extract is not None:
+ match = next((e for e in entries if e["id"] == args.extract), None)
+ if match is None:
+ print("\nerror: entry %d not found" % args.extract, file=sys.stderr)
+ return 1
+ extract(fh, match, args.out)
+ print("\nextracted entry %d (%d bytes) to %s"
+ % (match["id"], match["length"], args.out))
+ except (OSError, ValueError, struct.error) as exc:
+ print("error: %s" % exc, file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/ros_hash.py b/tools/ros_hash.py
new file mode 100644
index 0000000..11a4a6e
--- /dev/null
+++ b/tools/ros_hash.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""
+ros_hash.py - Compute PS3DumpChecker ROS hashes from a decrypted CoreOS.
+
+Given a decrypted CoreOS payload (the "content" file produced by unpacking
+CORE_OS_PACKAGE.pkg from a PS3UPDAT.PUP), this prints the MD5 that
+PS3DumpChecker stores in hashlist.xml, plus the ready-to-paste XML entries.
+
+The hash is computed exactly the way HashCheck.CheckHash does it:
+the first ROS_SIZE (0x6FFFE0) bytes of the region are MD5'd. For NOR the
+region is byte-swapped first; NAND is not swapped. Both dump layouts share
+the same CoreOS payload, so a single hash covers both -- but this tool
+reports the swapped variant too, so a mismatch is visible rather than silent.
+
+Usage:
+ python ros_hash.py content --name "4.93 CEX"
+ python ros_hash.py content --name "4.93 CEX Patched (Evilnat based)" --patched
+ python ros_hash.py ofw_content cfw_content --version 4.93
+
+Exit codes: 0 ok, 1 error.
+"""
+
+import argparse
+import hashlib
+import os
+import sys
+from xml.sax.saxutils import quoteattr
+
+# Size of the ROS region hashed by PS3DumpChecker (HashCheck.cs / hashlist.xml).
+ROS_SIZE = 0x6FFFE0
+
+# Region offsets per dump type, from hashlist.xml .
+OFFSETS = {
+ "NOR": {"size": 0x1000000, "ros0": 0xC0010, "ros1": 0x7C0010},
+ "NAND": {"size": 0x10000000, "ros0": 0xC0030, "ros1": 0x7C0020},
+}
+
+
+def swap_bytes(data):
+ """Byte-swap 16-bit words, mirroring Common.SwapBytes."""
+ if len(data) % 2:
+ raise ValueError("data length must be even to byte-swap")
+ out = bytearray(data)
+ out[0::2], out[1::2] = data[1::2], data[0::2]
+ return bytes(out)
+
+
+def read_ros(path):
+ """Read the first ROS_SIZE bytes of a decrypted CoreOS payload."""
+ size = os.path.getsize(path)
+ if size < ROS_SIZE:
+ raise ValueError(
+ "%s is %d bytes, need at least %d (0x%X). "
+ "Is this the decrypted 'content' file?" % (path, size, ROS_SIZE, ROS_SIZE)
+ )
+ with open(path, "rb") as fh:
+ data = fh.read(ROS_SIZE)
+ if len(data) != ROS_SIZE:
+ raise ValueError("short read on %s" % path)
+ return data, size
+
+
+def md5_upper(data):
+ return hashlib.md5(data).hexdigest().upper()
+
+
+def describe(path):
+ data, size = read_ros(path)
+ plain = md5_upper(data)
+ swapped = md5_upper(swap_bytes(data))
+ return {
+ "path": path,
+ "size": size,
+ "trailing": size - ROS_SIZE,
+ "md5": plain,
+ "md5_swapped": swapped,
+ }
+
+
+def xml_entry(name, md5, patched):
+ attrs = 'name=%s size="%X"' % (quoteattr(name), ROS_SIZE)
+ if patched:
+ attrs += ' patched="true"'
+ return ' \n %s\n ' % (attrs, md5)
+
+
+def report(info, label):
+ print("== %s ==" % label)
+ print(" file : %s" % info["path"])
+ print(" file size : %d bytes" % info["size"])
+ print(" hashed : %d bytes (0x%X)" % (ROS_SIZE, ROS_SIZE))
+ print(" trailing : %d bytes not hashed" % info["trailing"])
+ print(" MD5 : %s" % info["md5"])
+ print(" MD5 swapped : %s" % info["md5_swapped"])
+ print()
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(
+ description="Compute PS3DumpChecker ROS hashes from decrypted CoreOS payload(s).",
+ epilog="Obtain the 'content' file by unpacking CORE_OS_PACKAGE.pkg "
+ "(PUP entry 512) with PUAD GUI, pupunpack, or scetool.",
+ )
+ ap.add_argument("content", nargs="+",
+ help="decrypted CoreOS payload(s); with --version pass OFW then CFW")
+ ap.add_argument("--name", help="hash entry name, e.g. '4.93 CEX'")
+ ap.add_argument("--patched", action="store_true",
+ help="mark the entry as a patched (CFW) ROS")
+ ap.add_argument("--version",
+ help="firmware version, e.g. 4.93; emits both OFW and patched "
+ "entries from two inputs")
+ args = ap.parse_args(argv)
+
+ if len(args.content) > 2:
+ ap.error("pass at most two files (OFW and CFW)")
+ if len(args.content) == 2 and not args.version:
+ ap.error("--version is required when passing two files")
+
+ print("PS3DumpChecker ROS hash tool")
+ print("ROS region size: 0x%X (%d bytes)" % (ROS_SIZE, ROS_SIZE))
+ for kind, off in OFFSETS.items():
+ print(" %-4s dump 0x%X: ROS0 @ 0x%X, ROS1 @ 0x%X"
+ % (kind, off["size"], off["ros0"], off["ros1"]))
+ print()
+
+ try:
+ infos = [describe(p) for p in args.content]
+ except (OSError, ValueError) as exc:
+ print("error: %s" % exc, file=sys.stderr)
+ return 1
+
+ if len(infos) == 2:
+ report(infos[0], "OFW")
+ report(infos[1], "CFW (patched)")
+ if infos[0]["md5"] == infos[1]["md5"]:
+ print("error: OFW and CFW hash identically -- inputs are the same file",
+ file=sys.stderr)
+ return 1
+ entries = [
+ xml_entry("%s CEX Patched (Evilnat based)" % args.version,
+ infos[1]["md5"], True),
+ xml_entry("%s CEX" % args.version, infos[0]["md5"], False),
+ ]
+ else:
+ report(infos[0], "CoreOS")
+ if not args.name:
+ print("error: --name is required for a single input", file=sys.stderr)
+ return 1
+ entries = [xml_entry(args.name, infos[0]["md5"], args.patched)]
+
+ print("Paste into hashlist.xml, at the top of :")
+ print()
+ for entry in entries:
+ print(entry)
+ print()
+ print("The same payload is also the ROS patch: copy it to "
+ "src/PS3DumpChecker/Patches/patch.bin and update patch_info.txt.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/validate_data.py b/tools/validate_data.py
new file mode 100644
index 0000000..c13a3f4
--- /dev/null
+++ b/tools/validate_data.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+"""
+validate_data.py - Sanity-check PS3DumpChecker's data files.
+
+hashlist.xml and config.xml drive every check the app performs, and they are
+edited by hand on each firmware release. A malformed entry is only noticed at
+runtime, on a user's dump. This validates them up front.
+
+Checks performed on hashlist.xml:
+ - well-formed XML
+ - every has a name and a 32-hex-digit MD5 body
+ - no duplicate MD5s (two names claiming the same ROS)
+ - no duplicate names
+ - size attributes parse as hex and match the expected ROS size
+ - every has fsize/type/name/size and a hex offset body
+ - each hash's type has a matching block
+
+Checks performed on config.xml:
+ - well-formed XML
+ - offset/size attributes parse as hex where present
+
+Usage:
+ python validate_data.py
+ python validate_data.py --hashlist path --config path
+
+Exit codes: 0 all good, 1 problems found.
+"""
+
+import argparse
+import os
+import re
+import sys
+import xml.etree.ElementTree as ET
+
+ROS_SIZE = 0x6FFFE0
+MD5_RE = re.compile(r"^[0-9A-F]{32}$")
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(HERE)
+DEFAULT_HASHLIST = os.path.join(REPO, "src", "PS3DumpChecker", "hashlist.xml")
+DEFAULT_CONFIG = os.path.join(REPO, "src", "PS3DumpChecker", "config.xml")
+
+
+class Report(object):
+ def __init__(self):
+ self.errors = []
+ self.warnings = []
+
+ def error(self, msg):
+ self.errors.append(msg)
+
+ def warn(self, msg):
+ self.warnings.append(msg)
+
+
+def parse_hex(value):
+ try:
+ return int(value, 16)
+ except (TypeError, ValueError):
+ return None
+
+
+def validate_hashlist(path, rep):
+ if not os.path.exists(path):
+ rep.error("hashlist not found: %s" % path)
+ return
+ try:
+ root = ET.parse(path).getroot()
+ except ET.ParseError as exc:
+ rep.error("hashlist.xml is not well-formed: %s" % exc)
+ return
+
+ declared_types = set()
+ for type_el in root.iter("type"):
+ name = type_el.get("name")
+ if not name:
+ rep.error(" without a name attribute")
+ continue
+ if name in declared_types:
+ rep.error("duplicate " % name)
+ declared_types.add(name)
+
+ seen_md5 = {}
+ seen_name = {}
+ count = 0
+
+ for type_el in root.iter("type"):
+ type_name = type_el.get("name") or "?"
+ for hash_el in type_el.iter("hash"):
+ count += 1
+ name = hash_el.get("name")
+ body = (hash_el.text or "").strip().upper()
+
+ if not name:
+ rep.error(" without a name (type %s, md5 %s)"
+ % (type_name, body or "empty"))
+ name = ""
+
+ if not body:
+ rep.error("%s: empty MD5 body" % name)
+ elif not MD5_RE.match(body):
+ rep.error("%s: body is not a 32-hex-digit MD5: %r" % (name, body))
+ else:
+ if body in seen_md5:
+ rep.error("duplicate MD5 %s shared by %r and %r"
+ % (body, seen_md5[body], name))
+ else:
+ seen_md5[body] = name
+
+ if name in seen_name:
+ rep.error("duplicate hash name %r" % name)
+ else:
+ seen_name[name] = body
+
+ size_attr = hash_el.get("size")
+ if size_attr is None:
+ rep.error("%s: missing size attribute" % name)
+ else:
+ size = parse_hex(size_attr)
+ if size is None:
+ rep.error("%s: size=%r is not hex" % (name, size_attr))
+ elif size != ROS_SIZE:
+ rep.error("%s: size 0x%X differs from the ROS size 0x%X"
+ % (name, size, ROS_SIZE))
+
+ patched = hash_el.get("patched")
+ if patched is not None and patched.lower() not in ("true", "false"):
+ rep.error("%s: patched=%r must be true or false" % (name, patched))
+
+ for offset_el in root.iter("offset"):
+ name = offset_el.get("name") or ""
+ for attr in ("fsize", "type", "name", "size"):
+ if offset_el.get(attr) is None:
+ rep.error("%s: missing %s attribute" % (name, attr))
+
+ fsize = offset_el.get("fsize")
+ if fsize is not None:
+ try:
+ int(fsize)
+ except ValueError:
+ rep.error("%s: fsize=%r is not a decimal integer" % (name, fsize))
+
+ if offset_el.get("size") is not None and parse_hex(offset_el.get("size")) is None:
+ rep.error("%s: size=%r is not hex" % (name, offset_el.get("size")))
+
+ body = (offset_el.text or "").strip()
+ if not body:
+ rep.error("%s: has no offset value" % name)
+ elif parse_hex(body) is None:
+ rep.error("%s: offset body %r is not hex" % (name, body))
+
+ otype = offset_el.get("type")
+ if otype and otype not in declared_types:
+ rep.error("%s: references type %r with no matching block"
+ % (name, otype))
+
+ print("hashlist.xml : %d hash entries, %d types, %d offsets"
+ % (count, len(declared_types), len(list(root.iter("offset")))))
+
+
+def validate_config(path, rep):
+ if not os.path.exists(path):
+ rep.error("config not found: %s" % path)
+ return
+ try:
+ root = ET.parse(path).getroot()
+ except ET.ParseError as exc:
+ rep.error("config.xml is not well-formed: %s" % exc)
+ return
+
+ hex_attrs = ("offset", "size", "regionstart", "regionsize", "ldrsize")
+ elements = 0
+ for el in root.iter():
+ elements += 1
+ for attr in hex_attrs:
+ raw = el.get(attr)
+ if raw is None:
+ continue
+ if parse_hex(raw) is None:
+ rep.error("<%s> %s=%r is not hex" % (el.tag, attr, raw))
+
+ print("config.xml : %d elements" % elements)
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description="Validate PS3DumpChecker data files.")
+ ap.add_argument("--hashlist", default=DEFAULT_HASHLIST)
+ ap.add_argument("--config", default=DEFAULT_CONFIG)
+ args = ap.parse_args(argv)
+
+ rep = Report()
+ validate_hashlist(args.hashlist, rep)
+ validate_config(args.config, rep)
+ print()
+
+ for msg in rep.warnings:
+ print("warning: %s" % msg)
+ for msg in rep.errors:
+ print("error: %s" % msg, file=sys.stderr)
+
+ if rep.errors:
+ print("\nFAILED: %d error(s), %d warning(s)"
+ % (len(rep.errors), len(rep.warnings)), file=sys.stderr)
+ return 1
+ print("OK: no errors, %d warning(s)" % len(rep.warnings))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())