Skip to content

Commit e8b25b0

Browse files
committed
Version 0.0.2:
* Added support for scale keys. * Improved test coverage. * Removed numpy dependency. * Improved type hinting. * Changed interface for PsaReader, added `read_psa` function. * Removed matrix reading fuctions (can be downstreamed, no reason to put it in this package)
1 parent 4b6738a commit e8b25b0

File tree

18 files changed

+941
-246
lines changed

18 files changed

+941
-246
lines changed

pyproject.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "psk_psa_py"
7-
version = "0.0.1"
7+
version = "0.0.2"
88
authors = [
99
{ name="Colin Basnett", email="cmbasnett@proton.me" }
1010
]
@@ -17,9 +17,6 @@ classifiers = [
1717
]
1818
license = "MIT"
1919
license-files = ["LICEN[CS]E*"]
20-
dependencies = [
21-
"numpy",
22-
]
2320

2421
[project.urls]
2522
Homepage = "https://github.com/DarklightGames/psk_psa_py"

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
numpy
21
pytest
32
pytest-cov
3+
mypy

src/psk_psa_py/psa/config.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import re
22
from configparser import ConfigParser
3-
from typing import Dict, List
43

54
REMOVE_TRACK_LOCATION = (1 << 0)
65
REMOVE_TRACK_ROTATION = (1 << 1)
76

87

98
class PsaConfig:
109
def __init__(self):
11-
self.sequence_bone_flags: Dict[str, Dict[int, int]] = dict()
10+
self.sequence_bone_flags: dict[str, dict[int, int]] = dict()
1211

1312

1413
def _load_config_file(file_path: str) -> ConfigParser:
@@ -48,7 +47,7 @@ def _get_bone_flags_from_value(value: str) -> int:
4847
return 0
4948

5049

51-
def read_psa_config(psa_sequence_names: List[str], file_path: str) -> PsaConfig:
50+
def read_psa_config(psa_sequence_names: list[str], file_path: str) -> PsaConfig:
5251
psa_config = PsaConfig()
5352

5453
config = _load_config_file(file_path)

src/psk_psa_py/psa/data.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
from collections import OrderedDict
2-
from typing import List, OrderedDict as OrderedDictType
2+
from typing import OrderedDict as OrderedDictType
3+
from enum import Enum
34

45
from ctypes import Structure, c_char, c_int32, c_float
56
from ..shared.data import PsxBone, Quaternion, Vector3
67

78

9+
class PsaSectionName(bytes, Enum):
10+
ANIMHEAD = b'ANIMHEAD'
11+
BONENAMES = b'BONENAMES'
12+
ANIMINFO = b'ANIMINFO'
13+
ANIMKEYS = b'ANIMKEYS'
14+
SCALEKEYS = b'SCALEKEYS'
15+
16+
817
class Psa:
918
"""
1019
Note that keys are not stored within the Psa object.
@@ -46,15 +55,45 @@ def data(self):
4655

4756
def __repr__(self) -> str:
4857
return repr((self.location, self.rotation, self.time))
58+
59+
class ScaleKey(Structure):
60+
_fields_ = [
61+
('scale', Vector3),
62+
('time', c_float),
63+
]
64+
65+
@property
66+
def data(self):
67+
yield self.scale.x
68+
yield self.scale.y
69+
yield self.scale.z
4970

5071
def __init__(self):
51-
self.bones: List[PsxBone] = []
72+
self.bones: list[PsxBone] = []
5273
self.sequences: OrderedDictType[str, Psa.Sequence] = OrderedDict()
53-
self.keys: List[Psa.Key] = []
74+
self.keys: list[Psa.Key] = []
75+
self.scale_keys: list[Psa.ScaleKey] = []
76+
77+
def get_sequence_key_range(self, sequence_name: str) -> tuple[int, int]:
78+
sequence = self.sequences[sequence_name]
79+
frame_index = sequence.frame_start_index * len(self.bones)
80+
start = frame_index
81+
end = frame_index + len(self.bones) * sequence.frame_count
82+
return start, end
83+
84+
def get_sequence_keys(self, sequence_name: str) -> list[Psa.Key]:
85+
start, end = self.get_sequence_key_range(sequence_name)
86+
return self.keys[start:end]
5487

88+
def get_sequence_scale_keys(self, sequence_name: str) -> list[Psa.ScaleKey]:
89+
if len(self.scale_keys) == 0:
90+
return []
91+
start, end = self.get_sequence_key_range(sequence_name)
92+
return self.scale_keys[start:end]
5593

5694
__all__ = [
57-
'Psa'
95+
'Psa',
96+
'PsaSectionName'
5897
]
5998

6099

src/psk_psa_py/psa/data.pyi

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
from typing import OrderedDict as OrderedDictType, Generator
22

33
from ctypes import Structure
4+
from enum import Enum
45
from ..shared.data import PsxBone, Quaternion, Vector3
56

67

8+
class PsaSectionName(bytes, Enum):
9+
ANIMHEAD = ...
10+
BONENAMES = ...
11+
ANIMINFO = ...
12+
ANIMKEYS = ...
13+
SCALEKEYS = ...
14+
15+
716
class Psa:
817
"""
918
Note that keys are not stored within the Psa object.
@@ -30,16 +39,27 @@ class Psa:
3039
time: float
3140

