Skip to content

Fix Resolume bridge: wire-format bugs meant zero messages ever reached the conductor - #3

Merged
vdmo merged 1 commit into
mainfrom
fix/resolume-bridge-wire-format
Sep 30, 2026
Merged

vdmo merged 1 commit into
mainfrom
fix/resolume-bridge-wire-format

Conversation

@vdmo

@vdmo vdmo commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Summary

This is what "test the bridge end-to-end against a real conductor" (a planned next step after #1/#2) actually found: the Resolume bridge has never worked, at all, against the real conductor.

Two independent bugs meant every single message — not some, all — was rejected:

  1. Wrong wire field names. json.dumps(message.__dict__) sends the Python dataclass's field names verbatim (message_type, payload_type). The schema requires type (#[serde(rename = "type")]) and payloadType (#[serde(tag = "payloadType", ...)]). Every message: "missing field type".
  2. Wrong transport. Messages were sent via osc_client.send_message(...) — OSC's own binary framing wraps the JSON as a string argument, and the conductor's serde_json::from_slice reads the raw UDP payload directly with no OSC decoder. Fixed by sending plain JSON bytes over a raw UDP socket instead.

Heartbeats had a third bug on top: pir_invariants defaulted to {} and payload was never set — both required fields on the wire, no #[serde(default)]. Every heartbeat: "missing field equilibrium_check".

What I found while testing the fix

Fixing the wire format didn't mean everything now works — it surfaced a real, structural gap, documented as item 7 in docs/LIMITATIONS.md: message.validate() rejects a message outright if its self-reported PIR residual (a Prouhet-Thue-Morse-signed sum over the bridge's last 1–4 recently-set parameter values) exceeds 1e-12. A single layer's opacity change — one value — can never sum to zero, so it always fails. Sent 11 real control-parameter updates in testing: 5 applied, 6 dropped, essentially at random, depending on which arbitrary value-window each message happened to land on — not on whether the change was actually unsafe. Not fixed in this PR — what "harmonious" should mean for an arbitrary single-parameter change is a real design question, not something to guess at in a bug-fix PR.

Test plan

  • Ran a real conductor + the fixed bridge + synthetic OSC messages matching Resolume's actual address format (/composition/layers/{id}/video/{param})
  • Heartbeats: 22/22 succeeded cleanly after the fix (previously: 100% rejected)
  • Control-parameter updates: confirmed reaching the conductor and being structurally parsed (previously: 100% rejected as unparseable)
  • Confirmed the equilibrium-validation behavior directly against conductor logs, not just inferred

🤖 Generated with Claude Code

…the conductor; document equilibrium-check limitation found while testing

Tested this bridge against a real, running conductor for the first time
(previously: never run end-to-end). Found and fixed two independent bugs
that meant literally zero messages — not some, zero — had ever
successfully reached the conductor from this bridge:

- message_type/payload_type were serialized under their Python
  dataclass field names (json.dumps(message.__dict__)), not the wire
  names the schema's #[serde(rename = ...)] actually requires
  (`type`, `payloadType`). Every message: "missing field `type`".
- Messages were sent OSC-wrapped (self.osc_client.send_message(...)),
  which frames the JSON payload as an OSC string argument — bytes the
  conductor's serde_json::from_slice can't parse as JSON at all, since
  it reads the raw UDP payload directly and has no OSC decoder. Now
  sent as plain JSON over a raw UDP socket, to the same port, which is
  the only format primeswarm-pchi's receive loop accepts.
- Heartbeats additionally omitted pir_invariants and used the default
  empty payload — both required, non-optional fields on the wire with
  no #[serde(default)]. Every heartbeat: "missing field
  `equilibrium_check`". Fixed with a real, honest zero-residual
  invariant set (matching PIRCalculator.calculate_equilibrium([])) and
  a real (if semantically inert, since the conductor ignores payload
  content for MessageType::Heartbeat) control_parameter payload — the
  schema has no dedicated heartbeat payload variant.

