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.
Files changed (29) hide show
  1. {ifstools-2.2 → ifstools-3.0}/PKG-INFO +10 -2
  2. {ifstools-2.2 → ifstools-3.0}/README.md +9 -1
  3. {ifstools-2.2 → ifstools-3.0}/pyproject.toml +1 -1
  4. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/generic_file.py +18 -5
  5. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/generic_folder.py +11 -4
  6. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/image_decoders.py +8 -1
  7. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/image_file.py +12 -4
  8. ifstools-3.0/src/ifstools/handlers/node.py +126 -0
  9. {ifstools-2.2 → ifstools-3.0}/src/ifstools/ifs.py +39 -21
  10. {ifstools-2.2 → ifstools-3.0}/src/ifstools/ifstools.py +111 -96
  11. ifstools-3.0/src/ifstools/utils.py +13 -0
  12. ifstools-2.2/src/ifstools/handlers/node.py +0 -70
  13. ifstools-2.2/src/ifstools/utils.py +0 -26
  14. {ifstools-2.2 → ifstools-3.0}/.github/workflows/maturin.yml +0 -0
  15. {ifstools-2.2 → ifstools-3.0}/.gitignore +0 -0
  16. {ifstools-2.2 → ifstools-3.0}/Cargo.lock +0 -0
  17. {ifstools-2.2 → ifstools-3.0}/Cargo.toml +0 -0
  18. {ifstools-2.2 → ifstools-3.0}/LICENSE +0 -0
  19. {ifstools-2.2 → ifstools-3.0}/ifstools_bin.py +0 -0
  20. {ifstools-2.2 → ifstools-3.0}/rust/dxt.rs +0 -0
  21. {ifstools-2.2 → ifstools-3.0}/rust/lib.rs +0 -0
  22. {ifstools-2.2 → ifstools-3.0}/rust/lz77.rs +0 -0
  23. {ifstools-2.2 → ifstools-3.0}/rust/png_enc.rs +0 -0
  24. {ifstools-2.2 → ifstools-3.0}/src/ifstools/__init__.py +0 -0
  25. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/_lz77_py.py +0 -0
  26. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/afp_folder.py +0 -0
  27. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/lz77.py +0 -0
  28. {ifstools-2.2 → ifstools-3.0}/src/ifstools/handlers/md5_folder.py +0 -0
  29. {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: 2.2
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] [-m] [-s] [-r]
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] [-m] [-s] [-r]
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
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ifstools"
7
- version = "2.2"
7
+ version = "3.0"
8
8
  description = "Extractor/repacker for Konmai IFS files"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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
- # sometimes we don't get a timestamp
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
- elem.attrib['__type'] = '3s32'
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
- # offset, size, timestamp
65
- elem.text = '{} {} {}'.format(len(data_blob.getvalue()), len(data), self.time)
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
- manifest.attrib['__type'] = 's32'
137
- manifest.text = str(self.time)
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': None},
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
- elem.attrib['__type'] = '3s32'
98
- elem.text = '{} {} {}'.format(len(data_blob.getvalue()), len(data), self.time)
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
- FILE_VERSION = 3
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.file_version = header.get_u16()
68
+ self.flags = IFSFlags(header.get_u16())
63
69
  # next u16 is just NOT(version)
