PLUGIN=/Library/Audio/Plug-Ins/VST3/uaudio_lexicon_224.vst3
Everything else is derived from this line. The tools (render.py) read it too.
| Name | Meaning |
|---|---|
<bundle> |
the PLUGIN= path (.vst3 or .component) |
<exe> |
the Mach-O inside: <bundle>/Contents/MacOS/<file> (find it with ls) |
<name> |
<bundle> file name without the extension, e.g. uaudio_lexicon_224 |
<slice> |
analysis/slices/<name>-arm64 |
Understand the plugin's audio processing completely, write a behavioural spec, and build an independent C++ clone that sounds measurably the same.
- Measure the original: renders plus static analysis (REA/Ghidra).
- Specify:
analysis/SPEC.md, with enough detail to implement from. - Build the clone in
clone/. - Debug the clone against the original with A/B renders until it is within tolerance.
Principles:
- Audio processing comes first. Ignore the UI unless it explains a parameter.
- Original source code cannot be recovered, and that is not the goal.
- Describe behaviour: signal flow, delay lengths, gains, filter coefficients,
decay, modulation, nonlinearity, sample-rate handling. Never paste decompiled
code into the spec or the clone. Write the clone from
SPEC.md.
Everything you write goes under analysis/ or clone/. Do not use /tmp.
| Path | Contents |
|---|---|
analysis/TARGET.md |
Bundle path, exe path, version, SHA-256 of <exe> and <slice>, architectures, REA status |
analysis/FINDINGS.md |
Numbered findings (F-001, ...): Confirmed / Hypothesis / Unknown |
analysis/COVERAGE.md |
One row per host parameter: measured / static / in spec / in clone / passes A/B |
analysis/SPEC.md |
Behavioural spec for the clone (Phase C) |
analysis/LOG.md |
One entry per session: what was done, exact errors, open questions, Next step |
analysis/slices/ |
Single-architecture slices |
analysis/out/ |
Saved tool output (pseudo-code notes, string lists, analysis JSON, plots) |
analysis/audio/renders/ |
Rendered WAVs, each with a .json sidecar |
analysis/tools/ |
render.py, analyze.py: ready to use, see "Audio renders" |
analysis/archive/<name>/ |
Work on previous targets |
clone/ |
The C++ clone: source, CMake, build output in clone/build/ |
At the start of a session:
- Do the target check (below).
- Read
TARGET.md,FINDINGS.md,COVERAGE.md, then the last entry inLOG.md. - Continue from its Next step.
At the end of each step: update FINDINGS.md and COVERAGE.md, and append to LOG.md.
TARGET.mdmissing: run Phase 0.TARGET.mdnames a different bundle thanPLUGIN=: the user switched target. MoveTARGET.md,FINDINGS.md,COVERAGE.md,SPEC.md,LOG.md,out/,audio/renders/andslices/intoanalysis/archive/<old name>/. Moveclone/toanalysis/archive/<old name>/clone/. Then run Phase 0. Do not delete anything.- Same bundle: re-check only
shasum -a 256 "<exe>". If it differs fromTARGET.md, the plugin was updated. Stop and tell the user: old addresses and findings may no longer apply.
- Never modify, patch, re-sign, move, or overwrite the installed plugin:
<bundle>, its AU/VST3/AAX siblings, or the vendor's content or data folders. - Write only inside this project directory.
- Ask first before you: install or download anything (including JUCE or the VST3
SDK), edit any config (opencode, REA, shell, system, the plugin's own settings
files), delete files outside
analysis/andclone/, kill a process, or analyse files outside the target's bundles. - Methods on the original: static analysis (REA) and offline renders.
Licensing code, encrypted or obfuscated regions, and encrypted content files
hold no DSP. Note their address in
LOG.md, skip them, and measure the content's effect through renders. - Debug the clone with anything: lldb, sanitizers, logging, probes, unit tests.
- Never repeat a failing command or tool call unchanged. If two different approaches fail on the same problem, stop and report.
Run these and record the results in TARGET.md, including the bundle path
exactly as in PLUGIN=. These are macOS (BSD/LLVM) tools: do not use GNU-only
flags. Shell variables do not persist between commands, so write out full,
quoted paths.
| Purpose | Command |
|---|---|
| Executable name, version, compiler | plutil -p "<bundle>/Contents/Info.plist" |
| Bundle contents | ls -la "<bundle>/Contents" "<bundle>/Contents/MacOS" "<bundle>/Contents/Resources" |
| Manifests (class IDs, parameters, programs) | cat any moduleinfo.json / manifest.json / .plist in Resources |
| Sibling formats | ls /Library/Audio/Plug-Ins/Components /Library/Audio/Plug-Ins/VST3 | grep -i "<name>" |
| Architectures | lipo -info "<exe>" |
| Hash | shasum -a 256 "<exe>" |
| Linked libraries | otool -arch arm64 -L "<exe>" |
| Exported symbols | nm -arch arm64 -gU "<exe>" (count first: | wc -l) |
| Framework hints | strings -a "<exe>" | grep -m 20 -iE "juce|steinberg|iplug|dpf" |
| Signature (read-only) | codesign -dv --verbose=2 "<bundle>" 2>&1 |
| Make the slice | lipo -thin arm64 "<exe>" -output "<slice>" (if the exe is already arm64-only, cp it) |
| Slice hash | shasum -a 256 "<slice>" |
| Parameters | .venv/bin/python analysis/tools/render.py params -> creates the rows of COVERAGE.md |
| REA providers | rea doctor |
Large executables (50 MB or more) make Ghidra auto-analysis slow. Plan for that.
objdump on your PATH is Apple's LLVM objdump. GNU objdump is at
/opt/homebrew/opt/binutils/bin/objdump and has limited Mach-O/arm64 support.
Prefer otool, LLVM objdump, or REA.
Renders carry most of the work. Be systematic.
- Baseline. Render the defaults twice and
comparethem. This is the noise floor: how much two identical renders differ. Every effect you report must be clearly larger than this. - Deterministic mode. If renders are not repeatable, find which parameters cause it (usually modulation or random seeds): set them to minimum or off until two renders null below -100 dB. Measure everything else in that mode, then measure the modulation separately.
- Per-parameter sweep, one parameter at a time, everything else at default
(or in deterministic mode). For every row of
COVERAGE.md:- continuous: at least min / 25% / 50% / 75% / max (
raw:0,raw:0.25, ...) - stepped or enum:
--vary NAME=all - pick the signal for the effect: impulse (decay, EQ, delays), sine at
-30/-12/0 dBFS (gain, THD, compression curve),
modon a sine (LFO rate and depth), sweep (frequency response), burst (envelope, attack/release) - write the mapping as a table or formula: raw value -> display value -> measured value
- continuous: at least min / 25% / 50% / 75% / max (
- Sample rate.
--sr 44100,48000,96000: delays fixed in ms keep their ms; delays fixed in samples keep their sample count. Also check filter cutoffs and LFO rates. - Block size.
--block 64,512,2048should not change the output. If it does, record it. - Programs and presets. Render each program;
changed_from_defaultin the sidecar shows what it sets. Set programs before other parameters (render.py does this). - Interactions. After the single sweeps, test pairs that plausibly interact (for example size × decay, or drive × output).
Use the code to explain what the renders show: structure, exact constants, delay tables, coefficient formulas, smoothing, denormal handling, oversampling.
- Architecture map. Start at
_GetPluginFactory(VST3) or the AU factory symbol fromInfo.plist. Find the processor and controller classes (class IDs from the manifest). Search strings for RTTI names, framework names, parameter names (fromCOVERAGE.md), and resource names. Follow them inward to the DSP. - Parameter flow. For each parameter: where is it read, and what does it change? Map ID -> name -> range -> reader -> effect -> curve.
- Audio path. Find where the sample rate and block size arrive (setup / prepare), what is allocated there (buffer lengths = delay lines), and the per-block process function: filters, delays, feedback, gain stages, modulators.
- Constant tables. Arrays of floats or ints near the DSP (delay lengths,
coefficients, LFO tables) are very valuable. Dump them with
read_bytes, decode them with a command, and save them toanalysis/out/.
Write it so a developer who has never seen the plugin could implement it. Structure:
- Overview: block diagram (signal flow, mono/stereo, wet/dry topology), latency.
- One section per DSP block in signal order: purpose, inputs/outputs, the algorithm in plain words or formulas, every constant with units and its finding ID, and sample-rate behaviour.
- Parameter table: ID, name, range, default, taper/curve formula, smoothing time, which blocks it controls.
- Programs: the parameter values each program sets.
- Open items: everything still Hypothesis or Unknown, and how it was approximated.
Every spec statement cites a finding ID. If no finding exists, write the finding first.
- Framework: ask the user on first entry (default suggestion: JUCE + CMake, building VST3 and AU). Downloading a framework needs approval (see Rules).
- Layout:
clone/CMakeLists.txt,clone/src/dsp/(one class per SPEC block, no framework dependency),clone/src/plugin/(wrapper),clone/tests/. - Use the same parameter names, ranges and defaults as the original, so that
render.py --setand--varywork unchanged on both. - Build:
cmake -S clone -B clone/build -DCMAKE_BUILD_TYPE=Release && cmake --build clone/build. Record the exact command and the output path inLOG.md. - Never install the clone into
/Library/Audio/Plug-Ins. Render it fromclone/build/...with--plugin(use--settle 0if it loads instantly). - Unit-test each DSP block on its own (impulse response, gain, delay length) before you test the whole plugin.
Repeat until every COVERAGE.md row passes:
- Render the original and the clone with identical signal and settings
(
--name orig_X/--name clone_X, same--sr,--block,--signal). analyze.py compare orig.wav clone.wav; also runir,peaksormod, depending on the parameter.- Find the largest deviation. Find its cause (by bisecting: bypass blocks in the clone, add debug outputs, probe intermediate signals), fix one thing, then rebuild and re-render.
- Record the result per row in
COVERAGE.mdand per session inLOG.md.
Acceptance (default tolerances; tighten them if the noise floor allows):
| Metric | Tolerance |
|---|---|
| Deterministic settings, if the original is deterministic | null below -60 dB, or explain why not |
| T30 / EDT, broadband and per octave | ±5% |
| Band levels per octave | ±0.5 dB |
Discrete early arrivals (peaks) |
±0.5 ms, ±1 dB |
| Echo density, L/R correlation | within the original's run-to-run spread × 2 |
| Modulation rate / depth | ±5% |
| Static gain / THD at -30/-12/0 dBFS | ±0.2 dB / ±3 dB THD |
| Parameter curves | every swept point within the tolerances above |
| Sample rates 44.1 / 48 / 96 kHz | all pass |
Done means every parameter row passes at all three sample rates, the
programs pass, and SPEC.md has no open items left that are audible.
Ghidra runs headless inside REA. Tool names below are bare (open_binary).
opencode lists them as rea_<name>, Claude Code as mcp__rea__<name>. Use the
exact name your tool list shows.
Setup is done. Do not change it:
- Never run
ghidraRunor open the Ghidra GUI. - Do not set
GHIDRA_INSTALL_DIRorJAVA_HOME. REA's MCP config provides them. - If REA reports that Ghidra or Java was not found: stop and tell the user.
- Do not touch
~/Library/ghidra/, or Ghidra installers in/private/tmp.
Workflow:
open_binarywithpath= absolute path of<slice>. The parameter ispath, notinput_path. Never pass a universal binary. Auto-analysis can take many minutes. Wait for it.binary_overviewat most once per session (optional). If it times out or turns into a background task, do not call it again and do not guess its result.- Never pull a full list into context. Use
search_procedures/search_stringswith a pattern, or page throughinspect_analysis_viewin small pages.detail: summaryreturns counts only;detail: completereturns values. Save useful results toanalysis/out/. procedure_pseudo_codewith a name or address.xrefsonly with an explicit address.- Rename functions you have understood (
set_address_name) so later sessions benefit. close_binarywhen finished.
Errors:
target_unavailable: No app is open: runopen_binary, then retry once.mainor_startnot found: normal for a plugin. Start from the factory export.- Universal binary rejected: use the slice.
- Anything else: record the exact error text in
LOG.md, classified as install/discovery, import, architecture limitation, or unsupported option.
Process check (no GUI): ps -Ao pid,lstart,command | grep -i '[g]hidra'.
-Djava.awt.headless=true is REA's worker: leave it alone. ghidra.GhidraRun
is a GUI: stop it only with the user's approval.
Run the tools from the project root with the project venv:
.venv/bin/python analysis/tools/render.py ...
.venv/bin/python analysis/tools/analyze.py ...
Never use python3, /usr/bin/python3 or Xcode's Python: some plugins
(PACE-built) crash Apple-signed Python on load, and the user gets a crash
dialog. The tools refuse to start there. If .venv is broken (for example after
a Homebrew Python upgrade), stop and ask the user. Do not reinstall it yourself.
Do not rewrite the tools. Small fixes are allowed: describe them in LOG.md.
--plugin defaults to the PLUGIN= line. Pass it only for the clone or a sibling format.
render.py params # list parameters and valid values
render.py render [signal options] [settings] # render; writes WAV + JSON sidecar
render.py signal --signal sine --out FILE.wav # write a test signal only
Settings:
--set NAME=VALUE, repeatable. VALUE can be a display value (on,"Room A"), a number with or without a unit (720,4.8kHz; the nearest valid value is used), orraw:0.25(normalised 0..1).--vary NAME=V1,V2,V3(orNAME=allfor up to 128 values): one render per value. Several--varyflags render every combination.--program NAME. Parameters named like program/preset/bank/patch are always applied first.
Signal options: --signal impulse|sine|noise|sweep|burst|silence|file:PATH,
--sr 48000 (or a list 44100,48000,96000), --level -6 (dBFS), --dur 2,
--freq 1000, --tail 8, --preroll 0.05, --inject both|L|R.
Other: --plugin PATH, --plugin-name, --name BASE, --block 512, --save-input,
--settle auto|0|SECONDS (auto waits until the output is stable; plugins that
load data in the background output silence until then).
Output: analysis/audio/renders/<name>.wav (float32) and <name>.json. The
sidecar lists the requested values, the applied values as read back from
the plugin, changed_from_default, every parameter's final value, the plugin's
SHA-256, signal.impulse_index and settle. One line per render:
analysis/audio/renders/<name>.wav peak -34.0 dBFS settled 2.2s applied {...}
SILENT or WARNING: plugin did not settle means the render is invalid:
record it in LOG.md and do not analyse it. If applied differs from what you
meant, fix the value.
All times are relative to the signal start (from the sidecar), in ms and samples.
| Command | Use for |
|---|---|
info FILE |
length, peak, RMS, settings |
ir FILE [--plot] |
first arrival, peak, EDT/T20/T30 broadband and per octave, band levels, echo density, L/R correlation |
peaks FILE [--end-ms 120] [--n 25] |
strongest discrete early arrivals |
mod FILE [--plot] |
FM/AM rate and depth of a rendered sine |
compare A B |
alignment lag, null test, level difference, per-band T30 table |
spectrogram FILE |
PNG spectrogram |
Results are saved to analysis/out/<name>.<kind>.json (and .png). Quote
these paths as evidence. If you need a measurement that analyze.py lacks (THD,
transfer curve, frequency response from a sweep), write a small script in
analysis/tools/ and record it in LOG.md.
- Compare statistics (T30, band levels, arrival times), not sample values, unless the plugin is in deterministic mode.
- Change one parameter per test. Keep everything else identical.
- Keep the wet/dry mix at 100% wet unless you are testing the mix.
- Name the program or preset used in every finding.
Every finding is exactly one of:
- Confirmed: directly shown by inspection or a reproducible test.
- Hypothesis: plausible, not yet verified.
- Unknown: not enough evidence.
Each finding records: function name; address (Ghidra address in <slice>) or
render command; the evidence path; the relevant strings, constants or xrefs;
and how to confirm it. A constant found in code becomes Confirmed when a render
shows the matching behaviour.
- One suggestive string is not evidence of a language, framework or algorithm.
- Vendor marketing and descriptions of the original hardware are not evidence about this binary. Keep them as Hypothesis.
- Decompiled code is an approximation, not original source.
- Keep each extracted constant separate from what you think it means. Record
the raw hex and the decoded value, and decode with a command:
python3 -c "import struct; print(struct.unpack('>f', bytes.fromhex('3f800000'))[0])"('>d'and 16 hex digits for a double; this one-liner does not load the plugin, so plainpython3is fine).
Example:
### F-012 One-pole low-pass in the feedback path (Hypothesis)
- Where: FUN_0001a2c4, <slice> (SHA-256 in TARGET.md)
- Evidence: procedure_pseudo_code -> analysis/out/FUN_0001a2c4.notes.md
- Constant (extracted): 0x3f7b7e91 = 0.9824
- Meaning (inferred): a = exp(-2*pi*fc/fs); at 48 kHz, fc is about 136 Hz.
- To confirm: --vary <damping param> with --sr 44100,48000,96000; compare band levels.
In LOG.md and in your reply:
- the tool calls and renders you ran, with evidence paths
- new Confirmed / Hypothesis / Unknown items, by FINDINGS ID
COVERAGE.mdprogress (rows measured / specified / implemented / passing)- what is still open
- the Next step