From cd27bf38121519f3a16877066364f38febb9a3f0 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark (CryptoJones)" Date: Tue, 7 Jul 2026 01:24:47 -0500 Subject: [PATCH] Parse FL Studio 2024/2025 playlist items (80-byte records) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FL 2024/2025 (21.2+ / 25.x) grew each playlist item to an 80-byte record: a 20-byte tail (_u4) after FL 21's 28-byte tail (_u3). PlaylistEvent only knew the 32- and 60-byte layouts, so these events failed the size check and their clips were dropped (#177, #199, #200). Add the _u4 block gated on a `fl2025` param, extend SIZES to include 80, and detect it by the fixed size that divides the payload — preferring the 60-byte reading on a 60/80 common multiple so files that parsed before are unaffected. The record layout was verified byte-for-byte against FL Studio 2025 saves, and a synthetic-event test asserts the 80-byte records parse and round-trip. Known limitation (documented in-code): clips edited in FL grow past 80 bytes, so a heavily-edited project's Playlist event can be variable-length and still not match a single fixed size. --- CHANGELOG.md | 9 +++++++++ pyflp/arrangement.py | 27 +++++++++++++++++++++++++-- tests/test_arrangement.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cd5ac..16ae57e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Parse FL Studio 2024/2025 (21.2+ / 25.x) playlist items, stored as 80-byte records + (a 20-byte tail after FL 21's 28-byte tail). Previously these events failed the size + check and their clips were dropped (#177, #199, #200). Detection prefers the 60-byte + reading on a 60/80 common multiple, so existing files are unaffected. + ## [2.2.1] - 2023-06-05 ### Fixed diff --git a/pyflp/arrangement.py b/pyflp/arrangement.py index 6a96bb8..00544c7 100644 --- a/pyflp/arrangement.py +++ b/pyflp/arrangement.py @@ -68,6 +68,23 @@ class PLSelectionEvent(StructEventBase): class PlaylistEvent(ListEventBase): + # Each playlist item is a fixed-size record whose length grew across FL versions: + # * 32 bytes - up to FL 20. + # * 60 bytes - FL 21 added a 28-byte tail (``_u3``). + # * 80 bytes - FL 2024/2025 (21.2+ / 25.x) added a further 20-byte tail (``_u4``). + # Verified byte-for-byte against FL Studio 2025 saves; see #177, #199, #200. + # + # ``item_index`` selects the item type: ``< pattern_base`` (20480) references a channel + # by its iid - an audio clip if that channel's ``ChannelID.Type`` is 4, an automation + # clip if it is 5 - while ``>= pattern_base`` is a pattern clip (pattern number = + # ``item_index - pattern_base``). ``length`` is in PPQ ticks. + # + # NOTE: a *freshly placed* clip is exactly one of the sizes above, but clips edited in + # FL (fades, slices, resizes) carry extra per-clip data and grow beyond it, so a real + # project's Playlist event can be variable-length and match none of ``SIZES``. Payload + # lengths that are a common multiple (e.g. 240 = 3*80 = 4*60) are ambiguous without the + # project's FL version; the heuristic below prefers the FL 21 (60-byte) reading for + # backwards compatibility, so this change never regresses files that parsed before. STRUCT = c.GreedyRange( c.Struct( "position" / c.Int32ul, # 4 @@ -82,12 +99,18 @@ class PlaylistEvent(ListEventBase): "start_offset" / c.Float32l, # 28 "end_offset" / c.Float32l, # 32 "_u3" / c.If(c.this._params["new"], c.Bytes(28)) * "New in FL 21", # 60 + "_u4" / c.If(c.this._params["fl2025"], c.Bytes(20)) * "New in FL 2024/2025", # 80 ) ) - SIZES = [32, 60] + # Ordered so the fixed size that divides the payload matches the parsed record size: + # 60 wins a 60/80 common multiple (back-compat), 80 before 32 for FL 2024/2025 events. + SIZES = [60, 80, 32] def __init__(self, id: EventEnum, data: bytes) -> None: - super().__init__(id, data, new=not len(data) % 60) + # 80-byte records are FL 2024/2025; 60-byte are FL 21; 32-byte are older. Detect by + # the fixed size that divides the payload, preferring 60 on a 60/80 common multiple. + fl2025 = len(data) % 80 == 0 and len(data) % 60 != 0 + super().__init__(id, data, new=fl2025 or not len(data) % 60, fl2025=fl2025) @enum.unique diff --git a/tests/test_arrangement.py b/tests/test_arrangement.py index 92479c9..0ee35b8 100644 --- a/tests/test_arrangement.py +++ b/tests/test_arrangement.py @@ -180,3 +180,38 @@ def test_second_arrangement(arrangement: Callable[[int], Arrangement]): assert arr.name == "Just timemarkers" assert len(tuple(arr.timemarkers)) == 11 assert len(tuple(arr.tracks)) == 500 + + +def test_fl2025_80byte_playlist_records_parse(): + """FL 2024/2025 (21.2+ / 25.x) stores each playlist item as an 80-byte record. + + Before this, ``PlaylistEvent`` only knew the 32- and 60-byte layouts, so FL 2024/2025 + playlist events failed the size check and their clips were dropped (see #177, #199, #200). + Build a synthetic two-clip FL 2025 event and assert it parses and round-trips. + """ + import struct + + from pyflp.arrangement import ArrangementID, PlaylistEvent + + def rec(pos: int, iid: int, length: int, track: int, uid: int) -> bytes: + core = struct.pack( + "