Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The changelog is release-oriented and uses a small fixed set of categories:

### Added

- Compiled YCD clip samplers preserve the public phase/time evaluation interface while sharing immutable animation plans.
- Immutable compiled YCD animation samplers retain owned channel data for repeated native evaluation without stale caches after source edits.
- Per-track quaternion encoding overrides allow explicit facial rotations while keeping body and camera packing unchanged.

Expand Down
70 changes: 70 additions & 0 deletions docs/YCD_PLAYBACK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Compiled YCD playback

Mutable YCD models remain the authoring interface. Compile once when beginning
repeated playback, not once per frame:

```python
from fivefury import read_ycd

ycd = read_ycd("scene-0.ycd")
clip = ycd.get_clip("actor_dual-0")
sampler = clip.compile()
tracks = sampler.evaluate_tracks_at_time(1.234)
```

`YcdAnimation.compile()` returns `YcdAnimationSampler`, with the existing
`evaluate_tracks(frame, track=None, interpolate=True)` shape. A clip returns
`YcdClipSampler`, with `evaluate_tracks_at_time` and `evaluate_tracks_at_phase`.
Results remain dictionaries keyed by `(bone_id, track_id)`, containing nominal
`Vector4` or `Quaternion` objects. There are no application-facing native handles.

## Lifetime and edits

- Compilation copies decoded channel cycles into owned native float64 buffers.
It does not retain the editable model, reader, RPF or external buffer.
- A sampler is immutable. Source edits deliberately do not change it; recompile
after editing channels, bindings, sequence structure or clip timing.
- Dropping the last sampler reference releases its storage. There is no global
plan cache, sampled-frame cache or retained per-seek state.
- `sample_bytes` reports copied channel data, excluding metadata and output
objects. Static channels keep one value per component rather than a full frame
array. Indirect channels retain their decoded frame cycle.
- Samplers can be shared by concurrent actors. Evaluation releases the GIL for
native sampling and allocates independent output state per call.
- Multiple `YcdClipSampler` instances can explicitly share one compiled
`animation`, with independent duration/loop metadata.

Do not modify a model concurrently with its compilation. An already compiled
sampler is independent of subsequent mutations and seeks.

## Sampling contract

The compiled path uses decoded channel values; it does not requantize or change
the animation. Opcode 7/8 reconstruction or normalization occurs after scalar
interpolation, using the current block's overlapping sample. Ordinary quaternion
tracks retain their existing shortest-path normalized interpolation.

Integer arguments and `interpolate=False` retain integer evaluation behavior,
including cyclic channel lookup. Fractional requests retain the existing endpoint
clamping and missing-track rules. Nonfinite or unrepresentably large native frame
indices are rejected.

Clip time and phase conversion are shared with `YcdClipAnimation`. The existing
methods clamp to clip duration/phase; they do not apply an implicit rate change
or wrap solely because the loop flag is set. A caller that currently computes
looped phase or time must keep doing so. This is a performance path, not a change
to the runtime interpretation of clip timing metadata.

The compilation boundary expects valid decoded or built channel layouts. A
cached quaternion opcode without a matching cached sequence, an invalid omitted
component or an incomplete cached component set is rejected rather than guessed.

## Verification

Unit tests compare mutable and compiled results at integer/fractional frames,
sequence overlaps, out-of-range seeks, channel cycles and missing keys. They also
cover edits, detached lifetime, concurrent evaluation and output construction.
Performance cases are in `tests/performance/bench_ycd_playback.py`.

