Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ 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+ projects at all. FL writes event ID 172 (in the DWORD range)
with a **3-byte** payload; reading the 4 bytes the ID range implies consumes the
first byte of the next event and desyncs the rest of the stream. A 57KB FL 26.1.6
project parsed as 66 nonsense events with no channels, patterns, mixer or
arrangements. With the correct size the same file consumes to the exact byte
(1710 events) and every model is reachable. The ID belongs to no `EventEnum`, so it
is kept as an `UnknownDataEvent` and re-emitted unchanged.

## [2.2.1] - 2023-06-05

### Fixed
Expand Down
9 changes: 9 additions & 0 deletions pyflp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
DATA,
DWORD,
NEW_TEXT_IDS,
SHORT_DWORD_IDS,
TEXT,
WORD,
AnyEvent,
Expand Down Expand Up @@ -126,6 +127,12 @@ def parse(file: pathlib.Path | str) -> Project:
value = stream.read(1)
elif id < DWORD:
value = stream.read(2)
elif id in SHORT_DWORD_IDS:
# FL Studio 2024+ writes a handful of IDs in the DWORD range with a
# 3-byte payload instead of 4. Reading them as DWORDs desyncs the
# whole stream one byte in: every later event is garbage, and the
# project appears to have no channels, patterns or mixer at all.
value = stream.read(3)
elif id < TEXT:
value = stream.read(4)
else:
Expand All @@ -149,6 +156,8 @@ def parse(file: pathlib.Path | str) -> Project:
event_type = U8Event
elif id < DWORD:
event_type = U16Event
elif id in SHORT_DWORD_IDS:
event_type = UnknownDataEvent
elif id < TEXT:
event_type = U32Event
elif id < DATA or id.value in NEW_TEXT_IDS:
Expand Down
9 changes: 8 additions & 1 deletion pyflp/_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@
DWORD: Final = 128
TEXT: Final = 192
DATA: Final = 208
# FL Studio 2024+ emits these IDs from the DWORD range (128-191) with a 3-byte
# payload. The ID-range rule that otherwise fixes event sizes does not hold for
# them, and reading 4 bytes shifts every subsequent event by one: pyflp then
# reports a handful of nonsense events and no models at all. Verified against FL
# Studio 26.1.6 saves, where the stream parses to the exact byte with 3.
SHORT_DWORD_IDS: Final = (172,)

NEW_TEXT_IDS: Final = (
TEXT + 49, # ArrangementID.Name
TEXT + 39, # DisplayGroupID.Name
Expand Down Expand Up @@ -109,7 +116,7 @@ def __init__(self, id: EventEnum, data: bytes, **kwds: Any) -> None:
if self.ALLOWED_IDS and id not in self.ALLOWED_IDS:
raise EventIDOutOfRange(id, *self.ALLOWED_IDS)

if id < TEXT:
if id < TEXT and id not in SHORT_DWORD_IDS:
if id < WORD:
expected_size = 1
elif id < DWORD:
Expand Down
49 changes: 49 additions & 0 deletions tests/test_short_dword.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

import pathlib
import struct

import pyflp
from pyflp._events import SHORT_DWORD_IDS
from pyflp.project import ProjectID

# A minimal FL 2024+ style stream: the version string, then ID 172 with a
# 3-byte payload, then an event whose value is only readable if 172 consumed
# exactly 3 bytes. Read as a DWORD, 172 swallows the next event's ID and every
# event after it is garbage.
SHORT_ID = SHORT_DWORD_IDS[0]


def _flp(events: bytes) -> bytes:
header = struct.pack("<4sIHHH", b"FLhd", 6, 0, 1, 96)
return header + struct.pack("<4sI", b"FLdt", len(events)) + events


def _version_event(version: bytes = b"24.1.1.4285\0") -> bytes:
return bytes([ProjectID.FLVersion]) + bytes([len(version)]) + version


def test_short_dword_event_consumes_three_bytes(tmp_path: pathlib.Path):
events = _version_event()
events += bytes([SHORT_ID]) + b"\x01\x01\x00"
events += bytes([ProjectID.Tempo]) + struct.pack("<I", 140000)

flp = tmp_path / "short-dword.flp"
flp.write_bytes(_flp(events))

project = pyflp.parse(flp)
assert project.tempo == 140.0 # only correct if 172 consumed 3 bytes


def test_short_dword_event_round_trips(tmp_path: pathlib.Path):
events = _version_event()
events += bytes([SHORT_ID]) + b"\x01\x01\x00"
events += bytes([ProjectID.Tempo]) + struct.pack("<I", 140000)

src = tmp_path / "short-dword.flp"
src.write_bytes(_flp(events))

project = pyflp.parse(src)

# The event is unknown, so it must be re-emitted byte for byte.
assert b"".join(bytes(event) for event in project.events) == events
Loading