3241
@property
33-
def data(self) -> Generator[float]:
34-
pass
42+
def data(self) -> Generator[float]: ...
43+
44+
class ScaleKey(Structure):
45+
scale: Vector3
46+
time: float
47+
48+
@property
49+
def data(self) -> Generator[float]: ...
3550

3651
bones: list[PsxBone]
3752
sequences: OrderedDictType[str, Psa.Sequence]
3853
keys: list[Psa.Key]
54+
scale_keys: list[Psa.ScaleKey]
55+
56+
def get_sequence_keys(self, sequence_name: str) -> list[Psa.Key]: ...
57+
def get_sequence_scale_keys(self, sequence_name: str) -> list[Psa.ScaleKey]: ...
3958

4059

4160
__all__ = [
42-
'Psa'
61+
'Psa',
62+
'PsaSectionName'
4363
]
4464

4565

src/psk_psa_py/psa/reader.py

Lines changed: 100 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from ctypes import sizeof
2-
from typing import List
2+
from pathlib import Path
3+
from typing import BinaryIO, Sequence
34

4-
import numpy as np
5-
6-
from .data import Psa
5+
from .data import Psa, PsaSectionName
76
from ..shared.data import Section, PsxBone
7+
from ..shared.helpers import read_types
88

99

10-
def _try_fix_cue4parse_issue_103(sequences) -> bool:
10+
def _try_fix_cue4parse_issue_103(sequences: Sequence[Psa.Sequence]) -> bool:
1111
# Detect if the file was exported from CUE4Parse prior to the fix for issue #103.
1212
# https://github.com/FabianFG/CUE4Parse/issues/103
1313
# The issue was that the frame_start_index was not being set correctly, and was always being set to the same value
@@ -24,65 +24,81 @@ def _try_fix_cue4parse_issue_103(sequences) -> bool:
2424
return False
2525

2626

27-
class PsaReader(object):
27+
def read_psa(fp: BinaryIO):
28+
psa = Psa()
29+
while fp.read(1):
30+
fp.seek(-1, 1)
31+
section = Section.from_buffer_copy(fp.read(sizeof(Section)))
32+
match section.name:
33+
case PsaSectionName.ANIMHEAD:
34+
pass
35+
case PsaSectionName.BONENAMES:
36+
read_types(fp, PsxBone, section, psa.bones)
37+
case PsaSectionName.ANIMINFO:
38+
sequences: list[Psa.Sequence] = []
39+
read_types(fp, Psa.Sequence, section, sequences)
40+
# Try to fix CUE4Parse bug, if necessary.
41+
_try_fix_cue4parse_issue_103(sequences)
42+
for sequence in sequences:
43+
psa.sequences[sequence.name.decode()] = sequence
44+
case PsaSectionName.ANIMKEYS:
45+
read_types(fp, Psa.Key, section, psa.keys)
46+
case PsaSectionName.SCALEKEYS:
47+
read_types(fp, Psa.ScaleKey, section, psa.scale_keys)
48+
case _:
49+
fp.seek(section.data_size * section.data_count, 1)
50+
print(f'Unrecognized section in PSA: {section.name!r}')
51+
return psa
52+
53+
54+
class PsaReader:
2855
"""
2956
This class reads the sequences and bone information immediately upon instantiation and holds onto a file handle.
57+
3058
The keyframe data is not read into memory upon instantiation due to its potentially very large size.
31-
To read the key data for a particular sequence, call :read_sequence_keys.
59+
60+
To read the key data for a particular sequence, call `read_sequence_keys`.
3261
"""
3362