Library sampling improvements are not viewer FPS predictions: rendering,
skinning, expression evaluation and scheduling remain separate workloads.
2 changes: 2 additions & 0 deletions fivefury/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,7 @@
Ycd,
YcdAnimation,
YcdAnimationSampler,
YcdClipSampler,
YcdAnimationBoneId,
YcdAnimationTrack,
YcdAnimSequence,
Expand Down Expand Up @@ -2027,6 +2028,7 @@
"YcdAnimSequence",
"YcdAnimation",
"YcdAnimationSampler",
"YcdClipSampler",
"YcdAnimationBoneId",
"YcdAnimationTrack",
"YcdCameraAnimationSample",
Expand Down
3 changes: 2 additions & 1 deletion fivefury/ycd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
get_ycd_track_format,
parse_ycd_uv_clip_binding,
)
from .playback import YcdAnimationSampler
from .playback import YcdAnimationSampler, YcdClipSampler
from .reader import read_ycd, read_ycd_embedded_resource
from .runtime_headers import (
GEN9_YCD_RUNTIME_PROFILE,
Expand All @@ -75,6 +75,7 @@
"YcdAnimSequence",
"YcdAnimation",
"YcdAnimationSampler",
"YcdClipSampler",
"YcdAnimationBoneId",
"YcdAnimationTrack",
"YcdCameraAnimationSample",
Expand Down
23 changes: 13 additions & 10 deletions fivefury/ycd/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ..metahash import MetaHash
from ..resource import ResourceHeader
from ..vector import Quaternion, Vector3, Vector4, interpolate_vector4_many
from .sampling import animation_frame_at_phase, animation_frame_at_time
from .sequences import (
YcdAnimationTrack,
YcdAnimSequence,
Expand All @@ -38,7 +39,7 @@

if TYPE_CHECKING:
from ..authoring.context import BuildContext
from .playback import YcdAnimationSampler
from .playback import YcdAnimationSampler, YcdClipSampler

YCD_UV_CLIP_MARKER = "_uv_"
YCD_UV_UNKNOWN1C = 0x6B002400
Expand Down Expand Up @@ -950,6 +951,15 @@ class YcdClipAnimation(YcdClip):
reserved_68h: int = 0
reserved_6ch: int = 0

def compile(self) -> YcdClipSampler:
"""Snapshot clip timing and channels for stateless native playback."""
from .playback import YcdClipSampler

return YcdClipSampler(
self.animation.compile() if self.animation is not None else None,
self.duration, self.is_looped,
)

@property
def duration(self) -> float:
return max(0.0, float(self.end_time) - float(self.start_time))
Expand All @@ -975,17 +985,10 @@ def has_facial_animation(self) -> bool:
return bool(self.animation and self.animation.has_facial_animation)

def get_animation_frame(self, phase: float) -> float:
if self.animation is None or self.animation.frames <= 1:
return 0.0
phase_value = min(max(float(phase), 0.0), 1.0)
return phase_value * float(self.animation.frames - 1)
return animation_frame_at_phase(self.animation.frames if self.animation else 0, phase)

def get_animation_frame_at_time(self, seconds: float) -> float:
clip_duration = max(float(self.duration), 0.0)
if clip_duration <= 0.0:
return 0.0
phase = min(max(float(seconds) / clip_duration, 0.0), 1.0)
return self.get_animation_frame(phase)
return animation_frame_at_time(self.animation.frames if self.animation else 0, self.duration, seconds)

def evaluate_tracks_at_phase(
self,
Expand Down
48 changes: 48 additions & 0 deletions fivefury/ycd/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .._native import _ffi
from ..vector import Quaternion, Vector4
from .channel_samples import decoded_channel_components
from .sampling import animation_frame_at_phase, animation_frame_at_time
from .sequence_channels import YcdCachedQuaternionChannel, YcdChannelType
from .sequence_tracks import YcdAnimationTrack, is_ycd_rotation_track

Expand Down Expand Up @@ -114,3 +115,50 @@ def evaluate_tracks(
self.sequence_frame_limit,
None if track is None else int(track),
)


@dataclass(frozen=True, slots=True)
class YcdClipSampler:
"""Clip timing snapshot; shares an explicitly owned immutable animation plan."""

animation: YcdAnimationSampler | None
duration: float
is_looped: bool = False

def get_animation_frame(self, phase: float) -> float:
return animation_frame_at_phase(
self.animation.frames if self.animation else 0, phase
)

def get_animation_frame_at_time(self, seconds: float) -> float:
return animation_frame_at_time(
self.animation.frames if self.animation else 0, self.duration, seconds
)

def evaluate_tracks_at_phase(
self,
phase: float,
*,
track: int | YcdAnimationTrack | None = None,
interpolate: bool = True,
) -> dict[tuple[int, int], Vector4 | Quaternion]:
if self.animation is None:
return {}
return self.animation.evaluate_tracks(
self.get_animation_frame(phase), track=track, interpolate=interpolate
)

def evaluate_tracks_at_time(
self,
seconds: float,
*,
track: int | YcdAnimationTrack | None = None,
interpolate: bool = True,
) -> dict[tuple[int, int], Vector4 | Quaternion]:
if self.animation is None:
return {}
return self.animation.evaluate_tracks(
self.get_animation_frame_at_time(seconds),
track=track,
interpolate=interpolate,
)
12 changes: 12 additions & 0 deletions fivefury/ycd/sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
YcdTrackSample = float | Vector3 | Quaternion


def animation_frame_at_phase(frames: int, phase: float) -> float:
if frames <= 1:
return 0.0
return min(max(float(phase), 0.0), 1.0) * float(frames - 1)


def animation_frame_at_time(frames: int, duration: float, seconds: float) -> float:
if duration <= 0.0:
return 0.0
return animation_frame_at_phase(frames, float(seconds) / duration)


@dataclass(frozen=True, slots=True)
class YcdSampleWindow:
components: tuple[Sequence[float], ...]
Expand Down
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ game binaries or private samples:
| `FIVEFURY_TEST_FACE_PROBE_ROOT` | External probe export with `probe-report.json` and `loose/` |
| `FIVEFURY_TEST_YED_CONTRACT_CORPUS` | External `a_c_cat_01.yed`, `ambient.yed`, `player.yed` and `ig_natalia.yed` samples for the YED contract audit |
| `FIVEFURY_TEST_YCD_FACIAL_CORPUS` | External lossless facial NPZ files, recursively discovered; `translations_tags`/`rotations_tags` identify arrays named `translations_N`/`rotations_N` containing time then XYZ/XYZW samples |
| `FIVEFURY_TEST_YCD_PLAYBACK_CORPUS` | External loose YCD resources for differential compiled/mutable sampling at clip times and physical sequence overlaps |

The face-probe integration test also requires `FIVEFURY_TEST_FACE_PROBE_METADATA`,
the exact metadata registration path relative to `loose/`. It compares resolved
Expand Down
56 changes: 56 additions & 0 deletions tests/animation/ycd/test_ycd_clip_sampler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest

from fivefury import GameTarget, YcdChannelEncoding
from tests.support.ycd import facial_rig_builder


@pytest.mark.parametrize("looped", [False, True])
@pytest.mark.parametrize("rate", [0.5, 1.0, 2.0])
def test_clip_snapshot_matches_existing_timing_and_phase_contract(looped, rate):
builder, _ = facial_rig_builder(
GameTarget.GTA5, YcdChannelEncoding.RETAIL, controls=1
)
clip = builder.build_ycds()[0].clips[0]
clip.flags = int(looped)
clip.rate = rate
clip.start_time = 2
clip.end_time = 12
compiled = clip.compile()
assert compiled.is_looped == clip.is_looped
for seconds in (-5, 0, 0.125, 9.55, 9.575, 10, 20, 100):
assert compiled.get_animation_frame_at_time(
seconds
) == clip.get_animation_frame_at_time(seconds)
for interpolate in (True, False):
expected = clip.evaluate_tracks_at_time(seconds, interpolate=interpolate)
actual = compiled.evaluate_tracks_at_time(seconds, interpolate=interpolate)
assert actual.keys() == expected.keys()
for key in actual:
assert tuple(actual[key]) == pytest.approx(
tuple(expected[key]), abs=2e-12
)
for phase in (-1, 0, 0.001, 0.5, 1, 2):
assert compiled.get_animation_frame(phase) == clip.get_animation_frame(phase)
expected = clip.evaluate_tracks_at_phase(phase)
actual = compiled.evaluate_tracks_at_phase(phase)
assert actual.keys() == expected.keys()
for key in actual:
assert tuple(actual[key]) == pytest.approx(tuple(expected[key]), abs=2e-12)
before = compiled.evaluate_tracks_at_time(2.25)
clip.end_time = 0
clip.animation = None
assert compiled.evaluate_tracks_at_time(2.25) == before
assert clip.compile().evaluate_tracks_at_time(10) == {}


def test_clip_sampler_track_filter_and_stateless_seeks():
builder, _ = facial_rig_builder(
GameTarget.GTA5_ENHANCED, YcdChannelEncoding.RETAIL, controls=1
)
sampler = builder.build_ycds()[0].clips[0].compile()
original = sampler.evaluate_tracks_at_time(1.234)
sampler.evaluate_tracks_at_time(9.78)
assert sampler.evaluate_tracks_at_time(1.234) == original
assert sampler.evaluate_tracks_at_time(1, track=999) == {}
rotations = sampler.evaluate_tracks_at_time(1, track=26)
assert rotations and all(key[1] == 26 for key in rotations)
53 changes: 53 additions & 0 deletions tests/animation/ycd/test_ycd_playback_corpus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import os
from pathlib import Path

import pytest

from fivefury import YcdClipAnimation, read_ycd


@pytest.mark.integration("FIVEFURY_TEST_YCD_PLAYBACK_CORPUS")
def test_compiled_playback_matches_external_clip_corpus():
paths = sorted(Path(os.environ["FIVEFURY_TEST_YCD_PLAYBACK_CORPUS"]).rglob("*.ycd"))
assert paths
for path in paths:
ycd = read_ycd(path)
for clip in ycd.clips:
if not isinstance(clip, YcdClipAnimation):
continue
sampler = clip.compile()
frames = {0, 1, clip.get_animation_frame(1)}
if clip.animation:
frames.update(
boundary + delta
for boundary in range(
max(clip.animation.sequence_frame_limit, 1),
clip.animation.frames,
max(clip.animation.sequence_frame_limit, 1),
)
for delta in (-0.75, -0.5, -0.25, 0, 0.25)
)
for frame in frames:
expected = clip.animation.evaluate_tracks(frame)
actual = sampler.animation.evaluate_tracks(frame)
assert actual.keys() == expected.keys()
for key in expected:
assert type(actual[key]) is type(expected[key])
assert tuple(actual[key]) == pytest.approx(
tuple(expected[key]), abs=2e-12
)
for seconds in (
-1,
0,
0.017,
clip.duration / 2,
clip.duration,
clip.duration + 1,
):
expected = clip.evaluate_tracks_at_time(seconds)
actual = sampler.evaluate_tracks_at_time(seconds)
assert actual.keys() == expected.keys()
for key in expected:
assert tuple(actual[key]) == pytest.approx(
tuple(expected[key]), abs=2e-12
)
21 changes: 21 additions & 0 deletions tests/performance/bench_ycd_playback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import pytest

from fivefury import GameTarget, YcdChannelEncoding, build_ycd_bytes, read_ycd
from tests.support.ycd import facial_rig_builder

pytestmark = pytest.mark.performance


@pytest.fixture
def playback_animation():
builder, _ = facial_rig_builder(GameTarget.GTA5_ENHANCED, YcdChannelEncoding.RETAIL)
return read_ycd(build_ycd_bytes(builder.build_ycds()[0])).animations[0]


def test_mutable_ycd_playback(benchmark, playback_animation):
assert benchmark(playback_animation.evaluate_tracks, 123.37)


def test_compiled_ycd_playback(benchmark, playback_animation):
sampler = playback_animation.compile()
assert benchmark(sampler.evaluate_tracks, 123.37)