ifstools 2.0__tar.gz → 2.2__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 (27) hide show
  1. {ifstools-2.0 → ifstools-2.2}/PKG-INFO +7 -5
  2. {ifstools-2.0 → ifstools-2.2}/README.md +6 -4
  3. {ifstools-2.0 → ifstools-2.2}/pyproject.toml +1 -1
  4. {ifstools-2.0 → ifstools-2.2}/rust/dxt.rs +38 -18
  5. {ifstools-2.0 → ifstools-2.2}/rust/lib.rs +16 -0
  6. {ifstools-2.0 → ifstools-2.2}/rust/lz77.rs +39 -34
  7. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/generic_file.py +7 -1
  8. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/image_decoders.py +4 -1
  9. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/md5_folder.py +12 -2
  10. {ifstools-2.0 → ifstools-2.2}/.github/workflows/maturin.yml +0 -0
  11. {ifstools-2.0 → ifstools-2.2}/.gitignore +0 -0
  12. {ifstools-2.0 → ifstools-2.2}/Cargo.lock +0 -0
  13. {ifstools-2.0 → ifstools-2.2}/Cargo.toml +0 -0
  14. {ifstools-2.0 → ifstools-2.2}/LICENSE +0 -0
  15. {ifstools-2.0 → ifstools-2.2}/ifstools_bin.py +0 -0
  16. {ifstools-2.0 → ifstools-2.2}/rust/png_enc.rs +0 -0
  17. {ifstools-2.0 → ifstools-2.2}/src/ifstools/__init__.py +0 -0
  18. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/_lz77_py.py +0 -0
  19. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/afp_folder.py +0 -0
  20. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/generic_folder.py +0 -0
  21. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/image_file.py +0 -0
  22. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/lz77.py +0 -0
  23. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/node.py +0 -0
  24. {ifstools-2.0 → ifstools-2.2}/src/ifstools/handlers/tex_folder.py +0 -0
  25. {ifstools-2.0 → ifstools-2.2}/src/ifstools/ifs.py +0 -0
  26. {ifstools-2.0 → ifstools-2.2}/src/ifstools/ifstools.py +0 -0
  27. {ifstools-2.0 → ifstools-2.2}/src/ifstools/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ifstools
3
- Version: 2.0
3
+ Version: 2.2
4
4
  Requires-Dist: kbinxml>=1.5
5
5
  Requires-Dist: lxml
6
6
  Requires-Dist: pillow
@@ -79,12 +79,14 @@ optional arguments:
79
79
  ```
80
80
 
81
81
  ## Build an exe
82
- `pip install pyinstaller`
83
- `pyinstaller ifstools_bin.py --onefile -n ifstools`
84
- Recommend doing this in a fresh venv so the module finder doesn't include more than required.
82
+ ```shell
83
+ uv venv --clear
84
+ uv pip install . pyinstaller==6.19.0
85
+ uv run pyinstaller --onefile --name ifstools ifstools_bin.py
86
+ ```
85
87
 
86
88
  Notes:
87
- - dxt5 texture repacking is not fully supported - they will silently be converted to argb8888rev
89
+ - argb8888rev and dxt5 can be repacked. Other formats will silently be converted to argb8888rev
88
90
 
89
91
  Todo:
90
92
  - Recursive repacking for ifs inside ifs
@@ -65,12 +65,14 @@ optional arguments:
65
65
  ```
66
66
 
67
67
  ## Build an exe
68
- `pip install pyinstaller`
69
- `pyinstaller ifstools_bin.py --onefile -n ifstools`
70
- Recommend doing this in a fresh venv so the module finder doesn't include more than required.
68
+ ```shell
69
+ uv venv --clear
70
+ uv pip install . pyinstaller==6.19.0
71
+ uv run pyinstaller --onefile --name ifstools ifstools_bin.py
72
+ ```
71
73
 
72
74
  Notes:
73
- - dxt5 texture repacking is not fully supported - they will silently be converted to argb8888rev
75
+ - argb8888rev and dxt5 can be repacked. Other formats will silently be converted to argb8888rev
74
76
 
75
77
  Todo:
76
78
  - Recursive repacking for ifs inside ifs
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ifstools"
7
- version = "2.0"
7
+ version = "2.2"
8
8
  description = "Extractor/repacker for Konmai IFS files"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -9,11 +9,6 @@ use texpresso::Format;
9
9
  #[derive(Debug)]
