Skip to content

Instantly share code, notes, and snippets.

@kmorrill
Created November 2, 2025 17:22
Show Gist options
  • Select an option

  • Save kmorrill/506d69e251f225c0fffb2596c17b9db3 to your computer and use it in GitHub Desktop.

Select an option

Save kmorrill/506d69e251f225c0fffb2596c17b9db3 to your computer and use it in GitHub Desktop.
OP-XY File Format agent doc and findings

Agent Log

Original Purpose

  • Assist in reverse engineering the OP-XY .xy project files.
  • Preserve the originals by moving them into src/ and never modifying that directory.
  • Use separate working copies for any experiments or tooling.

Current Findings

  • The .xy binaries share a big-endian signature dd cc bb aa 09 13 03 86, which likely serves as the container magic before a series of chunk descriptors (OP-Z carries a similar design).
  • The first ~0x200 bytes form a structured header with version-like integers and float encodings (tempo/swing/global FX candidates). These fields differ per project in ways that line up with expected tempo/swing ranges.
  • Sample/library metadata begins near offset 0x240 and repeats a 12-byte big-endian prologue (>6H) followed by two null-terminated strings (library folder + file name) and at least 8 bytes of additional numeric metadata. Field distributions cluster by instrument family (drum, hihat, ensemble), reinforcing that tracks later reference this table by index.
  • We now see explicit chunk headers: 24-byte descriptors where size1 == size2 (often 0x0540), size3 is the payload length, chunk_type falls into {0x02f8, 0x06f8, 0x0af8, 0x0ef8} (and occasionally 0x050100), and flags frequently equal 0xffff003f. Examples:
    • ask why2.xy: chunk 0x0ef8 at 0x025c, additional 0x0ef8 at 0x3920, and 0x0ef8 with a different field6 at 0x41b4.
    • agent2.xy: chunk 0x0ef8 at 0x7af0 and chunk 0x0af8 at 0x8bb8, both with size1=size2=0x540.
  • Created scripts/analyze_xy.py to dump header words, enumerate sample records, and heuristically list the chunk headers. This tooling confirms 201–265 sample entries per project (matching factory content expectations) and surfaces per-field counters to guide flag decoding.
  • Added scripts/diff_xy.py which slices .xy files on the 0x00000AF8 FFFF003F pattern markers and compares each segment in isolation. Usage:
    python scripts/diff_xy.py path/to/base.xy path/to/other.xy --segments 0 1
    
    The tool highlights differing bytes with contextual hexdumps even when earlier chunks shift, which simplifies validating trig additions/removals and ring-rotation experiments.
  • Track blocks (e.g., at agent2.xy:0x5000) begin just after a chunk header with long stretches of 0xff00 masks, a track name string (bass, etc.), a patch alias (/vogel), and arrays of 32-bit big-endian parameters—consistent with the 16-track layout hypothesis.
  • Track pattern payloads (0x0af8, len ~0x160) show a repeating structure: 0x0000 0x0af8 marker, 0xffff 0x003f flags, a grid of alternating 0x0000ff00 / 0x00ff0000 / 0xff0000ff words (likely trig/step bitmasks across pages), followed by a per-pattern header (0x9b01 preset ID, 0x10f0 pattern length, counts like 0x0501, 0x020a, 0x0201, 0x0102). The tail section contains parameter values (e.g., 0x00e8ffff, 0x017f0000, 0x1140cdcc, 0x00014000) plausibly matching per-track envelopes, filters, and FX sends. Marker offsets (0x00000af8) repeat at ~300-byte intervals, clearly segmenting the up-to-9 pattern slots per track (unused patterns collapse to short 110-byte stubs with only preset/slot references).
  • Larger 0x0af8 chunks (size1=0x4761, len=0x358–0x375) begin with an inline length (e.g., 0x00000375) and pack arrays of 0x500a/0x50xx words that resemble per-track pattern pointers plus mix values—consistent with the 99-scene table. The same chunk includes what appears to be a second segment (duplicated header) suggesting multiple tables (scenes + song chain) are stored back-to-back.
  • 0x00000af8/0xffff003f signatures occur throughout the large track payloads, enabling deterministic slicing of each pattern. All used patterns in agent2.xy showed lengths between 347–371 bytes; unused slots degrade to 110-byte metadata blocks, which supports the idea that the pattern structure is sparse (only non-empty sections are serialized).
  • Project headers (offsets 0x08–0x18) hold aggregate sizes that match the string-table chunks (0x0ef8/0x06f8) plus small integers (e.g., 0x0118, 0x011e, 0x0100) aligning with tempo/time-signature/groove settings. Flags like 0xfdfe01ff/0x0000ff00 mirror bitmasks seen later in the project-scene chunks, reinforcing that the header keeps the global defaults.
  • Step-component roster census on comp2.xy (track 1/pattern 1): multiply on step 1, probability on step 5, jump on step 9, and the pair portamento/bend on step 13. The latter three share the “0x2700” marker variant that is byte-swapped compared to the canonical 0x0027 records. Those entries point into the same parameter ring and reuse the shared payload block (payload@0x40).
  • Step-component removal behaves like a ring buffer update rather than a hard delete. Removing the Step 1 multiply component on-device (creating comp2b.xy) advanced the component-table head by 0x200, shifted subsequent entries one slot “forward”, and rotated the 0x15-byte parameter payload by 2 bytes. The 0x27 record and payload remain intact; the slot’s step binding (w1) is cleared and the slot-tag (w3) flips from 0x3b00 (multiply) to an “unassigned” tag (0x4000). The pattern header near 0x170 also has its component-count byte zeroed. Attempting to zero these structures manually (our earlier comp3/comp4) caused loader assertion failures (serialize_latest.cpp:90 num_patterns > 0) because the ring pointers no longer lined up.
  • Removing the Step 5 probability component (comp2c.xy) shows a more complex rotation. The firmware re-tagged every table entry and rewound the parameter ring so the probability slot’s data no longer leads, yet the 0x27 probability record persists. The slot roster reordered, the head pointer stayed constant, and the pattern metadata (0x0170 onwards) flipped several bitmasks (0xff↔0x00, new 0x9b01 preset marker). Interpretation: the slot list is a circular queue; removal advances the head to the next active slot and copies default masks into the freed slot rather than deleting the record. Offline tools must replicate this rotation exactly to avoid corrupting the pattern index.
  • Removing the Step 9 jump together with one of the Step 13 (portamento/bend) components on-device (comp2d.xy) triggered a larger reshuffle: the roster entries now start with the same multiply slot but subsequent entries carry new type IDs (0x3700, 0x0132, …) and different step/page bindings (e.g. the shared slot now points at step 8/page 5). The 0x15-byte ring rotated again (0x0004…0x0404…), offsets of all three 0x27 records shifted earlier by one byte, and the pattern header/component mask block (0x170 & 0x2560–0x25c0) swapped its alternating 0xff/0x00 pairs. The shared 0x27 record at 0x18ea is still present but its lead-in bytes compacted (00 00 00 2700 00 27 5c), indicating the firmware slides structure boundaries rather than deleting the record. Overall length shrank by one byte, so downstream offsets change after each firmware edit.
  • Draft removal procedure (from Step 1 experiment): to clear a component safely we must (1) leave the 0x27 record untouched, (2) advance the component-table head (w0 += 0x200) and rotate the eight 4-word entries so the freed slot moves to the tail, (3) rotate the 0x15-byte parameter payload by two bytes so the old component data falls behind the new head, and (4) zero the component-count byte(s) in the pattern header (0x0170 region). Anything less leaves dangling pointers and crashes the loader. Need to generalise this to arbitrary slots—comp2c indicates that removing a mid-list entry triggers a full reordering rather than a single-slot rotation. We now detect both 0x0027 and 0x2700 markers in code to cover these variants.

