maxpat-probe
v0.1.0
Published
Read Max/MSP patchers and Max for Live devices from the command line — inspect, search, extract and diff .maxpat and .amxd files without opening Max.
Maintainers
Readme
maxpat-probe
Read Max/MSP patchers and Max for Live devices from the command line. Inspect,
search, extract and diff .maxpat and .amxd files without opening Max.
No dependencies. Node 18+.
npm install -g maxpat-probe
maxpat-probe params MyDevice.amxdWhy
Two problems, both annoying in the same way.
.amxd files look like binaries. Every tool refuses to read them, git diff
reports Bin 3103816 -> 3653532 bytes, and code review is impossible. They
aren't really opaque — the patcher JSON is sitting in plain UTF-8 in the middle
of the file — but nothing will show it to you.
.maxpat JSON is enormous. A moderately complex device serialises to
megabytes. Opening one to answer "what parameters does this expose?" or "where
is the playhead control?" means paging through hundreds of thousands of lines
of patching_rect noise. If you are working with an LLM, that is the whole
context window gone — and a real chunk of your usage allowance — to answer a
question whose useful answer is forty lines long.
maxpat-probe answers those questions directly:
$ maxpat-probe params SIGNe-Screen.amxd
PATH CLASS PARAMETER VISIBILITY RANGE/TYPE
Settings live.dial Scroll Progress automated+stored type=0
Settings live.text Show Background Grid hidden type=2
Settings live.text Invert Mouse Scroll stored type=2
…
77 parameters — 53 hidden, 6 automated+stored, 18 storedIt never writes to the file it reads
Every command opens the input read-only. No command modifies, moves or
re-saves a .maxpat or .amxd, and writing a .amxd back out is not
supported at all — see Reading only, on purpose.
The single command that writes anything is extract, which only ever creates
new files under the directory you name.
extract will overwrite existing files in that directory without asking, so
point it somewhere fresh if that matters.
Dependency names come out of the file being read, which makes them untrusted
input. A crafted container can name a payload something like ../../.bashrc;
extract resolves every target and refuses anything landing outside the
output directory, reporting it rather than writing it:
REFUSED (path escapes ./out): ../../ESCAPED.js
1 refused — the file names a path outside the output directory.Devices Max produces never do this. If you see that message, treat the file as suspect.
The workflow this is built for
maxpat-probe reads. It does not write .amxd files, and that is deliberate
(see Reading only, on purpose).
The loop it is designed for, when you are working on a patch with an LLM:
- The assistant reads the patch through
maxpat-probe—findto locate a control,paramsto see what is exposed to Live,treeto understand the structure. Forty lines of answer instead of two megabytes of JSON. - The assistant proposes a change in terms you can act on: which device, which subpatcher, which object, which inspector setting.
- You make the change in Max, by hand, in the editor, where you can see what you are doing.
maxpat-probe diffverifies it — comparing the device before and after confirms that exactly what was intended changed, and nothing else did.
Step 4 matters more than it sounds. Max rewrites parts of a patcher file on
every save, so "did my edit do what I think it did?" is a real question with a
non-obvious answer. diff answers it.
This division is not a limitation to work around. Max is a visual editor with
its own consistency rules; hand-writing patcher JSON produces files that load
but behave strangely. Let Max write, and use maxpat-probe to decide what to
change and to check what changed.
Commands
maxpat-probe <command> <file> [options]Inspect
stats <file>
Count boxes by maxclass — Max's name for an object's type (newobj for a
typed-in object, live.dial for a dial, comment for a comment, and so on).
A quick sense of size and composition.
$ maxpat-probe stats SIGNe-Screen.amxd
newobj 2054
message 179
comment 88
live.dial 27
…
TOTAL 2562tree <file> [--depth N]
The subpatcher hierarchy. A subpatcher is a patch inside a patch — an object
box typed p SomeName that opens into its own window. --depth 1 shows only
the top level.
$ maxpat-probe tree SIGNe-Screen.amxd --depth 1
p MarqueeSelection [-] boxes=143
p Settings [-] boxes=264
p Properties [-] boxes=1634Note that abstractions do not appear here. An abstraction is a separate
.maxpat file used as an object; it is referenced by name, not nested inline,
so its contents live elsewhere. In a frozen device they are inside the
container — see deps and extract.
params <file>
Every live.* object that exposes a parameter to Live, with its Parameter
Visibility — the setting that decides whether Live saves and automates it.
This is the first thing to check when a control "doesn't remember its setting";
that is usually a hidden that should be stored.
Max offers five modes. The stored encoding is parameter_invisible, confirmed
by setting one control to each mode and reading the saved file back:
| Value | Inspector label | Automatable | Saved with set |
| --- | --- | --- | --- |
| absent / 0 | Automated | yes | yes |
| 1 | Stored Only | no | yes |
| 2 | Hidden | no | no |
| 3 | Visible | no | yes |
| 4 | Visible (Not Stored) | no | no |
The two "Visible" modes are newer. They expose a parameter to Push and other control surfaces without giving it an automation lane, are offered only for Float, Int and Enum parameter types, and are the only ones that surface the extra Undo When Visible attribute in the inspector.
Any value outside this table is printed as unknown(N) rather than guessed at,
since a wrong label here would be worse than no label.
Add --json for machine-readable output including visibility (the raw
number), initialEnable, initial, modmode, unitstyle, shortname and
the object's rectangle.
Parameter Visibility is a different setting from Initial Enable, which only controls whether the stored Initial value is pushed to the object when it is first created. Initial Enable has no bearing on whether a value is saved with or recalled from a Live set.
ui <file>
UI objects with their on-screen rectangles — dials, panels, comments, pictures, buttons. Useful for "what is at these coordinates" and for spotting overlaps.
find <file> <regex> [--all]
Search every box's class, variable name and text. Case-insensitive.
$ maxpat-probe find SIGNe-Symbol.amxd 'rotation'
(root) live.dial Duplicate Rotation 3614 1754 27 37
(root) newobj - 3613 1814 176 20 AutoModGate DuplicateRotation 15--all also searches the abstractions embedded in the device, which is where a
lot of the real implementation lives. Matches are then prefixed with the file
they were found in.
The pattern is matched against "<maxclass> <varname> <text>" joined together,
so anchoring with ^ matches the start of the class, not the text.
boxes <file> <path>
Everything at one subpatcher path, as printed by tree. Use "" for the top
level.
$ maxpat-probe boxes SIGNe-Screen.amxd SettingsContainer
These apply to .amxd only; a .maxpat has no container around it.
deps <file>
The embedded dependencies, from the tail index. Also reports whether the device is frozen at all:
$ maxpat-probe deps SIGNe-Screen.amxd
TYPE NAME SIZE
JSON SIGNe-Screen.amxd 2906644
JSON PathManagement.maxpat 9071
TEXT marquee_selection_instancing.js 110313
TEXT signe_atlas_instancing.jxs 4007
PNG SIGNe_DefaultIcon.png 10025
…
17 embedded filesA device that carries an anno chunk has its own description printed first —
plain text written by whoever built it, and the only account of what a device
does that travels inside the file:
$ maxpat-probe deps LiveAPI-Interactive.amxd
Display an interactive 'Terminal' style prompt for querying the LiveAPI.
TYPE NAME SIZE
JSON LiveAPI-Interactive.maxpat 21835
…Only six of 5,183 community devices carry one, so most of the time you will not
see it. --json includes it as annotation.
An unfrozen device says so loudly, because shipping one is a common and badly-diagnosed mistake — it resolves its dependencies through the Max search path of whoever opens it, so it works on your machine and breaks on theirs.
That is not a hypothetical, and the rate is remarkably stable. Across all 5,085 readable devices available for direct download from maxforlive.com — every category, every page — 2,859 are frozen and 2,226 are not. Most of the unfrozen ones are harmless: 1,682 reference nothing outside Max, so there is nothing for them to fail to find.
But 544 of them, 11% of everything on the site, are unfrozen and name a file they do not carry. The proportion barely moves between categories:
| Category | Devices | Will not work elsewhere | | --- | --- | --- | | Audio effects | 1,834 | 189 (10%) | | Instruments | 541 | 57 (11%) | | MIDI effects | 2,622 | 293 (11%) | | MIDI generators | 43 | 3 (7%) | | MIDI transformations | 45 | 2 (4%) |
133 of them name an absolute path on their author's own disk, which cannot resolve anywhere else under any circumstances:
3D Panner.amxd Macintosh HD:/Users/<author>/Desktop/3D Pan.png
AirWindows_CreamCoat64… C:/Users/<author>/Website Assets/Pictures/…
CutOff 1.03.amxd D:/Imagenes/PERFILES/AMANTE_00.jpg
Elektron_Clock_Sync… /Users/<author>/Desktop/2touch/Want2_Assets/…
ESX8GT Grids_M1.amxd Macintosh HD:/Users/<author>/Desktop/MI_Icon.pngEach of those loads, shows its interface, and silently does not work. The author never saw it, because on their machine the file is right there.
This is the single most useful thing deps does, and it takes one command to
check before you publish.
A file with no index gets one of three answers, because they call for very
different responses: UNFROZEN (re-freeze it), DAMAGED (the patcher will
not parse either — most likely truncated in transit, so check the file size
against the source), or ENCRYPTED. The last is not a fault: Ableton ships
its own built-in devices with the patcher in a ciph chunk instead of ptch,
and nothing can be read out of one — 41 of the 404 devices installed on the
machine this was tested against. Your own devices are never encrypted.
extract <file> [--out DIR] [--only NAME]
Write the embedded files out to disk, byte-exact.
Why you want this: the script inside a frozen device is a copy. Editing
myscript.js in your source tree changes nothing until the device is
re-frozen. extract lets you diff what actually shipped against what is in
your repo — the two drift silently otherwise.
json <file> [--only NAME]
The patcher JSON to stdout. This is the whole document, so it is large; it
exists mainly to feed git (below) and to pipe into other tools. --only emits
one embedded dependency instead.
Compare
diff <old> <new>
A structural change report. git diff cannot help with a device file, and git
cannot three-way merge one either.
$ maxpat-probe diff before.amxd after.amxd
PARAMETERS
~ Show Background Grid
live.text in Settings type=2 hidden
live.text in Settings type=2 stored
BOXES (+1 added, -0 removed, 53 changed)
attributes changed: hidden×51, patching_rect×1, items×1
+ Settings newobj print OverlayMenuUIVisibility
~ Settings comment GRID
hidden: 1 -> (absent)
~ Properties umenu #4
items: ["SIGNe_DefaultIcon",",","User"] -> "SIGNe_DefaultIcon"
CONNECTIONS (+1 added, -0 removed)
+ Settings/[Settings] [0] -> Settings/print OverlayMenuUIVisibilit [0]Five things are compared, and the ones after the first are what catch the changes you weren't looking for:
Embedded dependencies — added, removed, or content changed (compared by hash, so a same-size edit is still caught). Also flags a change in frozen state. A device whose script changed but whose patcher didn't is otherwise indistinguishable from an unchanged one:
EMBEDDED DEPENDENCIES
- jit.gl.spoutsender.mxe64 311808B
- marquee_selection_instancing_v8.js 126136B
~ marquee_selection_instancing.js 125196B -> 110313B (content differs)Parameters — added, removed or changed. A change of visibility or initial value shows up rather than passing silently.
Boxes — paired on subpatcher path, class, variable name and text, then
compared in full. Not just position: a changed colour, hint, font, menu
contents, presentation_rect, or hidden flag is reported, with the attribute
named and its before/after values. The attributes changed: tally at the top
summarises a run of identical edits so that fifty boxes toggling one flag reads
as one line rather than fifty.
This matters more than it sounds. Max writes a lot of runtime state into the
patcher file when you save — which panel of a tabbed interface was showing,
what a umenu had been populated with — so opening a device to look at
something and saving it can persist changes you never intended to make. A diff
that only compared positions would show you none of that.
Connections — patchlines reference boxes by id, which is unstable, so a
raw comparison of the connection lists is meaningless. Each endpoint is
translated through the same stable box key, which makes rewiring visible.
Patcher attributes — settings on a patcher rather than on a box:
openinpresentation, gridsize, the window rect, the project manifest. A
change stamped across every patcher at once (appversion after a Max update,
typically) is reported once with a count rather than repeated for each:
PATCHER ATTRIBUTES (22 patchers changed)
appversion (×22 patchers): {"major":9,"minor":1,"revision":4,…} -> {"major":9,"minor":1,"revision":5,…}
~ Settings
visible: (absent) -> 1Long values are truncated for readability; maxpat-probe json has the full text.
A known limitation
Boxes identical in subpatcher, class, varname and text — several copies of the same abstraction side by side, say — can only be paired by document order. Reordering them can therefore read as a change. When such a group is involved in a reported change, the diff says so explicitly rather than leaving you to wonder:
NOTE 2 of the reported changes involve groups of identical sibling boxes
(same subpatcher, class, varname and text). These are paired by document
order, so a reordering within a group can read as a change.Changing an object's text also reads as one box removed and another added, since text is part of a box's identity.
Why not pair on
id? Every box in a patcher carries anidlike"obj-177". It is not a stable identifier — it is a per-document serial number that Max hands out as it writes the file, and it reshuffles on almost every save. Adding one object near the top can renumber hundreds below it. Diffing onidtherefore reports an entire patch as changed when nothing meaningful did.maxpat-probeignoresidentirely and identifies a box by where it sits and what it says, which is stable across saves. A test asserts this: renumbering everyidproduces an empty diff.
Options
| Option | Effect |
| --- | --- |
| --depth N | limit tree depth |
| --out DIR | output directory for extract |
| --only NAME | act on one embedded dependency instead of the device patcher |
| --all | find also searches embedded abstractions |
| --json | JSON output for params, stats, deps |
Reading patches from the forum
Patches on the Cycling '74 forum are almost never attached as files. They are pasted as a Copy Compressed block:
----------begin_max5_patcher----------
381.3ocyT91aBBCDF+89onoulsPgglsuJFioVu3pCZIsEGFie2Wuh+AyXLXK
yLR.JWeN5C+n2cXBwePWoqAK8Ex7vi3wgKiNKvO8sACSTvqE4bKlLUAuqWsk
…
-----------end_max5_patcher-----------maxpat-probe reads those. Save the post to a file, or pipe it in — every
command works on a blob, because it is decoded before anything else happens:
$ maxpat-probe stats forumpost.txt
flonum 1
newobj 1
number~ 1
--------------------------------
TOTAL 3
$ pbpaste | maxpat-probe find - 'gate'
$ pbpaste | maxpat-probe json - > patch.maxpatThat last one is the useful one: it turns a forum post into a file you can open, diff, or keep.
Going the other way
compress emits a block ready to paste into a post:
$ maxpat-probe compress MyAbstraction.maxpat
----------begin_max5_patcher----------
395.3ocoRsjabCCC8pXv0LAxNSBRzEHHKxIXfw.ZalYz.8wPR1wEF9tWHU25
…
-----------end_max5_patcher-----------It reduces the patcher to the shape Copy Compressed produces — boxes, lines,
and the version keys, without the window attributes that belong to a document
rather than to a selection — then compresses at level 9, which is what Max uses.
That last detail is checkable, and it is the one externally verifiable sign that
the zlib header is right: every block Max writes begins 3o, and the default
compression level would produce 3w.
Every block is decoded again before it is printed, so one that could not be read back is an error rather than something you find out about after posting it.
One caveat. This produces a block that is well-formed by every rule derived from real samples and that round-trips through the decoder. Whether Max accepts it on paste has not been verified, because that needs Max. If you try it, the result is worth an issue either way.
What you get is a fragment, not a document. Copy Compressed copies a
selection, so the JSON is {boxes, lines, appversion, classnamespace} with no
enclosing "patcher" key. maxpat-probe treats a bare object carrying boxes as
a patcher, so this is invisible in normal use — but it is why tree usually
shows nothing for a blob. There are rarely subpatchers in a pasted selection.
The encoding
Cycling '74 does not document it. What follows was derived by reading real blobs, and is checked against 71 of them found in the Max 8 and Max 9 documentation and in third-party repositories — all 71 decode to valid JSON and all 71 match their own declared length.
The 84 span roughly fifteen years: Max 9.2, 9.0, 8.6 and 8.1, plus six that
carry no appversion at all. Those six predate the field and default to Arial,
which places them in Max 5 — so the "max5" in the marker is accurate, and the
encoding has not changed since.
Note that the Cycling '74 forum itself will not give you a blob by reading the page. A posted patch renders as a card with a Copy Patch button and the text lives nowhere in the HTML, so the way to get one is the way a person does it: click Copy, then pipe it in.
381.3ocyT91aBBCD…
│ └── payload
└────── number of compressed bytes the payload encodesThe payload is a continuous least-significant-bit-first stream, six bits per character, over this 64-character alphabet:
.ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+Those bytes are a zlib stream; inflating gives the JSON.
Three things will catch you out if you write your own decoder:
- It is not base64. The alphabet is rotated so
.leads and+trails, and the bit order is reversed. Feeding it to a base64 decoder yields plausible bytes that inflate to nothing. - The trailing group can be partial. Four characters carry three bytes, but a final group of two or three characters is legal and carries one or two. Handle only whole groups and you truncate the stream — zlib then reports an unexpected end of file. Two thirds of real blobs end this way, which is exactly enough to make a naive decoder look like it works.
- The declared count is compressed bytes, not decompressed. It is worth checking: a forum post that lost a line while being copied fails this test before zlib gets a confusing chance to.
Rendering .amxd diffs in git
Git has a feature called textconv: a per-filetype command that converts a
file to text for display purposes only, so that a file git would otherwise
treat as binary can be diffed readably. ("Driver" here is git's word for a
pluggable handler — nothing to do with device drivers. It is also unrelated to
Microsoft's texconv texture tool, which is a similar-looking name for an
entirely different thing.)
Point it at maxpat-probe json and git will show device diffs as patcher JSON.
Two pieces are needed. First, in .gitattributes — commit this, it is shared:
*.amxd diff=amxdThat says "use the diff driver named amxd for these files" but does not say
what that driver is. Second, name the command — this is per clone, and cannot
be committed:
git config diff.amxd.textconv "maxpat-probe json"
git config diff.amxd.cachetextconv trueGit deliberately refuses to let a repository configure its own diff drivers: a driver is an arbitrary shell command, so cloning a repo would otherwise mean executing whatever its author chose. Every clone opts in explicitly.
After that, git diff, git log -p and git show render devices as JSON.
What this does and doesn't change
It is display-only. Git still stores .amxd as binary, still cannot merge
two changed versions, and the diff is not applyable as a patch. git diff
--stat still prints Bin 3103816 -> 2906500 bytes, because the stat line
reports storage, not display.
Only tools that run git diff benefit. The git CLI does. Anything that
implements its own diff rendering — including some GUI clients — may not, since
they often detect binary content themselves and skip the driver, or use a git
library rather than the git command. Worth testing in your client rather than
assuming: make a trivial change to a device and see whether it renders.
The .amxd container
Useful to know if you are debugging something odd.
offset 0 "ampf" u32le(4) "mmmm" magic, then the device type
offset 12 "meta" u32le(4) u32le(7) format metadata
offset 24 "ptch" u32le(size) everything below
offset 32 "mx@c" u32be(16) … 16-byte header ahead of the JSON
offset 48 { the device's own patcher JSON, plain UTF-8
… … embedded dependencies, concatenated
tail "dire" records index describing those dependenciesThose offsets are typical, not guaranteed. A device with no meta chunk
puts ptch at offset 12, so its content starts at 20 rather than 32. Walk the
chunk chain; do not hardcode the constant.
That is not a rare quirk. It is uncommon in Ableton's own output — 3 devices in 486, all copies of Max Compressor — but 10 of 55 third-party devices tested here are built that way, including every device in one published suite. A reader that assumes 32 works fine against factory content and then fails against real community devices, which is the worst way to discover it.
The file is a series of chunks. A chunk is a four-character tag naming what
follows, then a length, then that many bytes of content — a common way to build
extensible binary formats, in the same tradition as RIFF for WAV or FORM
for AIFF.
The tail index — the term used throughout this document — is the run of
dire records sitting at the end of the file, after all the embedded data.
"Tail" because it is at the end; "index" in the book sense, a table of
contents. Each record names one embedded file and says where to find it. It is
a descriptive name rather than an official one; Cycling '74 does not document
this format publicly, and everything here was derived by reading real files.
The four-character tags are Max's own, and short because the convention dates from when tags were literally four bytes:
| Tag | Meaning |
| --- | --- |
| ampf | the file's magic — the signature bytes at offset 0 that identify the format. Every .amxd starts with these four characters, which is how a tool knows what it is holding without trusting the file extension |
| ptch | patch — the chunk containing the patcher and everything embedded with it |
| ciph | cipher — stands in place of ptch when the patcher is encrypted. Ableton's built-in devices are shipped this way |
| anno | annotation — the device's own description, as plain text. Optional, and sits between ampf and ptch when present, which is another reason to walk the chunk chain rather than assume offsets |
| mx@c | a fixed 16-byte header sitting between the start of the ptch content and the JSON. Its length is declared in the big-endian u32 at +4 |
| dire | directory — one record in the tail index, describing one embedded file |
| type | the embedded file's kind — JSON, TEXT, svg, PNG, PICT, mx64, iLaX, or empty. See How externals are embedded |
| fnam | filename — e.g. MIDITrigger.maxpat |
| sz32 | size, 32-bit — how many bytes the embedded file occupies |
| of32 | offset, 32-bit — where it starts, measured from the beginning of the ptch content |
| vers, flag, mdat | version, flags, and a checksum-ish trailer; not needed for reading |
The four bytes immediately after the ampf tag and its length say what kind of
device this is. Counts are from the 219 devices in Live 11 Suite, Live 12 Suite
and SIGN/e:
| Code | Device type | Seen |
| --- | --- | --- |
| aaaa | audio effect | 152 |
| iiii | instrument | 40 |
| mmmm | MIDI effect | 23 |
| nagg | MIDI generator (a Live 12 MIDI Tool) | 2 |
| natt | MIDI transformation (a Live 12 MIDI Tool) | 2 |
A Max for Live device is frozen: every abstraction, script, shader and image
it depends on is copied inside it. One .amxd therefore holds many documents.
SIGNe-Text.amxd, for instance, carries 12 patcher documents plus JS and SVG
payloads. The dire records are what make them recoverable — of32 and sz32
give each one's exact byte range.
How externals are embedded
What an external looks like inside a device depends on which binaries its package shipped — not on the platform you read the file with, and not, as you might expect, on the platform it was frozen on either. Max embeds every build it finds, so a device frozen on a Mac can carry a Windows DLL, and does.
The record shape follows the kind of file. A DLL is one file and becomes one
record. A .mxo is a bundle — a directory — which cannot be stored as one
payload, so Max decomposes it into four.
A DLL becomes a single record tagged mx64:
mx64 jit.gl.spoutsender.mxe64 311808A .mxo bundle becomes a marker plus three parts, all three of which have
no type tag at all:
iLaX live.stripstyles.mxo 4
(untyped) live.stripstyles/zipfile 8406
(untyped) live.stripstyles/executable 32892
(untyped) live.stripstyles/plist 1530iLaXis a 4-byte marker standing for the bundle. Its contents are four NUL bytes and nothing else.- The three untyped records carry the real content: a ZIP of the whole bundle,
the Mach-O executable, and the
Info.plist. Thezipfilealready contains the other two, so Max stores them redundantly — byte-identical, confirmed by hash.
A device can carry both. ease_pedale.amxd, frozen on an Apple Silicon Mac
with Max 9.1.5, uses the ease package, which ships both builds:
iLaX ease~.mxo 4
(untyped) ease~/zipfile 171940
(untyped) ease~/executable 583296
(untyped) ease~/plist 1666
mx64 ease~.mxe64 422912file(1) on that last record: PE32+ executable (DLL) (console) x86-64, for MS
Windows. A macOS freeze embedded a Windows DLL, so the frozen device runs on
either platform. A reader that decides how to handle a payload from the platform
it is running on — or from the one the device was built on — gets this wrong.
The same rule explains a device that carries only mx64: SIGN/e embeds Spout,
which has no macOS build, so there is no bundle to decompose.
Ableton's Note Echo.amxd makes the point from the other direction. It was
frozen on a Mac, and the Windows and macOS installs of Live 10 ship the
identical file — so the Windows copy contains a Mach-O executable, complete
with __MACOSX AppleDouble sidecars, that could never load on Windows.
Three of those four bundle payloads end in a NUL byte — the marker, the ZIP
and the executable; only the Info.plist does not. Add the mx64 record from a
package shipping both builds and it is four out of five. That is why the
decision about what to NUL-trim has to be an allowlist of text types rather than
a list of known binaries: nobody had seen iLaX or an empty tag when this
parser was written, and both fell through to "binary" correctly because unknown
means binary. Invert that rule and you silently truncate a Mach-O.
maxpat-probe prints an empty tag as (untyped), since rendering it literally
produces a blank column that reads as a display fault.
Building a device that exercises all of this
No such device is committed here. One would embed a third-party package's compiled binaries, and redistributing those is that author's call rather than this project's — so the fixture is a recipe instead. It takes a few minutes in Max and produces a file that covers every record shape at once.
- Install a package that ships both a
.mxoand a.mxe64build of an external.easeis one. Package Manager, or drop it inPackages/. - Make a Max Audio Effect device. Put that external in it, and one trivial
abstraction saved under a filename with a non-ASCII character —
Pédale.maxpatis the one used above. - Freeze it.
The result carries JSON, iLaX, untyped and mx64 records in one container,
four of its five non-JSON payloads ending in NUL, plus a non-ASCII dependency
name. Check it with:
maxpat-probe deps yourdevice.amxd # every record shape, labelled
maxpat-probe extract yourdevice.amxd --out ./out
npm run corpus -- . # the NUL / trim summaryEvery payload should come out at exactly its declared size, except the device's own patcher, which loses one byte of NUL padding because it is a text type. If a binary comes out short, the trim allowlist has been broken.
The shape has held everywhere it has been looked for. Note Echo.amxd
(Ableton, frozen 2017, i386 + x86_64), ease_pedale.amxd (Max 9.1.5, frozen
2026 on Apple Silicon, x86_64 + arm64) and a published third-party device
carrying six FluCoMa externals all produce byte-for-byte the same four-record
structure, redundancy included — across nine years and two architecture
transitions. Bundle contents do vary: the newer bundles carry a
_CodeSignature directory the 2017 one does not, so nothing should assume a
fixed file list inside the ZIP.
The both-builds case is not exotic either. LivePilot_Analyzer.amxd embeds six
externals, each as a Windows DLL and a macOS bundle — 31 dependency records in
one device, of which 24 are the two external shapes interleaved.
Traps in the format
Every one of these was found by running the parser over real files and having it fail. They are recorded here so the next person writing a reader doesn't have to rediscover them.
of32 is relative, not absolute. Offsets are measured from the start of
the ptch chunk's content, not from the start of the file. Treat them as file
offsets and every extraction is 32 bytes off.
Sizes are stored inconsistently. The outer chunks use little-endian lengths
that exclude the 8-byte header; dire and its sub-chunks use big-endian
lengths that include it. Both conventions appear in the same file.
dire also occurs as ordinary text inside the patcher JSON — the word
"direction" contains it. So finding the index cannot just be a search for those
bytes. maxpat-probe validates a candidate by parsing the whole record chain
from it — each record's length says where the next begins, so the records form
a chain — and accepting it only if that chain lands exactly at EOF (end of
file). The real index is the last thing in the file; a false match inside the
JSON runs off into unparseable bytes long before the end.
Do not scan for the first { to find the JSON. The mx@c header ends in a
32-bit size, and that size can contain the byte 0x7b, which is {. Ableton's
Poly Vocoder has 0x000b2c7b, so a naive scan starts one byte early and every
parse of that device fails with a baffling message. Read the header's declared
length from the big-endian u32 at +4 instead.
A dire record's size is not always its document's length. Max
Compressor's own patcher record declares 101536 bytes for 98216 bytes of JSON;
the surplus is a PNG with no dire record of its own. Write the record out
verbatim and you get a .maxpat with an image glued to the end. Since a JSON
file holds exactly one top-level value, clamp to the closing brace — but only
when what follows is more than whitespace, or you will eat the trailing newline
that every .maxpat genuinely does end with.
Max's JSON is not quite JSON
Two things JSON.parse rejects turn up in files Max itself wrote. 333 of the
10,051 readable files installed with Max 8/9 and Live 9-12 have one or the
other:
Floats with a trailing point and no fractional digits — "rect" : [ 595.,
44., 497., 630. ]. Common in older patchers. The error you get is the
unhelpful Unterminated fractional number.
A raw control character inside a string literal, which JSON forbids outright. Six files have one, each inside a base64 blob of saved VST state.
maxpat-probe parses strictly first, so a well-formed document is never
rewritten, and only falls back to a repair pass on failure. That pass is
string-literal aware: a comment reading see fig. 3. must survive untouched,
or extracted text stops matching the source it came from.
Reading only, on purpose
maxpat-probe will not write a .amxd back out.
Changing one byte of the patcher JSON means, at minimum:
- rewriting the length stored in the
ptchchunk size field, since the chunk's content just got longer or shorter; - rewriting every offset in the tail index — each
of32is an absolute position within theptchcontent, so inserting a single character shifts every embedded file that follows it, and each one's recorded position is now wrong; - preserving the padding — the stored sizes for text payloads include trailing NUL and whitespace bytes that align records to predictable boundaries. Recompute a size without accounting for that padding and the index describes ranges that no longer match the data.
Get any of it subtly wrong and you get a device that loads but misbehaves, which is far worse than one that fails loudly — you would be debugging it in Live, at a distance from the actual cause. Max is the writer.
Plain .maxpat files are ordinary JSON, so edit those directly if you want.
Tests
npm testTwo layers.
Unit tests run against a small .maxpat committed in test/fixtures/, so
they work in a clean checkout with no setup. They cover parameter extraction,
three-state visibility, subpatcher walking, and brace-matching around braces
inside string literals. The diff tests are the load-bearing ones:
- renumbering every
idyields an empty diff; - a non-positional attribute change is still detected;
- an added connection is detected, and named by its endpoints rather than by the ids the file stores.
Container tests need real .amxd files, which cannot be committed here —
they would be other people's copyrighted devices. Point the suite at a
directory of your own:
MAXPAT_PROBE_FIXTURES=/path/to/devices npm testWithout that variable those tests skip rather than fail. When it is set, every device in the directory is checked to confirm that:
- the container is recognised and its patcher parses;
- every payload the tail index describes really is a well-formed document of
its declared type — each
JSONentry parses as JSON, eachPNGhas the PNG signature, eachsvglooks like SVG. This is the important one: it proves the offsets and sizes were read correctly, since a half-byte-misaligned slice will not parse; - the index agrees with an independent brace-scan of the file on how many patcher documents are present — two different methods reaching the same answer.
An unfrozen device is reported as skipped, not failed; it genuinely has no index to check. So is an encrypted one, which has nothing readable in it at all — so you can point the suite at a folder of Ableton's own devices without it drowning in failures.
Status
Early, but no longer only smoke-tested. The parser is run over every .amxd
and .maxpat shipped with Max and Live on this machine:
| Corpus | Files | Result |
| --- | --- | --- |
| Max for Live devices | 486 | 445 read clean, 41 correctly reported as encrypted, 0 failures |
| Plain .maxpat patchers | 9,606 | all parse |
Drawn from Live 9, 10, 11 and 12 Suite plus Max 8 and 9. A second machine ran the same suite over macOS installs of Live 9 through 12 — 6,100 files, also zero failures.
| Third-party corpus | Files | Result | | --- | --- | --- | | 24 openly-licensed GitHub repositories | 274 | 55 devices, 219 patchers, 0 failures | | maxforlive.com, every direct-download device | 5,271 | 5,183 devices, 0 parse failures, 10 inconsistent containers |
Those last two rows matter more than their size suggests. They are the only
parts of the corpus not written by Ableton or Cycling '74, and between them they
produced every surprise worth documenting here: the common missing-meta
layout, the both-builds external device, seventeen dependency type tags that do
not occur in a single factory file, and the only two containers whose index
disagrees with their own data.
The type tags are the clearest illustration of why the trim rule is an
allowlist. Six tags were known from 10,092 factory files. Reading a few hundred
community devices took that to twenty-three — gDSP, AIFF, WAVE, Mp3,
xdll, GenX, iLaF, maxb, GIFf, BMP and more. Every one was handled
correctly without a code change, and nine of them end in NUL bytes that a
denylist would have quietly trimmed away.
For the frozen devices that goes further than parsing the patcher: every payload the tail index describes is checked to be a well-formed document of its declared type, which is what proves the offsets and sizes were read correctly.
That corpus is older than it looks. Factory devices are frozen once and carried forward, so Live 12 still ships devices last saved by Max 6, and the spread runs from Max 5.1 to Max 9.1:
| Last saved by | Devices | Patchers | | --- | --- | --- | | Max 5.1 | — | 12 | | Max 6.0 – 6.1 | 253 | 819 | | Max 7.0 – 7.3 | 76 | 1,843 | | Max 8.0 – 8.6 | 16 | 2,328 | | Max 9.0 – 9.1 | 18 | 514 |
Testing on a second operating system is mostly redundant, which is worth
knowing before anyone spends a day setting one up. Live 11 and 12 ship
byte-identical .amxd files on Windows and macOS — same devices, same
encrypted set, same dialect-repair count — so agreement across platforms on
those files proves nothing about platform handling. A .amxd is a byte
container: what varies is the machine a device was frozen on, and that
travels inside the file. The parts genuinely worth running on a second OS are
the path-safety tests, which are platform-conditional, and extract, which
writes to a real filesystem.
What it does not cover is third-party devices. Every file above was built
by Ableton or Cycling '74 — one team, one freezing pipeline, one set of project
conventions. The layouts that break a parser come from everyone else. If you
have devices from other developers, running npm run corpus against them is
the single most useful thing you can do with this repo, and a failure there is
worth an issue.
The traps listed above were all found this way, and each one was invisible until a real file exercised it.
Run it against your own install, which takes about a minute and needs no setup:
npm run corpus # search the usual install locations
npm run corpus -- /path/to/devices # or point it somewhereIt walks everything it finds, reports failures, and prints the container
layouts and dependency type tags it saw. That last table is the one to look at
on a platform other than Windows: it flags any payload type that ends in a NUL
byte, which is the signature of the trimming bug that truncated a compiled
external by 104 bytes. A tag showing up there as text, trimmed while ending
in NUL is a bug — please open an issue.
Reporting a problem. The single most useful thing is the file that reproduces it. Failing that, the output of:
maxpat-probe deps yourdevice.amxd
maxpat-probe --versionplus your Max version and platform. If the file is private, deps output
alone is often enough — it shows the tail index, which is where format
surprises turn up.
If you have devices to test against, pointing the suite at a folder of them is a real check rather than a smoke test — it verifies that every payload the index describes is a well-formed document of its declared type:
MAXPAT_PROBE_FIXTURES=/path/to/devices npm testNot affiliated with or endorsed by Cycling '74 or Ableton.
License
MIT