10
10
  pub enum DxtError {
11
11
  UnknownFormat(String),
12
- SizeMismatch {
13
- expected: usize,
14
- got: usize,
15
- format: &'static str,
16
- },
17
12
  OddByteCount(usize),
18
13
  }
19
14
 
@@ -21,11 +16,6 @@ impl std::fmt::Display for DxtError {
21
16
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22
17
  match self {
23
18
  DxtError::UnknownFormat(s) => write!(f, "unknown DXT format: {}", s),
24
- DxtError::SizeMismatch { expected, got, format } => write!(
25
- f,
26
- "{}: expected {} compressed bytes, got {}",
27
- format, expected, got
28
- ),
29
19
  DxtError::OddByteCount(n) => write!(f, "input length {} is not a multiple of 2", n),
30
20
  }
31
21
  }
@@ -60,16 +50,12 @@ pub fn decode(
60
50
  let expected = fmt.compressed_size(width, height);
61
51
 
62
52
  // Konami's per-WORD byte swap, with zero padding if the source is short.
53
+ // n is always even: data.len() is checked above, expected is a DXT block size.
63
54
  let mut swapped = vec![0u8; expected];
64
55
  let n = data.len().min(expected);
65
- let pairs = n / 2;
66
- for i in 0..pairs {
67
- swapped[i * 2] = data[i * 2 + 1];
68
- swapped[i * 2 + 1] = data[i * 2];
69
- }
70
- if n % 2 != 0 {
71
- // Trailing odd byte (only possible when source is shorter than expected).
72
- swapped[n - 1] = data[n - 1];
56
+ swapped[..n].copy_from_slice(&data[..n]);
57
+ for chunk in swapped[..n].chunks_exact_mut(2) {
58
+ chunk.swap(0, 1);
73
59
  }
74
60
  let _ = fmt_name; // currently only used for error formatting
75
61
 
@@ -78,6 +64,27 @@ pub fn decode(
78
64
  Ok(rgba)
79
65
  }
80
66
 
67
+ /// Encode raw RGBA8 pixels (`width * height * 4` bytes) into Konami-format DXT
68
+ /// data. Inverse of `decode`: compress to canonical DXT, then apply the
69
+ /// per-WORD byte swap Konami's textures use.
70
+ pub fn encode(
71
+ rgba: &[u8],
72
+ width: usize,
73
+ height: usize,
74
+ format: &str,
75
+ ) -> Result<Vec<u8>, DxtError> {
76
+ let (fmt, _) = parse_format(format)?;
77
+
78
+ let mut out = vec![0u8; fmt.compressed_size(width, height)];
79
+ fmt.compress(rgba, width, height, texpresso::Params::default(), &mut out);
80
+
81
+ // compressed_size is always a multiple of the (even) block size.
82
+ for chunk in out.chunks_exact_mut(2) {
83
+ chunk.swap(0, 1);
84
+ }
85
+ Ok(out)
86
+ }
87
+
81
88
  #[cfg(test)]
82
89
  mod tests {
83
90
  use super::*;
@@ -106,6 +113,19 @@ mod tests {
106
113
  }
107
114
  }
108
115
 
116
+ #[test]
117
+ fn dxt5_roundtrip() {
118
+ // Encode a solid opaque red 4x4, decode it, expect it back (DXT is
119
+ // lossy but a single flat color survives exactly).
120
+ let mut rgba = Vec::new();
121
+ for _ in 0..16 {
122
+ rgba.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]);
123
+ }
124
+ let konami = encode(&rgba, 4, 4, "dxt5").unwrap();
125
+ let decoded = decode(&konami, 4, 4, "dxt5").unwrap();
126
+ assert_eq!(decoded, rgba);
127
+ }
128
+
109
129
  #[test]
110
130
  fn unknown_format_errors() {
111
131
  assert!(decode(&[0; 16], 4, 4, "garbage").is_err());
@@ -57,6 +57,21 @@ fn py_decode_dxt<'py>(
57
57
  Ok(PyBytes::new(py, &out))
58
58
  }
59
59
 
60
+ #[pyfunction]
61
+ #[pyo3(name = "encode_dxt")]
62
+ fn py_encode_dxt<'py>(
63
+ py: Python<'py>,
64
+ rgba: Vec<u8>,
65
+ width: usize,
66
+ height: usize,
67
+ format: &str,
68
+ ) -> PyResult<Bound<'py, PyBytes>> {
69
+ let out = py
70
+ .detach(|| dxt::encode(&rgba, width, height, format))
71
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
72
+ Ok(PyBytes::new(py, &out))
73
+ }
74
+
60
75
  #[pymodule]