64
- assert header.get_u16() ^ self.file_version == 0xFFFF
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.file_version > 1:
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
- self.manifest = KBinXML(self.file.read(manifest_end-header.offset))
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.file_version = FILE_VERSION
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
- manifest_info = etree.SubElement(self.manifest.xml_doc, '_info_')
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
- data_md5 = etree.SubElement(manifest_info, 'md5')
225
- data_md5.attrib['__type'] = 'bin'
226
- data_md5.attrib['__size'] = '16'
227
- data_md5.text = hashlib.md5(data).hexdigest()
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
- data_size = etree.SubElement(manifest_info, 'size')
230
- data_size.attrib['__type'] = 'u32'
231
- data_size.text = str(len(data))
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.file_version)
239
- head.append_u16(self.file_version ^ 0xFFFF)
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.file_version > 1:
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.file_version > 1:
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 # exe freeze
4
-
5
- from .ifs import IFS
6
-
7
- def get_choice(prompt):
8
- while True:
9
- q = input(prompt + ' [Y/n] ').lower()
10
- if not q:
11
- return True # default to yes
12
- elif q == 'y':
13
- return True
14
- elif q == 'n':
15
- return False
16
- else:
17
- print('Please answer y/n')
18
-
19
- def extract(i, args, path):
20
- if args.progress:
21
- print('Extracting...')
22
- i.extract(path=path, **vars(args))
23
-
24
- def repack(i, args, path):
25
- if args.progress:
26
- print('Repacking...')
27
- i.repack(path=path, **vars(args))
28
-
29
- def main():
30
- parser = argparse.ArgumentParser(description='Unpack/pack IFS files and textures')
31
- parser.add_argument('files', metavar='file_to_unpack.ifs|folder_to_repack_ifs', type=str, nargs='+',
32
- help='files/folders to process. Files will be unpacked, folders will be repacked')
33
- parser.add_argument('-e', '--extract-folders', action='store_true', help='do not repack folders, instead unpack any IFS files inside them', dest='extract_folders')
34
- parser.add_argument('-y', action='store_true', help='don\'t prompt for file/folder overwrite', dest='overwrite')
35
- parser.add_argument('-o', default='.', help='output directory', dest='out_dir')
36
- parser.add_argument('--tex-only', action='store_true', help='only extract textures')
37
- 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')
38
- parser.add_argument('--bounds', action='store_true', help='draw image bounds on the exported canvas in red', dest='draw_bbox')
39
- parser.add_argument('--uv', action='store_true', help='crop images to uvrect (usually 1px smaller than imgrect). Forces --tex-only', dest='crop_to_uvrect')
40
- parser.add_argument('--no-cache', action='store_true', dest='no_cache_deprecated',
41
- help=argparse.SUPPRESS)
42
- parser.add_argument('--rename-dupes', action='store_true',
43
- 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')
44
- parser.add_argument('-m', '--extract-manifest', action='store_true', help='extract the IFS manifest for inspection', dest='extract_manifest')
45
- parser.add_argument('--super-disable', action='store_true',
46
- help='only extract files unique to this IFS, do not follow "super" parent references at all')
47
- parser.add_argument('--super-skip-bad', action='store_true',
48
- help='if a "super" IFS reference has a checksum mismatch, do not extract it')
49
- parser.add_argument('--super-abort-if-bad', action='store_true',
50
- help='if a "super" IFS reference has a checksum mismatch, cancel and display an error')
51
- parser.add_argument('-s', '--silent', action='store_false', dest='progress',
52
- help='don\'t display files as they are processed')
53
- parser.add_argument('-r', '--norecurse', action='store_false', dest='recurse',
54
- help='if file contains another IFS, don\'t extract its contents')
55
-
56
- args = parser.parse_args()
57
-
58
- if args.no_cache_deprecated:
59
- print("WARNING: --no-cache is deprecated and has no effect; the texture cache has been removed.")
60
- delattr(args, 'no_cache_deprecated')
61
-
62
- if args.crop_to_uvrect:
63
- args.tex_only = True
64
-
65
- if args.extract_folders:
66
- dirs = [f for f in args.files if os.path.isdir(f)]
67
- # prune
68
- args.files = [f for f in args.files if not os.path.isdir(f)]
69
- # add the extras
70
- for d in dirs:
71
- args.files.extend((os.path.join(d,f) for f in os.listdir(d) if f.lower().endswith('.ifs')))
72
-
73
- for f in args.files:
74
- if args.progress:
75
- print(f)
76
- try:
77
- i = IFS(f, super_disable=args.super_disable, super_skip_bad=args.super_skip_bad,
78
- super_abort_if_bad=args.super_abort_if_bad)
79
- except IOError as e:
80
- # human friendly
81
- print('{}: {}'.format(os.path.basename(f), str(e)))
82
- exit(1)
83
-
84
- path = os.path.join(args.out_dir, i.default_out)
85
- if os.path.exists(path) and not args.overwrite:
86
- if not get_choice('{} exists. Overwrite?'.format(path)):
87
- continue
88
-
89
- if i.is_file:
90
- extract(i, args, path)
91
- else:
92
- repack(i, args, path)
93
-
94
-
95
- if __name__ == '__main__':
96
- main()
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