diff --git a/tools/tixl-native-operators/README.md b/tools/tixl-native-operators/README.md new file mode 100644 index 0000000..5ea392a --- /dev/null +++ b/tools/tixl-native-operators/README.md @@ -0,0 +1,78 @@ +# TiXL native PCHI operators + +Path B of the PCHI/TiXL integration (see `tools/tixl-pchi-bridge` for Path +A, the OSC bridge). Real C# operators that speak PCHI's wire protocol +**directly** — raw JSON over UDP out, WebSocket in — with no OSC +intermediary and no external bridge process. TiXL has no separate plugin +DLL system; per TiXL's own docs ([Writing C# operators](https://github.com/tixl3d/tixl/blob/main/.help/docs/advanced/WritingCodeOps.md)), +"TiXL *is* the SDK" — operators are C# source next to the graph, compiled +and hot-reloaded by the running editor. These two files are that source. + +## What's here + +- `Symbols/lib/io/pchi/PCHISendControlParameter.cs` — an outbound operator. + Inputs: `TargetId`, `Parameter`, `Value`, `SendTrigger`, `PchiHost`, + `PchiPort`. Sends a real PCHI `control_parameter` message straight to the + Conductor's UDP port. +- `Symbols/lib/io/pchi/PCHIGovernanceReceive.cs` — an inbound operator. + Connects to the Conductor's governance-event WebSocket and exposes + `GateState` (float: 1.0/0.5/0.0 = ALLOW/ESCALATE/DENY, same encoding the + OSC bridge uses), `ReceiptId`, `Tool`, and `IsConnected` as live graph + values. **Requires [vdmo/pchi#5](https://github.com/vdmo/pchi/pull/5)** + on the Conductor — without it the governance WebSocket is bound but + never accepts a connection, and this operator sits at + `IsConnected=false` forever. +- `verify-build/Pchi.csproj` — the exact project used to actually compile + these against TiXL's real `Core.dll`. See below. + +## Verification status — read this before trusting the claim + +**Genuinely compiled against TiXL's real `Core.dll` on Linux — this is +the strongest verification anything in this integration has had**, and +the reason: TiXL is fully open source +([tixl3d/tixl](https://github.com/tixl3d/tixl)), and while its `Editor` +needs Windows (DirectX 11 rendering, WinForms), `Core.csproj` — the +library operators actually compile against — builds successfully on +Linux with `-p:EnableWindowsTargeting=true` (a standard, well-supported +cross-compile flag; this is not a hack). That means these two operators +were checked by the real C# compiler against the real API +(`Instance`, `Slot`, `InputSlot`, `EvaluationContext`, +`IStatusProvider`), not written blind and hoped-correct — the build +succeeded on the first attempt, 0 warnings, 0 errors. + +**Not verified: running inside a live TiXL editor.** The editor itself — +the part that turns this `.cs` file into a node on a visual graph — needs +the Windows GUI application (DirectX 11), which doesn't run here. TiXL's +own workflow for adding a custom operator is to duplicate an existing one +in the editor ("Right click → Symbol Definition → Duplicate as new +Type"), which generates the accompanying `.t3`/`.t3ui` scene-graph +definition files alongside the `.cs` — those aren't something this +environment can produce; they're written by the editor itself. Getting +this into a real graph means: + +1. Open TiXL (Windows, per its own [dev setup docs](https://github.com/tixl3d/tixl/blob/main/.help/docs/install/InstallDev.md)), create or open a project. +2. Duplicate an existing IO operator (e.g. `OscOutput` for the send side, + `OscInput` for the receive side) as a new type — this scaffolds the + `.t3`/`.t3ui` files TiXL needs. +3. Replace the generated `.cs` body with the corresponding file here (the + `[Guid(...)]`/`[Input]`/`[Output]` attributes may need adjusting to + match what the duplication step assigned — TiXL owns those once a + symbol exists in the editor). + +## Reproducing the compile verification + +```bash +git clone git@github.com:tixl3d/tixl.git +mkdir -p tixl/Operators/Pchi +cp -r tools/tixl-native-operators/Symbols tixl/Operators/Pchi/Symbols +cp tools/tixl-native-operators/verify-build/Pchi.csproj tixl/Operators/Pchi/Pchi.csproj + +# .NET 10 SDK required (TixlNetFrameworkVersion = net10.0-windows) +cd tixl +dotnet build Operators/Pchi/Pchi.csproj -p:EnableWindowsTargeting=true -p:SolutionDir="$(pwd)/" +``` + +## What this does not do + +Same as the OSC bridge: this only carries the signal. Gating a DMX output +on `GateState` is downstream graph wiring the artist does themselves. diff --git a/tools/tixl-native-operators/Symbols/lib/io/pchi/PCHIGovernanceReceive.cs b/tools/tixl-native-operators/Symbols/lib/io/pchi/PCHIGovernanceReceive.cs new file mode 100644 index 0000000..f7ff0a0 --- /dev/null +++ b/tools/tixl-native-operators/Symbols/lib/io/pchi/PCHIGovernanceReceive.cs @@ -0,0 +1,229 @@ +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using Newtonsoft.Json.Linq; + +namespace Lib.io.pchi; + +/// +/// Connects directly to a PCHI Conductor's governance-event WebSocket +/// (real wire protocol, no OSC intermediary) and exposes the latest live +/// verdict as graph values: GateState as a float code (1.0/0.5/0.0 = +/// ALLOW/ESCALATE/DENY — the same encoding +/// tools/tixl-pchi-bridge/tixl_pchi_bridge.py uses over OSC, chosen +/// because it's a single comparable number an artist can wire straight +/// into a Less/Greater node), plus ReceiptId and Tool as strings for +/// display/logging. +/// +/// Requires vdmo/pchi#5 (the WS accept-loop fix) on the Conductor side — +/// without it, the Conductor's WebSocket is bound but never accepts a +/// connection, and this operator will sit at IsConnected=false forever. +/// +/// Unlike OscInput, which shares connection pooling through +/// T3.IoServices.OscConnectionManager, this operator owns its connection +/// directly: one ClientWebSocket per operator instance, reconnecting on +/// failure. That infrastructure is TiXL's own internal machinery for a +/// protocol (OSC) many operators share one listening port for; a single +/// governance WebSocket per Conductor has no equivalent multi-operator +/// sharing need today. +/// +[Guid("d94e6a5f-1234-4abc-9def-0123456789ab")] +internal sealed class PCHIGovernanceReceive : Instance, IStatusProvider +{ + [Output(Guid = "e05f7b60-2345-4bcd-8ef0-123456789abc")] + public readonly Slot GateState = new(); + + [Output(Guid = "f16a8c71-3456-4cde-9f01-23456789abcd")] + public readonly Slot ReceiptId = new(); + + [Output(Guid = "0271934d-4567-4def-8f12-3456789abcde")] + public readonly Slot Tool = new(); + + [Output(Guid = "1382a45e-5678-4ef0-9012-3456789abcd0")] + public readonly Slot IsConnected = new(); + + public PCHIGovernanceReceive() + { + GateState.UpdateAction += Update; + ReceiptId.UpdateAction += Update; + Tool.UpdateAction += Update; + IsConnected.UpdateAction += Update; + } + + private void Update(EvaluationContext context) + { + var host = PchiHost.GetValue(context); + var port = PchiWsPort.GetValue(context); + var target = $"ws://{host}:{port}"; + + if (target != _connectedTarget) + { + StopListening(); + _connectedTarget = target; + StartListening(target); + } + + lock (_lock) + { + GateState.Value = _lastGateStateCode; + ReceiptId.Value = _lastReceiptId ?? ""; + Tool.Value = _lastTool ?? ""; + IsConnected.Value = _isConnected; + } + } + + private void StartListening(string target) + { + _cts = new CancellationTokenSource(); + var token = _cts.Token; + _listenTask = Task.Run(() => ListenLoop(target, token), token); + } + + private void StopListening() + { + try + { + _cts?.Cancel(); + _listenTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (Exception) + { + // Best-effort teardown on reconnect/dispose; a hung or already- + // faulted socket must not block Update(). + } + finally + { + _cts?.Dispose(); + _cts = null; + _listenTask = null; + lock (_lock) + { + _isConnected = false; + } + } + } + + private async Task ListenLoop(string target, CancellationToken token) + { + var buffer = new byte[16 * 1024]; + while (!token.IsCancellationRequested) + { + try + { + using var socket = new ClientWebSocket(); + await socket.ConnectAsync(new Uri(target), token).ConfigureAwait(false); + lock (_lock) + { + _isConnected = true; + } + _lastErrorMessage = null; + + while (socket.State == WebSocketState.Open && !token.IsCancellationRequested) + { + using var ms = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(new ArraySegment(buffer), token).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + break; + ms.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) + break; + + HandleMessage(Encoding.UTF8.GetString(ms.ToArray())); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception e) + { + _lastErrorMessage = $"WebSocket error: {e.Message}"; + } + + lock (_lock) + { + _isConnected = false; + } + + if (!token.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(2), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + } + + private static readonly Dictionary GateStateCodes = new() + { + ["ALLOW"] = 1.0f, + ["ESCALATE"] = 0.5f, + ["DENY"] = 0.0f, + }; + + private void HandleMessage(string raw) + { + JObject parsed; + try + { + parsed = JObject.Parse(raw); + } + catch (Exception) + { + return; // not JSON we understand — ignore rather than fault the loop + } + + var payload = parsed["payload"] as JObject; + if (payload?["payloadType"]?.ToString() != "governance_event") + return; + + var data = payload["data"] as JObject; + var gateState = data?["gate_state"]?.ToString(); + if (gateState == null || !GateStateCodes.TryGetValue(gateState, out var code)) + return; + + lock (_lock) + { + _lastGateStateCode = code; + _lastReceiptId = data?["receipt_id"]?.ToString(); + _lastTool = data?["tool"]?.ToString(); + } + } + + protected override void Dispose(bool isDisposing) + { + if (isDisposing) + StopListening(); + } + + public IStatusProvider.StatusLevel GetStatusLevel() + => string.IsNullOrEmpty(_lastErrorMessage) ? IStatusProvider.StatusLevel.Success : IStatusProvider.StatusLevel.Warning; + + string IStatusProvider.GetStatusMessage() => _lastErrorMessage; + private string _lastErrorMessage; + + private readonly object _lock = new(); + private float _lastGateStateCode = 1.0f; // ALLOW until a real event says otherwise + private string _lastReceiptId; + private string _lastTool; + private bool _isConnected; + private string _connectedTarget; + private CancellationTokenSource _cts; + private Task _listenTask; + + [Input(Guid = "249b1b6f-6789-4f01-9123-456789abcde1")] + public readonly InputSlot PchiHost = new() { Value = "127.0.0.1" }; + + [Input(Guid = "35ac2c70-789a-4012-9234-56789abcde12")] + public readonly InputSlot PchiWsPort = new() { Value = 8889 }; +} diff --git a/tools/tixl-native-operators/Symbols/lib/io/pchi/PCHISendControlParameter.cs b/tools/tixl-native-operators/Symbols/lib/io/pchi/PCHISendControlParameter.cs new file mode 100644 index 0000000..0d2c858 --- /dev/null +++ b/tools/tixl-native-operators/Symbols/lib/io/pchi/PCHISendControlParameter.cs @@ -0,0 +1,149 @@ +using System.Net.Sockets; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Lib.io.pchi; + +/// +/// Sends a control-parameter update directly to a PCHI Conductor as raw +/// JSON over UDP — the real wire format +/// (pchi-schema/src/lib.rs::PCHIMessage), not OSC. This is the native +/// alternative to routing through tools/tixl-pchi-bridge: no external +/// process, no OSC round-trip, this operator speaks the protocol +/// directly. +/// +/// Field-name lesson learned the hard way while building the Resolume and +/// TiXL OSC bridges (vdmo/pchi#3): PCHIMessage.message_type serializes +/// under the wire name "type" (#[serde(rename = "type")] in the Rust +/// schema), and Payload is a tagged enum keyed "payloadType"/"data" +/// (#[serde(tag = "payloadType", content = "data")]) — not the Rust +/// field's own snake_case name. BuildWireJson() below is the one place +/// that mapping happens, matching the Python bridges' to_wire_dict(). +/// +[Guid("6d6aa537-f7de-452f-ade6-b063694f0cee")] +internal sealed class PCHISendControlParameter : Instance, IStatusProvider +{ + [Output(Guid = "d11a663b-c0cd-49fc-ad9f-c7ce132b5335")] + public readonly Slot Result = new(); + + public PCHISendControlParameter() + { + Result.UpdateAction += Update; + } + + private void Update(EvaluationContext context) + { + var shouldSend = SendTrigger.GetValue(context); + if (!shouldSend) + return; + + var host = PchiHost.GetValue(context); + var port = PchiPort.GetValue(context); + var targetId = TargetId.GetValue(context); + var parameter = Parameter.GetValue(context); + var value = Value.GetValue(context); + + if (string.IsNullOrWhiteSpace(targetId) || string.IsNullOrWhiteSpace(parameter)) + { + _lastErrorMessage = "TargetId and Parameter must both be set"; + return; + } + + if (port is <= 0 or > 65535) + { + _lastErrorMessage = "PchiPort must be in the 1-65535 range"; + return; + } + + try + { + var json = BuildWireJson(targetId, parameter, value); + var bytes = Encoding.UTF8.GetBytes(json); + + _client ??= new UdpClient(); + _client.Send(bytes, bytes.Length, host, port); + _lastErrorMessage = null; + } + catch (Exception e) + { + _lastErrorMessage = $"Failed to send PCHI message: {e.Message}"; + } + } + + /// + /// The full PCHIMessage envelope, with a complete (zero-residual) + /// pir_invariants block — the schema has no #[serde(default)] on any + /// PIRInvariants field, so an incomplete or omitted block is rejected + /// outright, not defaulted. A single control-parameter value has no + /// genuine multi-value equilibrium claim to make in the first place + /// (see vdmo/pchi#4) — this reports an honest zero, not a fabricated + /// one, and (post vdmo/pchi#4) it's informational only for this + /// message type, not a pass/fail gate. + /// + private static string BuildWireJson(string targetId, string parameter, float value) + { + var message = new JObject + { + ["version"] = "2.0.0", + ["type"] = "control_parameter", + ["source_id"] = "tixl_native_operator", + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ["pir_invariants"] = new JObject + { + ["equilibrium_check"] = new JObject + { + ["residual"] = 0.0, + ["precision"] = 1e-12, + ["sign_sequence"] = new JArray(), + }, + ["coherence_gap"] = 0.0, + ["curvature_signature"] = "0.0, 0.0, 0.0", + ["residual"] = 0.0, + }, + ["payload"] = new JObject + { + ["payloadType"] = "control_parameter", + ["data"] = new JObject + { + ["target_id"] = targetId, + ["parameter"] = parameter, + ["value"] = value, + }, + }, + }; + return message.ToString(Formatting.None); + } + + protected override void Dispose(bool isDisposing) + { + if (isDisposing) + _client?.Dispose(); + } + + private UdpClient _client; + + public IStatusProvider.StatusLevel GetStatusLevel() + => string.IsNullOrEmpty(_lastErrorMessage) ? IStatusProvider.StatusLevel.Success : IStatusProvider.StatusLevel.Warning; + + string IStatusProvider.GetStatusMessage() => _lastErrorMessage; + private string _lastErrorMessage; + + [Input(Guid = "7f4ed76b-ae25-41d5-b0d8-b4af2c7aa971")] + public readonly InputSlot SendTrigger = new(); + + [Input(Guid = "71739432-4939-4070-828f-25a58ef2239e")] + public readonly InputSlot PchiHost = new() { Value = "127.0.0.1" }; + + [Input(Guid = "3127a0de-730f-4585-a2ee-ec70520117d9")] + public readonly InputSlot PchiPort = new() { Value = 8888 }; + + [Input(Guid = "782ad5a0-ffd4-4d58-a303-57233a65a6b7")] + public readonly InputSlot TargetId = new(); + + [Input(Guid = "8a1c2e3f-4b5d-6e7f-8091-a2b3c4d5e6f7")] + public readonly InputSlot Parameter = new(); + + [Input(Guid = "9b2d3f4a-5c6e-7f80-91a2-b3c4d5e6f708")] + public readonly InputSlot Value = new(); +} diff --git a/tools/tixl-native-operators/verify-build/Pchi.csproj b/tools/tixl-native-operators/verify-build/Pchi.csproj new file mode 100644 index 0000000..0c9565c --- /dev/null +++ b/tools/tixl-native-operators/verify-build/Pchi.csproj @@ -0,0 +1,45 @@ + + + + net10.0-windows + disable + disable + true + 2 + Pchi + $(RootNamespace) + Debug;Release + AnyCPU + 1.0.0 + 00000000-0000-0000-0000-000000000000 + 4.2.0 + false + true + true + true + $(NoWarn);NU1900;NU1701 + b1e2c3d4-e5f6-4718-9203-a4b5c6d7e8f9 + false + + + + + + + + + + + + + + + + + + + + + + +