Skip to content

Parse FL Studio 2024/2025 playlist items (80-byte records) - #205

Open
CryptoJones wants to merge 2 commits into
demberto:masterfrom
CryptoJones:fl2025-playlist-80byte-records
Open

Parse FL Studio 2024/2025 playlist items (80-byte records)#205
CryptoJones wants to merge 2 commits into
demberto:masterfrom
CryptoJones:fl2025-playlist-80byte-records

Conversation

@CryptoJones

@CryptoJones CryptoJones commented Jul 7, 2026

Copy link
Copy Markdown

What

FL Studio 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 FL 2024/2025 playlist events fail the size check and their clips are dropped. Fixes #200 and the same underlying cause behind #199 / #177.

The record

Full 80-byte layout, verified byte-for-byte against FL Studio 2025 (25.x) saves:

offset field notes
0 position u32, PPQ ticks
4 pattern_base u16, always 20480
6 item_index u16
8 length u32, PPQ ticks
12 track_rvidx u16, 499 − track
14 group u16
16 _u1 / item_flags / _u2 existing constants
24 start_offset, end_offset f32 ×2
32 _u3 28 bytes (FL 21)
60 _u4 20 bytes — new in FL 2024/2025

item_index selects the item type: < 20480 references a channel by iid (an audio clip if that channel's ChannelID.Type == 4, an automation clip if == 5); >= 20480 is a pattern clip (pattern_base + pattern#).

The change

  • Add _u4 gated on a new fl2025 struct param; extend SIZES to include 80.
  • Detect the record size by which fixed length divides the payload, preferring the 60-byte reading on a 60/80 common multiple, so files that parsed before are unaffected.
  • Add a synthetic-event unit test asserting the 80-byte records parse and round-trip; full suite stays green.

Known limitation (documented in-code)

A freshly placed clip is exactly 80 bytes, but clips edited in FL (fades, slices, resizes) carry extra per-clip data and grow past it — so a heavily-edited project's Playlist event can be variable-length and still not match a single fixed size. Fully general handling would need the project's FL version to disambiguate; this PR fixes the common (fresh / programmatically-written) case without regressing anything.

How this came up

I hit this building FL-Studio-MCP-Server — an MCP server that lets Claude Code drive FL Studio and generate/edit .flp projects, with PyFLP powering its offline route. Writing FL 2025 audio-clip arrangements is what surfaced the 80-byte record, so the parse fix belongs upstream. Thanks for PyFLP — it's a joy to build on. 🙏

🤖 Generated with Claude Code

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

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 (demberto#177, demberto#199, demberto#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.
@Meowrium

Meowrium commented Aug 8, 2026

Copy link
Copy Markdown

Additional findings: FL26 needs two more fixes on top of this PR

I hit the same wall parsing FL26.1.1 projects (both re-saved FL26 files and legacy FL20.5 files on my rig). This PR's 80-byte layout is correct for FL 2024/2025, but FL26 grows each playlist item to 88 bytes — a further 8-byte tail (_u5) after the 20-byte _u4. Verified byte-for-byte: 32 base + 28 _u3 (FL21) + 20 _u4 (FL2024/25) + 8 _u5 (FL26) = 88.
More importantly, the 80/88-byte PlaylistEvent is not the first thing that derails PyFLP on FL26 files. There are two earlier format changes:

  1. Event 0xAC (DWORD+44) is varint-length in FL26, not fixed 4 bytes. This is the real stream derailer — itoccurs in the project header (right after FLBuild), so every subsequent event is misaligned and channels ends upempty (NoModelsFound). Old files never contain 0xAC, so gating it as a varint read is regression-free:
elif id < TEXT:
if id == EventEnum(0xAC):
size = c.VarInt.parse_stream(stream)
value = stream.read(size)
else:
value = stream.read(4)
  1. FL26 writes a small NotesEvent (0xE0, ~21–34 bytes) in the project header before any PatternID.New. PyFLP'sPatternCollection.iter buckets it under cur_pat_id=0 and surfaces a phantom pattern (1 junk note, no Newevent → Pattern.iid raises KeyError → arrangements crashes). Skip the id-0 bucket when it contains noPatternID.New.

With all three changes (this PR's 80-byte + the 88-byte extension + 0xAC varint + phantom-pattern skip) I parseFL26 projects fully: 10 channels / 616 notes, and a 15-channel project at 1302 notes, with legacy FL20.5 filesunaffected (regression-checked). Happy to open a PR with the combined diff + tests if you're interested.

Sorry for AI sloping, as I'm really poor at progamming, but it works.

FL 2025+ can write a small NotesEvent into the project header, before any
PatternID.New. Patterns.__iter__ buckets events by the last-seen New, so that
event lands in the cur_pat_id = 0 bucket and was yielded as a pattern - one whose
every property raises, because Pattern.iid reads PatternID.New and that bucket
has none. It also made __iter__ disagree with __len__.

Buckets keyed by a real pattern always contain the New that keyed them, so a
bucket without one is not a pattern and is skipped.

Reported by @Meowrium on demberto#205 and reproduced on a real FL 25.2.5 save: iteration
yielded 1000 patterns against a reported length of 999, the extra bucket holding
a single PatternID.Notes, and .iid raised KeyError. It now yields 999 with
working properties.

Checked against 34 real .flp files (FL 24.x and 25.2.5): that one file changes
behaviour and the other 33 parse byte-identically. The new test fails without the
fix. Also reformats tests/test_arrangement.py, which the previous commit left
failing the pinned black 23.3.0 hook.

@Meowrium reported two further FL 26 changes on the same thread - 88-byte
playlist records and a varint-length 0xAC. Both are held back from this PR
pending a real FL 26 save; see the thread for the measurements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HrZZMUyXqTmMWKZo3zAfU
@CryptoJones

Copy link
Copy Markdown
Author

Thanks — this was a genuinely useful report, and two of the three findings hold up. I have FL Studio 25.2.5 and a corpus of 34 real .flp files here, so I was able to check each claim against actual bytes rather than reasoning about them. Results below, including one place where the proposed patch would have broken things.

3. The phantom pattern — confirmed, fixed, and pushed

Reproduced exactly on a real FL 25.2.5 save. The id = 0 bucket held a single PatternID.Notes and no New, so Patterns.__iter__ yielded 1000 patterns while __len__ reported 999, and .iid raised KeyError on the extra one.

Implemented slightly more generally than "skip the id-0 bucket": any bucket with no PatternID.New is skipped. Buckets keyed by a real pattern always contain the New that keyed them, so the two are equivalent in practice, and the general form also survives a pattern legitimately numbered 0. That's in this PR now, with a test that fails without the fix.

2. The 0xAC varint change — the premise doesn't hold for FL 2025

This is the one to flag. The patch as written applies the varint read unconditionally, on the reasoning that "old files never contain 0xAC, so gating it as a varint read is regression-free."

FL 2025 files do contain 0xAC. It appears 27 times across my 34 files, always as a fixed 4-byte DWORD with the payload:

ac 01 01 00 c0

Read as a varint, that leading 01 means "length 1", so the read consumes 2 bytes instead of 4 and every subsequent event is misaligned. Applying the patch verbatim, 8 of 12 of my FL 25.2.5 projects stopped parsing:

InvalidEventChunkSize: Expected a bytes object of length 4; got 1

That's fixable with a version gate — ProjectID.FLVersion is the first event in the chunk (verified: 34/34 files), so the parser knows the version before it reaches 0xAC. I have that working locally.

But gating alone isn't enough to ship it, because of two things I hit afterwards:

  • EventBase.__init__ enforces size by ID range, regardless of event class. I tried FL 26 headers with 0xAC payloads of 1, 2, 4, 5 and 8 bytes — only 4 parses; every other length raises. So a varint read only helps if FL 26's payload happens to be exactly 4 bytes, and if it were, there'd be no reason for Image-Line to have changed the framing at all.

  • EventBase.__bytes__ writes no length prefix below TEXT, so the varint length is dropped on save:

    source : ac 04 aa aa aa aa
    resaved: ac    aa aa aa aa
    

    A file PyFLP wrote would not open in FL 26.

Doing this properly needs a dedicated event class that skips the fixed-size check and serialises with a varint length — a change to _events.py, not a two-line gate. I'd rather not guess at that shape blind.

1. The 88-byte record — same shape of problem

80 and 88 share multiples and real projects land on them: one of my saves has a 17600-byte playlist, which is both 220 × 80 and 200 × 88. So payload length alone can't separate the two layouts, and picking 88 by divisibility would silently drop clips from FL 2024/2025 projects. Version-gating solves that too, and I have it working — but it's still reconstructed from a description, with no FL 26 file to check the _u5 contents against.

What would unblock both

You mentioned you have FL 26 projects. Could you attach a few small saves? Specifically:

  1. A minimal FL 26 project with 2–3 freshly placed playlist clips — pins the 88-byte record and the _u5 bytes.
  2. One where a clip has been edited (fade, slice, resize) — the variable-length case, where the payload stops being a clean multiple.
  3. Any FL 26 save at all, even empty — that alone settles the 0xAC payload length, which is the thing blocking the varint work.

A zip of those would let both changes land with real fixtures instead of synthetic ones, matching how the rest of tests/assets works. Happy to do the implementation; I just don't want to push format changes to @demberto that neither of us can verify.

Nothing here is meant as a knock on the report — the phantom pattern is a real bug I wouldn't have found, and the 0xAC lead was right about something changing, just not about it being safe to apply unconditionally.


AI disclosure

  • Authoring / analysis: Claude Opus 5 (Anthropic, claude-opus-5) via Claude Code.
  • Human decisions and testing: @CryptoJones — supplied the FL 25.2.5 corpus, ran FL Studio, decided the PR scope, and approved every push.
  • Independent review of the diff before this comment, one model per lane, no lane seeing another's answer: poolside/laguna-s-2.1; z-ai/glm-5.3-flash; anthropic/claude-opus-5, claude-sonnet-5, claude-haiku-4.5, claude-fable-5.1; google/gemini-3.6-flash; deepseek/deepseek-v4-flash; mistralai/mistral-large-2512; nvidia/nemotron-3-super-120b-a12b; openai/gpt-oss-120b; and two local Qwen3 builds.

That review is what caught the serialisation and payload-size problems described above; the first draft of this change had both and would have shipped them.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

Meowrium added a commit to Meowrium/PyFLP that referenced this pull request Sep 8, 2026
…tom-pattern skip

Upstream review (PR demberto#205) verified our FL26 patches against a 34-file
FL 25.2.5 corpus and found two regressions in the original unconditional
fixes:

- Event 0xAC is varint-length only in FL26. FL 2024/2025 also contain
  0xAC, always as a fixed 4-byte DWORD (27 occurrences / 34 files); an
  unconditional varint read derailed 8/12 real FL25.2.5 projects
  (InvalidEventChunkSize). ProjectID.FLVersion is the first event in the
  chunk (34/34), so gate the varint read on FLVersion >= 26.

- PlaylistEvent 80B (FL 2024/25) and 88B (FL26) records share payload
  multiples (17600 = 220*80 = 200*88), so length divisibility cannot pick
  the layout; version-gating is required. FLVersion now flows into
  PlaylistEvent(id, data, version). 88B = 32+28(_u3)+20(_u4)+8(_u5), so
  fl26 implies fl2025/new.

- Phantom-pattern skip generalized per upstream: any bucket without a
  PatternID.New is skipped (not just id-0), which also survives a pattern
  legitimately numbered 0.

Verified: full test suite passes; 侠/翩然 (FL26.1.1) and 1.3.flp (FL20.5)
still parse with identical channel/pattern/event counts; real FL26 0xAC
payload measured as varint length 1 (0x01).
@icedream

icedream commented Sep 8, 2026

Copy link
Copy Markdown

Empty FL 2025/2026 project files + some clip edits as requested.

icedream added a commit to icedream/PyFLP that referenced this pull request Sep 8, 2026
FL Studio 26 changed two things in the project header that broke
parsing entirely (NoModelsFound / StringError on every FL 26 project):

1. Event 0xAC (DWORD+44, ProjectID range) is usually still a plain
   4-byte value (matches every occurrence in FL 2024/2025 projects and
   most occurrences in FL 26 projects), but at least once per FL 26
   project it is followed by a 1-byte length and that many bytes of
   null-terminated UTF-16LE text (seen holding a build/generator tag
   such as "FL Studio 26.1.5.5618.5618"). The two forms are told
   apart by the top two bits of the 4th value byte. Verified
   byte-for-byte against multiple real FL 26 project files.

2. When a project has more than one custom display/track group, FL 26
   bundles all of their names into one new container event, and reuses
   the same numeric ID (194) already claimed by ProjectID.Title. This
   collision cannot be disambiguated from the ID alone with pyflp's
   current single-pass, stateless parsing, so rather than crash the
   whole parse, any text-typed event that fails to decode as text now
   degrades to a raw UnknownDataEvent with a warning (matching the
   existing VSTPluginEvent unknown-marker warning style), instead of
   raising and aborting parse() entirely.

See docs/format-notes.md in this fork for the full byte-level traces
both of these were reverse engineered from.

Verified against a small corpus of FL 11/12/24.2/26 projects: FL
11/12/24.2 files are unaffected (byte-identical parse results), FL 26
projects no longer fail immediately in the project header (they now
progress to the already-known-and-reported 80/88-byte Playlist record
issue, see demberto#205).
icedream added a commit to icedream/PyFLP that referenced this pull request Sep 8, 2026
FL Studio 26 changed two things in the project header that broke
parsing entirely (NoModelsFound / StringError on every FL 26 project):

1. Event 0xAC (DWORD+44, ProjectID range) is usually still a plain
   4-byte value (matches every occurrence in FL 2024/2025 projects and
   most occurrences in FL 26 projects), but at least once per FL 26
   project it is followed by a 1-byte length and that many bytes of
   null-terminated UTF-16LE text (seen holding a build/generator tag
   such as "FL Studio 26.1.5.5618.5618"). The two forms are told
   apart by the top two bits of the 4th value byte. Verified
   byte-for-byte against multiple real FL 26 project files.

2. When a project has more than one custom display/track group, FL 26
   bundles all of their names into one new container event, and reuses
   the same numeric ID (194) already claimed by ProjectID.Title. This
   collision cannot be disambiguated from the ID alone with pyflp's
   current single-pass, stateless parsing, so rather than crash the
   whole parse, any text-typed event that fails to decode as text now
   degrades to a raw UnknownDataEvent with a warning (matching the
   existing VSTPluginEvent unknown-marker warning style), instead of
   raising and aborting parse() entirely.

Verified against a small corpus of FL 11/12/24.2/26 projects: FL
11/12/24.2 files are unaffected (byte-identical parse results), FL 26
projects no longer fail immediately in the project header (they now
progress to the already-known-and-reported 80/88-byte Playlist record
issue, see demberto#205).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐞 Missing playlist data in FL Studio 2025 project files

3 participants