34-
def __init__(self, path):
35-
self.keys_data_offset: int = 0
36-
self.fp = open(path, 'rb')
37-
self.psa: Psa = self._read(self.fp)
63+
def __init__(self, fp: BinaryIO):
64+
self._keys_data_offset: int = 0
65+
self._scale_keys_data_offset: int | None = None
66+
self._fp = fp
67+
self._psa: Psa = self._read(self._fp)
68+
69+
@staticmethod
70+
def from_path(path: str | Path):
71+
return PsaReader(open(path, 'rb'))
3872

3973
def __enter__(self):
4074
return self
4175

4276
def __exit__(self, exc_type, exc_val, exc_tb):
43-
self.fp.close()
77+
self._fp.close()
4478

4579
@property
4680
def bones(self):
47-
return self.psa.bones
81+
return self._psa.bones
4882

4983
@property
5084
def sequences(self):
51-
return self.psa.sequences
85+
return self._psa.sequences
5286

53-
def read_sequence_data_matrix(self, sequence_name: str) -> np.ndarray:
54-
"""
55-
Reads and returns the data matrix for the given sequence.
56-
57-
@param sequence_name: The name of the sequence.
58-
@return: An FxBx7 matrix where F is the number of frames, B is the number of bones.
59-
"""
60-
sequence = self.psa.sequences[sequence_name]
61-
keys = self.read_sequence_keys(sequence_name)
62-
bone_count = len(self.bones)
63-
matrix_size = sequence.frame_count, bone_count, 7
64-
matrix = np.zeros(matrix_size)
65-
keys_iter = iter(keys)
66-
for frame_index in range(sequence.frame_count):
67-
for bone_index in range(bone_count):
68-
matrix[frame_index, bone_index, :] = list(next(keys_iter).data)
69-
return matrix
70-
71-
def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]:
87+
def read_sequence_keys(self, sequence_name: str) -> list[Psa.Key]:
7288
"""
7389
Reads and returns the key data for a sequence.
7490
7591
@param sequence_name: The name of the sequence.
7692
@return: A list of Psa.Keys.
7793
"""
7894
# Set the file reader to the beginning of the keys data
79-
sequence = self.psa.sequences[sequence_name]
95+
sequence = self._psa.sequences[sequence_name]
8096
data_size = sizeof(Psa.Key)
81-
bone_count = len(self.psa.bones)
97+
bone_count = len(self._psa.bones)
8298
buffer_length = data_size * bone_count * sequence.frame_count
83-
sequence_keys_offset = self.keys_data_offset + (sequence.frame_start_index * bone_count * data_size)
84-
self.fp.seek(sequence_keys_offset, 0)
85-
buffer = self.fp.read(buffer_length)
99+
sequence_keys_offset = self._keys_data_offset + (sequence.frame_start_index * bone_count * data_size)
100+
self._fp.seek(sequence_keys_offset, 0)
101+
buffer = self._fp.read(buffer_length)
86102
offset = 0
87103
keys = []
88104
for _ in range(sequence.frame_count * bone_count):
@@ -91,44 +107,72 @@ def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]:
91107
offset += data_size
92108
return keys
93109

