Done digging ā here's the full picture from the HISE source, structured so you
can relay it to ustk.
The duplicate MatrixData blocks ā fully explained
There are two independent writers of MatrixData into a preset:
- ScriptModulationMatrix (the object from Engine.createModulationMatrix())
registers itself as a user-preset state manager with the id MatrixData
(ScriptModulationMatrix.h:231, registration in the constructor at
ScriptModulationMatrix.cpp:609). On preset save, every registered manager
appends its own child ā UserPresetStateManager::saveUserPresetState()
(PresetHandler.cpp:2440) does a blind addChild() with no dedup check. - GlobalModulatorContainer::exportAsValueTree()
(GlobalModulatorContainer.h:613) appends a copy of the matrix tree into the
module state. So Engine.addModuleStateToUserPreset("Global Modulator
Container") produces the copy inside the block ā that's the
redundancy ustk already spotted, and he's right that it's harmless: on load,
module states restore first and the root-level MatrixData restores last
(UserPresetHandler.cpp:678), overwriting it. He can drop that
addModuleStateToUserPreset call unless he needs the container's own chain
state in presets.
Two root-level blocks means two live ScriptModulationMatrix instances at save
time. Unlike most Engine.createX calls there's no caching ā each call to
Engine.createModulationMatrix() constructs a fresh object and registers a
fresh state manager, even for the same container ID. So ustk should search his
scripts for a second call (a second script processor, an included file, or a
call inside a function that runs more than once). Restore-side it's mostly
benign (each manager restores from the first MatrixData child via
getChildWithName), but it's the smoking gun that two matrix objects are alive
ā and two objects both performing remove-all/re-add restores on the same tree
is exactly the kind of thing that could leave the runtime side confused.
Why connections can be "visible but dead"
The matrix UI draws from the matrixData ValueTree, but the audible part is
separate: MatrixModulators and slider cable connections watch that tree and
resolve runtime connections (source pointers into the container's
voiceStartData/timeVariantData/envelopeData arrays, pushed to targets via
RuntimeSource::updateTargets()). The tree restoring correctly guarantees the
lines, not the signal. The signal path goes stale if:
- the container's child modulators get rebuilt after the matrix restore
(remember MatrixData restores last, but postPresetLoad, sample preloading ā
prepareToPlay, or anything his preset postCallback does runs after that), or - source modulators are bypassed when refreshList() last ran ā the data arrays
only include active modulators (GlobalModulatorContainer.cpp:639), and a
missing source resolves to a null mod function (getModFunction hits
jassertfalse; return {nullptr,nullptr}) ā connection present, zero modulation.
This fits his symptoms exactly: new connections work because every edit goes
through connect() ā callSuspended ā full rebuild with fresh pointers, and his
fromBase64(toBase64()) "dirty fix" works for the same reason ā it re-fires the
whole remove/add listener storm after everything else has settled, which is
effectively a manual re-resolution pass. It's not even that dirty; it's
re-running the exact same code path HISE itself uses (restoreFromValueTree and
the base64 round-trip are the same function underneath).
Practical suggestions for ustk
- Grep the project for createModulationMatrix ā ensure exactly one call, in
onInit, stored in one const var. If duplicates persist in fresh saves after
that, an old instance is being kept alive. - Drop addModuleStateToUserPreset("Global Modulator Container").
- Check whether the broken targets' source modulators are bypassed/disabled
at the moment the preset finishes loading (the active-list trap above). - Note whether broken targets are MatrixModulators in mod chains vs.
script-slider parameter targets ā they use different listener paths
(MatrixModulator::onMatrixChange vs MatrixCableConnection), which would narrow
it to one code path for a proper bug report to Christoph.
Why yours works: Yours creates the matrix exactly once (Interface.js:4), never
registers the container as module state, and your container sources stay
active ā so you never exercise any of the fragile paths. None of your three
open PRs touch this, as you said.
If you want, I can build a minimal repro (one container, one LFO, two matrix
objects + a bypassed source) to confirm which mechanism kills the connections
ā that would turn this from insight into a fileable HISE issue.