61
76
  #[pyo3(name = "_native")]
62
77
  fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -64,5 +79,6 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
64
79
  m.add_function(wrap_pyfunction!(py_compress, m)?)?;
65
80
  m.add_function(wrap_pyfunction!(py_encode_png, m)?)?;
66
81
  m.add_function(wrap_pyfunction!(py_decode_dxt, m)?)?;
82
+ m.add_function(wrap_pyfunction!(py_encode_dxt, m)?)?;
67
83
  Ok(())
68
84
  }
@@ -15,6 +15,10 @@ const HASH_SIZE: usize = 1 << HASH_BITS;
15
15
  const HASH_MASK: u32 = HASH_SIZE as u32 - 1;
16
16
  const NIL: u32 = u32::MAX;
17
17
 
18
+ // Greedy match-search depth. 128 hits a good speed/ratio knee on real-world
19
+ // texture data: ~1.5× faster than an unbounded chain at <0.5% size cost.
20
+ const MAX_CHAIN: u32 = 128;
21
+
18
22
  #[derive(Debug)]
19
23
  pub enum DecompressError {
20
24
  Truncated,
@@ -33,9 +37,10 @@ impl std::error::Error for DecompressError {}
33
37
  pub fn decompress(input: &[u8]) -> Result<Vec<u8>, DecompressError> {
34
38
  let mut out: Vec<u8> = Vec::with_capacity(input.len() * 2);
35
39
  let mut i = 0usize;
40
+ let n = input.len();
36
41
 
37
42
  loop {
38
- if i >= input.len() {
43
+ if i >= n {
39
44
  return Err(DecompressError::Truncated);
40
45
  }
41
46
  let flag = input[i];
@@ -43,13 +48,13 @@ pub fn decompress(input: &[u8]) -> Result<Vec<u8>, DecompressError> {
43
48
 
44
49
  for bit in 0..8 {
45
50
  if (flag >> bit) & 1 == 1 {
46
- if i >= input.len() {
51
+ if i >= n {
47
52
  return Err(DecompressError::Truncated);
48
53
  }
49
54
  out.push(input[i]);
50
55
  i += 1;
51
56
  } else {
52
- if i + 1 >= input.len() {
57
+ if i + 1 >= n {
53
58
  return Err(DecompressError::Truncated);
54
59
  }
55
60
  let w = u16::from_be_bytes([input[i], input[i + 1]]);
@@ -65,22 +70,31 @@ pub fn decompress(input: &[u8]) -> Result<Vec<u8>, DecompressError> {
65
70
  // References into the virtual zero-prefilled window before stream start.
66
71
  if pos > out.len() {
67
72
  let diff = (pos - out.len()).min(len);
68
- out.extend(std::iter::repeat(0u8).take(diff));
73
+ let new_len = out.len() + diff;
74
+ out.resize(new_len, 0);
69
75
  len -= diff;
70
76
  }
71
77
 
72
- // Self-overlapping copy: each output byte may feed the next.
78
+ if len == 0 {
79
+ continue;
80
+ }
81
+
73
82
  let start = out.len() - pos;
74
- for k in 0..len {
75
- let b = out[start + k];
76
- out.push(b);
83
+ if pos >= len {
84
+ // Non-overlapping run: single memcpy.
85
+ out.extend_from_within(start..start + len);
86
+ } else {
87
+ // Self-overlapping copy: each output byte may feed the next.
88
+ for k in 0..len {
89
+ let b = out[start + k];
90
+ out.push(b);
91
+ }
77
92
  }
78
93
  }
79
94
  }
80
95
  }
81
96
  }
82
97
 
83
- /// Hash a 3-byte prefix into the chain table.
84
98
  #[inline(always)]
85
99
  fn hash3(a: u8, b: u8, c: u8) -> usize {
86
100
  let h = ((a as u32) << 16) | ((b as u32) << 8) | (c as u32);
@@ -88,9 +102,7 @@ fn hash3(a: u8, b: u8, c: u8) -> usize {
88
102
  ((h >> (32 - HASH_BITS)) & HASH_MASK) as usize
89
103
  }
90
104
 
91
- /// Compress a buffer. Output is byte-identical to the reference Python encoder
92
- /// when the matcher decisions agree (greedy longest-match here vs. greedy
93
- /// longest-match there).
105
+ /// Compress a buffer. Greedy longest-match with a bounded hash chain.
94
106
  pub fn compress(input: &[u8]) -> Vec<u8> {
95
107
  // Pre-pend a 4 KB zero window so matches at the start can legitimately
96
108
  // reference into the zero-prefilled history that the decoder synthesises.
@@ -115,15 +127,17 @@ pub fn compress(input: &[u8]) -> Vec<u8> {
115
127
  let mut pos = WINDOW;
116
128
 
117
129
  while pos < buf_len {
130
+ // Reserve a flag byte we'll patch in once the group is done. Avoids the
131
+ // per-group scratch Vec the older impl allocated.
132
+ let flag_idx = out.len();
133
+ out.push(0);
118
134
  let mut flag_byte: u8 = 0;
119
- let mut group: Vec<u8> = Vec::with_capacity(8 * 2);
120
135
 
121
136
  for bit in 0..8 {
122
137
  if pos >= buf_len {
123
138
  // Out of input mid-group: leave the bit as match (0). The
124
- // decoder will read into our trailing 0x00 0x00 0x00 sentinel
125
- // and exit on the position == 0 check before reaching this
126
- // phantom slot.
139
+ // decoder reads our trailing 0x00 0x00 0x00 sentinel and
140
+ // exits on the position == 0 check before reaching this slot.
127
141
  continue;
128
142
  }
129
143
 
@@ -132,10 +146,9 @@ pub fn compress(input: &[u8]) -> Vec<u8> {
132
146
  if best_len >= THRESHOLD {
133
147
  let dist = best_dist as u16;
134
148
  let info: u16 = (dist << 4) | ((best_len - THRESHOLD) as u16);
135
- group.extend_from_slice(&info.to_be_bytes());
149
+ out.extend_from_slice(&info.to_be_bytes());
136
150
 
137
- // Insert hash entries for every position covered by the match,
138
- // including the start (which we are about to skip past).
151
+ // Insert hash entries for every position covered by the match.
139
152
  for k in 0..best_len {
140
153
  let p = pos + k;
141
154
  if p + 2 < buf_len {
@@ -146,7 +159,7 @@ pub fn compress(input: &[u8]) -> Vec<u8> {
146
159
  }
147
160
  pos += best_len;
148
161
  } else {
149
- group.push(buf[pos]);
162
+ out.push(buf[pos]);
150
163
  flag_byte |= 1 << bit;
151
164
 
152
165
  if pos + 2 < buf_len {
@@ -158,8 +171,7 @@ pub fn compress(input: &[u8]) -> Vec<u8> {
158
171
  }
159
172
  }
160
173
 
161
- out.push(flag_byte);
162
- out.extend_from_slice(&group);
174
+ out[flag_idx] = flag_byte;
163
175
  }
164
176
 
165
177
  // EOS sentinel: flag byte saying "next code is a match", then a 0x0000
@@ -188,28 +200,24 @@ fn find_match(buf: &[u8], pos: usize, head: &[u32], prev: &[u32]) -> (usize, usi
188
200
 
189
201
  let mut best_len = 0usize;
190
202
  let mut best_dist = 0usize;
191
-
192
- // Bound on chain depth — at N=4096 chains are short anyway, but cap to keep
193
- // worst-case behaviour bounded on highly repetitive input.
194
- let mut chain_remaining: u32 = 4096;
203
+ let mut chain_remaining = MAX_CHAIN;
195
204
 
196
205
  while candidate != NIL {
197
206
  let cand = candidate as usize;
198
207
  if cand < limit {
199
208
  break;
200
209
  }
210
+ if chain_remaining == 0 {
211
+ break;
212
+ }
201
213
  chain_remaining -= 1;
202
214
 
203
215
  // Quick reject: if the byte at best_len doesn't match, skip.
204
- if best_len > 0 && buf[cand + best_len] != buf[pos + best_len] {
205
- // fall through to chain step
206
- } else {
207
- // Compare bytes up to max_len.
216
+ if best_len == 0 || buf[cand + best_len] == buf[pos + best_len] {
208
217
  let mut len = 0usize;
209
218
  while len < max_len && buf[cand + len] == buf[pos + len] {
210
219
  len += 1;
211
220
  }
212
-
213
221
  if len > best_len {
214
222
  best_len = len;
215
223
  best_dist = pos - cand;
@@ -219,9 +227,6 @@ fn find_match(buf: &[u8], pos: usize, head: &[u32], prev: &[u32]) -> (usize, usi
219
227
  }
220
228
  }
221
229
 
222
- if chain_remaining == 0 {
223
- break;
224
- }
225
230
  candidate = prev[cand & (WINDOW - 1)];
226
231
  }
227
232
 
@@ -2,6 +2,7 @@ import os
2
2
 
3
3
  import lxml.etree as etree
4
4
  from kbinxml import KBinXML
5
+ from kbinxml.kbinxml import KBinException
5
6
 
6
7
  from .. import utils
7
8
  from .node import Node
@@ -37,7 +38,12 @@ class GenericFile(Node):
37
38
  data = self.ifs_data.get(self.start, self.size)
38
39
 
39
40
  if convert_kbin and self.name.endswith('.xml') and KBinXML.is_binary_xml(data):
40
- data = KBinXML(data).to_text().encode('utf8')
41
+ try:
42
+ data = KBinXML(data).to_text().encode('utf8')
43
+ except KBinException as e:
44
+ if 'convert_illegal_things' in e.args[0]:
45
+ print("Corrupt xml found, trying to decode as utf8")
46
+ data = KBinXML(data, convert_illegal_things=True).to_text().encode('utf8')
41
47
  return data
42
48
 
43
49
  def _load_from_filesystem(self, **kwargs):
@@ -53,12 +53,15 @@ def decode_dxt5(ifs_img, data):
53
53
  def decode_dxt1(ifs_img, data):
54
54
  return decode_dxt(ifs_img, data, 'dxt1')
55
55
 
56
+ def encode_dxt5(ifs_img, image):
57
+ return _native.encode_dxt(image.tobytes('raw', 'RGBA'), image.width, image.height, 'dxt5')
58
+
56
59
 
57
60
  image_formats = {
58
61
  'argb8888rev' : {'decoder': decode_argb8888rev, 'encoder': encode_argb8888rev},
59
62
  'argb4444' : {'decoder': decode_argb4444, 'encoder': None},
60
63
  'dxt1' : {'decoder': decode_dxt1, 'encoder': None},
61
- 'dxt5' : {'decoder': decode_dxt5, 'encoder': None},
64
+ 'dxt5' : {'decoder': decode_dxt5, 'encoder': encode_dxt5 if _native else None},
62
65
  }
63
66
 
64
67
  cachable_formats = [key for key, val in image_formats.items() if val['encoder'] is not None]
@@ -1,6 +1,7 @@
1
1
  from hashlib import md5
2
2
 
3
3
  from kbinxml import KBinXML
4
+ from kbinxml.kbinxml import KBinException
4
5
 
5
6
  from .generic_folder import GenericFolder
6
7
 
@@ -20,6 +21,7 @@ class MD5Folder(GenericFolder):
20
21
 
21
22
  self.info_kbin = None
22
23
  self.info_file = None
24
+ self.encoding_override = None
23
25
  for filename, file in self.files.items():
24
26
  if filename.endswith('.xml'):
25
27
  self.info_file = file
@@ -29,7 +31,15 @@ class MD5Folder(GenericFolder):
29
31
  # _super_ references to info XML breaks things - just extract what we can
30
32
  return
31
33
 
32
- self.info_kbin = KBinXML(self.info_file.load(convert_kbin = False))
34
+ kbin_raw = self.info_file.load(convert_kbin = False)
35
+ try:
36
+ self.info_kbin = KBinXML(kbin_raw)
37
+ except KBinException as e:
38
+ if 'convert_illegal_things' in e.args[0]:
39
+ print("Corrupt texturelist xml found, trying to decode as utf8")
40
+ self.info_kbin = KBinXML(kbin_raw, convert_illegal_things=True)
41
+ self.encoding_override = 'utf8'
42
+
33
43
  self._apply_md5()
34
44
 
35
45
  def _apply_md5(self):
@@ -39,7 +49,7 @@ class MD5Folder(GenericFolder):
39
49
 
40
50
  def _apply_md5_folder(self, plain_list, folder):
41
51
  for plain in plain_list:
42
- hashed = md5(plain.encode(self.info_kbin.encoding)).hexdigest()
52
+ hashed = md5(plain.encode(self.encoding_override or self.info_kbin.encoding)).hexdigest()
43
53
 
44
54
  if self.extension:
45
55
  plain += self.extension
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