├── .gitignore ├── README.md ├── img └── info.png ├── main.py ├── package.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pkg 2 | *.xml 3 | .idea 4 | .DS_Store 5 | __pycache__ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ps4-pkgtools 2 | 3 | Performs some read-only operations on PS4 .pkg files: 4 | 5 | ## Usage 6 | 7 | ### Extract 8 | 9 | ``` 10 | python3 main.py extract File.pkg --file 0x126C --out out.mxl 11 | ``` 12 | 13 | Extracts file with ID `0x126C` to `out.xml`. 14 | The argument to `--file` can be a file ID or filename 15 | 16 | ### Info 17 | 18 | ``` 19 | python3 main.py info File.pkg 20 | ``` 21 | 22 | returns:Extracts everything to directory `extracted` 23 | 24 | ![info.png](img/info.png) 25 | 26 | ### Dump 27 | 28 | ``` 29 | python3 main.py dump File.pkg --out extracted 30 | ``` 31 | 32 | Extracts everything to directory `extracted` -------------------------------------------------------------------------------- /img/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mc-17/ps4_pkg_tool/ec3af468bd05caedf361df9d15b8c000872d8c8b/img/info.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from package import Package 5 | import argparse 6 | 7 | if __name__ == "__main__": 8 | args = argparse.ArgumentParser(description="PS4 PKG tool") 9 | args.add_argument("cmd", choices=["info", "extract", "dump"], help="Dump information about a PKG") 10 | args.add_argument("pkg", help="A PKG") 11 | args.add_argument("--file", required="extract" in sys.argv, help="Extract file (by ID or name)") 12 | args.add_argument("--out", required="--file" in sys.argv or "dump" in sys.argv, help="Output location for file") 13 | args = args.parse_args() 14 | 15 | target = Package(args.pkg) 16 | 17 | if args.cmd == "info": 18 | target.info() 19 | elif args.cmd == "extract": 20 | target.extract(args.file, args.out) 21 | elif args.cmd == "dump": 22 | target.dump(args.out) 23 | -------------------------------------------------------------------------------- /package.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import os 3 | import typing 4 | from enum import Enum 5 | 6 | from utils import print_aligned, bcolors 7 | 8 | 9 | class Type(Enum): 10 | PAID_STANDALONE_FULL = 1 11 | UPGRADABLE = 2 12 | DEMO = 3 13 | FREEMIUM = 4 14 | 15 | 16 | class DRMType(Enum): 17 | NONE = 0x0 18 | PS4 = 0xF 19 | 20 | 21 | class ContentType(Enum): 22 | CONTENT_TYPE_GD = 0x1A 23 | CONTENT_TYPE_AC = 0x1B 24 | CONTENT_TYPE_AL = 0x1C 25 | CONTENT_TYPE_DP = 0x1E 26 | 27 | 28 | class IROTag(Enum): 29 | SHAREFACTORY_THEME = 0x1 30 | SYSTEM_THEME = 0x2 31 | 32 | 33 | TYPE_MASK = 0x0000FFFF 34 | 35 | 36 | class Package: 37 | MAGIC = 0x7F434E54 38 | TYPE_THEME = 0x81000001 39 | TYPE_GAME = 0x40000001 40 | FLAG_RETAIL = 1 << 31 41 | FLAG_ENCRYPTED = 0x80000000 42 | 43 | def __init__(self, file: str): 44 | self.original_file = file 45 | if os.path.isfile(file): 46 | header_format = ">5I2H2I4Q36s12s12I" 47 | with open(file, "rb") as fp: 48 | data = fp.read(struct.calcsize(header_format)) 49 | # Load the header 50 | self.pkg_magic, self.pkg_type, self.pkg_0x008, self.pkg_file_count, self.pkg_entry_count, \ 51 | self.pkg_sc_entry_count, self.pkg_entry_count_2, self.pkg_table_offset, self.pkg_entry_data_size, \ 52 | self.pkg_body_offset, self.pkg_body_size, self.pkg_content_offset, self.pkg_content_size, \ 53 | self.pkg_content_id, self.pkg_padding, self.pkg_drm_type, self.pkg_content_type, \ 54 | self.pkg_content_flags, self.pkg_promote_size, self.pkg_version_date, self.pkg_version_hash, \ 55 | self.pkg_0x088, self.pkg_0x08C, self.pkg_0x090, self.pkg_0x094, self.pkg_iro_tag, \ 56 | self.pkg_drm_type_version = struct.unpack(header_format, data) 57 | # Decode content ID 58 | self.pkg_content_id = self.pkg_content_id.decode() 59 | 60 | # Load hashes 61 | fp.seek(0x100, os.SEEK_SET) 62 | data = fp.read(struct.calcsize("128H")) 63 | self.digests = [data[0:32].hex(), data[32:64].hex(), data[64:96].hex(), data[96:128].hex()] 64 | 65 | self.pkg_content_type = ContentType(self.pkg_content_type) 66 | if self.pkg_iro_tag > 0: 67 | self.pkg_iro_tag = IROTag(self.pkg_iro_tag) 68 | 69 | self.__load_files(fp) 70 | 71 | def __load_files(self, fp): 72 | old_pos = fp.tell() 73 | fp.seek(self.pkg_table_offset, os.SEEK_SET) 74 | 75 | entry_format = ">6IQ" 76 | self._files = {} 77 | for i in range(self.pkg_entry_count): 78 | file_id, filename_offset, flags1, flags2, offset, size, padding = struct.unpack( 79 | entry_format, fp.read(struct.calcsize(entry_format))) 80 | self._files[file_id] = { 81 | "fn_offset": filename_offset, 82 | "flags1": flags1, 83 | "flags2": flags2, 84 | "offset": offset, 85 | "size": size, 86 | "padding": padding, 87 | "key_idx": (flags2 & 0xF00) >> 12, 88 | "encrypted": (flags1 & Package.FLAG_ENCRYPTED) == Package.FLAG_ENCRYPTED 89 | } 90 | for key, file in self._files.items(): 91 | fp.seek(self._files[0x200]["offset"] + file["fn_offset"]) 92 | 93 | fn = ''.join(iter(lambda: fp.read(1).decode('ascii'), '\x00')) 94 | 95 | if fn: 96 | self._files[key]["name"] = fn 97 | fp.seek(old_pos) 98 | 99 | def info(self) -> None: 100 | print_aligned("Magic:", f"0x{format(self.pkg_magic, 'X')}", color=bcolors.OKGREEN 101 | if self.pkg_magic == Package.MAGIC else bcolors.FAIL) 102 | 103 | if self.pkg_magic != Package.MAGIC: 104 | exit("Bad magic!") 105 | 106 | print_aligned("ID:", self.pkg_content_id) 107 | print_aligned("Type:", f"0x{format(self.pkg_type, 'X')}, {self.pkg_content_type.name}" 108 | f"{', ' + self.pkg_iro_tag.name if self.pkg_iro_tag else ''}") 109 | print_aligned("DRM:", DRMType(self.pkg_drm_type).name) 110 | print_aligned("Entries:", self.pkg_entry_count) 111 | print_aligned("Entries(SC):", self.pkg_sc_entry_count) 112 | print_aligned("Files:", self.pkg_file_count) 113 | 114 | print_aligned("Main Entry 1 Hash:", self.digests[0]) 115 | print_aligned("Main Entry 2 Hash:", self.digests[1]) 116 | print_aligned("Digest Table Hash:", self.digests[2]) 117 | print_aligned("Main Table Hash:", self.digests[3]) 118 | 119 | print_aligned("Files:", "") 120 | for key, file in self._files.items(): 121 | enc_txt = bcolors.OKGREEN if not file["encrypted"] else bcolors.FAIL 122 | enc_txt += f"{'UN' if not file['encrypted'] else ''}ENCRYPTED{bcolors.ENDC}" 123 | print_aligned(f"0x{format(key, 'X')}:", f"{file.get('name', '')} ({file['size']} bytes, " 124 | f"starts 0x{format(file['offset'], 'X')}, {enc_txt})") 125 | 126 | def extract(self, file_name_or_id: typing.Union[str, int], out_path: str) -> None: 127 | try: 128 | file_name_or_id = int(file_name_or_id, 16) 129 | except TypeError: 130 | pass # is a file name 131 | 132 | print_aligned("File Identifier:", file_name_or_id) 133 | 134 | dir = os.path.dirname(out_path) 135 | if dir: 136 | os.makedirs(dir, exist_ok=True) 137 | 138 | # Find the target 139 | chosen_file = self._files[file_name_or_id] if file_name_or_id in self._files else None 140 | chosen_key = file_name_or_id 141 | if not chosen_file: 142 | for key in self._files: 143 | if self._files[key].get("name", "") == file_name_or_id: 144 | chosen_file = self._files[key] 145 | chosen_key = key 146 | break 147 | 148 | if not chosen_file: 149 | raise ValueError(f"Couldn't find file {file_name_or_id} in package!") 150 | 151 | if "name" in chosen_file: 152 | print_aligned("File Name:", chosen_file["name"], color=bcolors.OKGREEN) 153 | print_aligned("File ID:", f"0x{format(chosen_key, 'X')}", color=bcolors.OKGREEN) 154 | print_aligned("File Offset:", f"0x{format(chosen_file['offset'], 'X')}", color=bcolors.OKGREEN) 155 | print_aligned("File Size:", f"0x{format(chosen_file['size'], 'X')}", color=bcolors.OKGREEN) 156 | 157 | # Open the file and seek to offset 158 | with open(self.original_file, "rb") as pkg_file: 159 | pkg_file.seek(chosen_file["offset"]) 160 | with open(out_path, "wb") as out_file: 161 | out_file.write(pkg_file.read(chosen_file["size"])) 162 | 163 | def extract_raw(self, offset: int, size: int, out_file: str): 164 | with open(self.original_file, "rb") as pkg_file: 165 | pkg_file.seek(offset) 166 | with open(out_file, "wb") as out_file: 167 | out_file.write(pkg_file.read(size)) 168 | 169 | def dump(self, out_path: str): 170 | if not os.path.isdir(out_path): 171 | os.makedirs(out_path) 172 | 173 | for key in self._files: 174 | out = os.path.join(out_path, self._files[key].get("name", f"{key}")) 175 | 176 | if os.path.isfile(out): 177 | print_aligned("Error:", f"Cancelled dump as found file with matching ({out}) already exists!", 178 | color=bcolors.FAIL) 179 | exit(1) 180 | self.extract(key, out) 181 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | class bcolors: 2 | HEADER = '\033[95m' 3 | OKBLUE = '\033[94m' 4 | OKCYAN = '\033[96m' 5 | OKGREEN = '\033[92m' 6 | WARNING = '\033[93m' 7 | FAIL = '\033[91m' 8 | ENDC = '\033[0m' 9 | BOLD = '\033[1m' 10 | UNDERLINE = '\033[4m' 11 | 12 | 13 | def print_aligned(name: str, text: str, color: str = ''): 14 | print(f"{name:>20} {color}{bcolors.BOLD}{text}{bcolors.ENDC}") 15 | --------------------------------------------------------------------------------