ifstools 2.2__tar.gz → 3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ifstools-2.2 → ifstools-3.0}/PKG-INFO +10 -2
- {ifstools-2.2 → ifstools-3.0}/README.md +9 -1
- {ifstools-2.2 → ifstools-3.0}/pyproject.toml +1 -1
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/generic_file.py +18 -5
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/generic_folder.py +11 -4
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/image_decoders.py +8 -1
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/image_file.py +12 -4
- ifstools-3.0/src/ifstools/handlers/node.py +126 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/ifs.py +39 -21
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/ifstools.py +111 -96
- ifstools-3.0/src/ifstools/utils.py +13 -0
- ifstools-2.2/src/ifstools/handlers/node.py +0 -70
- ifstools-2.2/src/ifstools/utils.py +0 -26
- {ifstools-2.2 → ifstools-3.0}/.github/workflows/maturin.yml +0 -0
- {ifstools-2.2 → ifstools-3.0}/.gitignore +0 -0
- {ifstools-2.2 → ifstools-3.0}/Cargo.lock +0 -0
- {ifstools-2.2 → ifstools-3.0}/Cargo.toml +0 -0
- {ifstools-2.2 → ifstools-3.0}/LICENSE +0 -0
- {ifstools-2.2 → ifstools-3.0}/ifstools_bin.py +0 -0
- {ifstools-2.2 → ifstools-3.0}/rust/dxt.rs +0 -0
- {ifstools-2.2 → ifstools-3.0}/rust/lib.rs +0 -0
- {ifstools-2.2 → ifstools-3.0}/rust/lz77.rs +0 -0
- {ifstools-2.2 → ifstools-3.0}/rust/png_enc.rs +0 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/__init__.py +0 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/_lz77_py.py +0 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/afp_folder.py +0 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/lz77.py +0 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/md5_folder.py +0 -0
- {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/tex_folder.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ifstools
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0
|
|
4
4
|
Requires-Dist: kbinxml>=1.5
|
|
5
5
|
Requires-Dist: lxml
|
|
6
6
|
Requires-Dist: pillow
|
|
@@ -37,7 +37,8 @@ Have [uv](https://docs.astral.sh/uv/) installed? You can just `uvx ifstools`.
|
|
|
37
37
|
## Usage
|
|
38
38
|
```
|
|
39
39
|
usage: ifstools [-h] [-e] [-y] [-o OUT_DIR] [--tex-only] [-c]
|
|
40
|
-
[--bounds] [--uv] [--no-cache] [
|
|
40
|
+
[--bounds] [--uv] [--no-cache] [--flags FLAG[,FLAG...]]
|
|
41
|
+
[-m] [-s] [-r]
|
|
41
42
|
file_to_unpack.ifs|folder_to_repack_ifs
|
|
42
43
|
[file_to_unpack.ifs|folder_to_repack_ifs ...]
|
|
43
44
|
|
|
@@ -65,6 +66,13 @@ optional arguments:
|
|
|
65
66
|
--rename-dupes if two files have the same name but differing case
|
|
66
67
|
(A.png vs a.png) rename the second as "a (1).png" to
|
|
67
68
|
allow both to be extracted on Windows
|
|
69
|
+
--flags FLAG[,FLAG...]
|
|
70
|
+
flags to use when repacking. Almost never need to set
|
|
71
|
+
this, but some old games don't support HasMD5.
|
|
72
|
+
One or more of:
|
|
73
|
+
ManifestHasTimestamps,HasMD5,UncompressedManifest
|
|
74
|
+
Default:
|
|
75
|
+
ManifestHasTimestamps,HasMD5
|
|
68
76
|
-m, --extract-manifest
|
|
69
77
|
extract the IFS manifest for inspection
|
|
70
78
|
--super-disable only extract files unique to this IFS, do not follow
|
|
@@ -23,7 +23,8 @@ Have [uv](https://docs.astral.sh/uv/) installed? You can just `uvx ifstools`.
|
|
|
23
23
|
## Usage
|
|
24
24
|
```
|
|
25
25
|
usage: ifstools [-h] [-e] [-y] [-o OUT_DIR] [--tex-only] [-c]
|
|
26
|
-
[--bounds] [--uv] [--no-cache] [
|
|
26
|
+
[--bounds] [--uv] [--no-cache] [--flags FLAG[,FLAG...]]
|
|
27
|
+
[-m] [-s] [-r]
|
|
27
28
|
file_to_unpack.ifs|folder_to_repack_ifs
|
|
28
29
|
[file_to_unpack.ifs|folder_to_repack_ifs ...]
|
|
29
30
|
|
|
@@ -51,6 +52,13 @@ optional arguments:
|
|
|
51
52
|
--rename-dupes if two files have the same name but differing case
|
|
52
53
|
(A.png vs a.png) rename the second as "a (1).png" to
|
|
53
54
|
allow both to be extracted on Windows
|
|
55
|
+
--flags FLAG[,FLAG...]
|
|
56
|
+
flags to use when repacking. Almost never need to set
|
|
57
|
+
this, but some old games don't support HasMD5.
|
|
58
|
+
One or more of:
|
|
59
|
+
ManifestHasTimestamps,HasMD5,UncompressedManifest
|
|
60
|
+
Default:
|
|
61
|
+
ManifestHasTimestamps,HasMD5
|
|
54
62
|
-m, --extract-manifest
|
|
55
63
|
extract the IFS manifest for inspection
|
|
56
64
|
--super-disable only extract files unique to this IFS, do not follow
|
|
@@ -11,7 +11,9 @@ from .node import Node
|
|
|
11
11
|
class GenericFile(Node):
|
|
12
12
|
def from_xml(self, element):
|
|
13
13
|
info = self._split_ints(element.text)
|
|
14
|
-
#
|
|
14
|
+
# controlled by IFSFlags.ManifestHasTimestamps, but recent AVS never
|
|
15
|
+
# checks that flag and just tries node/s32 (which get treated as
|
|
16
|
+
# folders) then 2s32/3s32 for files
|
|
15
17
|
if len(info) == 2:
|
|
16
18
|
self.start, self.size = info
|
|
17
19
|
self.time = -1
|
|
@@ -52,17 +54,28 @@ class GenericFile(Node):
|
|
|
52
54
|
self.size = len(ret)
|
|
53
55
|
return ret
|
|
54
56
|
|
|
55
|
-
def repack(self, manifest, data_blob, tqdm_progress, **kwargs):
|
|
57
|
+
def repack(self, manifest, data_blob, tqdm_progress, flags=None, **kwargs):
|
|
58
|
+
# muh circular deps
|
|
59
|
+
from ..ifs import IFSFlags
|
|
60
|
+
|
|
56
61
|
if tqdm_progress:
|
|
57
62
|
tqdm_progress.write(self.full_path)
|
|
58
63
|
tqdm_progress.update(1)
|
|
59
64
|
elem = etree.SubElement(manifest, self.packed_name)
|
|
60
|
-
|
|
65
|
+
|
|
61
66
|
data = self.load(convert_kbin = False, **kwargs)
|
|
62
67
|
if self.name.endswith('.xml') and not KBinXML.is_binary_xml(data):
|
|
63
68
|
data = KBinXML(data).to_binary()
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
|
|
70
|
+
if flags and flags & IFSFlags.ManifestHasTimestamps:
|
|
71
|
+
elem.attrib['__type'] = '3s32'
|
|
72
|
+
# offset, size, timestamp
|
|
73
|
+
elem.text = '{} {} {}'.format(len(data_blob.getvalue()), len(data), self.time)
|
|
74
|
+
else:
|
|
75
|
+
elem.attrib['__type'] = '2s32'
|
|
76
|
+
# offset, size
|
|
77
|
+
elem.text = '{} {}'.format(len(data_blob.getvalue()), len(data))
|
|
78
|
+
|
|
66
79
|
data_blob.write(data)
|
|
67
80
|
# 16 byte alignment
|
|
68
81
|
align = len(data) % 16
|
|
@@ -30,6 +30,9 @@ class GenericFolder(Node):
|
|
|
30
30
|
file_handler = GenericFile
|
|
31
31
|
|
|
32
32
|
def from_xml(self, element):
|
|
33
|
+
# controlled by IFSFlags.ManifestHasTimestamps, but recent AVS never
|
|
34
|
+
# checks that flag and just tries node/s32 (which get treated as
|
|
35
|
+
# folders) then 2s32/3s32 for files
|
|
33
36
|
if element.text:
|
|
34
37
|
self.time = int(element.text)
|
|
35
38
|
|
|
@@ -130,14 +133,18 @@ class GenericFolder(Node):
|
|
|
130
133
|
for f in self.files.values():
|
|
131
134
|
f.tree_complete()
|
|
132
135
|
|
|
133
|
-
def repack(self, manifest, data_blob, tqdm_progress, **kwargs):
|
|
136
|
+
def repack(self, manifest, data_blob, tqdm_progress, flags=None, **kwargs):
|
|
137
|
+
# muh circular deps
|
|
138
|
+
from ..ifs import IFSFlags
|
|
139
|
+
|
|
134
140
|
if self.name:
|
|
135
141
|
manifest = etree.SubElement(manifest, self.packed_name)
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
if flags and flags & IFSFlags.ManifestHasTimestamps:
|
|
143
|
+
manifest.attrib['__type'] = 's32'
|
|
144
|
+
manifest.text = str(self.time)
|
|
138
145
|
|
|
139
146
|
for name, entry in chain(self.folders.items(), self.files.items()):
|
|
140
|
-
entry.repack(manifest, data_blob, tqdm_progress, **kwargs)
|
|
147
|
+
entry.repack(manifest, data_blob, tqdm_progress, flags=flags, **kwargs)
|
|
141
148
|
|
|
142
149
|
@property
|
|
143
150
|
def all_files(self):
|
|
@@ -43,6 +43,13 @@ def decode_argb4444(ifs_img, data):
|
|
|
43
43
|
r, g, b, a = im.split()
|
|
44
44
|
return Image.merge('RGBA', (b, g, r, a))
|
|
45
45
|
|
|
46
|
+
def encode_argb4444(ifs_img, image):
|
|
47
|
+
# there's no BGRA;4B
|
|
48
|
+
r, g, b, a = image.split()
|
|
49
|
+
image = Image.merge('RGBA', (b, g, r, a))
|
|
50
|
+
|
|
51
|
+
return image.tobytes('raw', 'RGBA;4B')
|
|
52
|
+
|
|
46
53
|
def decode_dxt(ifs_img, data, version):
|
|
47
54
|
rgba = _native.decode_dxt(data, ifs_img.img_size[0], ifs_img.img_size[1], version)
|
|
48
55
|
return Image.frombytes('RGBA', ifs_img.img_size, rgba)
|
|
@@ -59,7 +66,7 @@ def encode_dxt5(ifs_img, image):
|
|
|
59
66
|
|
|
60
67
|
image_formats = {
|
|
61
68
|
'argb8888rev' : {'decoder': decode_argb8888rev, 'encoder': encode_argb8888rev},
|
|
62
|
-
'argb4444' : {'decoder': decode_argb4444, 'encoder':
|
|
69
|
+
'argb4444' : {'decoder': decode_argb4444, 'encoder': encode_argb4444},
|
|
63
70
|
'dxt1' : {'decoder': decode_dxt1, 'encoder': None},
|
|
64
71
|
'dxt5' : {'decoder': decode_dxt5, 'encoder': encode_dxt5 if _native else None},
|
|
65
72
|
}
|
|
@@ -83,7 +83,10 @@ class ImageFile(GenericFile):
|
|
|
83
83
|
# Compress in parallel; the actual write loop in repack() runs serially.
|
|
84
84
|
self._packed = self._build_packed()
|
|
85
85
|
|
|
86
|
-
def repack(self, manifest, data_blob, tqdm_progress, **kwargs):
|
|
86
|
+
def repack(self, manifest, data_blob, tqdm_progress, flags=None, **kwargs):
|
|
87
|
+
# muh circular deps
|
|
88
|
+
from ..ifs import IFSFlags
|
|
89
|
+
|
|
87
90
|
if tqdm_progress:
|
|
88
91
|
tqdm_progress.write(self.full_path)
|
|
89
92
|
tqdm_progress.update(1)
|
|
@@ -92,10 +95,15 @@ class ImageFile(GenericFile):
|
|
|
92
95
|
if data is None:
|
|
93
96
|
data = self._build_packed()
|
|
94
97
|
|
|
95
|
-
# offset, size, timestamp
|
|
96
98
|
elem = etree.SubElement(manifest, self.packed_name)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
if flags and flags & IFSFlags.ManifestHasTimestamps:
|
|
100
|
+
# offset, size, timestamp
|
|
101
|
+
elem.attrib['__type'] = '3s32'
|
|
102
|
+
elem.text = '{} {} {}'.format(len(data_blob.getvalue()), len(data), self.time)
|
|
103
|
+
else:
|
|
104
|
+
# offset, size
|
|
105
|
+
elem.attrib['__type'] = '2s32'
|
|
106
|
+
elem.text = '{} {}'.format(len(data_blob.getvalue()), len(data))
|
|
99
107
|
data_blob.write(data)
|
|
100
108
|
# 16 byte alignment
|
|
101
109
|
align = len(data) % 16
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import lxml.etree as etree
|
|
5
|
+
|
|
6
|
+
special_names = ('_info_', '_super_')
|
|
7
|
+
|
|
8
|
+
escapes = {
|
|
9
|
+
' ': '_A',
|
|
10
|
+
'$': '_B',
|
|
11
|
+
'+': '_C',
|
|
12
|
+
'-': '_D',
|
|
13
|
+
'.': '_E',
|
|
14
|
+
':': '_F',
|
|
15
|
+
'@': '_G',
|
|
16
|
+
'~': '_H',
|
|
17
|
+
'_': '__',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sanitize_lookup = str.maketrans(escapes)
|
|
21
|
+
|
|
22
|
+
fix_lookup = {escaped[1]: real for real, escaped in escapes.items()}
|
|
23
|
+
|
|
24
|
+
class Node(object):
|
|
25
|
+
|
|
26
|
+
def __init__(self, ifs_data, obj, parent = None, path = '', name = ''):
|
|
27
|
+
self.ifs_data = ifs_data
|
|
28
|
+
self.parent = parent
|
|
29
|
+
self.path = path
|
|
30
|
+
self.name = name
|
|
31
|
+
# xml sanitisation performed by the property
|
|
32
|
+
self._packed_name = name
|
|
33
|
+
self.time = None
|
|
34
|
+
if isinstance(obj, etree._Element):
|
|
35
|
+
self.from_ifs = True
|
|
36
|
+
self.from_xml(obj)
|
|
37
|
+
else:
|
|
38
|
+
self.from_ifs = False
|
|
39
|
+
self.from_filesystem(obj)
|
|
40
|
+
|
|
41
|
+
def from_xml(self, elem):
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def from_filesystem(self, path):
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def tree_complete(self):
|
|
48
|
+
'''Call this when the entire tree is parsed and ready for modification'''
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def __str__(self):
|
|
52
|
+
return os.path.join(self.path, self.name)
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return '<{}: {}>'.format(self.__class__.__name__, self.full_path)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def packed_name(self):
|
|
59
|
+
return self.sanitize_name(self._packed_name)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def full_path(self):
|
|
63
|
+
return os.path.join(self.path, self.name)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def sanitize_name(n):
|
|
67
|
+
if n in special_names:
|
|
68
|
+
return n
|
|
69
|
+
|
|
70
|
+
if not n:
|
|
71
|
+
raise ValueError("Sanitizing an empty name is invalid")
|
|
72
|
+
|
|
73
|
+
sanitized = n.translate(sanitize_lookup)
|
|
74
|
+
|
|
75
|
+
if sanitized[0].isdigit():
|
|
76
|
+
sanitized = '_' + sanitized
|
|
77
|
+
|
|
78
|
+
return sanitized
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def fix_name(n):
|
|
82
|
+
if n in special_names:
|
|
83
|
+
return n
|
|
84
|
+
|
|
85
|
+
start = 0
|
|
86
|
+
fixed = n
|
|
87
|
+
while (idx := fixed.find('_', start)) != -1:
|
|
88
|
+
try:
|
|
89
|
+
key = fixed[idx+1]
|
|
90
|
+
if idx == 0 and key.isdigit():
|
|
91
|
+
new = key
|
|
92
|
+
else:
|
|
93
|
+
new = fix_lookup[key]
|
|
94
|
+
except (IndexError, KeyError):
|
|
95
|
+
raise ValueError(f"Invalid escaped string {n!r}") from None
|
|
96
|
+
|
|
97
|
+
fixed = fixed[:idx] + new + fixed[idx+2:]
|
|
98
|
+
|
|
99
|
+
start = idx + 1
|
|
100
|
+
|
|
101
|
+
return fixed
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _split_ints(text, delim = ' '):
|
|
105
|
+
return list(map(int, text.split(delim)))
|
|
106
|
+
|
|
107
|
+
# who needs real tests anyway)
|
|
108
|
+
assert Node.sanitize_name("_A9") == "__A9"
|
|
109
|
+
assert Node.sanitize_name("9_A9") == "_9__A9"
|
|
110
|
+
assert Node.sanitize_name("__") == "____"
|
|
111
|
+
|
|
112
|
+
assert Node.fix_name("__A9") == "_A9"
|
|
113
|
+
assert Node.fix_name("_9_A9") == "9 9"
|
|
114
|
+
assert Node.fix_name("____9") == "__9"
|
|
115
|
+
with contextlib.suppress(ValueError):
|
|
116
|
+
Node.fix_name("___Q")
|
|
117
|
+
assert False
|
|
118
|
+
with contextlib.suppress(ValueError):
|
|
119
|
+
Node.fix_name("_")
|
|
120
|
+
assert False
|
|
121
|
+
with contextlib.suppress(ValueError):
|
|
122
|
+
Node.fix_name("_9_A_9")
|
|
123
|
+
assert False
|
|
124
|
+
with contextlib.suppress(ValueError):
|
|
125
|
+
Node.sanitize_name("")
|
|
126
|
+
assert False
|
|
@@ -2,6 +2,7 @@ import hashlib
|
|
|
2
2
|
import threading
|
|
3
3
|
from collections import defaultdict
|
|
4
4
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from enum import IntFlag
|
|
5
6
|
from io import BytesIO
|
|
6
7
|
from os import utime, walk
|
|
7
8
|
from os.path import basename, getmtime, isdir, isfile, join, splitext
|
|
@@ -19,7 +20,12 @@ from .handlers.tex_folder import ImageCanvas
|
|
|
19
20
|
|
|
20
21
|
SIGNATURE = 0x6CAD8F89
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
class IFSFlags(IntFlag):
|
|
24
|
+
ManifestHasTimestamps = 1
|
|
25
|
+
HasMD5 = 2
|
|
26
|
+
UncompressedManifest = 4
|
|
27
|
+
|
|
28
|
+
DEFAULT_FLAGS = IFSFlags.ManifestHasTimestamps | IFSFlags.HasMD5
|
|
23
29
|
|
|
24
30
|
class FileBlob(object):
|
|
25
31
|
''' a basic wrapper around a file to deal with IFS data offset '''
|
|
@@ -59,20 +65,24 @@ class IFS:
|
|
|
59
65
|
signature = header.get_u32()
|
|
60
66
|
if signature != SIGNATURE:
|
|
61
67
|
raise IOError('Given file was not an IFS file!')
|
|
62
|
-
self.
|
|
68
|
+
self.flags = IFSFlags(header.get_u16())
|
|
63
69
|
# next u16 is just NOT(version)
|
|
64
|
-
assert header.get_u16() ^ self.
|
|
70
|
+
assert header.get_u16() ^ self.flags == 0xFFFF
|
|
65
71
|
self.time = header.get_u32()
|
|
66
72
|
ifs_tree_size = header.get_u32()
|
|
67
73
|
manifest_end = header.get_u32()
|
|
68
74
|
self.data_blob = FileBlob(self.file, manifest_end)
|
|
69
75
|
|
|
70
76
|
self.manifest_md5 = None
|
|
71
|
-
if self.
|
|
77
|
+
if self.flags & IFSFlags.HasMD5:
|
|
72
78
|
self.manifest_md5 = header.get_bytes(16)
|
|
73
79
|
|
|
74
80
|
self.file.seek(header.offset)
|
|
75
|
-
|
|
81
|
+
manifest_raw = self.file.read(manifest_end-header.offset)
|
|
82
|
+
# older IFS files have 0-padding in a plaintext XML manifest
|
|
83
|
+
if not KBinXML.is_binary_xml(manifest_raw):
|
|
84
|
+
manifest_raw = manifest_raw.rstrip(b'\0')
|
|
85
|
+
self.manifest = KBinXML(manifest_raw)
|
|
76
86
|
self.tree = GenericFolder(self.data_blob, self.manifest.xml_doc,
|
|
77
87
|
super_disable=super_disable, super_skip_bad=super_skip_bad,
|
|
78
88
|
super_abort_if_bad=super_abort_if_bad
|
|
@@ -93,7 +103,7 @@ class IFS:
|
|
|
93
103
|
self.ifs_out = self.folder_out + '.ifs'
|
|
94
104
|
self.default_out = self.ifs_out
|
|
95
105
|
|
|
96
|
-
self.
|
|
106
|
+
self.flags = DEFAULT_FLAGS
|
|
97
107
|
self.time = int(getmtime(path))
|
|
98
108
|
self.data_blob = None
|
|
99
109
|
self.manifest = None
|
|
@@ -207,46 +217,54 @@ class IFS:
|
|
|
207
217
|
extract_manifest=extract_manifest, path=rpath.replace('.ifs','_ifs'),
|
|
208
218
|
rename_dupes=rename_dupes, **kwargs)
|
|
209
219
|
|
|
210
|
-
def repack(self, progress = True, path = None, **kwargs):
|
|
220
|
+
def repack(self, progress = True, path = None, flags = None, **kwargs):
|
|
211
221
|
if path is None:
|
|
212
222
|
path = self.ifs_out
|
|
223
|
+
if flags is not None:
|
|
224
|
+
self.flags = flags
|
|
213
225
|
# open first in case path is bad
|
|
214
226
|
ifs_file = open(path, 'wb')
|
|
215
227
|
|
|
216
228
|
self.data_blob = BytesIO()
|
|
217
229
|
|
|
218
230
|
self.manifest = KBinXML(etree.Element('imgfs'))
|
|
219
|
-
|
|
231
|
+
|
|
232
|
+
# no md5 in the file also means no _info_ in the manifest for whatever reason
|
|
233
|
+
if self.flags & IFSFlags.HasMD5:
|
|
234
|
+
manifest_info = etree.SubElement(self.manifest.xml_doc, '_info_')
|
|
220
235
|
|
|
221
236
|
# the important bit
|
|
222
237
|
data = self._repack_tree(progress, **kwargs)
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
if self.flags & IFSFlags.HasMD5:
|
|
240
|
+
assert manifest_info is not None # type: ignore
|
|
241
|
+
|
|
242
|
+
data_md5 = etree.SubElement(manifest_info, 'md5')
|
|
243
|
+
data_md5.attrib['__type'] = 'bin'
|
|
244
|
+
data_md5.attrib['__size'] = '16'
|
|
245
|
+
data_md5.text = hashlib.md5(data).hexdigest()
|
|
228
246
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
data_size = etree.SubElement(manifest_info, 'size')
|
|
248
|
+
data_size.attrib['__type'] = 'u32'
|
|
249
|
+
data_size.text = str(len(data))
|
|
232
250
|
|
|
233
|
-
manifest_bin = self.manifest.to_binary()
|
|
251
|
+
manifest_bin = self.manifest.to_binary(compressed=not (self.flags & IFSFlags.UncompressedManifest))
|
|
234
252
|
manifest_hash = hashlib.md5(manifest_bin).digest()
|
|
235
253
|
|
|
236
254
|
head = ByteBuffer()
|
|
237
255
|
head.append_u32(SIGNATURE)
|
|
238
|
-
head.append_u16(self.
|
|
239
|
-
head.append_u16(self.
|
|
256
|
+
head.append_u16(int(self.flags))
|
|
257
|
+
head.append_u16(int(self.flags) ^ 0xFFFF)
|
|
240
258
|
head.append_u32(int(unixtime()))
|
|
241
259
|
head.append_u32(self.manifest.mem_size)
|
|
242
260
|
|
|
243
261
|
manifest_end = len(manifest_bin) + head.offset + 4
|
|
244
|
-
if self.
|
|
262
|
+
if self.flags & IFSFlags.HasMD5:
|
|
245
263
|
manifest_end += 16
|
|
246
264
|
|
|
247
265
|
head.append_u32(manifest_end)
|
|
248
266
|
|
|
249
|
-
if self.
|
|
267
|
+
if self.flags & IFSFlags.HasMD5:
|
|
250
268
|
head.append_bytes(manifest_hash)
|
|
251
269
|
|
|
252
270
|
ifs_file.write(head.data)
|
|
@@ -280,6 +298,6 @@ class IFS:
|
|
|
280
298
|
tqdm_progress = None
|
|
281
299
|
if progress:
|
|
282
300
|
tqdm_progress = tqdm(desc='Writing', total=len(files))
|
|
283
|
-
self.tree.repack(self.manifest.xml_doc, self.data_blob, tqdm_progress, **kwargs)
|
|
301
|
+
self.tree.repack(self.manifest.xml_doc, self.data_blob, tqdm_progress, flags=self.flags, **kwargs)
|
|
284
302
|
|
|
285
303
|
return self.data_blob.getvalue()
|
|
@@ -1,96 +1,111 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
import os
|
|
3
|
-
from sys import exit
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
parser.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
parser.add_argument('--
|
|
46
|
-
|
|
47
|
-
parser.add_argument('
|
|
48
|
-
|
|
49
|
-
parser.add_argument('
|
|
50
|
-
|
|
51
|
-
parser.add_argument('
|
|
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
|
-
if
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from sys import exit # exe freeze
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from .ifs import DEFAULT_FLAGS, IFS, IFSFlags
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_flags(value):
|
|
10
|
+
flags = IFSFlags(0)
|
|
11
|
+
for name in value.split(','):
|
|
12
|
+
try:
|
|
13
|
+
flags |= IFSFlags[name.strip()]
|
|
14
|
+
except KeyError:
|
|
15
|
+
raise argparse.ArgumentTypeError('invalid flag {!r} (choose from {})'.format(
|
|
16
|
+
name.strip(), ', '.join(IFSFlags.__members__)))
|
|
17
|
+
return flags
|
|
18
|
+
|
|
19
|
+
def get_choice(prompt):
|
|
20
|
+
while True:
|
|
21
|
+
q = input(prompt + ' [Y/n] ').lower()
|
|
22
|
+
if not q:
|
|
23
|
+
return True # default to yes
|
|
24
|
+
elif q == 'y':
|
|
25
|
+
return True
|
|
26
|
+
elif q == 'n':
|
|
27
|
+
return False
|
|
28
|
+
else:
|
|
29
|
+
print('Please answer y/n')
|
|
30
|
+
|
|
31
|
+
def extract(i, args, path):
|
|
32
|
+
if args.progress:
|
|
33
|
+
print('Extracting...')
|
|
34
|
+
i.extract(path=path, **vars(args))
|
|
35
|
+
|
|
36
|
+
def repack(i, args, path):
|
|
37
|
+
if args.progress:
|
|
38
|
+
print('Repacking...')
|
|
39
|
+
i.repack(path=path, **vars(args))
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
parser = argparse.ArgumentParser(description='Unpack/pack IFS files and textures')
|
|
43
|
+
parser.add_argument('files', metavar='file_to_unpack.ifs|folder_to_repack_ifs', type=str, nargs='+',
|
|
44
|
+
help='files/folders to process. Files will be unpacked, folders will be repacked')
|
|
45
|
+
parser.add_argument('-e', '--extract-folders', action='store_true', help='do not repack folders, instead unpack any IFS files inside them', dest='extract_folders')
|
|
46
|
+
parser.add_argument('-y', action='store_true', help='don\'t prompt for file/folder overwrite', dest='overwrite')
|
|
47
|
+
parser.add_argument('-o', default='.', help='output directory', dest='out_dir')
|
|
48
|
+
parser.add_argument('--tex-only', action='store_true', help='only extract textures')
|
|
49
|
+
parser.add_argument('-c', '--canvas', action='store_true', help='dump the image canvas as defined by the texturelist.xml in _canvas.png', dest='dump_canvas')
|
|
50
|
+
parser.add_argument('--bounds', action='store_true', help='draw image bounds on the exported canvas in red', dest='draw_bbox')
|
|
51
|
+
parser.add_argument('--uv', action='store_true', help='crop images to uvrect (usually 1px smaller than imgrect). Forces --tex-only', dest='crop_to_uvrect')
|
|
52
|
+
parser.add_argument('--no-cache', action='store_true', dest='no_cache_deprecated',
|
|
53
|
+
help=argparse.SUPPRESS)
|
|
54
|
+
parser.add_argument('--rename-dupes', action='store_true',
|
|
55
|
+
help='if two files have the same name but differing case (A.png vs a.png) rename the second as "a (1).png" to allow both to be extracted on Windows')
|
|
56
|
+
parser.add_argument('--flags', type=parse_flags, metavar='FLAG[,FLAG...]',
|
|
57
|
+
help='flags to use when repacking. Almost never need to set this, but some old games don\'t support HasMD5. One or more of: {} Default: {}'.format(
|
|
58
|
+
','.join(IFSFlags.__members__), ','.join(cast(str, f.name) for f in DEFAULT_FLAGS)))
|
|
59
|
+
parser.add_argument('-m', '--extract-manifest', action='store_true', help='extract the IFS manifest for inspection', dest='extract_manifest')
|
|
60
|
+
parser.add_argument('--super-disable', action='store_true',
|
|
61
|
+
help='only extract files unique to this IFS, do not follow "super" parent references at all')
|
|
62
|
+
parser.add_argument('--super-skip-bad', action='store_true',
|
|
63
|
+
help='if a "super" IFS reference has a checksum mismatch, do not extract it')
|
|
64
|
+
parser.add_argument('--super-abort-if-bad', action='store_true',
|
|
65
|
+
help='if a "super" IFS reference has a checksum mismatch, cancel and display an error')
|
|
66
|
+
parser.add_argument('-s', '--silent', action='store_false', dest='progress',
|
|
67
|
+
help='don\'t display files as they are processed')
|
|
68
|
+
parser.add_argument('-r', '--norecurse', action='store_false', dest='recurse',
|
|
69
|
+
help='if file contains another IFS, don\'t extract its contents')
|
|
70
|
+
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
|
|
73
|
+
if args.no_cache_deprecated:
|
|
74
|
+
print("WARNING: --no-cache is deprecated and has no effect; the texture cache has been removed.")
|
|
75
|
+
delattr(args, 'no_cache_deprecated')
|
|
76
|
+
|
|
77
|
+
if args.crop_to_uvrect:
|
|
78
|
+
args.tex_only = True
|
|
79
|
+
|
|
80
|
+
if args.extract_folders:
|
|
81
|
+
dirs = [f for f in args.files if os.path.isdir(f)]
|
|
82
|
+
# prune
|
|
83
|
+
args.files = [f for f in args.files if not os.path.isdir(f)]
|
|
84
|
+
# add the extras
|
|
85
|
+
for d in dirs:
|
|
86
|
+
args.files.extend((os.path.join(d,f) for f in os.listdir(d) if f.lower().endswith('.ifs')))
|
|
87
|
+
|
|
88
|
+
for f in args.files:
|
|
89
|
+
if args.progress:
|
|
90
|
+
print(f)
|
|
91
|
+
try:
|
|
92
|
+
i = IFS(f, super_disable=args.super_disable, super_skip_bad=args.super_skip_bad,
|
|
93
|
+
super_abort_if_bad=args.super_abort_if_bad)
|
|
94
|
+
except IOError as e:
|
|
95
|
+
# human friendly
|
|
96
|
+
print('{}: {}'.format(os.path.basename(f), str(e)))
|
|
97
|
+
exit(1)
|
|
98
|
+
|
|
99
|
+
path = os.path.join(args.out_dir, i.default_out)
|
|
100
|
+
if os.path.exists(path) and not args.overwrite:
|
|
101
|
+
if not get_choice('{} exists. Overwrite?'.format(path)):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if i.is_file:
|
|
105
|
+
extract(i, args, path)
|
|
106
|
+
else:
|
|
107
|
+
repack(i, args, path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == '__main__':
|
|
111
|
+
main()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def mkdir_silent(dir):
|
|
5
|
+
os.makedirs(dir, exist_ok=True)
|
|
6
|
+
|
|
7
|
+
def save_with_timestamp(filename, data, timestamp):
|
|
8
|
+
mkdir_silent(os.path.dirname(filename))
|
|
9
|
+
with open(filename, 'wb') as f:
|
|
10
|
+
f.write(data)
|
|
11
|
+
# we store invalid timestamps as -1
|
|
12
|
+
if timestamp >= 0:
|
|
13
|
+
os.utime(filename, (timestamp,timestamp))
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
import lxml.etree as etree
|
|
4
|
-
|
|
5
|
-
escapes = [
|
|
6
|
-
('_E', '.'),
|
|
7
|
-
('__', '_'),
|
|
8
|
-
]
|
|
9
|
-
|
|
10
|
-
class Node(object):
|
|
11
|
-
|
|
12
|
-
def __init__(self, ifs_data, obj, parent = None, path = '', name = ''):
|
|
13
|
-
self.ifs_data = ifs_data
|
|
14
|
-
self.parent = parent
|
|
15
|
-
self.path = path
|
|
16
|
-
self.name = name
|
|
17
|
-
# xml sanitisation performed by the property
|
|
18
|
-
self._packed_name = name
|
|
19
|
-
self.time = None
|
|
20
|
-
if isinstance(obj, etree._Element):
|
|
21
|
-
self.from_ifs = True
|
|
22
|
-
self.from_xml(obj)
|
|
23
|
-
else:
|
|
24
|
-
self.from_ifs = False
|
|
25
|
-
self.from_filesystem(obj)
|
|
26
|
-
|
|
27
|
-
def from_xml(self, elem):
|
|
28
|
-
raise NotImplementedError
|
|
29
|
-
|
|
30
|
-
def from_filesystem(self, path):
|
|
31
|
-
raise NotImplementedError
|
|
32
|
-
|
|
33
|
-
def tree_complete(self):
|
|
34
|
-
'''Call this when the entire tree is parsed and ready for modification'''
|
|
35
|
-
pass
|
|
36
|
-
|
|
37
|
-
def __str__(self):
|
|
38
|
-
return os.path.join(self.path, self.name)
|
|
39
|
-
|
|
40
|
-
def __repr__(self):
|
|
41
|
-
return '<{}: {}>'.format(self.__class__.__name__, self.full_path)
|
|
42
|
-
|
|
43
|
-
@property
|
|
44
|
-
def packed_name(self):
|
|
45
|
-
return self.sanitize_name(self._packed_name)
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def full_path(self):
|
|
49
|
-
return os.path.join(self.path, self.name)
|
|
50
|
-
|
|
51
|
-
@staticmethod
|
|
52
|
-
def sanitize_name(n):
|
|
53
|
-
for e in escapes[::-1]:
|
|
54
|
-
n = n.replace(e[1], e[0])
|
|
55
|
-
if n[0].isdigit():
|
|
56
|
-
n = '_' + n
|
|
57
|
-
return n
|
|
58
|
-
|
|
59
|
-
@staticmethod
|
|
60
|
-
def fix_name(n):
|
|
61
|
-
for e in escapes:
|
|
62
|
-
n = n.replace(*e)
|
|
63
|
-
if n[0] == '_' and n[1].isdigit():
|
|
64
|
-
n = n[1:]
|
|
65
|
-
return n
|
|
66
|
-
|
|
67
|
-
@staticmethod
|
|
68
|
-
def _split_ints(text, delim = ' '):
|
|
69
|
-
return list(map(int, text.split(delim)))
|
|
70
|
-
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import errno
|
|
2
|
-
import os
|
|
3
|
-
|
|
4
|
-
def mkdir_silent(dir):
|
|
5
|
-
try: # python 3
|
|
6
|
-
FileExistsError
|
|
7
|
-
try:
|
|
8
|
-
os.mkdir(dir)
|
|
9
|
-
except FileExistsError:
|
|
10
|
-
pass
|
|
11
|
-
except NameError: # python 2
|
|
12
|
-
try:
|
|
13
|
-
os.mkdir(dir)
|
|
14
|
-
except OSError as e:
|
|
15
|
-
if e.errno == errno.EEXIST:
|
|
16
|
-
pass
|
|
17
|
-
else:
|
|
18
|
-
raise
|
|
19
|
-
|
|
20
|
-
def save_with_timestamp(filename, data, timestamp):
|
|
21
|
-
mkdir_silent(os.path.dirname(filename))
|
|
22
|
-
with open(filename, 'wb') as f:
|
|
23
|
-
f.write(data)
|
|
24
|
-
# we store invalid timestamps as -1
|
|
25
|
-
if timestamp >= 0:
|
|
26
|
-
os.utime(filename, (timestamp,timestamp))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|