Skip to content

Auto-adapting resolver: zero hardcoded obfuscated names#2

Merged
Axyom merged 28 commits into
masterfrom
feature/resolver-self-test
Jun 10, 2026
Merged

Auto-adapting resolver: zero hardcoded obfuscated names#2
Axyom merged 28 commits into
masterfrom
feature/resolver-self-test

Conversation

@Axyom

@Axyom Axyom commented Jun 8, 2026

Copy link
Copy Markdown
Owner

@

What

Make openwig auto-adapt across Bitwig builds with zero obfuscated names hardcoded in code, and make openwig doctor mandatory 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 doctor runs 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 writes symbols_cache.json keyed by a build fingerprint (Bitwig version + jar size/mtime + schema).
  • Mandatory gate: until the symbol mapping is validated for the exact build (a matching cache loaded at init, or doctor validated this session), the bridge refuses every op except the diagnostic + connectivity surface doctor itself needs. After a Bitwig update the fingerprint no longer matches and the gate re-arms.
  • Defaults are data, not code: symbols_default.json ships the current-build mapping as doctors resolution seed; openwig install copies it next to the cache/log. No obfuscated names live in the .js.
  • Discovery: descriptor reader resolved structurally + validated by sentinel read-back; arranger automation resolved structurally; clip-create / note-insert command hosts resolved by stable numeric op-id (jar scan, not names); serialize filter, parameter normalize, and arranger audio-clip insert resolved structurally (audio insert validated by execution).

Capability matrix (Bitwig 6.0)

  automation   : OK   (inserted 2 (discovered) | verified)
  clip create  : OK   (created clip, 1 note(s) | verified)
  descriptor   : OK   (walk ~225k chars)
  serialize    : OK   (filter ok)
  normalize    : OK   (resolved)
  audio clip   : OK   (inserted + read back)
  => all reflection paths verified on this Bitwig build

Tests / CI

  • tests/test_autoadapt_live.py (gated behind OPENWIG_LIVE=1): normal resolve+verify, blind name-free structural discovery, audio-clip insert end to end, cache round-trip.
  • GitHub Actions: pytest (3.11/3.12) + node --check on the controller.

Notes

  • Sidechain feature removed (source routing is opaque document state; deferred to keep the system robust).
  • Note self-test sentinel made float32-aware: Bitwig stores note times as 32-bit floats, so the written decimal is read back rounded; the probe now matches the Math.fround form (this also repaired the structural reader fallback that relied on the same sentinel).
    @

Axyom added 22 commits June 8, 2026 16:15
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.
@
@Axyom Axyom changed the title Resolver self-test in doctor + auto-adapting arranger automation Auto-adapting resolver: zero hardcoded obfuscated names Jun 9, 2026
Axyom added 6 commits June 9, 2026 14:47
@
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).
@Axyom
Axyom merged commit d9ffed4 into master Jun 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant