Last active
May 6, 2021 21:42
-
-
Save AzureDVBB/3df6de89a0d290d75dfbcd06f46210fc to your computer and use it in GitHub Desktop.
A commented version of the python dungeondraft unpacker this link (with minor improvements): https://www.reddit.com/r/dungeondraft/comments/gjvlud/python_script_to_unpack_dungeondraft_pack_assets/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# source: https://www.reddit.com/r/dungeondraft/comments/gjvlud/python_script_to_unpack_dungeondraft_pack_assets/ | |
# dungeondraft_pack-unpacker.py | |
# version 0.1 | |
# Based upon: https://github.com/tehskai/godot-unpacker | |
import sys | |
import os | |
import pathlib | |
import mmap | |
import struct | |
def main(args): | |
rip_textures = True # change to False if you want textures untouched in godot .tex format | |
if not args: | |
return "Usage: python godot-unpacker.py data.pck" | |
arg_location_name = args[0] | |
print(arg_location_name) | |
if not os.path.exists(arg_location_name): | |
return "Error: file not found" | |
if os.path.isdir(arg_location_name): | |
with os.scandir(arg_location_name) as folder: | |
for entry in folder: | |
if entry.name.endswith(".dungeondraft_pack") and entry.is_file(): | |
extract_pck(entry.path, rip_textures) | |
elif os.path.isfile(arg_location_name): | |
extract_pck(arg_location_name) | |
def rip_texture(data): # this actually converts '.tex' texture files inside the godot '.pck' files. Unnecessary for dungeondraft files | |
# files seem to be stored in three seperate pieces: | |
# extension at the front (and at the end in the case of png/jpg formats) | |
# size in bytes at after the extension (with a different byte order for the size in case of webp format) | |
# the data crammed somewhere in between the start and the end marker bytes | |
# note webp has no end marker bytes but use a different byte order for the size for some reason >.> | |
# webp | |
start = data.find(bytes.fromhex("52 49 46 46")) # find the start of the file using the extension as guidance | |
if start >= 0: # check if this is the correct filetype (by determining if the file starts later in the datastream) | |
size = int.from_bytes(data[start+4:start+8], byteorder="little") # read the filesize | |
return [".webp", data[start:start+8+size]] # return the extension and the data | |
# png | |
start = data.find(bytes.fromhex("89 50 4E 47 0D 0A 1A 0A")) | |
if start >= 0: | |
# need to find the end of the data in this format | |
end = data.find(bytes.fromhex("49 45 4E 44 AE 42 60 82")) + 8 # offset by 8 bytes as 'find' returns the start of the pattern | |
return [".png", data[start:end]] | |
# jpg | |
start = data.find(bytes.fromhex("FF D8 FF")) | |
if start >= 0: | |
end = data.find(bytes.fromhex("FF D9")) + 2 # and here the end is offset by 2 bytes as 'find' returns the start of the pattern | |
return [".jpg", data[start:end]] | |
# none of the above | |
return False | |
def extract_pck(pck_file_location, rip_textures = False, output_folder_name = None): | |
pck_folder_path, pck_file_name = os.path.split(pck_file_location) # uneccessary (just for filewrites) | |
if output_folder_name is None: # uneccessary (just for output folder determining) | |
output_folder_name, _ = os.path.splitext(pck_file_name) | |
file_list = [] | |
with open(pck_file_location, "r+b") as d: | |
with mmap.mmap(d.fileno(), 0) as f: | |
magic = bytes.fromhex('47 44 50 43') # GDPC | |
if f.read(4) == magic: # check if it is a pck archive (Godot Package) by reading 'GDPC' in hex at the file start | |
print(pck_file_location + " looks like a pck archive") | |
f.seek(0) # reset pointer to file start | |
else: # check if it is a self-contained exe (godot game to decompile) // unneccessary | |
f.seek(-4, os.SEEK_END) | |
if f.read(4) == magic: | |
print(pck_file_location + " looks like a self-contained exe") | |
f.seek(-12, os.SEEK_END) | |
main_offset = int.from_bytes(f.read(8), byteorder='little') | |
f.seek(f.tell()-main_offset-8) | |
if f.read(4) == magic: | |
f.seek(f.tell()-4) | |
else: # f.close() uneccessary due to context manager, error in case file is not godot .pck file | |
f.close() | |
return "Error: file not supported" | |
# using struct library to interpret bytes as packed binary data | |
# first argument is the format | |
# second argument is data (reading the header that is 88 bytes long) | |
package_headers = struct.unpack_from("IIIII16II", f.read(20+64+4)) # read package header (advancing file pointer) | |
file_count = package_headers[-1] # get file count from header | |
print (pck_file_location + " info:", package_headers) # uncessessary printing | |
for file_num in range(1, file_count+1): # step through each file | |
# read how many bytes the file_path is encoded in (advancing the file pointer) | |
filepath_length = int.from_bytes(f.read(4), byteorder="little") | |
# read the file info using struct library (advancing the file pointer) | |
# file_info = struct.unpack_from("<{}sQQ16B".format(filepath_length), f.read(filepath_length+8+8+16)) # outdated .format() use | |
file_info = struct.unpack_from(f"<{filepath_length}sQQ16B", f.read(filepath_length+8+8+16)) # replaced with f-string | |
path, offset, size = file_info[0:3] # extract the filepath, the offset and the filesize from file header | |
# decode raw path data into utf-8 text, replacing the standard godot 'res://' root folder with the output folder path | |
path = path.decode("utf-8").replace("res://",output_folder_name + "/") | |
# md5 sum of the file, used to check file integrity // and is always 0 and never used with dungeondraft it seems | |
md5 = "".join([format(x, 'x') for x in file_info[-16:]]) | |
# add this file to the list of files (path to file, offset number of bytes to file start, filesize in bytes, md5 sum) | |
file_list.append({ 'path': path, 'offset': offset, 'size': size, 'md5': md5 }) | |
# uneccessary printing | |
print(file_num, "/", file_count, sep="", end=" ") | |
print(path, offset, size, md5) | |
# extraction takes place here, creating folder structure, reading and writing all the files from assetpack to disk | |
for packed_file in file_list: | |
path = os.path.join(pck_folder_path, os.path.dirname(packed_file['path'])) # sets up the output directory | |
file_name_full = os.path.basename(packed_file['path']) # full relative path of file | |
file_name, file_extension = os.path.splitext(file_name_full) # seperate file name from extension for some reason? | |
pathlib.Path(path).mkdir(parents=True, exist_ok=True) # using pathlib just to make/ensure the filestructure? | |
f.seek(packed_file['offset']) # seek file read pointer to the file-start offset | |
file_data = f.read(packed_file['size']) # read the entire file (using the file size in bytes to guide us) | |
# do md5 check here /// they never do though? | |
if file_extension == '.tex' and rip_textures: | |
data = rip_texture(file_data) # convert file data to just data necessary to write the file and it's extension | |
print(f"Weird file type for '{file_name}.{file_extension}' ... attempting to convert.") | |
if isinstance(data, list): # just checking if the return is a list and not False (replace with 'if data:' instead) | |
file_extension, file_data = data # unpack return value | |
print(f"converted '{file_name}' from '.tex' to '.{file_extension}'") | |
file_name_full = file_name + data[0] # add extension to the filename, but then why seperate the extension? | |
# finally writing the unpacked file to disk | |
with open(os.path.join(path, file_name_full.rstrip("\0")), "w+b") as p: | |
p.write(file_data) | |
if __name__ == "__main__": | |
sys.exit(main(sys.argv[1:])) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment