|
| 1 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +# SPDX-FileCopyrightText: 2026 sp00ktober |
| 3 | +"""Animation packs of both games: streams of slot tables, each slot a clip or empty, each clip one |
| 4 | +keyframe track per bone. Only the clip encoding differs between the games; it lives in |
| 5 | +`mhp_formats.fu.anim` and `mhp_formats.p3rd.anim`. |
| 6 | +
|
| 7 | +MHFU splits one rig across its streams (a big monster's body, head and tail play the same slot |
| 8 | +together); MHP3rd's streams are separate clip sets over the whole rig. |
| 9 | +""" |
| 10 | + |
| 11 | +import math |
| 12 | +import struct |
| 13 | +from collections.abc import Callable |
| 14 | +from dataclasses import dataclass, field |
| 15 | +from functools import partial |
| 16 | +from itertools import chain |
| 17 | +from typing import Literal, NamedTuple, Self |
| 18 | + |
| 19 | +from construct import Array, Int32ul |
| 20 | + |
| 21 | +from ._base import FormatError |
| 22 | + |
| 23 | +EMPTY = 0xFFFFFFFF |
| 24 | +"""A slot without a clip.""" |
| 25 | + |
| 26 | +Kind = Literal["rot", "loc", "scl"] |
| 27 | + |
| 28 | +CHANNEL_BITS: dict[int, tuple[Kind, int]] = { |
| 29 | + 0x008: ("rot", 0), |
| 30 | + 0x010: ("rot", 1), |
| 31 | + 0x020: ("rot", 2), |
| 32 | + 0x040: ("loc", 0), |
| 33 | + 0x080: ("loc", 1), |
| 34 | + 0x100: ("loc", 2), |
| 35 | + 0x200: ("scl", 0), |
| 36 | + 0x400: ("scl", 1), |
| 37 | + 0x800: ("scl", 2), |
| 38 | +} |
| 39 | +"""Channel bit -> (kind, axis). Bits 0x1/0x2/0x4 also occur, with values near 16; their meaning |
| 40 | +is not known. MHFU's engine crashes on the scale bits.""" |
| 41 | + |
| 42 | +_STEP: dict[Kind, float] = {"rot": math.pi / 8192, "loc": 1 / 16, "scl": 1 / 256} |
| 43 | +"""What one raw unit is worth: 4096 = 90 degrees, 16 = 1.0, 256 = 1.0.""" |
| 44 | + |
| 45 | +_MAX_HEADER = 0x800 |
| 46 | + |
| 47 | + |
| 48 | +class Keyframe(NamedTuple): |
| 49 | + """Raw s16 words; `value` goes through `dequantize`.""" |
| 50 | + |
| 51 | + value: int |
| 52 | + frame: int |
| 53 | + ease_in: int = 0 |
| 54 | + ease_out: int = 0 |
| 55 | + |
| 56 | + |
| 57 | +@dataclass |
| 58 | +class Channel: |
| 59 | + """One animated component of a bone.""" |
| 60 | + |
| 61 | + bit: int |
| 62 | + keyframes: list[Keyframe] = field(default_factory=list) |
| 63 | + |
| 64 | + |
| 65 | +@dataclass |
| 66 | +class Track: |
| 67 | + """The channels of one bone.""" |
| 68 | + |
| 69 | + channels: list[Channel] = field(default_factory=list) |
| 70 | + |
| 71 | + @property |
| 72 | + def mask(self) -> int: |
| 73 | + mask = 0 |
| 74 | + for channel in self.channels: |
| 75 | + mask |= channel.bit |
| 76 | + return mask |
| 77 | + |
| 78 | + |
| 79 | +@dataclass |
| 80 | +class Clip: |
| 81 | + tracks: list[Track] = field(default_factory=list) |
| 82 | + loop: int = 0 |
| 83 | + loop_start: float = 0.0 |
| 84 | + |
| 85 | + |
| 86 | +Stream = list[Clip | None] |
| 87 | + |
| 88 | + |
| 89 | +def channel_kind(bit: int) -> tuple[Kind, int] | None: |
| 90 | + """`(kind, axis)` of a channel bit, None for the bits nobody has named.""" |
| 91 | + return CHANNEL_BITS.get(bit) |
| 92 | + |
| 93 | + |
| 94 | +def dequantize(kind: Kind, raw: int) -> float: |
| 95 | + """Rotation comes out in radians.""" |
| 96 | + return raw * _STEP[kind] |
| 97 | + |
| 98 | + |
| 99 | +def quantize(kind: Kind, value: float) -> int: |
| 100 | + """Rounds to nearest and clamps to s16.""" |
| 101 | + return max(-0x8000, min(0x7FFF, round(value / _STEP[kind]))) |
| 102 | + |
| 103 | + |
| 104 | +# Keyframes and the section headers around them are packed with `struct`, not construct: a pack |
| 105 | +# holds up to ~200k keyframes, and construct parses one 8-byte keyframe in ~4 us against ~0.1 us. |
| 106 | +_KEY = struct.Struct("<4h") |
| 107 | +_to_keyframe = partial(tuple.__new__, Keyframe) |
| 108 | + |
| 109 | + |
| 110 | +def read_keyframes(view: memoryview) -> list[Keyframe]: |
| 111 | + return list(map(_to_keyframe, _KEY.iter_unpack(view))) |
| 112 | + |
| 113 | + |
| 114 | +def write_keyframes(keyframes: list[Keyframe]) -> bytes: |
| 115 | + try: |
| 116 | + return struct.pack(f"<{4 * len(keyframes)}h", *chain.from_iterable(keyframes)) |
| 117 | + except struct.error as e: |
| 118 | + raise ValueError(f"a keyframe does not fit s16: {e}") from None |
| 119 | + |
| 120 | + |
| 121 | +@dataclass |
| 122 | +class AnimPack: |
| 123 | + """Header: N x (u32 slot count, u32 table offset), u32 0, u32 end of the tables; then the |
| 124 | + tables, then the clips back to back in the order the slots first use them. Slots holding the |
| 125 | + same `Clip` object share one copy in the file.""" |
| 126 | + |
| 127 | + streams: list[Stream] = field(default_factory=list) |
| 128 | + tail: bytes = b"" |
| 129 | + """Bytes after the last clip (a standalone pack pads to 0x800).""" |
| 130 | + |
| 131 | + @classmethod |
| 132 | + def _read_clip(cls, data: memoryview, off: int) -> tuple[Clip, int]: |
| 133 | + """The clip at `off` and where it ends.""" |
| 134 | + raise NotImplementedError |
| 135 | + |
| 136 | + @staticmethod |
| 137 | + def _write_clip(clip: Clip, out: bytearray) -> None: |
| 138 | + raise NotImplementedError |
| 139 | + |
| 140 | + @classmethod |
| 141 | + def from_bytes(cls, data: bytes) -> Self: |
| 142 | + layout = _layout(data) |
| 143 | + if layout is None: |
| 144 | + raise FormatError("not an animation pack: the header does not describe its tables") |
| 145 | + tables, cursor = layout |
| 146 | + view = memoryview(data) |
| 147 | + clips: dict[int, Clip] = {} |
| 148 | + streams: list[Stream] = [] |
| 149 | + for table in tables: |
| 150 | + stream: Stream = [] |
| 151 | + for off in table: |
| 152 | + clip = clips.get(off) |
| 153 | + if clip is None and off != EMPTY: |
| 154 | + if off != cursor: |
| 155 | + raise FormatError(f"clip at {off:#x}, expected the next one at {cursor:#x}") |
| 156 | + try: |
| 157 | + clip, cursor = cls._read_clip(view, off) |
| 158 | + except struct.error as e: |
| 159 | + raise FormatError(f"clip at {off:#x} runs past the data") from e |
| 160 | + clips[off] = clip |
| 161 | + stream.append(clip) |
| 162 | + streams.append(stream) |
| 163 | + return cls(streams, data[cursor:]) |
| 164 | + |
| 165 | + def to_bytes(self) -> bytes: |
| 166 | + if not self.streams: |
| 167 | + raise ValueError("an animation pack has at least one stream") |
| 168 | + header = [] |
| 169 | + run = 8 * (len(self.streams) + 1) |
| 170 | + for stream in self.streams: |
| 171 | + header += [len(stream), run] |
| 172 | + run += 4 * len(stream) |
| 173 | + header += [0, run] |
| 174 | + out = bytearray(run) |
| 175 | + placed: dict[int, int] = {} |
| 176 | + for stream in self.streams: |
| 177 | + for clip in stream: |
| 178 | + if clip is not None and id(clip) not in placed: |
| 179 | + placed[id(clip)] = len(out) |
| 180 | + self._write_clip(clip, out) |
| 181 | + for stream in self.streams: |
| 182 | + header += [EMPTY if clip is None else placed[id(clip)] for clip in stream] |
| 183 | + out[:run] = Array(len(header), Int32ul).build(header) |
| 184 | + return bytes(out) + self.tail |
| 185 | + |
| 186 | + |
| 187 | +def sniff_pack(data: bytes, is_clip: Callable[[int], bool]) -> bool: |
| 188 | + """A pack header whose first clip starts with a word `is_clip` accepts.""" |
| 189 | + layout = _layout(data) |
| 190 | + if layout is None: |
| 191 | + return False |
| 192 | + tables, start = layout |
| 193 | + if all(off == EMPTY for table in tables for off in table): |
| 194 | + return True |
| 195 | + return len(data) >= start + 4 and is_clip(int.from_bytes(data[start : start + 4], "little")) |
| 196 | + |
| 197 | + |
| 198 | +def _layout(data: bytes) -> tuple[list[list[int]], int] | None: |
| 199 | + """The slot tables and where the clips start, or None if the header does not chain.""" |
| 200 | + if len(data) < 16: |
| 201 | + return None |
| 202 | + hsize = int.from_bytes(data[4:8], "little") |
| 203 | + if not 16 <= hsize <= min(len(data), _MAX_HEADER) or hsize % 8: |
| 204 | + return None |
| 205 | + words = Array(hsize // 4, Int32ul).parse(data) |
| 206 | + run = hsize |
| 207 | + for i in range(0, hsize // 4 - 2, 2): |
| 208 | + if words[i + 1] != run: |
| 209 | + return None |
| 210 | + run += 4 * words[i] |
| 211 | + if words[-2] != 0 or words[-1] != run or run > len(data): |
| 212 | + return None |
| 213 | + flat = Array((run - hsize) // 4, Int32ul).parse(data[hsize:run]) |
| 214 | + tables = [] |
| 215 | + for i in range(0, hsize // 4 - 2, 2): |
| 216 | + start = (words[i + 1] - hsize) // 4 |
| 217 | + tables.append(list(flat[start : start + words[i]])) |
| 218 | + return tables, run |
0 commit comments