forked from cranksters/playdate-reverse-engineering
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpdz.py
114 lines (99 loc) · 3.09 KB
/
pdz.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# based on https://gist.github.com/zhuowei/666c7e6d21d842dbb8b723e96164d9c3
# PDZ docs: https://github.com/jaames/playdate-reverse-engineering/blob/main/formats/pdz.md
from os import path, makedirs
from struct import unpack
from zlib import decompress
PDZ_IDENT = b'Playdate PDZ'
FILE_TYPES = {
1: 'luac',
2: 'pdi',
3: 'pdt',
4: 'unknown', # guess would be pdv
5: 'pda',
6: 'str',
7: 'pft',
}
class PlaydatePdz:
@classmethod
def open(cls, path):
with open(path, "rb") as buffer:
return cls(buffer)
def __init__(self, buffer):
self.buffer = buffer
self.entries = {}
self.num_entries = 0
self.read_header()
self.read_entries()
def read_header(self):
self.buffer.seek(0)
magic = self.buffer.read(16)
magic = magic[:magic.index(b'\0')] # trim null bytes
assert magic == PDZ_IDENT, 'Invalid PDZ file ident'
def read_string(self):
res = b''
while True:
char = self.buffer.read(1)
if char == b'\0': break
res += char
return res.decode()
def read_entries(self):
self.buffer.seek(0, 2)
ptr = 0x10
pdz_len = self.buffer.tell()
self.buffer.seek(ptr)
while ptr < pdz_len:
head = unpack('<I', self.buffer.read(4))[0]
flags = head & 0xFF
entry_len = (head >> 8) & 0xFFFFFF
# doesn't seem to be any other flags
is_compressed = (flags >> 7) & 0x1
file_type = FILE_TYPES[flags & 0xF]
# file name is a null terminated string
file_name = self.read_string()
# align offset to next nearest multiple of 4
self.buffer.seek((self.buffer.tell() + 3) & ~3)
# if compression flag is set, there's another uint32 with the decompressed size
if is_compressed:
decompressed_size = unpack('<I', self.buffer.read(4))[0]
entry_len -= 4
else:
decompressed_size = entry_len
data = self.buffer.read(entry_len)
ptr = self.buffer.tell()
self.num_entries += 1
self.entries[file_name] = {
'name': file_name,
'type': file_type,
'data': data,
'size': entry_len,
'compressed': is_compressed,
'decompressed_size': decompressed_size
}
def get_entry_data(self, name):
assert name in self.entries
entry = self.entries[name]
if entry['compressed']:
return decompress(entry['data'])
return entry['data']
def save_entry_data(self, name, outdir):
assert name in self.entries
entry = self.entries[name]
data = self.get_entry_data(name)
filepath = outdir + '/' + entry['name'] + '.' + entry['type']
if '/' in filepath:
makedirs(path.dirname(filepath), exist_ok=True)
with open(filepath, 'wb') as outfile:
outfile.write(data)
def save_entries(self, outdir):
for name in self.entries:
self.save_entry_data(name, outdir)
if __name__ == "__main__":
from sys import argv
if (len(argv) < 3):
print('pdz.py')
print('Unpack a Playdate .pdz executable file archive')
print('Usage:')
print('python3 pdz.py input.pdz output_directory')
exit()
pdz = PlaydatePdz.open(argv[1])
pdz.save_entries(argv[2])