External Research Notes (Teenage Engineering docs & community reconnaissance)

  • Project hierarchy from the OP-XY guide: Project → Songs → Scenes → Patterns → Tracks → Steps, with 16 total tracks (8 instrument, 8 auxiliary), 9 patterns per track, up to 99 scenes per project, and 9–14 songs per project depending on firmware revision. Patterns cap at 64 bars / 4 pages and 120 notes, with 14 step components available and 1920 PPQN timing.
  • Scene data stores per-track pattern index, volume, and mute; scene duration equals the longest pattern unless overridden by the project setting introduced in firmware 1.1.0.
  • Track types imply distinct data payloads: instrument tracks carry engine/sampler settings, envelopes, FX sends; auxiliary tracks hold brain routing (scale/key + track targets), punch-in FX states, external MIDI (channel/program/CCs), CV mappings, tape/FX send automation, etc.
  • Sample management limits: WAV/AIFF up to 20 s per recording, drum sampler exposes 24 one-shot slots, multisampler allows 24 zones. Device storage is arranged under projects/, samples/, and presets/; backups live alongside project files.
  • Expect project/global config blocks for tempo, groove (10 styles), time signature options (3/4, 4/4, 5/4, 6/8, 7/8, 12/8), voice allocation (24 voices), and master EQ/saturator/compressor.
  • Firmware 1.1.0 (2025-10-15) introduced a scene-length policy toggle plus sampler parameter locks, implying future .xy variants may encode additional flags or blocks—parser should accommodate optional sections.
  • Chunk census (flags 0xffff003f):
    • 0x0ef8 (size1 0x540, len 0xd40 or 0x8ae): appears near header and throughout the string-table region; wraps blocks of sample path/name pairs (e.g., agent2.xy:0x025d, 0x4fff). Likely “library index” segments feeding the track sampler references.
    • 0x06f8 (size1 0xff7f, len 0xd40): additional sample-table segments (e.g., agent2.xy:0x509) containing consecutive content/samples/... entries.
    • 0x0af8 splits into three subtypes:
      • size1 0x0160, len 0x8ae: per-track preset blocks. They carry the sample path + alias plus a 0x150-ish byte parameter bank; markers like 0x9b01 identify the preset ID that tracks reference from pattern slots.
      • size1 0xffff017f, len 0xff00: instrument-track payloads. Each chunk contains up to nine pattern segments, delimited by 0x0000 0x0af8 headers. Within each segment we see:
        • 0xffff003f flag, followed by alternating 0x0000ff00 / 0x00ff0000 / 0xff0000ff words that act as the 64-step trig/component bitmasks across pages.
        • A consistent pattern header (0x9b01 preset, 0x10f0 length) and slot counters like 0x0501, 0x020a, 0x0201, 0x0102.
        • A trailing parameter array (0x00e8ffff, 0x017f0000, 0x1140cdcc, 0x00014000, etc.) that aligns with per-pattern filter/envelope/FX settings. Unused slots shrink to a 110-byte stub that retains only preset IDs and default masks.
      • size1 0x4761, len 0x358–0x375: mix/scene tables. Each begins with 0x00000af8 ffff003f 00000375 and a body of 59 fixed 15-byte records (five 3-byte fields). Those fields vary with scenes (e.g., [0x8f22, 0x00ae, 0x4700, 0xff7f, 0x00ff] for the first scene and 0x00ff triplets for muted/default tracks). Extra arrays in the remainder of the chunk (repeating 0x500a0700, 0x0501, etc.) look like per-track level/mix data and probably host the song chain.
    • 0x02f8 (size1 ≈ 0xe50b, len 0xd40): contains snapshot/... strings in agent/basby/curio dumps, suggesting project-history slots.
  • Pattern trig block (validated in step05.xy, step01050913.xy, step03_note.xy, step_ascvel_soft.xy, step05nudge.xy, step05nudgeleft.xy):
    • Lives directly ahead of the parameter words at offset 0x7a4 in our test projects.
    • Byte layout (little-endian):
      • Bytes 0–2: signature 00 00 25.
      • Byte 3: trig count (e.g. 0x01 for a single hit, 0x04 for four hits).
      • For each trig we observe two 3-byte triplets:
        • Tick position (two little-end bytes + pad). Examples: 80 07 00 → 0x0780 ticks (step 5), C0 03 00 → 0x03C0 ticks (step 3), 00 0F 00 → 0x0F00 ticks (step 9), 80 16 00 → 0x1680 ticks (step 13). Nudging step 5 fully left/right produced 0x0690 (−240 ticks) and 0x086F (+239 ticks), so micro timing lives in the same field with roughly ±240 ticks of swing around the 480‑tick grid.
        • Note ID + velocity: two little-end bytes for the note (e.g. 0x0135, 0x0136) and one byte for velocity (confirmed by tests at 20/40/80/120).
    • After all trig triplets, the familiar pattern parameter block (03ff00fc, 00000508, etc.) remains unchanged.
    • Gate length bytes: in len.xy (steps 1/5/9/13 stretched to 1–4 steps) the 24-bit little-end field starting at offset+4 grows with the gate—e.g. 00 C0 03 → 0x03C000 (960 ticks ≈ 2 steps), 00 A0 05 → 0x05A000 (1424 ticks due to clip near the pattern end), 00 DC 05 → 0x05DC00 (≈1500 ticks). A full 16-step gate in 16len.xy yields 03 1E 00, which shifts down to 0x001E ticks (=30) once the triads repeat, showing the 24-bit value carries both the raw gate in ticks and redundant bytes at the tail; more tests are needed to decode the exact packing, but the length clearly scales with the 24-bit field.
    • Gate length bytes: in len.xy (steps 1/5/9/13 stretched to 1–4 steps) the 24-bit little-end field starting at offset+4 grows with the gate—e.g. 00 C0 03 → 0x03C000 (960 ticks ≈ 2 steps), 00 A0 05 → 0x05A000 (1440 ticks ≈ 3 steps), 00 DC 05 → 0x05DC00 (≈1500 ticks capped by the pattern end). Default hits show 02 F0 00, suggesting a base gate of ~240 ticks even before stretching.
  • Sample-record schema (>6H + trailer):
    • field0 tags resource type: 0x0002 drum family, 0x0200 aux/performance, 0x0000 shaker alt, 0xB41F/0x01E1F synth presets.
    • field3 aligns with root key / keyboard zone (e.g., 256 for drums at C4, 60 for synth middle C, 512 for closed/open hihat).
    • field4 behaves as a category mask (clustered per drum subgroup; synth presets use 0x8000/0x6400 style values).
    • Trailer stores two 32‑bit pointers/IDs: drum/percussion groups share paired IDs (suggesting left/right sample slots), while factory synth presets use 0xFF0000FF sentinel IDs.

