Build Quad Cortex presets with correct grid routing, I/O, and multi-amp/parallel topologies — and verify them. Use when creating or editing presets (especially multi-amp, parallel, or shared-front rigs), wiring inputs/outputs, sharing one pedal chain across parallel amps, or whenever a build "doesn't take" / reads come back empty. Encodes the routing rules, I/O conventions, and the reliability tricks that make building actually work over the bridge.
Installs into .claude/skills of the current project.
Are you the author of Build Preset Routing?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/lexasoft123-build-preset-routing)
---
name: build-preset-routing
description: Build Quad Cortex presets with correct grid routing, I/O, and multi-amp/parallel topologies — and verify them. Use when creating or editing presets (especially multi-amp, parallel, or shared-front rigs), wiring inputs/outputs, sharing one pedal chain across parallel amps, or whenever a build "doesn't take" / reads come back empty. Encodes the routing rules, I/O conventions, and the reliability tricks that make building actually work over the bridge.
---
# Build QC presets: routing, I/O, and parallel topologies
The grid is **4 rows × 8 columns**, fed by two DSP cores (see `optimize-preset-cpu` /
`docs/CPU.md`). Building reliably needs three things right: the **routing model**, the
**I/O conventions**, and the **read/verify loop**. Get any wrong and you ship silent or
mis-wired presets — every failure below was hit for real.
## 0. Always verify — never trust the write
`build_preset`'s own trailing read is unreliable (it fires amid the edit-echo storm).
After any build/edit, **read back with `get_current_preset`** (now includes
`split_points` per chain) AND, for topology, **screenshot the grid**:
`tools/gui/gui.py home && tools/gui/gui.py shot out.png` then look at the wiring.
Cross-check with `cpu_load` (its block list reveals duplicates / orphaned blocks).
## 1. Routing model
- **Lane ports** — `in_port` / `out_port` per chain (row). Full `out_port` map (verified by
clicking through Cortex Control's output block and reading `chains[i].out_port`):
| out | dest | out | dest | out | dest |
|----|------|----|------|----|------|
| 1 | Out 1/2 | 8–9 | Send 1 / 2 | 16 | **Row 3** |
| 2 | Out 3/4 | 10–13 | USB 5–8 | 17 | **Row 4** |
| 3 | Send 1/2 | 14 | USB 5/6 | 18 | **Row 3/4** |
| 4–7 | Out 1 / 2 / 3 / 4 | 15 | USB 7/8 | 19 | **Multi Out** |
| | | 20–22 | USB 3 / 4 / 3‑4 | | |
So **`out_port=16/17/18` route a lane's output *into* Row 3/4** (the "patch‑cable to a
row" jumper), `out_port=19` = Multi Out, and physical jacks are 1‑9 — e.g. `out=7` is the
**Out 4 jack**, NOT a row (a trap). On the INPUT side, **`in_port=7` = "Prev. row"** (take
the row directly above's output); `in_port=1` = In 1. Idle branch lanes: `in=0, out=0`.
- **Parallel split** — `split_control_points {split, mix}` per row (in `build_preset`:
`"splitter":{"split_col":S,"mix_col":M}`). `split=S` branches the signal **before the
block at column S**; `mix=M` merges the branch back at column M. `(-1,-1)` = none.
**A split is 1→2 only.** The underlying law (per the official manual): **rows 1&3 are
"Path A", rows 2&4 are "Path B"; splitters only route Path A → Path B.** That's WHY a
branch (Path B) row can't host its own split (rejected), while row 3 — even when
patch-fed via in=7 — CAN split (it's Path A).
- **Read-vs-delta indexing:** in a full READ, row = chain-array index, column =
models-array index, param index = params-array index (the `.row/.column/.index`
*fields* are 0 there). In edits you set those fields explicitly.
## 2. Multi-amp / parallel patterns
- **N parallel amps, no shared front** (simplest, most robust — the "SRV multiamp"
skeleton): each amp lane `in=1` taps the input directly; one lane is the **merge/output
lane** `in=0, out=19` carrying shared post-FX. All amp lanes `out=0` sum into it.
- **Shared front-end feeding parallel amps** (saves CPU — one pedal chain, not N):
put the shared blocks on row 0, then **split AFTER them**. `split_col` = the column of
the **first block that should NOT be shared** (pedals at cols 0-2 → `split_col=3`, so
the amp at col 3 and the branch both get the post-pedal signal). Splitting at col 2
would leave the col-2 block (e.g. the TS) out of the branch — a classic bug.
- **3+ parallel amps sharing ONE pedal group before all of them** (verified against a
hand-built reference). Do NOT nest `scp` splits on a *split-branch* (rejected). The
working recipe uses a **no-merge tap → empty jumper row → split a patch-fed lane**:
1. **Row 1:** In 1 → shared pedals → amp 1 → cab → reverb → `out=19` (Multi Out), with
**`split_col` after the pedals and `mix_col = -1` (NO merge)**. The `-1` is critical:
the split *only taps* the post-pedal signal down to row 2; row 1 continues to amp 1
independently. (A real `mix_col` would merge the tap back and pollute the amp-1 lane —
the classic bug.)
2. **Row 2 = empty jumper:** receives row 1's tap; set its output to **`out=16` ("Row 3")**
→ feeds row 3.
3. **Row 3 (amp 2):** `in_port=7` ("Prev. row" = the jumper). Give THIS lane its own
**split `scp=(0, M)`** — split at col 0 (its input, pre-amp) to feed row 4, merge the
return at col M (after the cab, before this lane's reverb). A **patch-fed lane (in=7)
CAN be split** even though a split-branch can't — this is how the 3rd amp gets the
shared signal in parallel. Then amp 2 → cab → reverb → `out=19`.
4. **Row 4 (amp 3):** `in_port=7`, fed by row 3's `scp` split; amp 3 → cab → merges back
into row 3 at col M.
So one `comp→Klon→TS` feeds all three: amp 1 on row 1, amps 2+3 via jumper→row 3→split.
Put reverb per amp-group (row 1's amp; the row 3+4 merge) so every amp is wet. Verify by
screenshot: each amp row reads "Prev. row"/In → "Multi Out"; none should be **series**
("Prev. row" pointing at another amp's output) or **orphaned** (output on `+`).
(`out=17`="Row 4", `out=18`="Row 3/4" also route to rows but the split-the-lane method
above is the confirmed-correct one. Simpler alternative that does NOT share the front: the
SRV 2-stage merge — amps tap `in=1`, drive on one lane, mix `out=16`→`out=19`.)
- **Branch-row column budget:** a branch that merges at column M reserves the columns
between its last block and M as "runway"; it can't hold blocks all the way to M. If a
block silently won't land at the end of a branch row, move the merge column out or
place the block earlier.
- **Post-merge (shared) FX — e.g. a stereo reverb for the whole blend:** place it on
the output row **after `mix_col`** so it processes the merged signal, not one lane. FX
before the merge only affect that lane (why one amp ended up "dry" of the delay once).
## 2b. Transparent Blend — parallel/side-chain without spending lanes
`Transparent Blend` (hash **16013**, Utility; sibling `Plugin Blend` 16008) is a
**single-slot** block that taps another point in the grid and blends it into the current
slot — so you get a parallel/return path **without** consuming a splitter lane + mixer
lane. Params (display): `SOURCE` (0–44 — picks the tap), `BLEND` 0–100 %, `OUTPUT`
−60..+12 dB, `Delay` 0–255 (samples, for time-alignment), plus `InputChannel`/
`StereoInput` for stereo handling.
- **`SOURCE` is a picklist**, not a fixed enum: physical **Input 1/2/1+2**, **FX Return
1/2/1+2**, **USB inputs 5–8/5+6/7+8**, then **every block on the grid by position**
(shown as `R{row}C{col} <name>`). So it can pull the output of any block anywhere.
- `SOURCE` can be **assigned to Scenes** (long-press in the app) — the tap point itself
becomes scene-varying. Verify the exact stored index by reading the block back
(`get_current_preset`): the list is dynamic (grid blocks are appended after the fixed
input/return/USB entries), so the integer depends on the current grid.
- **CONFIRMED multi-amp recipe (built & saved "4 Amp Blend", 2026-07-24):** this beats
the jumper/split method and enables layouts it CAN'T do — e.g. **4 amps sharing one
pedal**, impossible the old way (4 rows, all consumed by amps). Recipe:
1. Row 0: `In 1 → shared pedal(s) → amp1 → cab → out=19` (amp1 inline, normal).
2. Rows 1–3: `Transparent Blend(col0) → ampN → cab → out=19`, each blend
`BLEND=100`, `SOURCE = R1C1 <first shared pedal>`. All amps then get the same
post-pedal signal; pan the lanes + drop volumes (4-way sum) as usual.
- **Selecting a side-chain SOURCE auto-sets that lane's `in_port` to 0** — the lane is
fed *purely* by the blend (BLEND=100 = 100% source), so you don't wire its input.
Confirmed by read-back (in=0) + the on-screen side-chain taps + signal on the meter.
- **`SOURCE` is fiddly to set programmatically** (the stored value is not a clean list
index; a linear display write lands on the wrong entry — `SOURCE=12`→"Input 1/2").
Robust method: set ONE blend's source via the app dropdown, read its stored value
back, then replicate that exact value to the other blends (worked here: display 38.5
= R1C1 Myth Drive, same for all three since the pedal shares a list position). Verify
each in the app (the block editor shows the resolved source name). See the
`transparent-blend-block` memory.
## 3. I/O and stereo
- Output "Multi Out" (`out_port=19`) is this device's main out — leave it unless told.
- **Stereo comes from panning the lanes**, not from the output alone. Two places to pan:
- **Lane Output Control** — params `VOLUME=0, PAN=1, MUTE=2, SOLO=3` (PAN 0.0=hard L,
0.5=center, 1.0=hard R). Use `set_lane_output(row, pan=…, volume=…)`.
- **Splitter / mixer blocks** — these also carry a pan/balance param, so you can
position the branches at the split/merge instead of (or with) the lane output.
Pan the **branch** amp lanes (e.g. one left, one right) and leave the **main/output
row centered** — panning the output row pans the whole mix. "Not 100%" → ~0.3 / 0.7.
- Mono (M) cabs + a stereo (ST) reverb is a cheap way to get width; ST cabs cost ~2×.
- **Gain-staging — don't clip the sum.** Parallel lanes **add** at the mixer, so N amps
each at unity overloads the output. **Reduce each parallel lane's output volume** (Lane
Output Control VOLUME, or the mixer level) so the summed level stays below clipping —
order of −20·log₁₀(N) dB, e.g. ≈1/3 for three amps (default lane VOLUME is 0.769 → drop
the amp lanes to ~0.4–0.5). **Verify with `output_meter(hold_s=2)` while playing** —
`limiters.*` must all be false; leave headroom for the reverb. For a real number
rather than a meter, `measure_loudness()` gives LUFS + true peak (needs `.[audio]`).
## 4. Build gotchas that cause silent failures
- **`clear_grid` needs working reads.** It deletes what it reads; if reads are empty,
it clears nothing and the next `build_preset` **merges onto the old grid** (ghost
blocks, duplicated amps). Confirm reads work first.
- **`add_block` de-dupes:** re-adding a hash already present on that row is a no-op. To
move a block, delete it first (or place a different hash); don't "overwrite" with the
same model.
- **Whole-preset Grid UPDATE merges**, doesn't replace — build incrementally or clear.
- Recalling an empty slot doesn't always reset the live grid over the bridge; use
`clear_grid` + verify.
## 5. Reliability — reads must work or nothing above is verifiable
The bridge (sharing Cortex Control's session) is what makes live building possible; it
was made robust this way (`bridge.py`, `transport.py`):
- Out FIFO opened **O_RDWR** so the reader never hits EOF when the app's forwarder
blinks; the reader thread **self-heals** (reopens, never dies).
- Reads correlate on **`request_id`** — the device echoes it on solicited replies, so a
buffered/older push or the app's own traffic can't be mistaken for our response. Our
ids use a high base to avoid colliding with the app's.
- **Streamed telemetry** (CPULoad, IOMeter) is `request_id=0` broadcast — take the
**latest** message, not the first buffered one (drain, then read).
`transport.latest_broadcast(name, hold_s)` does this; `cpu_load` and `output_meter`
both use it.
- If reads still come back empty, **reconnect** (`disconnect`→`connect`) to revive a
stale bridge session; `get_current_preset` also auto-reconnects+retries once.
- **Only one reader on the bridge at a time.** The out FIFO is a single stream: if a
second process (a script alongside the MCP server) also reads it, the two steal each
other's frames and reads return empty while telemetry keeps flowing — it looks
exactly like a dead session. This is another reason to do everything through the
MCP tools rather than side scripts.
- **After a CorOS update, re-instrument the app** (`interceptor/build.sh`): the bridge
runs a *copy* of Cortex Control, so an updated app leaves the old binary talking to
new firmware. Then refresh the schema and catalog — see CLAUDE.md.
Bottom line: **build → read back split_points + routing → screenshot → cpu_load**. Only
then is a preset "done."
## 6. Device presets — don't dial from scratch (CorOS 4.1+)
Every device carries saved settings: ~2750 factory ones across 602 models, plus up to 32
user presets each. `list_device_presets("<model>")` shows them;
`load_device_preset(row, column, "<name>")` applies one to the block at that position,
replacing all its parameters at once.
Use it as the **first pass on a block** — load the closest-named preset, then tweak — and
for utility blocks (Output EQ, I/O Settings) where the factory presets encode real
voicings. `device_info` reports `firmware` and `protocol_generation`; on CorOS 4.0 these
tools return a "needs CorOS 4.1" error rather than failing silently. Loading is a Grid
UPDATE under the hood, so verify it the same way as any other write (read the block back
— `load_device_preset` returns `params_changed` and the new values).
`save_device_preset(row, column, name)` stores a block's current knobs as a reusable
user preset (32 per device); `delete_device_preset` removes one. The device **refuses
a save whose parameters already match an existing preset** for that model — change
something, or just load the preset that already holds those values.
## 7. Footswitch (stomp) assignments
`assign_stomp(row, column, "E")` binds a block to a footswitch (A-H), so it can be
toggled live in Stomp or Hybrid mode; `unassign_stomp` removes it, and
`get_current_preset` reports `stomp_assignments`. A block holds one assignment —
assigning again moves it. `momentary=True` = active only while held.
`kind="secondary"` is the CorOS 4.1 Dual Footswitch feature: a second function on its
own switch, and only meaningful on devices that have one (Vintage Digital, Aeons
Reverb). For a player in Hybrid mode, plan stomps alongside scenes — drives, boosts
and delay toggles are what a footswitch is for, while scenes carry the whole tone.
## MCP tools — the complete interface (no scripts needed)
The bridge is reliable now, so **do everything through MCP tools** — never fall back to
ad-hoc scripts for device control. The full toolbox:
- **Blocks:** `build_preset` (whole topology), `add_block` / `remove_block`, `clear_grid`,
`add_capture`, `add_ir`.
- **Params (display units):** `set_parameter(row, col, index, value)`. Read a model's param
names/order from `get_current_preset` or `find_devices` before setting indices.
- **Routing / stereo:** `set_lane_routing(row, in_portid, out_portid)`,
`set_lane_output(row, pan, volume, mute, solo)`, `add_split` (splits are also set via
`build_preset`'s `splitter`).
- **Scenes:** `set_parameter_scenes(row, col, index, values[8])` (assign-to-scenes + write;
the tool confirms each scene switch via Scene READ — racing drops values). Scene the AMP
params too (gain ladder, lead master bump, EQ trims), not just drive bypass.
`set_block_bypass(row, col, bypassed, scenes=[…])` (per-scene bypass — works on drives/
amps, silent NO-OP on delays: scene the delay MIX instead), `switch_scene(0-7)`,
`set_preset_meta(scene_labels=[…], scene_colors=[…])` — labels/colors go over dedicated
SceneLabel/SceneColor ops (verified); scenes with data but no label show "Undefined".
Preset `name` is NOT settable live — it's set by the save op (pass it to save_preset/_as).
- **Slot picking:** `list_empty_slots(setlist_key)` — free "Unsaved" positions (with
bank labels like 4E). Always build/save into one of these, never a named preset's slot.
- **Save:** `save_preset(name)` / `save_preset_as(name, setlist_key, position)`. BOTH now
persist via the app's real op — **File CREATE** (`File{action=CREATE, folder{key,
files{index=<position>, name}}}`, **no preset_payload**; the device commits its live
working grid). Decoded from Cortex Control's own Save. **Never** save via *RecallPreset
UPDATE reason=SAVE* — it **hangs the device** on an empty/Unsaved slot (had to reboot).
The tool's success string is not proof of commit: verify with `current_preset_position`
+ the app header showing the name **without a `*`** (dirty marker). See the
`save-empty-slot-hangs` memory.
- **Device presets (CorOS 4.1+):** `list_device_presets(model)` /
`load_device_preset(row, column, preset)` — a whole knob set on one block (§6) —
plus `save_device_preset` / `delete_device_preset`.
- **Footswitches:** `assign_stomp(row, column, 'A'-'H', kind, momentary)` /
`unassign_stomp` (§7).
- **Read / verify:** `get_current_preset`, `cpu_load(detail)`, `get_io_settings`,
`current_preset_position`, `device_info` (firmware + which features this unit has),
**`output_meter(hold_s, detail)`** (per-port peak-hold in dB + limiter flags — the
fastest check for "is signal reaching the outputs / is it clipping"). With the
`.[audio]` extra: `measure_loudness()` and `suggest_levels()` compare presets by
measured loudness without writing anything (docs/LEVELING.md). The only non-MCP helper is `tools/gui/gui.py shot` for a
*visual* wiring screenshot — verification, not control.
If an operation has no tool yet, **add the MCP tool** rather than scripting it — keep the
MCP the single functional interface. When a write seems to succeed but doesn't take
(e.g. Grid UPDATE with preset-level scene_labels was a silent no-op), **capture the op
from Cortex Control** (logmark → do it in the app → decode) and match it exactly.