ifstools 2.0__tar.gz → 2.1__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.1}/PKG-INFO +1 -1
  2. {ifstools-2.0 → ifstools-2.1}/pyproject.toml +1 -1
  3. {ifstools-2.0 → ifstools-2.1}/rust/dxt.rs +38 -18
  4. {ifstools-2.0 → ifstools-2.1}/rust/lib.rs +16 -0
  5. {ifstools-2.0 → ifstools-2.1}/rust/lz77.rs +39 -34
  6. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/image_decoders.py +4 -1
  7. {ifstools-2.0 → ifstools-2.1}/.github/workflows/maturin.yml +0 -0
  8. {ifstools-2.0 → ifstools-2.1}/.gitignore +0 -0
  9. {ifstools-2.0 → ifstools-2.1}/Cargo.lock +0 -0
  10. {ifstools-2.0 → ifstools-2.1}/Cargo.toml +0 -0
  11. {ifstools-2.0 → ifstools-2.1}/LICENSE +0 -0
  12. {ifstools-2.0 → ifstools-2.1}/README.md +0 -0
  13. {ifstools-2.0 → ifstools-2.1}/ifstools_bin.py +0 -0
  14. {ifstools-2.0 → ifstools-2.1}/rust/png_enc.rs +0 -0
  15. {ifstools-2.0 → ifstools-2.1}/src/ifstools/__init__.py +0 -0
  16. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/_lz77_py.py +0 -0
  17. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/afp_folder.py +0 -0
  18. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/generic_file.py +0 -0
  19. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/generic_folder.py +0 -0
  20. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/image_file.py +0 -0
  21. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/lz77.py +0 -0
  22. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/md5_folder.py +0 -0
  23. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/node.py +0 -0
  24. {ifstools-2.0 → ifstools-2.1}/src/ifstools/handlers/tex_folder.py +0 -0
  25. {ifstools-2.0 → ifstools-2.1}/src/ifstools/ifs.py +0 -0
  26. {ifstools-2.0 → ifstools-2.1}/src/ifstools/ifstools.py +0 -0
  27. {ifstools-2.0 → ifstools-2.1}/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.1
4
4
  Requires-Dist: kbinxml>=1.5
5
5
  Requires-Dist: lxml
6
6
  Requires-Dist: pillow
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ifstools"
7
- version = "2.0"
7
+ version = "2.1"
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
 
@@ -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]
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