Auto-adapting resolver: zero hardcoded obfuscated names#2
Merged
Conversation
The bridge reaches into Bitwig's obfuscated internals to write arranger automation, create arranger clips, and read the document graph. Those internal class/method names are stable per Bitwig build but get re-obfuscated each release, so a different build can break the reflection paths. doctor now runs a self-test that proves, on a throwaway track, that each path actually works on the LIVE build: it round-trips an automation write, a clip+note, and a descriptor read, and verifies each landed by finding distinctive sentinels in the descriptor walk. It also reports which internal classes still load. Fails safe and never touches existing tracks. - control.js: extract _insertAutomationPoints / _insertClip so the probe exercises the real code paths; add resolver.classes / resolver.probe / resolver.result handlers. - diagnostics.py: run_selftest() orchestrates the throwaway-track probe, identifying the track by name (not the flaky exists flag). - install.py: doctor prints the capability matrix and exits non-zero on any failed path.
The reflection paths hardcode Bitwig's obfuscated names, which are re-obfuscated each release - so a different build can break them. This makes the headline path (arranger automation write) resolve its symbols by SHAPE instead of by name, so it keeps working when the names move. How it resolves, all anchored on stable public-API objects (a track target + a param proxy): - the breakpoint-insert method by its 8-arg shape (valueRef, 3x double, 2x boolean, enum interp, object); - the value-ref class + its static factory, and the value base (fj), from the insert signature; - the real automation_lanes accessor by trying every shape-matching candidate and keeping the first whose insert actually succeeds (the shape is not unique; wrong lanes throw); - LINEAR/HOLD from the interp enum's SEMANTIC constant names. The discovered path is preferred; the hardcoded-name path remains as a fallback. A "blind" probe mode forces structural-only resolution (no name hints, no fallback) to prove it works when the names are wrong - verified on Bitwig 6.0.6 (blind discovery finds a valid accessor and the write round-trips). Notes: - _classOf(): inside a long reflection loop on the document thread, JS-side obj.getClass() intermittently fails with a GraalJS "Message not supported" interop error; invoking Object.getClass() reflectively avoids it. - doctor reports how automation resolved (discovered vs hardcoded) + the resolved symbol names, for actionable bug reports on unsupported builds. - diagnostics: the self-test is self-healing (clears probe tracks left by a crashed run, before and after).
Add tests/test_diagnostics.py with a scriptable FakeBridge: happy path (probe created + always deleted, report connected), not-connected short-circuit, probe-never-appears error, leftover self-heal, _delete_all_named (all/none/limit), and _print_selftest rc/printout cases.
The resolver previously verified only automation/clip/descriptor. This widens
coverage so a different Bitwig build's breakage is detected and reported across
every reflection-dependent path, not just automation.
- class-load matrix now covers all 9 obfuscated classes the bridge loads:
fj, oJk, a1x, X2S, alU, SZo, ZjS, BOg, WZK (was 6).
- self-test adds two executed-and-verified capabilities on the throwaway track:
- serialize: Bitwig's own serializer (SZo) turns the track into bytes;
- normalize: resolve a parameter's native->0..1 normalize fn (the structural,
behavioural _findNormalizeFn that underpins device.remote_normalize and exact
breakpoint-value conversion).
- mark cursor-track volume/pan values in init so the normalize probe can read
their raw + normalized values.
- doctor prints serialize + normalize lines; overall ok now requires all five
capabilities to pass.
Tier summary (documented for maintainers): automation auto-adapts structurally;
clip-create / descriptor-read / serialize / normalize are verified by execution
and reported; sidechain (BOg), audio/file insert (ZjS), device-target (WZK) are
class-load checked. Structurally auto-adapting the command-pattern and generic
serialization paths needs a validate-and-persist symbol cache and is left as a
follow-up (guessing those method names risks silently corrupting reads).
Extend the all-ok report builders to the new five-capability shape (automation_write, clip_create, descriptor_read, serialize, normalize) and a realistic nine-entry classes map. Add coverage for the new behaviour: rc 3 when a new capability (serialize/normalize) fails, all five capability lines printed on an all-ok report, and the missing-class line at 7/9 naming the two failed classes.
doctor now produces a build-specific symbol mapping and the bridge loads it, so the obfuscated descriptor-reader names are no longer hardcoded at runtime. Reader discovery (the read path behind obj.walk / read_project / read.notes): - discovers mX_, KRt, bf, ngq, nI_, Xzy, uEK structurally from a track target, validated by EXECUTION (walk the throwaway track, confirm the sentinels we just wrote appear). Seed (known) names are tried first so a supported build resolves the canonical reader exactly; blind mode forces the pure structural path and proves it finds a working reader with zero name hints. - ngq (prop id) is chosen by value-uniqueness; the heterogeneous descriptor list is sampled across elements; _classOf() (reflective getClass) avoids the GraalJS "Message not supported" interop error seen in long reflection loops. Symbol cache: - a central SYM table holds the reader names + serializer class; the core reader (_descriptors, _walkObj, _relChildren, _szPass) now reads names from SYM. - doctor's probe discovers + validates the reader, then writes symbols_cache.json (keyed by a build fingerprint: Bitwig version + jar size/mtime + schema). - bridge init loads the cache when the fingerprint matches, else falls back to seeds and reports the build as needing doctor. resolver.status exposes this. self-test: - diagnostics identifies the probe track by INDEX-DIFF (the new slot), not by name, so a failed post-create rename no longer orphans a track; cleanup removes every slot that appeared during the probe. - doctor prints the reader resolution, cache path, and symbol source. Automation/clip still resolve + validate live on every write (no cache needed); the cache exists for the reader, which cannot self-validate during a plain read.
Update FakeBridge to support the index-diff probe identification: seed pre-existing tracks, append on track.create, remove by index on track.delete. Drop imports of removed _find_track_index/_delete_all_named and their tests; add coverage for _occupied, _delete_index, failed-rename cleanup, probe-never-appears, and the new reader/cache/symbol_source lines in _print_selftest.
The clip-create (X2S) and note-insert (alU) command-host classes are top-level
obfuscated classes not reachable from the public-API object graph, so they could
not be discovered structurally like the reader/automation. But each command
exposes a STABLE op-id via ngq() (clip create = 7350, note insert = 7349) - wire
protocol data, not an obfuscated symbol. doctor now scans the Bitwig jar once and
resolves the command host classes by that op-id, then caches them; the bridge
loads them at init. This removes the X2S/alU hardcoded dependence at runtime.
- _findCommandsByOpIds: single jar pass (early-stops once all op-ids found) that
loads default-package classes (no static-init), finds singleton command
factories whose command's ngq() matches the wanted op-id, validated by the
existing clip+note sentinel round-trip.
- SYM.clipCmd / SYM.noteCmd hold {cls, field, factory, exec}; _insertClip invokes
through them (seed defaults = X2S/alU). The probe resolves them by op-id before
creating the clip, so a renamed build uses the resolved classes; the cache
carries them and init applies them. Cache schema bumped to 3.
- The op-id lookup runs only in doctor (jar scan, ~15s); normal operation loads
the cached class names with no scan.
- diagnostics: run_selftest timeout raised to 90s to cover the jar scan; doctor
prints how the commands were resolved.
Still hardcoded (peripheral, not graph- or op-id-reachable yet): ZjS (audio/file
insert enum), WZK (device-param automation), BOg (sidechain, a blocked feature).
…onPoint) modulator.map (set_default_modulation_mapping via WZK) and modulator.insert (ModulatorGridInsertionPoint + ZjS, prop 6727) are not called by the Python API: the modulator feature was pulled from the release. Removing the dead handlers drops the WZK and ModulatorGridInsertionPoint obfuscated class literals and the 5438/6727 prop-id constants. modulator.open_browser (public-API getAction, no obfuscated names) is kept. Also drops WZK from the doctor class-load list.
device.set_sidechain_source walked device internals via kGL()/AYB()/fCq()/jB1() and the BOg base class. Unlike commands (op-id), the reader (structural shape), or automation (signature), these device-internal accessor methods have NO stable anchor to discover them by, so the feature could not be made build-independent. It was also already limited (routing setters are read-only) and a known blocked path. Removing it lets the bridge be fully auto-adaptable with no obfuscated-name dependence. - control.js: drop the device.set_sidechain_source handler + BOg from the class list. track.routing_info (read-only, public API) is kept. - song.py: drop Track.sidechain_from. - docs/README/live_smoke: drop sidechain references.
Track automation now resolves the cluster purely by structural discovery, validated by execution, removing the a1x/oJk/fj/zer literals from the fallback path. Discovery is reliable (blind mode already runs without a fallback).
Add [tool.pytest.ini_options] (testpaths=tests so scratch is never collected, -q addopts, registered live marker). conftest registers the live marker, skips live tests unless OPENWIG_LIVE=1, and adds a live_bridge fixture. New unit test validates the controller JS via node --check (skips if node absent) plus a structural sanity check.
Marked live (auto-skipped unless OPENWIG_LIVE=1). Each test stands up a throwaway probe track by index-diff (mirrors diagnostics.run_selftest) and deletes it in a finally. Covers: normal probe resolves and verifies every capability plus reader and commands; blind probe discovers structurally with no name seeds and does not cache; cache roundtrip via resolver.status.
Two jobs on push and pull_request: tests (ubuntu, python 3.11 and 3.12, pip install -e .[dev] then pytest; live tests auto-skip with OPENWIG_LIVE unset, win32-only PyAudioWPatch excluded by marker) and controller-syntax (node 20, node --check on the controller script). Docs gain a concise Running the tests section in install.md.
… notes Two robustness fixes so the resolver works without name seeds end to end: - op-id command resolution no longer depends on the reader's ngq: it scans each candidate command's own no-arg int/long getters for the target op-id (_hasNumericNoArg / _cmdMatchOpId). This breaks the circular dependency (commands needed ngq, the complete reader needed notes, notes needed commands). - probe reorder: automation -> commands (op-id) -> clip+note -> reader discovery. The reader is now discovered AFTER both the automation point and the clip note exist, so it is validated against automation AND notes, and the structural fallback scores candidates by how many distinct sentinels they surface (a complete reader reaches notes too), then by walk richness. The blind live test asserts the guarantees pure structural discovery actually provides (automation verifies, reader skeleton + walk resolve, clip is created, no cache write); note read-back is guaranteed on real builds by the seed-first / cache path, not the blind stress path.
…ndlers Eliminates the remaining hardcoded automation/serializer class literals from the active paths: - _fjFrom resolves the value-base class via SYM.fj (a bootstrap seed overwritten by automation discovery + the cache), not a literal. - tempo.write_offline reuses the structurally-discovered automation cluster (_insertAutoDiscovered with the transport float-document as byU), dropping its a1x/oJk/insert literals. _findAutomationInsert is removed (last user gone). - the descriptor reader's serialize filter and the serialize self-test load the SZo class via SYM.SZo. The dead track.serialize / track.serialize_result handlers (not called by the SDK) are removed. The blind live test now asserts what pure structural discovery guarantees on any build (automation cluster + reader skeleton resolve name-free, clip is created, no cache write); full read-back verification is covered by the normal path. Remaining obfuscated class literal: ZjS (audio-clip insert), handled next.
…ved symbols track.insert_audio_clip used the obfuscated ZjS import-mode plus an ambiguous HrV accessor (TD among 8 candidates) that cannot be disambiguated structurally without a validation audio file. Like sidechain, it could not be made build-independent, so it is removed. Audio file import is still available into launcher slots via slot.insert_audio_file (public Controller API, no reflection). With it goes the last obfuscated Java.type CLASS literal in the controller: every reflection class is now resolved structurally / by op-id / from SYM, or gone. - control.js: remove the track.insert_audio_clip handler (ZjS / ArrangerClip- InsertionPoint / TD). _resolverClasses now derives the class-load check from the RESOLVED symbols (SYM.fj/SZo/clipCmd.cls/noteCmd.cls + the discovered automation avr/interp) instead of a hardcoded name list. - song.py: remove Track.audio_clip / audio_clips. - docs: drop the audio_clip rows.
Brings back track.insert_audio_clip / Track.audio_clip, made build-independent: - ArrangerClipInsertionPoint is a STABLE class; its 5-arg constructor, the (File, mode, hook) dispatch method, the ZjS mode class (dispatch param type) and the mode singleton (its only self-typed static field) all resolve structurally on any build. - the one ambiguous bit, the track-as-HrV accessor, is validated by EXECUTION at doctor time: insert a uniquely-named silent test wav via each candidate accessor, wait for the async decode, and keep the one whose clip surfaces in the descriptor walk. The validated accessor is cached (resolver.set_audio_hrv); the default is carried in data, not relied on blindly. - diagnostics: _validate_audio orchestrates the async insert/wait/walk and reports an "audio clip" capability; doctor prints it and exits non-zero if it fails. - audio file import into launcher slots (slot.insert_audio_file) was always public-API and is unaffected.
Move obfuscated symbols out of code into a data file; fix note sentinel The controller no longer carries any obfuscated class/method names as code literals. They live in symbols_default.json (DATA), which `openwig install` copies next to the cache/log; the controller loads it at init and doctor overrides it per-build by writing symbols_cache.json. Zero obfuscated names remain in the .js. Also fix the clip-create self-test: Bitwig stores note start times as 32-bit floats, so the written 1.6180339 is read back as 1.6180343627929688. The probe searched the verbatim decimal and missed it; it now matches the float32 form (Math.fround, leading prefix), which also repairs the structural reader fallback that relied on the same sentinel. All capabilities pass on Bitwig 6.0. @
Make doctor mandatory; add audio-clip-insert live test Gate the bridge on validated symbols: until doctor has resolved + cached the obfuscated symbols for THIS exact Bitwig build (matching per-build cache loaded at init, or doctor validated this session), every op except the diagnostic / connectivity surface doctor itself needs is refused with a clear "run doctor" error. After a Bitwig update the fingerprint no longer matches and the gate re-arms until doctor re-validates, so a re-obfuscated build fails loud instead of silently misbehaving. Add a live test that drives arranger audio-clip insert end to end (write a uniquely-named silent wav, insert on a throwaway track, wait for the async decode, assert the descriptor walk surfaces the file). Update install message + install/README docs to state doctor is required. @
Gate the public audio-insert op; reach it for validation via a resolver alias track.insert_audio_clip was the one allowlist entry that was both mutating AND reflection-dependent - exactly what the doctor gate should refuse. Move doctors validation onto a gate-exempt resolver.audio_probe_insert alias (same handler body) and drop the public op from the allowlist, so it is now gated like every other reflection-dependent mutation. Verified: pre-doctor the public op is blocked while the alias reaches its handler; doctor still validates audio. @
…l self-test
Review fixes:
- descriptor_read.ok now requires a sentinel read-back (json.length > 2 passed
on a broken reader's {_err} walk, caching broken names and opening the gate)
- the cache persists the reader that actually validated (rd2, not the stale rd)
plus fj/SZo/szFilter/autoLanes, and is only written when ALL capabilities
verified; schema bumped to 4
- a failed cache write now surfaces a reason and fails doctor (the gate stays
shut, so doctor must not exit 0)
- _doInsert reaches fj via getAtom() first: remote params are proxy atoms, so
remote automation silently failed with the one-hop lookup
- explicit gate allowlist replaces the resolver.* prefix exemption
- _findCommandsByOpIds skips undefined op-ids (no more full-jar scan when the
data file is missing) and counts distinct ids
- blind probe restores SYM reader names; _AUTO_SYM falls back to rediscovery
on a foreign anchor; _acipParts prefers the modeField hint over unspecified
field order
- run_selftest guards its cleanup finally (a bridge death no longer eats the
partial report) and skips audio validation when the reader is unverified
- install fails hard when symbols_default.json cannot be copied
- openwig.paths dedupes the data-dir logic; probe-track lifecycle shared
between diagnostics and the live tests; unit tests isolated from the real
user data dir; dead controller globals/helpers removed
- reference.md: t.audio_clip / t.audio_clips were restored on this branch but never re-documented - install.md: install also copies symbols_default.json (now required), and the expected doctor output matches the current report (audio line, symbol source) - drop examples/demo_sidechain.py: it called Track.sidechain_from(), removed with the sidechain feature
PyAudioWPatch is win32-only (pip marker), but render.py imported it at module level and openwig/__init__ pulls render in via wire, so `import openwig` died on linux. This is why the ubuntu CI workflow never passed while the windows tests workflow was green. Only render_to_wav() actually needs the package.
Two workflows ran pytest on every PR (test.yml on windows, ci.yml on linux) under different names, so a red CI next to a green tests was easy to misread. One CI workflow now runs the suite on both OSes (py3.11 + py3.12) plus the controller syntax check, and skips duplicate push runs on feature branches (pull_request already covers them).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@
What
Make openwig auto-adapt across Bitwig builds with zero obfuscated names hardcoded in code, and make
openwig doctormandatory so an unvalidated/re-obfuscated build fails loud instead of silently misbehaving.Bitwig re-obfuscates its internal class/method names every release, so the bridge previously broke on any build other than the one it was reverse-engineered against. This PR resolves every internal symbol the bridge needs by shape and behaviour (or a stable op-id), validates it on the live build, caches it per-build, and refuses to operate until that validation has happened.
How it works
openwig doctorruns a self-test on a throwaway track: writes arranger automation, creates an arranger clip + note, reads the document graph back, and verifies each landed via distinctive sentinels. On success it writessymbols_cache.jsonkeyed by a build fingerprint (Bitwig version + jar size/mtime + schema).symbols_default.jsonships the current-build mapping as doctors resolution seed;openwig installcopies it next to the cache/log. No obfuscated names live in the.js.Capability matrix (Bitwig 6.0)
Tests / CI
tests/test_autoadapt_live.py(gated behindOPENWIG_LIVE=1): normal resolve+verify, blind name-free structural discovery, audio-clip insert end to end, cache round-trip.node --checkon the controller.Notes
Math.froundform (this also repaired the structural reader fallback that relied on the same sentinel).@