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.
- dsts_extractor/__init__.py +0 -0
- dsts_extractor/__main__.py +4 -0
- dsts_extractor/anim.py +587 -0
- dsts_extractor/anim_build.py +278 -0
- dsts_extractor/anim_sharing.py +76 -0
- dsts_extractor/assets/missing.png +0 -0
- dsts_extractor/blender_import.py +1752 -0
- dsts_extractor/build_index.py +215 -0
- dsts_extractor/catalog.py +134 -0
- dsts_extractor/chr_env.py +117 -0
- dsts_extractor/cli.py +1333 -0
- dsts_extractor/clut.py +103 -0
- dsts_extractor/data/digimon_index.json +3610 -0
- dsts_extractor/data/effect_textures.json +87 -0
- dsts_extractor/data/npc_face_map.json +538 -0
- dsts_extractor/data/npc_full_index.json +870 -0
- dsts_extractor/data/render_state.json +48 -0
- dsts_extractor/data/same_animation_data.json +261 -0
- dsts_extractor/data/shader_param_names.json +3168 -0
- dsts_extractor/doctor.py +155 -0
- dsts_extractor/dxbc.py +170 -0
- dsts_extractor/effects.py +50 -0
- dsts_extractor/export.py +462 -0
- dsts_extractor/extras.py +191 -0
- dsts_extractor/eyes.py +942 -0
- dsts_extractor/geom.py +853 -0
- dsts_extractor/glb_bundle.py +888 -0
- dsts_extractor/glb_optimize.py +180 -0
- dsts_extractor/glb_validate.py +538 -0
- dsts_extractor/gltf_writer.py +232 -0
- dsts_extractor/material_routing.py +538 -0
- dsts_extractor/mvgl.py +418 -0
- dsts_extractor/native_export.py +757 -0
- dsts_extractor/nlst.py +62 -0
- dsts_extractor/paths.py +196 -0
- dsts_extractor/setup_cmd.py +181 -0
- dsts_extractor/shaders/__init__.py +8 -0
- dsts_extractor/shaders/analyze.py +346 -0
- dsts_extractor/steam.py +226 -0
- dsts_extractor-0.8.1.dist-info/METADATA +20 -0
- dsts_extractor-0.8.1.dist-info/RECORD +45 -0
- dsts_extractor-0.8.1.dist-info/WHEEL +5 -0
- dsts_extractor-0.8.1.dist-info/entry_points.txt +3 -0
- dsts_extractor-0.8.1.dist-info/licenses/LICENSE +21 -0
- dsts_extractor-0.8.1.dist-info/top_level.txt +1 -0
|
File without changes
|
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())
|