94-
@staticmethod
95-
def _read_types(fp, data_class, section: Section, data):
96-
buffer_length = section.data_size * section.data_count
97-
buffer = fp.read(buffer_length)
110+
def read_sequence_scale_keys(self, sequence_name: str) -> list[Psa.ScaleKey]:
111+
"""
112+
Reads and returns the scale key data for a sequence.
113+
114+
@param sequence_name: The name of the sequence.
115+
@return: A list of Psa.ScaleKeys.
116+
"""
117+
if self._scale_keys_data_offset is None:
118+
# This PSA has no scale keys, return an empty list.
119+
return []
120+
sequence = self._psa.sequences[sequence_name]
121+
data_size = sizeof(Psa.ScaleKey)
122+
bone_count = len(self._psa.bones)
123+
buffer_length = data_size * bone_count * sequence.frame_count
124+
if buffer_length == 0:
125+
# In many cases, files are written with this section, but have no data (particularly out of FModel).
126+
# Therefore, simply return an empty array.
127+
return []
128+
sequence_scale_keys_offset = self._scale_keys_data_offset + (sequence.frame_start_index * bone_count * data_size)
129+
self._fp.seek(sequence_scale_keys_offset)
130+
buffer = self._fp.read(buffer_length)
98131
offset = 0
99-
for _ in range(section.data_count):
100-
data.append(data_class.from_buffer_copy(buffer, offset))
101-
offset += section.data_size
132+
scale_keys = []
133+
for _ in range(sequence.frame_count * bone_count):
134+
scale_key = Psa.ScaleKey.from_buffer_copy(buffer, offset)
135+
scale_keys.append(scale_key)
136+
offset += data_size
137+
return scale_keys
102138

103-
def _read(self, fp) -> Psa:
139+
def _read(self, fp: BinaryIO) -> Psa:
104140
psa = Psa()
105141
while fp.read(1):
106142
fp.seek(-1, 1)
107143
section = Section.from_buffer_copy(fp.read(sizeof(Section)))
108144
match section.name:
109-
case b'ANIMHEAD':
145+
case PsaSectionName.ANIMHEAD:
110146
pass
111-
case b'BONENAMES':
112-
PsaReader._read_types(fp, PsxBone, section, psa.bones)
113-
case b'ANIMINFO':
114-
sequences = []
115-
PsaReader._read_types(fp, Psa.Sequence, section, sequences)
147+
case PsaSectionName.BONENAMES:
148+
read_types(fp, PsxBone, section, psa.bones)
149+
case PsaSectionName.ANIMINFO:
150+
sequences: list[Psa.Sequence] = []
151+
read_types(fp, Psa.Sequence, section, sequences)
116152
# Try to fix CUE4Parse bug, if necessary.
117153
_try_fix_cue4parse_issue_103(sequences)
118154
for sequence in sequences:
119155
psa.sequences[sequence.name.decode()] = sequence
120-
case b'ANIMKEYS':
156+
case PsaSectionName.ANIMKEYS:
121157
# Skip keys on this pass. We will keep this file open and read from it as needed.
122-
self.keys_data_offset = fp.tell()
158+
self._keys_data_offset = fp.tell()
159+
fp.seek(section.data_size * section.data_count, 1)
160+
case PsaSectionName.SCALEKEYS:
161+
if section.data_count == 0:
162+
# An empty SCALEKEYS section is common for exports from FModel, treat it as though it doesn't exist.
163+
continue
164+
# Skip scale keys on this pass. We will keep this file open and read from it as needed.
165+
self._scale_keys_data_offset = fp.tell()
123166
fp.seek(section.data_size * section.data_count, 1)
124167
case _:
125168
fp.seek(section.data_size * section.data_count, 1)
126-
print(f'Unrecognized section in PSA: "{section.name}"')
169+
print(f'Unrecognized section in PSA: {section.name!r}')
127170
return psa
128171

129172

130173
__all__ = [
131-
'PsaReader'
174+
'PsaReader',
175+
'read_psa'
132176
]
133177

134178

0 commit comments

Comments
 (0)