Verified live: real conductor + real (fixed) bridge + synthetic OSC
messages, matching what Resolume would actually send. Heartbeats now
succeed cleanly (22/22 after the fix took effect). Confirmed via
docs/LIMITATIONS.md item 7: a real, structural finding, not a bug —
message.validate() rejects a message outright if its self-reported PTM-
signed residual exceeds 1e-12, computed over the bridge's last 1-4
recently-set parameter values. A single layer's opacity change (one
value) always fails; four matched values sometimes passed and sometimes
didn't depending on which arbitrary window a given message happened to
land on. 5 of 11 real control-parameter sends were applied, 6 dropped,
in this test — not because any were unsafe, but because of incidental
numeric cancellation among unrelated values. Not fixed here: what
"harmonious" should mean for an arbitrary single-parameter change is a
real design decision, not a bug fix.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
@vdmo
vdmo merged commit cfc9f76 into main Sep 30, 2026
vdmo added a commit that referenced this pull request Sep 30, 2026
Path A of the PCHI/TiXL integration: an OSC bridge connecting TiXL
(https://github.com/tixl3d/tixl, 5.1k-star open-source real-time motion
graphics) to the PCHI Conductor, following the same shape as the
existing Resolume bridge — but this time verified against TiXL's real
operator source (Operators/Io/Symbols/lib/io/osc/{OscOutput,OscInput}.cs)
before writing a line of bridge code, rather than guessing at wire
compatibility. TiXL's OSC operators are built on Rug.Osc, a real
spec-compliant OSC library, so python-osc interoperates correctly with
no translation guesswork — unlike the first version of the Resolume
bridge, which shipped with three independent wire-format bugs (#3)
because none of that was verified against real source.

Two legs: TiXL's OscOutput -> raw JSON-over-UDP -> the Conductor
(control updates, same wire-format lesson as the Resolume fix applied
from day one: correct type/payloadType field names, no OSC-wrapping,
complete required pir_invariants on every message including heartbeats);
the Conductor's governance-event WebSocket -> OSC -> TiXL's OscInput
(live verdicts as a numeric gate_state code, since OscInput's outputs
are float-only — confirmed from source, no string channel).

Governance telemetry depends on #5 (the WS accept-loop fix) — without
it the conductor's WS never accepts a connection and this leg silently
receives nothing. Control updates work standalone regardless; verified
against a build of plain `main` (without #5) to confirm exactly that.

Verified live: a real conductor, this bridge, and real OSC traffic
constructed to exactly match TiXL's actual OscOutput/OscInput wire
shape (confirmed from source) — both legs work correctly, including
governance telemetry (tested against a build with #5's fix). Not
verified against real TiXL itself, which is a Windows/DirectX GUI
application — README states this plainly.

Co-authored-by: vdmo <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
vdmo added a commit that referenced this pull request Sep 30, 2026
…rect, no OSC (#8)

Path B of the PCHI/TiXL integration (Path A: tools/tixl-pchi-bridge,
#6). TiXL has no separate plugin DLL system — per its own docs, "TiXL
*is* the SDK": operators are C# source the running editor compiles and
hot-reloads. These two files are real operators that speak PCHI's wire
protocol directly (raw JSON-over-UDP out, WebSocket in), eliminating
both the OSC round-trip and the external bridge process Path A needs,
for TiXL specifically.

- PCHISendControlParameter: TargetId/Parameter/Value/SendTrigger inputs,
  sends a real control_parameter PCHIMessage straight to the Conductor's
  UDP port. BuildWireJson() is the one place Rust's #[serde(rename)]
  wire names (type, payloadType) get applied — the exact field-name
  lesson from the Resolume/TiXL OSC bridges (#3), applied natively
  instead of relearned.
- PCHIGovernanceReceive: owns its own ClientWebSocket (reconnecting,
  background Task), exposes GateState (float code, same ALLOW/ESCALATE/
  DENY=1.0/0.5/0.0 encoding the OSC bridge uses so both paths are
  interchangeable to an artist), ReceiptId, Tool, IsConnected as live
  graph values. Requires #5 (the WS accept-loop fix) on the Conductor
  side.

## Verification — stronger than anything else in this integration, and why

TiXL's Editor needs Windows (DirectX 11, WinForms) and can't run here,
but Core.csproj — the library operators actually compile against —
builds on Linux with -p:EnableWindowsTargeting=true (a standard
cross-compile flag, not a workaround). Cloned tixl3d/tixl, installed
.NET 10 SDK (TixlNetFrameworkVersion = net10.0-windows) locally, and
compiled these two files against the real Core.dll: 0 warnings, 0
errors, on the first attempt and confirmed again on a clean rebuild
using the exact command documented in this commit's README. That's the
real compiler checking real API usage — Instance<T>, Slot<T>,
InputSlot<T>, EvaluationContext, IStatusProvider — not written blind.

Not verified: running inside a live TiXL editor. Turning this .cs file
into an actual graph node needs TiXL's GUI (duplicate an existing
operator to scaffold the accompanying .t3/.t3ui files TiXL's editor
owns, then replace the generated body with this code) — documented
plainly in the README as the one step this environment cannot perform
or verify.

verify-build/Pchi.csproj is included so the compile check is
reproducible by anyone, not just asserted.

Co-authored-by: vdmo <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
vdmo added a commit that referenced this pull request Sep 30, 2026
Third PCHI integration this session, alongside Resolume (#3) and TiXL
(#6, #8). cables.gl (https://github.com/cables-gl/cables, 562-star
open-source browser-based node/patch tool) runs in a browser sandbox,
which can't open the raw UDP socket the Conductor's control-update path
requires — so unlike TiXL's native operators, this needs a relay: HTTP
in (cables can always fetch()) -> raw JSON-over-UDP out to the
Conductor, and the Conductor's real governance-event WebSocket ->
relayed straight through to a WebSocket server cables can connect to
directly.

The governance-telemetry leg needed zero new protocol work: confirmed
from a real official example (cables-gl/websocket-example) that cables
already ships a working WebSocket-receive op against a plain `ws`
server — this relay's cables-facing WS server is deliberately that same
shape, not something invented for this integration.

Requires #5 (WS accept-loop fix) on the Conductor for governance
telemetry; control updates work regardless.

## A real mistake found and fixed along the way

Building this surfaced that the Heartbeat-exclusion fix (originally
meant to land in #5) had never actually made it into main — I'd pushed
it as a second commit onto that PR's branch and reported it as included,
but the merged PR only ever contained the first commit. Live-testing
this relay reproduced the exact duplicate-DENY bug the fix was supposed
to have eliminated. Re-landed as #9, cherry-picked cleanly, re-verified
live against this relay specifically (22s, 4+ heartbeat cycles, 1
receipt instead of 5) before writing this commit.

## Verification

Live, both directions, against a real Conductor:
- Outbound: a real POST (with an Origin header, and an explicit OPTIONS
  preflight check with Access-Control-Request-* headers — not just a
  bare curl a browser's CORS enforcement wouldn't accept) reaches the
  Conductor and is applied.
- Inbound: a real DENY is broadcast by the Conductor, relayed, and
  arrives at a real external WS client with the documented enriched
  shape (flattened gate_state/receipt_id/tool plus the full raw event).

Not tested against real cables.gl itself — the WebSocket-receive shape
is confirmed from a real official example; the HTTP-request op and
message parsing on the cables side are not independently confirmed the
way TiXL's operators were confirmed against real compiled source.
README states this plainly.

Co-authored-by: vdmo <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
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