Open Questions / Next Steps

  • Quantify how the 0x0ef8 / 0x06f8 string-table chunks cover the full sample catalogue and document how track payloads point back into those segment-local indices.
  • Reverse-map the project header words to documented settings (tempo, groove, time signature, voice allocation, scene-length policy) by comparing multiple .xy files and the device UI.
  • Decode the large 0x0af8 (len 0xff00) payloads into their 9 pattern slots and 64-step grids, identifying the per-step bitfields for trigs, components, and parameter locks.
  • Locate the scene (99) and song (9/14) tables—likely within the mid-sized 0x0af8 blocks (len 0x358–0x375)—and describe the per-track pattern index plus mute/volume encoding.
  • Validate interpretations with controlled device experiments (toggling a single scene mute, changing groove, adding songs) once the parser extracts these sections.
  • Automation PoC runbook for MIDI-driven project generation is captured in docs/automation_poc.md, with the executable flow in scripts/automation_poc_run.py.

Track M-Parameter Encoding Notes

  • The per-track synth expression knobs (M1–M4) live in the short header that precedes the pattern payload. When every knob is zero the block is omitted entirely.
  • Each knob is serialized as four little-endian bytes: three bytes of 24-bit fixed-point magnitude (0x000000 = far left, 0xFFFFFF = far right), followed by an identifier byte. Teenage Engineering reuses identifier codes across knobs (e.g. both mid knobs carry 0x47, while the fourth uses 0x04).
  • Example (param2.xy, track 1): dc ff ff 01 7f ff 80 47 41 ff 80 47 41 00 00 04 translates to:
    • M1 = 0xFFFFDC → ~0.999998 (hard right)
    • M2 = 0x80FF7F → ~0.504 (midway)
    • M3 = 0x80FF41 → ~0.504 (midway)
    • M4 = 0x000041 → ~0.000004 (effectively zero)
  • Editing those 24-bit values in-place directly controls the M1–M4 positions without touching the larger pattern payload.
  • Untested theory (based on adsr.xyadsr2.xy, track 3): the M2 amp/filter ADSR sliders serialize as the same 24-bit-plus-ID tuples, appended right after the M1 block inside the track’s large 0x0af8 payload. Maxing amp ADSR and zeroing filter ADSR pushed twelve new records into the list at 0x2450, each tagged by stage IDs (amp attack 0x99, decay split across 0x02/0x0a, sustain 0x26/0x7f, release 0x19; filter stages surfaced as 0x40/0x9a). Older values remain in place, so the device appears to treat the table as a ring buffer and consumes the most recent entry per ID.
  • Firmware likely maintains an undo ring for these parameter tables. Each slider tweak appends a fresh tuple instead of overwriting the current value, which keeps historical entries available for undo/redo. Because the deserializer expects the ring structure intact (see serialize_latest.cpp:90 num_patterns > 0 crash when we clobbered existing records), offline tools must append new ADSR entries—or perform the same head/tail rotation the device does—rather than editing bytes in place.
  • Track 4 experiment (adsr2.xyadsr2b1.xy) shows how the device serializes injected ADSR edits. Each track carries two contiguous (value24,id8) tables ahead of its 0x00000af8 ffff003f pattern payload: the amp envelope block begins at src/adsr2b1.xy:0x2050, and the filter envelope block starts at 0x21c0. New slider moves are prepended to these blocks, so the first tuple with a given ID is always the current value while the older history trails behind. Example deltas:
    • Amp release (ID 0x00) shifted from 0x017f00 (src/adsr2.xy:0x2050) to a fresh head entry 0x0000ff at src/adsr2b1.xy:0x2054, leaving the older value a few slots deeper.
    • Filter release (ID 0xff) moved from 0x3f0000 (src/adsr2.xy:0x21fc) to 0xff0000 at src/adsr2b1.xy:0x21c4. The amp block also received matching 0xff0000 entries because the history queue records every intermediate tweak.
    • The device wrote 11 new tuples for these three knob moves, pushing the first pattern marker from 0x20a4 to 0x20d0 (+0x2c bytes). Chunk size fields inside the surrounding track descriptor grow by 4 bytes per tuple, so offline injectors must bump both the amp/filter blocks and the enclosing 0x0af8 length metadata when splicing in new records.
    • Practical edit recipe: locate the desired block, insert your new 4-byte (value,id) tuple(s) immediately before the existing head entry, adjust the chunk’s serialized size counters by 4 × tuples_added, and leave the historical records untouched. This mirrors the firmware’s undo-friendly ring and avoids the loader assert hit we saw with in-place edits.

MIDI CC automation patterns

  • 55 (Shift) stays latched until a release event is found. For scripted runs, finish by pressing Shift on-device to clear the modifier.
  • Project quick-iteration macro (0 → 7 → 6 on CC#106, channel 1): open Project menu, trigger the current "save" shortcut (mapped to M2), then hit M1 to start a new project slot. Handy for smoke-testing edited .xy files quickly.
  • CC#106 value 55 (Shift) cannot be released over MIDI; once sent, Shift stays held until the physical key is pressed again, limiting its automation utility.

Step-component keyboard map (left→right)

  1. Pulse
  2. Hold
  3. Multiply
  4. Velocity
  5. Up
  6. Down
  7. Random
  8. Portamento
  9. Bend
  10. Tonality
  11. Jump
  12. Parameter
  13. Component
  14. Trigger

The black-key digits map to CC#106 values 27,29,31,34,36,39,41,43,46,48 (digits 1–0 respectively) and set the component config numeric value (max digit = 9).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment