dsts-extractor 0.8.1__py3-none-any.whl

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 (45) hide show
  1. dsts_extractor/__init__.py +0 -0
  2. dsts_extractor/__main__.py +4 -0
  3. dsts_extractor/anim.py +587 -0
  4. dsts_extractor/anim_build.py +278 -0
  5. dsts_extractor/anim_sharing.py +76 -0
  6. dsts_extractor/assets/missing.png +0 -0
  7. dsts_extractor/blender_import.py +1752 -0
  8. dsts_extractor/build_index.py +215 -0
  9. dsts_extractor/catalog.py +134 -0
  10. dsts_extractor/chr_env.py +117 -0
  11. dsts_extractor/cli.py +1333 -0
  12. dsts_extractor/clut.py +103 -0
  13. dsts_extractor/data/digimon_index.json +3610 -0
  14. dsts_extractor/data/effect_textures.json +87 -0
  15. dsts_extractor/data/npc_face_map.json +538 -0
  16. dsts_extractor/data/npc_full_index.json +870 -0
  17. dsts_extractor/data/render_state.json +48 -0
  18. dsts_extractor/data/same_animation_data.json +261 -0
  19. dsts_extractor/data/shader_param_names.json +3168 -0
  20. dsts_extractor/doctor.py +155 -0
  21. dsts_extractor/dxbc.py +170 -0
  22. dsts_extractor/effects.py +50 -0
  23. dsts_extractor/export.py +462 -0
  24. dsts_extractor/extras.py +191 -0
  25. dsts_extractor/eyes.py +942 -0
  26. dsts_extractor/geom.py +853 -0
  27. dsts_extractor/glb_bundle.py +888 -0
  28. dsts_extractor/glb_optimize.py +180 -0
  29. dsts_extractor/glb_validate.py +538 -0
  30. dsts_extractor/gltf_writer.py +232 -0
  31. dsts_extractor/material_routing.py +538 -0
  32. dsts_extractor/mvgl.py +418 -0
  33. dsts_extractor/native_export.py +757 -0
  34. dsts_extractor/nlst.py +62 -0
  35. dsts_extractor/paths.py +196 -0
  36. dsts_extractor/setup_cmd.py +181 -0
  37. dsts_extractor/shaders/__init__.py +8 -0
  38. dsts_extractor/shaders/analyze.py +346 -0
  39. dsts_extractor/steam.py +226 -0
  40. dsts_extractor-0.8.1.dist-info/METADATA +20 -0
  41. dsts_extractor-0.8.1.dist-info/RECORD +45 -0
  42. dsts_extractor-0.8.1.dist-info/WHEEL +5 -0
  43. dsts_extractor-0.8.1.dist-info/entry_points.txt +3 -0
  44. dsts_extractor-0.8.1.dist-info/licenses/LICENSE +21 -0
  45. dsts_extractor-0.8.1.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,4 @@
