Skip to content

Commit

Permalink
bcskla parse/build for dread
Browse files Browse the repository at this point in the history
- adds support for bcskla (animation) files
- adds an optional field to AlignTo construct to change the padding
  pattern
- adds tests for all bcskla
  • Loading branch information
steven11sjf committed Jul 16, 2024
1 parent 2bff8bc commit d808d4e
Show file tree
Hide file tree
Showing 5 changed files with 276 additions and 3 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Construct type definitions for Mercury Engine
| BCLGT | ✗ | ✗ | Missing | Missing |
| BCMDL | ✗ | ✗ | ✓ | ✗ |
| BCPTL | ✗ | ✗ | ✗ | ✗ |
| BCSKLA | ✗ | ✗ | ✗ | ✗ |
| BCSKLA | ✗ | ✗ | ✓ | ✓ |
| BCTEX | ✗ | ✗ | ✓ | ✗ |
| BCURV | Missing | Missing | ✗ | ✗ |
| BCUT | ✗ | ✗ | Missing | Missing |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@


class AlignTo(Construct):
def __init__(self, modulus):
def __init__(self, modulus, pattern = b"\x00"):
super().__init__()
self.modulus = modulus
self.pattern = b"\x00"
self.pattern = pattern
self.flagbuildnone = True

def _parse(self, stream, context, path):
Expand Down
2 changes: 2 additions & 0 deletions src/mercury_engine_data_structures/formats/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from mercury_engine_data_structures.formats.base_resource import AssetType, BaseResource
from mercury_engine_data_structures.formats.bcmdl import Bcmdl
from mercury_engine_data_structures.formats.bcskla import Bcskla
from mercury_engine_data_structures.formats.bctex import Bctex
from mercury_engine_data_structures.formats.bldef import Bldef
from mercury_engine_data_structures.formats.blsnd import Blsnd
Expand Down Expand Up @@ -41,6 +42,7 @@
ALL_FORMATS = {
"PKG": Pkg,
"BCMDL": Bcmdl,
"BCSKLA": Bcskla,
"BCTEX": Bctex,
"BLDEF": Bldef,
"BLSND": Blsnd,
Expand Down
258 changes: 258 additions & 0 deletions src/mercury_engine_data_structures/formats/bcskla.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import construct
from construct.core import (
Array,
Const,
Construct,
Container,
Hex,
If,
IfThenElse,
Int8ul,
Int16ul,
Int32ul,
Int64ul,
ListContainer,
Struct,
Tell
)

from pprint import pprint
from dataclasses import dataclass
from typing import Union

from mercury_engine_data_structures.construct_extensions.alignment import AlignTo
from mercury_engine_data_structures.common_types import Float, CVector2D
from mercury_engine_data_structures.formats.base_resource import BaseResource
from mercury_engine_data_structures.formats.property_enum import PropertyEnumDoubleUnsafe
from mercury_engine_data_structures.game_check import Game


@dataclass
class KeyFrameValue:
time: int # byte or short, dependent on timing_type
value: float
derivative: float

def init(self, t: int, v: int, d: int):
self.time = t
self.value = v
self.derivative = d


@dataclass
class KeyFramedValues:
timing_type: int # short, 0 or 8
keyframes: list[KeyFrameValue]

def auto_set_timing_type(self) -> int:
tt = 8

for kf in self.keyframes:
if kf.time > 0xFF:
tt = 0
if kf.time > 0xFFFF:
raise ValueError(f"Keyframe has too large of a time! Max size is 65535, keyframe has {kf.time}")

self.timing_type = tt
return tt

def add(self, time: int, val: int, derivative: int):
if time > 0xFFFF:
raise ValueError(f"Cannot add time {time} as max value is {0xFFFF}")

new_kf = KeyFrameValue(time, val, derivative)
self.keyframes.append(new_kf)
self.keyframes.sort(key=lambda kfv: kfv.time)


class KeyFramedValuesConstruct(Construct):
@classmethod
def _parse(self, stream, context, path) -> KeyFramedValues:
timing_type = Int16ul._parsereport(stream, context, path)
kf_count = Int16ul._parsereport(stream, context, path)

# timings
timings = Array(kf_count, Int8ul if timing_type == 8 else Int16ul)._parsereport(stream, context, f"{path} -> Timings")
AlignTo(4, b"\xff")._parse(stream, context, path)

values = Array(kf_count, CVector2D)._parsereport(stream, context, f"{path} -> Values")

kf_data = [KeyFrameValue(timings[i], values[i][0], values[i][1]) for i in range(kf_count)]
return KeyFramedValues(timing_type=timing_type, keyframes=kf_data)

@classmethod
def _build(self, obj: KeyFramedValues, stream, context, path) -> None:
count = len(obj.keyframes)
Int16ul._build(obj.timing_type, stream, context, path)
Int16ul._build(count, stream, context, path)

field = Int8ul if obj.timing_type == 8 else Int16ul
Array(count, field)._build([k.time for k in obj.keyframes], stream, context, path)
AlignTo(4, b"\xff")._build(None, stream, context, path)
Array(count, CVector2D)._build([[k.value, k.derivative] for k in obj.keyframes], stream, context, path)
context["final_offset"] = construct.stream_tell(stream, path)


@dataclass
class BcsklaTrack:
bone_name: str
values: list[Union[KeyFramedValues, float]]

def __init__(self, bone_name: str, values: list[Union[KeyFramedValues, float]]) -> None:
self.bone_name = bone_name
self.values = values

@property
def pos_x(self) -> Union[KeyFramedValues, float]:
return self.values[0]

@property
def pos_y(self) -> Union[KeyFramedValues, float]:
return self.values[1]

@property
def pos_z(self) -> Union[KeyFramedValues, float]:
return self.values[2]

@property
def rot_x(self) -> Union[KeyFramedValues, float]:
return self.values[3]

@property
def rot_y(self) -> Union[KeyFramedValues, float]:
return self.values[4]

@property
def rot_z(self) -> Union[KeyFramedValues, float]:
return self.values[5]

@property
def scale_x(self) -> Union[KeyFramedValues, float]:
return self.values[6]

@property
def scale_y(self) -> Union[KeyFramedValues, float]:
return self.values[7]

@property
def scale_z(self) -> Union[KeyFramedValues, float]:
return self.values[8]


class BcsklaTrackConstruct(Construct):
STRUCT = Struct(
# TODO add bone names into dread_properties.json
bone_hash = PropertyEnumDoubleUnsafe,
flags = Int32ul,
offset = Tell,
)

@classmethod
def _parse(self, stream, context, path) -> BcsklaTrack:
data = self.STRUCT._parse(stream, context, path)
values: list[Union[KeyFramedValues, float]] = []
for i in range(9):
construct.stream_seek(stream, data.offset + (4 * i), 0, path)
if (2 ** i) & data.flags:
off = Int32ul._parsereport(stream, context, path)
construct.stream_seek(stream, data.offset + off, 0, path)
values.append(KeyFramedValuesConstruct._parse(stream, context, f"{path} -> KF Value {i}"))
else:
values.append(Float._parsereport(stream, context, path))

return BcsklaTrack(data.bone_hash, values)

@classmethod
def _build(self, obj: BcsklaTrack, stream, context, path) -> None:
flags = 0
for i, val in enumerate(obj.values):
if type(val) == KeyFramedValues:
flags += 2**i

self.STRUCT._build(Container(bone_hash=obj.bone_name, flags=flags), stream, context, path)
offset = construct.stream_tell(stream, path)

for i, val in enumerate(obj.values):
construct.stream_seek(stream, offset + (4 * i), 0, path)
if type(val) == KeyFramedValues:
Int32ul._build(context["final_offset"] - offset, stream, context, path)
construct.stream_seek(stream, context["final_offset"], 0, path)
KeyFramedValuesConstruct._build(val, stream, context, f"{path} -> value {i}")
else:
Float._build(val, stream, context, path)


@dataclass
class BcsklaData:
unk: int
frame_count: float
tracks: list[BcsklaTrack]

def validate(self) -> None:
if not self.frame_count.is_integer():
raise ValueError("Frame count must be an integer!")

expected_timing_type = 8 if self.frame_count <= 0xFF else 0

for track in self.tracks:
for i, value in enumerate(track.values):
if type(value) == KeyFramedValues:
value.auto_set_timing_type()
if expected_timing_type != value.timing_type:
raise ValueError(f"Track {track.bone_name} ({i}) has unexpected TT!")

if value.keyframes[-1].time != self.frame_count:
raise ValueError(f"KFV for {track.bone_name}, value {i} does not end on keyframe {self.frame_count}!")

def __str__(self) -> str:
pprint(self)
return ""


class BcsklaConstruct(Construct):
HEADER_STRUCT: Struct = Struct(
_magic=Const(b"MANM"),
ver=Const(0x000A0001, Int32ul),
unk=Int32ul,
frame_count=Float,
track_count=Int32ul,
padding=If(
construct.this.track_count != 0,
Const(0xFFFFFFFF, Int32ul)
),
)

def _parse(self, stream, context, path) -> BcsklaData:
hdr = self.HEADER_STRUCT._parse(stream, context, f"{path} -> Header")

offset = construct.stream_tell(stream, path)
tracks: list[BcsklaTrack] = []
for i in range(hdr.track_count):
construct.stream_seek(stream, offset + (i * 0x30), 0, path)
tracks.append(BcsklaTrackConstruct._parse(stream, context, f"{path} -> Track {i}"))

return BcsklaData(hdr.unk, hdr.frame_count, tracks)

def _build(self, obj: BcsklaData, stream, context, path):
obj.validate()
hdr = Container(
unk=obj.unk,
frame_count=obj.frame_count,
track_count=len(obj.tracks)
)
self.HEADER_STRUCT._build(hdr, stream, context, f"{path} -> Header")

context["final_offset"] = (hdr.track_count * 0x30) + construct.stream_tell(stream, path)
for i, track in enumerate(obj.tracks):
construct.stream_seek(stream, 0x18 + (i * 0x30), 0, path)
BcsklaTrackConstruct._build(track, stream, context, f"{path} -> Track {i}")


class Bcskla(BaseResource):
@classmethod
def construct_class(cls, target_game: Game) -> Construct:
return BcsklaConstruct()

def validate(self) -> None:
# shortcut for the BcsklaData validator
return self.raw.validate()
13 changes: 13 additions & 0 deletions tests/formats/test_bcskla.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pytest
from tests.test_lib import parse_build_compare_editor

from mercury_engine_data_structures import dread_data
from mercury_engine_data_structures.formats.bcskla import Bcskla


all_dread_bcskla = [name for name in dread_data.all_name_to_asset_id().keys()
if name.endswith(".bcskla")]

@pytest.mark.parametrize("bcskla_path", all_dread_bcskla)
def test_bcskla(dread_file_tree, bcskla_path):
parse_build_compare_editor(Bcskla, dread_file_tree, bcskla_path)

0 comments on commit d808d4e

Please sign in to comment.