1
+ from dsts_extractor.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
dsts_extractor/anim.py ADDED
@@ -0,0 +1,587 @@
1
+ """Reader for Digimon Story: Time Stranger .anim files.
2
+
3
+ Format reverse-engineered from chr391 / chr090 samples. The layout descends
4
+ from Media.Vision's DSCS .anim format (see Pherakki/Romsstar Blender-Tools-for-DSCS,
5
+ FileReaders/AnimReader.py) with these DSTS-specific differences:
6
+
7
+ - The 0x14 "always_16384" field (rotation precision marker in DSCS) is **0** in
8
+ every observed DSTS file — repurposed or removed.
9
+ - The pointer table at 0x30..0x5C has been reshuffled and grown. DSTS adds two
10
+ new rel-ptrs at 0x30/0x34 (chunk-data shortcuts) and one at 0x5C (a 16-byte
11
+ block of mostly-zeros — purpose still TBD), while the original six DSCS
12
+ rel-ptrs are pushed from 0x30..0x44 down to 0x38..0x4C.
13
+ - The kf_chunks_ptrs entries are `<HHI>` per chunk like DSCS, but with the
14
+ first two H's **swapped**: DSCS = (always_zero, length, abs_ptr), DSTS =
15
+ (length, always_zero, abs_ptr).
16
+ - Each KeyframeChunk has a **32-byte** header (8 × u32) in place of DSCS's
17
+ 16-byte header. The bytecount fields live in u32 slots; two of the u32
18
+ slots (I[2] and I[7]) hold values whose meaning is still TBD — they don't
19
+ affect skeletal animation when anim_scl/anim_su counts are zero.
20
+
21
+ Smallest-3 quaternion encoding is the same as DSCS: 48 bits per quaternion,
22
+ 1 reserved bit + 3 × 15-bit signed-int components + 2-bit largest-index.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ import struct
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ _FILETYPE_MAGIC = b"80AE"
33
+ _HEADER_SIZE = 0x60
34
+ _CHUNK_HEADER_SIZE = 32
35
+ _IDX_LIST_SECTION_START = 0x60
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AnimHeader:
40
+ """Parsed 96-byte file header. Field offsets in comments."""
41
+
42
+ filetype: str # 0x00: "80AE"
43
+ duration_seconds: float # 0x04
44
+ fps: float # 0x08
45
+ setup_and_static_data_size: int # 0x0C — file offset where bone_mask starts
46
+ num_bones: int # 0x0E
47
+ total_frames: int # 0x10
48
+ num_keyframe_chunks: int # 0x12
49
+ static_rot_count: int # 0x16
50
+ static_loc_count: int # 0x18
51
+ static_scl_count: int # 0x1A
52
+ static_su_count: int # 0x1C
53
+ anim_rot_count: int # 0x1E
54
+ anim_loc_count: int # 0x20
55
+ anim_scl_count: int # 0x22
56
+ anim_su_count: int # 0x24
57
+ bone_mask_bytes: int # 0x28
58
+ num_uv_channels: int # 0x2A (always 12 in chr391/chr090)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class KeyframeChunk:
63
+ """Decoded per-chunk keyframe data.
64
+
65
+ Each track is sparse: a list of (frame_index_within_chunk, value) tuples.
66
+ Frame 0 of the chunk always has a value for every animated channel; later
67
+ frames have a value only where the keyframes_in_use bitmask is set.
68
+ """
69
+
70
+ cumulative_frames: int # frame index (in the whole anim) where this chunk starts
71
+ frames_in_chunk: int # = increment + 1
72
+ rotations: tuple[tuple[tuple[int, tuple[float, float, float, float]], ...], ...]
73
+ """Per-animated-bone list of (frame_in_chunk, quaternion_wxyz)."""
74
+ locations: tuple[tuple[tuple[int, tuple[float, float, float]], ...], ...]
75
+ """Per-animated-bone list of (frame_in_chunk, position)."""
76
+ scales: tuple[tuple[tuple[int, tuple[float, float, float]], ...], ...]
77
+ """Per-animated-bone list of (frame_in_chunk, scale)."""
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class AnimFile:
82
+ header: AnimHeader
83
+ static_rot_bones: tuple[int, ...] # bone indices with static rotation
84
+ static_loc_bones: tuple[int, ...]
85
+ static_scl_bones: tuple[int, ...]
86
+ static_su_channels: tuple[int, ...]
87
+ anim_rot_bones: tuple[int, ...] # bone indices that animate rotation
88
+ anim_loc_bones: tuple[int, ...]
89
+ anim_scl_bones: tuple[int, ...]
90
+ anim_su_channels: tuple[int, ...]
91
+ static_rotations: tuple[tuple[float, float, float, float], ...] # WXYZ per static_rot bone
92
+ static_locations: tuple[tuple[float, float, float], ...]
93
+ static_scales: tuple[tuple[float, float, float], ...]
94
+ static_shader_uniforms: tuple[float, ...]
95
+ bone_mask: tuple[int, ...] # 0 = bone has no data of any kind
96
+ chunks: tuple[KeyframeChunk, ...]
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Smallest-3 quaternion decode (DSCS-compatible)
101
+
102
+
103
+ def _decode_quaternion(raw: bytes) -> tuple[float, float, float, float]:
104
+ """Decode a 6-byte smallest-3 quaternion into (w, x, y, z).
105
+
106
+ Bit layout (MSB-first within each byte):
107
+ bit 0 : reserved (always 0)
108
+ bits 1..15 : component 0 → X (or W if largest_index says so)
109
+ bits 16..30: component 1 → Y
110
+ bits 31..45: component 2 → Z
111
+ bits 46..47: largest_index (0..3), index in XYZW where the omitted
112
+ (largest-magnitude) component is reinserted.
113
+
114
+ Components are scaled by 1/sqrt(2). The omitted component is recovered
115
+ via |q| = 1.
116
+ """
117
+ if len(raw) != 6:
118
+ raise ValueError(f"quaternion blob must be 6 bytes, got {len(raw)}")
119
+ bits = 0
120
+ for b in raw:
121
+ bits = (bits << 8) | b
122
+ # `bits` is now a 48-bit integer; bit 47 (LSB after the OR-shift) maps to
123
+ # MSB position 0 of the input. Iterating j=2,1,0 reads comp0 first (= the
124
+ # 15 bits just below the reserved top bit), comp1 next, comp2 last.
125
+ largest_index = bits & 0b11
126
+ comps: list[float] = []
127
+ for j in (2, 1, 0):
128
+ v = (bits >> (2 + 15 * j)) & 0x7FFF
129
+ comps.append((v - 16383) / 16384.0 / math.sqrt(2.0))
130
+ # comps == [comp0, comp1, comp2] → these map to (X, Y, Z) before the
131
+ # omitted component is reinserted. Do NOT reverse — that swaps X with Z.
132
+ sum_sq = sum(c * c for c in comps)
133
+ largest = math.sqrt(max(0.0, 1.0 - sum_sq))
134
+ xyzw = comps[:]
135
+ xyzw.insert(largest_index, largest)
136
+ x, y, z, w = xyzw
137
+ return (w, x, y, z)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Header + idx-list parsing
142
+
143
+
144
+ def _parse_header(blob: bytes) -> AnimHeader:
145
+ if len(blob) < _HEADER_SIZE:
146
+ raise ValueError(f"anim too small for header: {len(blob)} < {_HEADER_SIZE}")
147
+ filetype = blob[0:4]
148
+ if filetype != _FILETYPE_MAGIC:
149
+ raise ValueError(f"unexpected anim magic: {filetype!r}")
150
+ duration, fps = struct.unpack_from("<ff", blob, 0x04)
151
+ (
152
+ setup_data_size,
153
+ num_bones,
154
+ total_frames,
155
+ num_kf_chunks,
156
+ always_zero,
157
+ ) = struct.unpack_from("<HHHHH", blob, 0x0C)
158
+ if always_zero != 0:
159
+ # DSCS expects 16384 here; DSTS samples we've seen all have 0.
160
+ raise ValueError(
161
+ f"unexpected value at 0x14: {always_zero} (expected 0 for DSTS)"
162
+ )
163
+ (
164
+ s_rot,
165
+ s_loc,
166
+ s_scl,
167
+ s_su,
168
+ a_rot,
169
+ a_loc,
170
+ a_scl,
171
+ a_su,
172
+ ) = struct.unpack_from("<HHHHHHHH", blob, 0x16)
173
+ # The u16 at 0x26 is a parallel shader-uniform-anim count that we don't
174
+ # decode. It equals `anim_su_count` in effect/camera anims and ranges
175
+ # 1..15 on some chr anims whose `anim_su_count` is 0. Verified empirically:
176
+ # it does not shift the offsets of any section the parser walks (every
177
+ # decoded quaternion stays unit). Read it for completeness, then drop.
178
+ _su_count_2, bone_mask_bytes, num_uv = struct.unpack_from("<HHH", blob, 0x26)
179
+ return AnimHeader(
180
+ filetype=filetype.decode("ascii"),
181
+ duration_seconds=duration,
182
+ fps=fps,
183
+ setup_and_static_data_size=setup_data_size,
184
+ num_bones=num_bones,
185
+ total_frames=total_frames,
186
+ num_keyframe_chunks=num_kf_chunks,
187
+ static_rot_count=s_rot,
188
+ static_loc_count=s_loc,
189
+ static_scl_count=s_scl,
190
+ static_su_count=s_su,
191
+ anim_rot_count=a_rot,
192
+ anim_loc_count=a_loc,
193
+ anim_scl_count=a_scl,
194
+ anim_su_count=a_su,
195
+ bone_mask_bytes=bone_mask_bytes,
196
+ num_uv_channels=num_uv,
197
+ )
198
+
199
+
200
+ def _read_rel_ptr(blob: bytes, field_offset: int) -> int:
201
+ """Read a u32 rel-pointer at `field_offset` and resolve to an absolute file offset.
202
+
203
+ Returns 0 (sentinel for "no data section") when the rel value is 0.
204
+ """
205
+ val = struct.unpack_from("<I", blob, field_offset)[0]
206
+ if val == 0:
207
+ return 0
208
+ return field_offset + val
209
+
210
+
211
+ def _align(n: int, boundary: int) -> int:
212
+ return (n + boundary - 1) & ~(boundary - 1)
213
+
214
+
215
+ def _read_idx_list(
216
+ blob: bytes,
217
+ cursor: int,
218
+ count: int,
219
+ boundary: int,
220
+ sentinel: int,
221
+ ) -> tuple[tuple[int, ...], int]:
222
+ """Read `count` u16 indices, then skip sentinel-padding to `boundary` alignment.
223
+
224
+ Returns (idxs, new_cursor).
225
+ """
226
+ if count == 0:
227
+ return (), cursor
228
+ idxs = struct.unpack_from(f"<{count}H", blob, cursor)
229
+ cursor += count * 2
230
+ end_aligned = _align(count * 2, boundary)
231
+ pad_bytes = end_aligned - count * 2
232
+ if pad_bytes:
233
+ pad_values = struct.unpack_from(f"<{pad_bytes // 2}H", blob, cursor)
234
+ if any(v != sentinel for v in pad_values):
235
+ raise ValueError(
236
+ f"idx-list padding sentinel mismatch at 0x{cursor:x}: "
237
+ f"got {pad_values!r}, expected {sentinel}"
238
+ )
239
+ cursor += pad_bytes
240
+ return idxs, cursor
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Keyframe-chunk parsing
245
+
246
+
247
+ def _read_keyframes_in_use(
248
+ blob: bytes,
249
+ cursor: int,
250
+ total_anim_channels: int,
251
+ increment_frames: int,
252
+ ) -> tuple[list[list[bool]], int]:
253
+ """Decode the keyframes-in-use bitmask.
254
+
255
+ Layout: total_anim_channels × increment_frames bits, channel-major,
256
+ MSB-first within each byte. Returns one list-of-bools per animated channel,
257
+ each of length `increment_frames`.
258
+
259
+ Pad bytes (before and after the mask) live in the chunk header's
260
+ pre_mask_pad / post_mask_pad fields and are advanced by the caller.
261
+ """
262
+ total_bits = total_anim_channels * increment_frames
263
+ if total_bits == 0:
264
+ return [[] for _ in range(total_anim_channels)], cursor
265
+ nbytes = (total_bits + 7) // 8
266
+ raw = blob[cursor : cursor + nbytes]
267
+ cursor += nbytes
268
+ # Unpack bits MSB-first
269
+ flat = []
270
+ for byte in raw:
271
+ for bit in range(8):
272
+ flat.append(bool(byte & (0x80 >> bit)))
273
+ per_channel: list[list[bool]] = []
274
+ for ch in range(total_anim_channels):
275
+ start = ch * increment_frames
276
+ per_channel.append(flat[start : start + increment_frames])
277
+ return per_channel, cursor
278
+
279
+
280
+ def _read_chunk(
281
+ blob: bytes,
282
+ chunk_start: int,
283
+ anim_rot_count: int,
284
+ anim_loc_count: int,
285
+ anim_scl_count: int,
286
+ anim_su_count: int,
287
+ cumulative_frames: int,
288
+ frames_in_chunk: int,
289
+ ) -> KeyframeChunk:
290
+ """Read one keyframe chunk starting at `chunk_start`.
291
+
292
+ DSTS chunk layout:
293
+ [0..32) chunk header (8 × u32):
294
+ I[0] = frame_0_rot_bytecount (= anim_rot_count × 6)
295
+ I[1] = frame_0_loc_bytecount (= anim_loc_count × 12)
296
+ I[2] = pad bytes between frame_0 sections and the mask
297
+ I[3] = frame_0_su_bytecount (= anim_su_count × 4)
298
+ I[4] = keyframed_rot_bytecount
299
+ I[5] = keyframed_loc_bytecount
300
+ I[6] = pad bytes between the mask and keyframed_rot
301
+ I[7] = pad bytes after the keyframed sections (chunk-final pad)
302
+ [32..) frame_0_rot (anim_rot_count × 6, smallest-3 quat)
303
+ frame_0_loc (anim_loc_count × 12, fff)
304
+ frame_0_scl (anim_scl_count × 12, fff)
305
+ frame_0_su (anim_su_count × 4, f)
306
+ pre_mask_pad (I[2] bytes — between frame_0 sections and the mask)
307
+ mask (ceil((total_anim_channels) × (frames_in_chunk-1) / 8) bytes,
308
+ channel-major, MSB-first within each byte)
309
+ keyframed_rot
310
+ keyframed_loc
311
+ keyframed_scl
312
+ keyframed_su
313
+ end_pad (I[6] + I[7] bytes, after the keyframed sections)
314
+
315
+ DSCS used implicit alignment for the equivalent pads; DSTS stores the
316
+ pad-byte counts explicitly. I[6] and I[7] are both trailing-pad fields:
317
+ I[7] alone matched chunk size when I[6] happened to be zero (which it
318
+ is for most anims), but anims like chr391_fe04 set I[6]=1 and require
319
+ BOTH to land at the right total. Treating I[6] as a pad between mask
320
+ and kf_rot (the obvious DSCS-style guess) shifts every keyframed quat
321
+ by 1 byte and silently corrupts the smallest-3 decode.
322
+ """
323
+ hdr = struct.unpack_from("<8I", blob, chunk_start)
324
+ f0_rot_bc = hdr[0]
325
+ f0_loc_bc = hdr[1]
326
+ pre_mask_pad = hdr[2]
327
+ f0_su_bc = hdr[3]
328
+ kf_rot_bc = hdr[4]
329
+ kf_loc_bc = hdr[5]
330
+ end_pad_a = hdr[6]
331
+ end_pad_b = hdr[7]
332
+
333
+ expected_f0_rot = anim_rot_count * 6
334
+ expected_f0_loc = anim_loc_count * 12
335
+ expected_f0_su = anim_su_count * 4
336
+ if f0_rot_bc != expected_f0_rot:
337
+ raise ValueError(
338
+ f"chunk@0x{chunk_start:x}: frame_0_rot_bytecount={f0_rot_bc} != expected {expected_f0_rot}"
339
+ )
340
+ if f0_loc_bc != expected_f0_loc:
341
+ raise ValueError(
342
+ f"chunk@0x{chunk_start:x}: frame_0_loc_bytecount={f0_loc_bc} != expected {expected_f0_loc}"
343
+ )
344
+ if f0_su_bc != expected_f0_su:
345
+ raise ValueError(
346
+ f"chunk@0x{chunk_start:x}: frame_0_su_bytecount={f0_su_bc} != expected {expected_f0_su}"
347
+ )
348
+
349
+ cursor = chunk_start + _CHUNK_HEADER_SIZE
350
+
351
+ # frame_0_rot
352
+ frame_0_rotations: list[tuple[float, float, float, float]] = []
353
+ for i in range(anim_rot_count):
354
+ q = _decode_quaternion(bytes(blob[cursor + i * 6 : cursor + (i + 1) * 6]))
355
+ frame_0_rotations.append(q)
356
+ cursor += f0_rot_bc
357
+
358
+ # frame_0_loc
359
+ frame_0_locations: list[tuple[float, float, float]] = []
360
+ for i in range(anim_loc_count):
361
+ frame_0_locations.append(
362
+ struct.unpack_from("<fff", blob, cursor + i * 12) # type: ignore[arg-type]
363
+ )
364
+ cursor += f0_loc_bc
365
+
366
+ # No frame_0_scl block in the chunk: the chunk header declares only
367
+ # f0_rot_bc, f0_loc_bc, pre_mask_pad, f0_su_bc (no f0_scl_bc), and a
368
+ # mask-bytecount audit confirms the mask sits immediately after f0_su.
369
+ # Default animated-scl tracks to identity at frame 0; subsequent keyframes
370
+ # in the mask provide the actual per-frame values.
371
+ frame_0_scales: list[tuple[float, float, float]] = [
372
+ (1.0, 1.0, 1.0) for _ in range(anim_scl_count)
373
+ ]
374
+
375
+ # frame_0_su
376
+ frame_0_su: list[float] = []
377
+ for i in range(anim_su_count):
378
+ frame_0_su.append(struct.unpack_from("<f", blob, cursor + i * 4)[0])
379
+ cursor += f0_su_bc
380
+
381
+ # pre-mask explicit pad
382
+ cursor += pre_mask_pad
383
+
384
+ # keyframes_in_use mask: total_anim_channels × increment_frames bits,
385
+ # channel-major, MSB-first within each byte.
386
+ increment_frames = max(0, frames_in_chunk - 1)
387
+ total_anim_channels = (
388
+ anim_rot_count + anim_loc_count + anim_scl_count + anim_su_count
389
+ )
390
+ per_channel_mask, cursor = _read_keyframes_in_use(
391
+ blob, cursor, total_anim_channels, increment_frames
392
+ )
393
+ # No explicit pad between mask and keyframed_rot — kf_rot starts immediately
394
+ # after the mask. The remaining `end_pad_a` (= I[6]) and `end_pad_b` (= I[7])
395
+ # bytes live at the END of the chunk, after kf_su.
396
+ _ = (end_pad_a, end_pad_b) # consumed by caller for chunk-end accounting
397
+
398
+ # keyframed_rot — only values for set bits in the rot portion of the mask
399
+ rot_mask = per_channel_mask[0:anim_rot_count]
400
+ loc_mask = per_channel_mask[anim_rot_count : anim_rot_count + anim_loc_count]
401
+ scl_mask = per_channel_mask[
402
+ anim_rot_count + anim_loc_count : anim_rot_count + anim_loc_count + anim_scl_count
403
+ ]
404
+ # su_mask kept unused at present (no anim_su in observed chr files)
405
+ _ = per_channel_mask[anim_rot_count + anim_loc_count + anim_scl_count :]
406
+
407
+ expected_kf_rot = sum(sum(m) for m in rot_mask) * 6
408
+ expected_kf_loc = sum(sum(m) for m in loc_mask) * 12
409
+ if kf_rot_bc != expected_kf_rot:
410
+ raise ValueError(
411
+ f"chunk@0x{chunk_start:x}: keyframed_rot_bytecount={kf_rot_bc} != "
412
+ f"expected {expected_kf_rot} from mask"
413
+ )
414
+ if kf_loc_bc != expected_kf_loc:
415
+ raise ValueError(
416
+ f"chunk@0x{chunk_start:x}: keyframed_loc_bytecount={kf_loc_bc} != "
417
+ f"expected {expected_kf_loc} from mask"
418
+ )
419
+ # DSCS stored keyframed_scl_bytecount at hdr[6]; in DSTS that slot was
420
+ # repurposed as post_mask_pad. No samples observed for anim_scl != 0 yet.
421
+
422
+ # Assemble per-bone sparse tracks: (frame_in_chunk, value) pairs.
423
+ # Frame 0 always has a sample; subsequent frames i (1..increment_frames)
424
+ # have a sample iff mask[i-1] is set.
425
+ rotations: list[tuple[tuple[int, tuple[float, float, float, float]], ...]] = []
426
+ rot_cursor = cursor
427
+ for ch_idx in range(anim_rot_count):
428
+ track = [(0, frame_0_rotations[ch_idx])]
429
+ for frame_minus_1, present in enumerate(rot_mask[ch_idx]):
430
+ if present:
431
+ q = _decode_quaternion(bytes(blob[rot_cursor : rot_cursor + 6]))
432
+ rot_cursor += 6
433
+ track.append((frame_minus_1 + 1, q))
434
+ rotations.append(tuple(track))
435
+ cursor += kf_rot_bc
436
+
437
+ locations: list[tuple[tuple[int, tuple[float, float, float]], ...]] = []
438
+ loc_cursor = cursor
439
+ for ch_idx in range(anim_loc_count):
440
+ track = [(0, frame_0_locations[ch_idx])]
441
+ for frame_minus_1, present in enumerate(loc_mask[ch_idx]):
442
+ if present:
443
+ v: tuple[float, float, float] = struct.unpack_from( # type: ignore[assignment]
444
+ "<fff", blob, loc_cursor
445
+ )
446
+ loc_cursor += 12
447
+ track.append((frame_minus_1 + 1, v))
448
+ locations.append(tuple(track))
449
+ cursor += kf_loc_bc
450
+
451
+ scales: list[tuple[tuple[int, tuple[float, float, float]], ...]] = []
452
+ scl_cursor = cursor
453
+ for ch_idx in range(anim_scl_count):
454
+ track = [(0, frame_0_scales[ch_idx])]
455
+ for frame_minus_1, present in enumerate(scl_mask[ch_idx]):
456
+ if present:
457
+ v2: tuple[float, float, float] = struct.unpack_from( # type: ignore[assignment]
458
+ "<fff", blob, scl_cursor
459
+ )
460
+ scl_cursor += 12
461
+ track.append((frame_minus_1 + 1, v2))
462
+ scales.append(tuple(track))
463
+
464
+ return KeyframeChunk(
465
+ cumulative_frames=cumulative_frames,
466
+ frames_in_chunk=frames_in_chunk,
467
+ rotations=tuple(rotations),
468
+ locations=tuple(locations),
469
+ scales=tuple(scales),
470
+ )
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # Top-level parse
475
+
476
+
477
+ def parse_anim(blob: bytes | bytearray | memoryview) -> AnimFile:
478
+ blob = bytes(blob)
479
+ header = _parse_header(blob)
480
+
481
+ # Bone idx lists at 0x60. DSCS padding boundaries: rotations to 16, others to 8.
482
+ cursor = _IDX_LIST_SECTION_START
483
+ sentinel = header.num_bones # padded with num_bones value, matching DSCS convention
484
+ static_rot_idxs, cursor = _read_idx_list(blob, cursor, header.static_rot_count, 16, sentinel)
485
+ static_loc_idxs, cursor = _read_idx_list(blob, cursor, header.static_loc_count, 8, sentinel)
486
+ static_scl_idxs, cursor = _read_idx_list(blob, cursor, header.static_scl_count, 8, sentinel)
487
+ su_sentinel = header.num_uv_channels
488
+ static_su_idxs, cursor = _read_idx_list(blob, cursor, header.static_su_count, 8, su_sentinel)
489
+ anim_rot_idxs, cursor = _read_idx_list(blob, cursor, header.anim_rot_count, 8, sentinel)
490
+ anim_loc_idxs, cursor = _read_idx_list(blob, cursor, header.anim_loc_count, 8, sentinel)
491
+ anim_scl_idxs, cursor = _read_idx_list(blob, cursor, header.anim_scl_count, 8, sentinel)
492
+ anim_su_idxs, cursor = _read_idx_list(blob, cursor, header.anim_su_count, 8, su_sentinel)
493
+
494
+ # Rel ptrs to static-pose sections (DSTS reshuffled offsets).
495
+ abs_rot_ptr = _read_rel_ptr(blob, 0x40)
496
+ abs_loc_ptr = _read_rel_ptr(blob, 0x44)
497
+ abs_scl_ptr = _read_rel_ptr(blob, 0x48)
498
+ abs_su_ptr = _read_rel_ptr(blob, 0x4C)
499
+
500
+ # Static rotations
501
+ static_rotations: list[tuple[float, float, float, float]] = []
502
+ for i in range(header.static_rot_count):
503
+ raw = blob[abs_rot_ptr + i * 6 : abs_rot_ptr + (i + 1) * 6]
504
+ static_rotations.append(_decode_quaternion(bytes(raw)))
505
+
506
+ # Static locations
507
+ static_locations: list[tuple[float, float, float]] = []
508
+ for i in range(header.static_loc_count):
509
+ static_locations.append(
510
+ struct.unpack_from("<fff", blob, abs_loc_ptr + i * 12) # type: ignore[arg-type]
511
+ )
512
+
513
+ # Static scales
514
+ static_scales: list[tuple[float, float, float]] = []
515
+ for i in range(header.static_scl_count):
516
+ static_scales.append(
517
+ struct.unpack_from("<fff", blob, abs_scl_ptr + i * 12) # type: ignore[arg-type]
518
+ )
519
+
520
+ # Static shader-uniform values
521
+ static_shader_uniforms: list[float] = []
522
+ for i in range(header.static_su_count):
523
+ static_shader_uniforms.append(
524
+ struct.unpack_from("<f", blob, abs_su_ptr + i * 4)[0]
525
+ )
526
+
527
+ # Bone mask at setup_and_static_data_size (per-bone byte, padded).
528
+ mask_start = header.setup_and_static_data_size
529
+ bone_mask: tuple[int, ...] = ()
530
+ if header.bone_mask_bytes:
531
+ raw_mask = blob[mask_start : mask_start + header.num_bones]
532
+ bone_mask = tuple(raw_mask)
533
+
534
+ # Keyframe chunks
535
+ chunks: list[KeyframeChunk] = []
536
+ if header.num_keyframe_chunks > 0:
537
+ abs_kf_ptrs = _read_rel_ptr(blob, 0x38)
538
+ abs_kf_counts = _read_rel_ptr(blob, 0x3C)
539
+ if abs_kf_ptrs == 0 or abs_kf_counts == 0:
540
+ raise ValueError("num_keyframe_chunks > 0 but kf_ptrs/kf_counts rel-ptrs are 0")
541
+ for i in range(header.num_keyframe_chunks):
542
+ # DSTS kf_ptrs entry: <HHI> = (length, always_zero, abs_ptr).
543
+ # length is "bytes from chunk start to end-of-file" (not the chunk's actual size).
544
+ length, always_zero, abs_ptr = struct.unpack_from(
545
+ "<HHI", blob, abs_kf_ptrs + i * 8
546
+ )
547
+ if always_zero != 0:
548
+ raise ValueError(
549
+ f"chunk[{i}] expected always_zero=0, got {always_zero}"
550
+ )
551
+ cumulative_frames, frames_minus_1 = struct.unpack_from(
552
+ "<HH", blob, abs_kf_counts + i * 4
553
+ )
554
+ frames_in_chunk = frames_minus_1 + 1
555
+ chunk = _read_chunk(
556
+ blob,
557
+ abs_ptr,
558
+ header.anim_rot_count,
559
+ header.anim_loc_count,
560
+ header.anim_scl_count,
561
+ header.anim_su_count,
562
+ cumulative_frames,
563
+ frames_in_chunk,
564
+ )
565
+ chunks.append(chunk)
566
+
567
+ return AnimFile(
568
+ header=header,
569
+ static_rot_bones=static_rot_idxs,
570
+ static_loc_bones=static_loc_idxs,
571
+ static_scl_bones=static_scl_idxs,
572
+ static_su_channels=static_su_idxs,
573
+ anim_rot_bones=anim_rot_idxs,
574
+ anim_loc_bones=anim_loc_idxs,
575
+ anim_scl_bones=anim_scl_idxs,
576
+ anim_su_channels=anim_su_idxs,
577
+ static_rotations=tuple(static_rotations),
578
+ static_locations=tuple(static_locations),
579
+ static_scales=tuple(static_scales),
580
+ static_shader_uniforms=tuple(static_shader_uniforms),
581
+ bone_mask=bone_mask,
582
+ chunks=tuple(chunks),
583
+ )
584
+
585
+
586
+ def read_anim(path: Path) -> AnimFile:
587
+ return parse_anim(path.read_bytes())