From 3b37fbde0e0773ae8abfc8fc61cf6062d900c74e Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 18:26:17 +0200 Subject: [PATCH 1/6] fix(core): surface deserialization failures on request paths A payload that arrives and cannot be read came back as null, so the caller was told OBS had returned nothing. GetCanvasList over MessagePack reported a missing formatter as a server-side problem for every user. The receive loop keeps the tolerant form: one unmodellable event from a newer OBS must not tear the connection down. --- ObsWebSocket.Core/ObsWebSocketClient.cs | 41 +++-- .../IWebSocketMessageSerializer.cs | 46 +++++- .../Serialization/JsonMessageSerializer.cs | 84 ++++++++-- .../Serialization/MsgPackMessageSerializer.cs | 72 +++++++-- .../ObsWebSocketClientEventTests.cs | 147 ++++++++---------- .../ObsWebSocketClientRequestTests.cs | 83 ++++++++++ ObsWebSocket.Tests/SerializerBehaviorTests.cs | 80 +++++++++- 7 files changed, 418 insertions(+), 135 deletions(-) diff --git a/ObsWebSocket.Core/ObsWebSocketClient.cs b/ObsWebSocket.Core/ObsWebSocketClient.cs index b07c92e..892ccc6 100644 --- a/ObsWebSocket.Core/ObsWebSocketClient.cs +++ b/ObsWebSocket.Core/ObsWebSocketClient.cs @@ -1577,10 +1577,15 @@ private void HandleRequestResponseMessage(object? payloadData) try { - RequestResponsePayload? response = _serializer.DeserializePayload< - RequestResponsePayload - >(payloadData); - if (response is null) + // Tolerant on purpose: the requestId lives inside the payload that failed to parse, + // so there is no pending request to fault. The awaiting caller times out instead, + // and this log is the only record of why. + if ( + !_serializer.TryDeserializePayload( + payloadData, + out RequestResponsePayload? response + ) || response is null + ) { LogEventDataDeserializationError("RequestResponse wrapper", payloadData); return; @@ -1623,10 +1628,12 @@ private void HandleRequestBatchResponseMessage(object? payloadData) try { - RequestBatchResponsePayload? response = _serializer.DeserializePayload< - RequestBatchResponsePayload - >(payloadData); - if (response is null) + if ( + !_serializer.TryDeserializePayload( + payloadData, + out RequestBatchResponsePayload? response + ) || response is null + ) { LogEventDataDeserializationError("RequestBatchResponse wrapper", payloadData); return; @@ -1670,10 +1677,12 @@ private void HandleEventMessage(object? payloadData) EventPayloadBase? eventPayloadBase = null; try { - eventPayloadBase = _serializer.DeserializePayload>( - payloadData - ); - if (eventPayloadBase is null) + // Tolerant on purpose: a newer OBS sending an event this build cannot model must not + // tear the connection down. + if ( + !_serializer.TryDeserializePayload(payloadData, out eventPayloadBase) + || eventPayloadBase is null + ) { LogEventDataDeserializationError("base event structure", payloadData); return; @@ -1730,8 +1739,10 @@ private void HandleCustomEvent(object? rawData) { try { - JsonElement? broadcastData = _serializer.DeserializeValuePayload(rawData); - if (broadcastData is null) + if ( + !_serializer.TryDeserializeValuePayload(rawData, out JsonElement? broadcastData) + || broadcastData is null + ) { LogEventDataDeserializationError("CustomEvent payload", rawData); return; @@ -1756,7 +1767,7 @@ Action invoker { try { - TPayload? payload = _serializer.DeserializePayload(rawData); + _ = _serializer.TryDeserializePayload(rawData, out TPayload? payload); if (payload is not null) { _metrics.EventsReceived.Add(1, new TagList { { "obsws.event_type", eventType } }); diff --git a/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs b/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs index 9a29f97..4df817a 100644 --- a/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs +++ b/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs @@ -40,20 +40,58 @@ Task SerializeAsync( ); /// - /// Deserializes the raw payload data (e.g., JsonElement, object from MessagePack) into a specific target type. + /// Deserializes the raw payload data (e.g., JsonElement, object from MessagePack) into a + /// specific target type, throwing when the payload cannot be read. /// + /// + /// Use this on paths where a caller is awaiting a result. A payload that is absent still + /// returns , because many requests answer with no data at all; only a + /// payload that is present and unreadable raises. + /// /// The target type to deserialize into. /// The raw payload data object received within an IncomingMessage<TData>.D field. - /// The deserialized payload object, or default if null or deserialization fails. + /// The deserialized payload object, or if no payload was present. + /// + /// Thrown when a payload is present but cannot be deserialized into . + /// TPayload? DeserializePayload(object? rawPayloadData) where TPayload : class; /// - /// Deserializes the raw payload data into a specific target value type. + /// Deserializes the raw payload data into a specific target value type, throwing when the + /// payload cannot be read. /// /// The target value type to deserialize into. /// The raw payload data object received within an IncomingMessage<TData>.D field. - /// The deserialized payload value, or default if null or deserialization fails. + /// The deserialized payload value, or if no payload was present. + /// + /// Thrown when a payload is present but cannot be deserialized into . + /// TPayload? DeserializeValuePayload(object? rawPayloadData) where TPayload : struct; + + /// + /// Deserializes the raw payload data, reporting failure instead of throwing. + /// + /// + /// Use this on the receive loop. A newer OBS sending an event this build cannot model, or a + /// wrapper that arrives malformed, must not tear the connection down, so the failure is + /// logged and the message dropped. + /// + /// The target type to deserialize into. + /// The raw payload data object received within an IncomingMessage<TData>.D field. + /// The deserialized payload, or on failure. + /// when a payload was read; otherwise . + bool TryDeserializePayload(object? rawPayloadData, out TPayload? payload) + where TPayload : class; + + /// + /// Deserializes the raw payload data into a value type, reporting failure instead of throwing. + /// + /// The target value type to deserialize into. + /// The raw payload data object received within an IncomingMessage<TData>.D field. + /// The deserialized payload, or on failure. + /// when a payload was read; otherwise . + bool TryDeserializeValuePayload(object? rawPayloadData, out TPayload? payload) + where TPayload : struct; } diff --git a/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs b/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs index 4385e5a..1fd4a97 100644 --- a/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs +++ b/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs @@ -13,6 +13,8 @@ namespace ObsWebSocket.Core.Serialization; public class JsonMessageSerializer(ILogger logger) : IWebSocketMessageSerializer { + private const int RawTextLimit = 512; + private readonly ILogger _logger = logger; private static readonly JsonSerializerOptions s_options = ObsWebSocketJsonContext .Default @@ -105,6 +107,30 @@ public Task SerializeAsync( /// public TPayload? DeserializePayload(object? rawPayloadData) + where TPayload : class => DeserializePayloadCore(rawPayloadData); + + /// + public bool TryDeserializePayload(object? rawPayloadData, out TPayload? payload) + where TPayload : class + { + try + { + payload = DeserializePayloadCore(rawPayloadData); + return payload is not null; + } + catch (ObsWebSocketSerializationException ex) + { + _logger.LogJsonFailedToDeserializePayloadToRaw( + ex, + typeof(TPayload).Name, + RawTextOf(rawPayloadData) + ); + payload = default; + return false; + } + } + + private TPayload? DeserializePayloadCore(object? rawPayloadData) where TPayload : class { if ( @@ -224,19 +250,38 @@ out JsonElement dataElement (JsonTypeInfo)s_options.GetTypeInfo(typeof(TPayload)); return jsonElement.Deserialize(typeInfo); } - catch (Exception ex) + catch (Exception ex) when (ex is not ObsWebSocketSerializationException) { - _logger.LogJsonFailedToDeserializePayloadToRaw( + throw new ObsWebSocketSerializationException(FailureMessage(jsonElement), ex); + } + } + + /// + public TPayload? DeserializeValuePayload(object? rawPayloadData) + where TPayload : struct => DeserializeValuePayloadCore(rawPayloadData); + + /// + public bool TryDeserializeValuePayload(object? rawPayloadData, out TPayload? payload) + where TPayload : struct + { + try + { + payload = DeserializeValuePayloadCore(rawPayloadData); + return payload.HasValue; + } + catch (ObsWebSocketSerializationException ex) + { + _logger.LogJsonFailedToDeserializePayloadToValue( ex, typeof(TPayload).Name, - jsonElement.GetRawText() + RawTextOf(rawPayloadData) ); - return default; + payload = default; + return false; } } - /// - public TPayload? DeserializeValuePayload(object? rawPayloadData) + private TPayload? DeserializeValuePayloadCore(object? rawPayloadData) where TPayload : struct { if ( @@ -267,14 +312,27 @@ is not null (JsonTypeInfo)s_options.GetTypeInfo(typeof(TPayload)); return jsonElement.Deserialize(typeInfo); } - catch (Exception ex) + catch (Exception ex) when (ex is not ObsWebSocketSerializationException) { - _logger.LogJsonFailedToDeserializePayloadToValue( - ex, - typeof(TPayload).Name, - jsonElement.GetRawText() - ); - return default; + throw new ObsWebSocketSerializationException(FailureMessage(jsonElement), ex); + } + } + + /// + /// Builds the message for a payload that could not be read, keeping enough of the raw JSON to + /// identify it without pasting an entire scene list into an exception. + /// + private static string FailureMessage(JsonElement element) + { + string raw = element.GetRawText(); + if (raw.Length > RawTextLimit) + { + raw = string.Concat(raw.AsSpan(0, RawTextLimit), "..."); } + + return $"Failed to deserialize the payload as '{typeof(TPayload).Name}'. Raw JSON: {raw}"; } + + private static string RawTextOf(object? rawPayloadData) => + rawPayloadData is JsonElement element ? element.GetRawText() : string.Empty; } diff --git a/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs b/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs index 839e7f4..4c642e7 100644 --- a/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs +++ b/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs @@ -97,6 +97,30 @@ public Task SerializeAsync( /// public TPayload? DeserializePayload(object? rawPayloadData) + where TPayload : class => DeserializePayloadCore(rawPayloadData); + + /// + public bool TryDeserializePayload(object? rawPayloadData, out TPayload? payload) + where TPayload : class + { + try + { + payload = DeserializePayloadCore(rawPayloadData); + return payload is not null; + } + catch (ObsWebSocketSerializationException ex) + { + _logger.LogMessagepackFailedToDeserializePayloadObjectTo( + ex, + typeof(TPayload).Name, + rawPayloadData?.GetType().Name ?? "null" + ); + payload = default; + return false; + } + } + + private TPayload? DeserializePayloadCore(object? rawPayloadData) where TPayload : class { if (rawPayloadData is not ReadOnlyMemory raw) @@ -121,19 +145,38 @@ public Task SerializeAsync( ? (TPayload)(object)DeserializeRequestBatchResponsePayload(raw) : MessagePackSerializer.Deserialize(raw, s_msgPackOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not ObsWebSocketSerializationException) { - _logger.LogMessagepackFailedToDeserializePayloadObjectTo( + throw new ObsWebSocketSerializationException(FailureMessage(raw), ex); + } + } + + /// + public TPayload? DeserializeValuePayload(object? rawPayloadData) + where TPayload : struct => DeserializeValuePayloadCore(rawPayloadData); + + /// + public bool TryDeserializeValuePayload(object? rawPayloadData, out TPayload? payload) + where TPayload : struct + { + try + { + payload = DeserializeValuePayloadCore(rawPayloadData); + return payload.HasValue; + } + catch (ObsWebSocketSerializationException ex) + { + _logger.LogMessagepackFailedToDeserializePayloadObjectTo2( ex, typeof(TPayload).Name, - rawPayloadData.GetType().Name + rawPayloadData?.GetType().Name ?? "null" ); - return default; + payload = default; + return false; } } - /// - public TPayload? DeserializeValuePayload(object? rawPayloadData) + private TPayload? DeserializeValuePayloadCore(object? rawPayloadData) where TPayload : struct { if (rawPayloadData is not ReadOnlyMemory raw) @@ -145,17 +188,20 @@ public Task SerializeAsync( { return MessagePackSerializer.Deserialize(raw, s_msgPackOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not ObsWebSocketSerializationException) { - _logger.LogMessagepackFailedToDeserializePayloadObjectTo2( - ex, - typeof(TPayload).Name, - rawPayloadData.GetType().Name - ); - return default; + throw new ObsWebSocketSerializationException(FailureMessage(raw), ex); } } + /// + /// Builds the message for a payload that could not be read. MessagePack is binary, so the + /// byte count is the useful detail; a hex dump of a scene list would not be. + /// + private static string FailureMessage(ReadOnlyMemory raw) => + $"Failed to deserialize the payload as '{typeof(TPayload).Name}' " + + $"from {raw.Length} byte(s) of MessagePack."; + private static IncomingMessage> DeserializeIncomingEnvelope( ReadOnlyMemory payload ) diff --git a/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs index 8490081..ff04206 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs @@ -245,27 +245,26 @@ public async Task HandleEventMessage_SceneListChanged_RaisesCorrectEvent() _ = mockSerializer .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(incomingMessage); + EventPayloadBase? envelope = new EventPayloadBase( + "SceneListChanged", + (int)EventSubscription.Scenes, + innerEventDataJsonElement + ); _ = mockSerializer - .Setup(s => - s.DeserializePayload>(It.Is(o => o is JsonElement)) - ) - .Returns( - new EventPayloadBase( - "SceneListChanged", - (int)EventSubscription.Scenes, - innerEventDataJsonElement - ) - ); + .Setup(s => s.TryDeserializePayload(It.Is(o => o is JsonElement), out envelope)) + .Returns(true); + SceneListChangedPayload? scenePayload = expectedPayloadDto; _ = mockSerializer .Setup(s => - s.DeserializePayload( + s.TryDeserializePayload( It.Is(o => o is JsonElement && ((JsonElement)o).GetRawText() == innerEventDataJsonElement.GetRawText() - ) + ), + out scenePayload ) ) - .Returns(expectedPayloadDto); + .Returns(true); // Mock WebSocket mockWebSocket.Reset(); // Reset setups from helper @@ -292,15 +291,11 @@ await Task.WhenAny(eventReceivedSignal.Task, Task.Delay(TimeSpan.FromSeconds(3)) Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload>( - It.Is(o => o is JsonElement) - ), + s => s.TryDeserializePayload(It.IsAny(), out envelope), Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload(It.Is(o => o is JsonElement)), + s => s.TryDeserializePayload(It.IsAny(), out scenePayload), Times.Once ); mockWebSocket.Verify( @@ -356,27 +351,26 @@ public async Task HandleEventMessage_StudioModeStateChanged_RaisesCorrectEvent() _ = mockSerializer .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(incomingMessage); + EventPayloadBase? envelope = new EventPayloadBase( + "StudioModeStateChanged", + (int)EventSubscription.Ui, + innerEventDataJsonElement + ); _ = mockSerializer - .Setup(s => - s.DeserializePayload>(It.Is(o => o is JsonElement)) - ) - .Returns( - new EventPayloadBase( - "StudioModeStateChanged", - (int)EventSubscription.Ui, - innerEventDataJsonElement - ) - ); + .Setup(s => s.TryDeserializePayload(It.Is(o => o is JsonElement), out envelope)) + .Returns(true); + StudioModeStateChangedPayload? studioPayload = expectedPayloadDto; _ = mockSerializer .Setup(s => - s.DeserializePayload( + s.TryDeserializePayload( It.Is(o => o is JsonElement && ((JsonElement)o).GetRawText() == innerEventDataJsonElement.GetRawText() - ) + ), + out studioPayload ) ) - .Returns(expectedPayloadDto); + .Returns(true); // Mock WebSocket mockWebSocket.Reset(); @@ -407,17 +401,11 @@ await Task.WhenAny(eventReceivedSignal.Task, Task.Delay(TimeSpan.FromSeconds(3)) Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload>( - It.Is(o => o is JsonElement) - ), + s => s.TryDeserializePayload(It.IsAny(), out envelope), Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload( - It.Is(o => o is JsonElement) - ), + s => s.TryDeserializePayload(It.IsAny(), out studioPayload), Times.Once ); @@ -469,13 +457,14 @@ public async Task HandleEventMessage_ExitStarted_RaisesCorrectEvent() .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(incomingMessage); // Mock only the base deserialization, as EventData is null + EventPayloadBase? envelope = new EventPayloadBase( + "ExitStarted", + (int)EventSubscription.General, + null + ); _ = mockSerializer - .Setup(s => - s.DeserializePayload>(It.Is(o => o is JsonElement)) - ) - .Returns( - new EventPayloadBase("ExitStarted", (int)EventSubscription.General, null) - ); + .Setup(s => s.TryDeserializePayload(It.Is(o => o is JsonElement), out envelope)) + .Returns(true); // Mock WebSocket mockWebSocket.Reset(); @@ -502,17 +491,14 @@ await Task.WhenAny(eventReceivedSignal.Task, Task.Delay(TimeSpan.FromSeconds(3)) Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload>( - It.Is(o => o is JsonElement) - ), + s => s.TryDeserializePayload(It.IsAny(), out envelope), Times.Once ); - // Verify no *specific* payload deserialization happened + // Only the envelope was read; the event carries no data to deserialize. mockSerializer.Verify( - s => s.DeserializePayload>(It.IsAny()), + s => s.TryDeserializePayload(It.IsAny(), out It.Ref.IsAny), Times.Exactly(1) - ); // Only the base deserialize was called + ); // Cleanup await StopReceiveLoopAsync(client, receiveLoopTask); @@ -559,11 +545,14 @@ public async Task HandleEventMessage_UnhandledEventType_DoesNotThrowAndLogsWarni .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(incomingMessage); // Mock ONLY the base deserialization + EventPayloadBase? envelope = new EventPayloadBase( + unhandledEventType, + 1, + innerEventData + ); _ = mockSerializer - .Setup(s => - s.DeserializePayload>(It.Is(o => o is JsonElement)) - ) - .Returns(new EventPayloadBase(unhandledEventType, 1, innerEventData)); + .Setup(s => s.TryDeserializePayload(It.Is(o => o is JsonElement), out envelope)) + .Returns(true); // --- Mock WebSocket --- mockWebSocket.Reset(); @@ -602,15 +591,12 @@ public async Task HandleEventMessage_UnhandledEventType_DoesNotThrowAndLogsWarni Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload>( - It.Is(o => o is JsonElement) - ), + s => s.TryDeserializePayload(It.IsAny(), out envelope), Times.Once ); - // Ensure NO specific payload deserialization was attempted + // Only the envelope was read; the unknown event type has no handler to deserialize for. mockSerializer.Verify( - s => s.DeserializePayload>(It.IsAny()), + s => s.TryDeserializePayload(It.IsAny(), out It.Ref.IsAny), Times.Exactly(1) ); @@ -665,22 +651,21 @@ public async Task HandleEventMessage_PayloadDeserializationThrows_LogsError() .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(incomingMessage); // Base deserialization succeeds + EventPayloadBase? envelope = new( + eventType, + (int)EventSubscription.Ui, + innerEventDataJsonElement + ); _ = mockSerializer - .Setup(s => - s.DeserializePayload>(It.Is(o => o is JsonElement)) - ) - .Returns( - new EventPayloadBase( - eventType, - (int)EventSubscription.Ui, - innerEventDataJsonElement - ) - ); - // Specific payload deserialization *throws* + .Setup(s => s.TryDeserializePayload(It.Is(o => o is JsonElement), out envelope)) + .Returns(true); + // The serializer itself faults, which the tolerant path is not supposed to do. The event + // handler still has to survive it rather than tearing the receive loop down. _ = mockSerializer .Setup(s => - s.DeserializePayload( - It.Is(o => o is JsonElement) + s.TryDeserializePayload( + It.Is(o => o is JsonElement), + out It.Ref.IsAny ) ) .Throws(simulatedException); @@ -722,17 +707,15 @@ public async Task HandleEventMessage_PayloadDeserializationThrows_LogsError() Times.Once ); mockSerializer.Verify( - s => - s.DeserializePayload>( - It.Is(o => o is JsonElement) - ), + s => s.TryDeserializePayload(It.IsAny(), out envelope), Times.Once ); // Verify the failing call was made mockSerializer.Verify( s => - s.DeserializePayload( - It.Is(o => o is JsonElement) + s.TryDeserializePayload( + It.IsAny(), + out It.Ref.IsAny ), Times.Once ); diff --git a/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs index 32551b6..b0b525f 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs @@ -152,6 +152,89 @@ CancellationToken ct ); } + /// + /// A response payload that arrives but cannot be read has to reach the awaiting caller as a + /// serialization failure. Returning null instead reported it as "OBS returned no payload", + /// which sent people looking at the wrong machine. + /// + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetVersionAsync_ResponsePayloadUnreadable_ThrowsSerializationException() + { + // Arrange + ( + ObsWebSocketClient? client, + Mock? mockSerializer, + Mock? mockWebSocket + ) = TestUtils.SetupConnectedClientForceState(); + + JsonElement? rawResponseData = TestUtils.ToJsonElement(new { obsVersion = "32.2.2" }); + Assert.IsNotNull(rawResponseData); + + _ = mockWebSocket + .Setup(ws => + ws.SendAsync( + It.IsAny>(), + It.IsAny(), + true, + It.IsAny() + ) + ) + .Callback( + ( + ReadOnlyMemory buffer, + WebSocketMessageType msgType, + bool endOfMsg, + CancellationToken ct + ) => + { + OutgoingMessage? requestMsg = JsonSerializer.Deserialize< + OutgoingMessage + >(buffer.Span, TestUtils.s_jsonSerializerOptions); + if (requestMsg?.D?.RequestType != "GetVersion") + { + return; + } + + RequestResponsePayload response = new( + RequestType: "GetVersion", + RequestId: requestMsg.D.RequestId, + RequestStatus: new RequestStatus( + Result: true, + Code: (int)Core.Protocol.Generated.RequestStatusCode.Success + ), + ResponseData: rawResponseData.Value + ); + _ = TestUtils.SimulateIncomingResponse( + client, + requestMsg.D.RequestId, + response + ); + } + ) + .Returns(ValueTask.CompletedTask); + + ObsWebSocketSerializationException simulated = new( + "no formatter for GetVersionResponseData" + ); + _ = mockSerializer + .Setup(s => s.DeserializePayload(It.IsAny())) + .Throws(simulated); + + // Act / Assert + ObsWebSocketSerializationException thrown = + await Assert.ThrowsExactlyAsync(async () => + await client.General.GetVersionAsync() + ); + + Assert.AreSame(simulated, thrown); + + ConcurrentDictionary>? pendingRequests = + TestUtils.GetPendingRequests(client); + Assert.IsNotNull(pendingRequests); + Assert.AreEqual(0, pendingRequests.Count, "Pending request should have been removed."); + } + // --- Test Request WITH Request Data and NO Response Data --- /// diff --git a/ObsWebSocket.Tests/SerializerBehaviorTests.cs b/ObsWebSocket.Tests/SerializerBehaviorTests.cs index 2bdd592..8f4fc66 100644 --- a/ObsWebSocket.Tests/SerializerBehaviorTests.cs +++ b/ObsWebSocket.Tests/SerializerBehaviorTests.cs @@ -223,7 +223,7 @@ public void JsonSerializer_DeserializePayload_NestedTypeRequestData_Deserializes } [TestMethod] - public void JsonSerializer_DeserializePayload_InvalidGeneratedShape_ReturnsDefault() + public void JsonSerializer_DeserializePayload_InvalidGeneratedShape_Throws() { JsonMessageSerializer serializer = CreateJsonSerializer(); JsonElement payload = JsonDocument @@ -236,9 +236,32 @@ public void JsonSerializer_DeserializePayload_InvalidGeneratedShape_ReturnsDefau ) .RootElement.Clone(); - CreateSceneRequestData? data = serializer.DeserializePayload( - payload - ); + ObsWebSocketSerializationException ex = + Assert.ThrowsExactly(() => + serializer.DeserializePayload(payload) + ); + + StringAssert.Contains(ex.Message, nameof(CreateSceneRequestData)); + Assert.IsNotNull(ex.InnerException); + } + + [TestMethod] + public void JsonSerializer_TryDeserializePayload_InvalidGeneratedShape_ReturnsFalse() + { + JsonMessageSerializer serializer = CreateJsonSerializer(); + JsonElement payload = JsonDocument + .Parse( + """ + { + "sceneName": 12345 + } + """ + ) + .RootElement.Clone(); + + bool read = serializer.TryDeserializePayload(payload, out CreateSceneRequestData? data); + + Assert.IsFalse(read); Assert.IsNull(data); } @@ -395,19 +418,60 @@ public void MsgPackSerializer_DeserializePayload_SceneItemList_WithTransformAndE } [TestMethod] - public void MsgPackSerializer_DeserializePayload_InvalidGeneratedShape_ReturnsDefault() + public void MsgPackSerializer_DeserializePayload_InvalidGeneratedShape_Throws() { MsgPackMessageSerializer serializer = CreateMsgPackSerializer(); byte[] bytes = BuildInvalidFilterListPayloadBytes(); - GetSourceFilterListResponseData? payload = - serializer.DeserializePayload( - new ReadOnlyMemory(bytes) + ObsWebSocketSerializationException ex = + Assert.ThrowsExactly(() => + serializer.DeserializePayload( + new ReadOnlyMemory(bytes) + ) ); + StringAssert.Contains(ex.Message, nameof(GetSourceFilterListResponseData)); + Assert.IsNotNull(ex.InnerException); + } + + [TestMethod] + public void MsgPackSerializer_TryDeserializePayload_InvalidGeneratedShape_ReturnsFalse() + { + MsgPackMessageSerializer serializer = CreateMsgPackSerializer(); + byte[] bytes = BuildInvalidFilterListPayloadBytes(); + + bool read = serializer.TryDeserializePayload( + new ReadOnlyMemory(bytes), + out GetSourceFilterListResponseData? payload + ); + + Assert.IsFalse(read); Assert.IsNull(payload); } + /// + /// The failure this split exists for: a payload with no registered formatter used to come back + /// as null, and the caller was told OBS had returned nothing. + /// + [TestMethod] + public void MsgPackSerializer_DeserializePayload_UnregisteredFormatter_ThrowsNamingTheType() + { + MsgPackMessageSerializer serializer = CreateMsgPackSerializer(); + byte[] bytes = MessagePack.MessagePackSerializer.Serialize(new Dictionary()); + + ObsWebSocketSerializationException ex = + Assert.ThrowsExactly(() => + serializer.DeserializePayload(new ReadOnlyMemory(bytes)) + ); + + StringAssert.Contains(ex.Message, nameof(UnmodelledPayload)); + } + + private sealed class UnmodelledPayload + { + public string? Whatever { get; set; } + } + [TestMethod] public void MsgPackSerializer_SerializeThenDeserialize_FilterPayload_RoundTripsWithValues() { From 2bbe97090fa2200b3df059bd8be6303fa7ebb348 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 18:40:15 +0200 Subject: [PATCH 2/6] fix(core): map each Array to the shape OBS actually sends Three arrays were read as the wrong stub and failed on required fields the payload never carries, so the messages never surfaced at all: - GetCanvasList had no stub, so MessagePack had no formatter for it. - InputVolumeMeters was read as InputStub; the meter payload has no input kind. - SceneItemListReindexed was read as SceneItemStub; the reindex event asks OBS for the basic list, which is id and index only. The generator keyed the mapping on the field name alone, so payloads sharing a name shared a stub. Parent-specific cases come first now. --- .../Generation/Emitter.Helpers.cs | 41 ++- .../Generation/Emitter.JsonContext.cs | 5 + .../InputVolumeMeters.EventPayload.g.cs | 4 +- .../SceneItemListReindexed.EventPayload.g.cs | 4 +- .../Responses/GetCanvasList.Response.g.cs | 4 +- .../ObsWebSocketJsonContext.g.cs | 5 + .../Protocol/Common/StubTypes.cs | 199 ++++++++++++ .../MsgPackStubExtensionDataResolver.cs | 106 +++---- ObsWebSocket.Example/Worker.cs | 154 +++++++++- ObsWebSocket.Tests/JsonElementListTests.cs | 63 ---- ObsWebSocket.Tests/StubArrayTests.cs | 283 ++++++++++++++++++ 11 files changed, 707 insertions(+), 161 deletions(-) delete mode 100644 ObsWebSocket.Tests/JsonElementListTests.cs create mode 100644 ObsWebSocket.Tests/StubArrayTests.cs diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs index c373b9a..149be92 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs @@ -324,14 +324,18 @@ string parentDtoName // Use fully qualified names for stub types to avoid ambiguity string? stubType = fieldName switch { + // The reindex event asks OBS for the basic list, which is id and index only. + "sceneItems" when parentDtoName == "SceneItemListReindexedPayload" => + $"{GeneratedCommonNamespace}.SceneItemOrderStub", "sceneItems" => $"{GeneratedCommonNamespace}.SceneItemStub", "filters" => $"{GeneratedCommonNamespace}.FilterStub", - // Need to check fully qualified parent name to exclude InputVolumeMetersPayload - "inputs" - when parentDtoName - != $"{GeneratedEventsNamespace}.InputVolumeMetersPayload" => - $"{GeneratedCommonNamespace}.InputStub", + // The meter payload carries only name, uuid and levels, so it is not an + // InputStub. parentDtoName arrives unqualified. + "inputs" when parentDtoName == "InputVolumeMetersPayload" => + $"{GeneratedCommonNamespace}.InputVolumeMeterStub", + "inputs" => $"{GeneratedCommonNamespace}.InputStub", "scenes" => $"{GeneratedCommonNamespace}.SceneStub", + "canvases" => $"{GeneratedCommonNamespace}.CanvasStub", "outputs" => $"{GeneratedCommonNamespace}.OutputStub", "transitions" => $"{GeneratedCommonNamespace}.TransitionStub", "monitors" => $"{GeneratedCommonNamespace}.MonitorStub", @@ -344,24 +348,17 @@ when parentDtoName // Use fully qualified List return ($"System.Collections.Generic.List<{stubType}>?", false); // List of specific stub type } - else // Fallback for unknown or explicitly excluded Array + else // Fallback for an array whose item type is not mapped to a stub. { - // Only warn if it's truly unknown, not the handled InputVolumeMeters case - if ( - fieldName != "inputs" - || parentDtoName != $"{GeneratedEventsNamespace}.InputVolumeMetersPayload" - ) - { - context.ReportDiagnostic( - Diagnostic.Create( - Diagnostics.ArrayItemTypeUnknownWarning, - Location.None, - fieldName, - parentDtoName, - obsType - ) - ); - } + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.ArrayItemTypeUnknownWarning, + Location.None, + fieldName, + parentDtoName, + obsType + ) + ); // Fallback to List for InputVolumeMetersPayload.inputs and any other unmapped Array return ( "System.Collections.Generic.List?", diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs index aea9103..b9c257c 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs @@ -71,6 +71,11 @@ ProtocolDefinition protocol _ = builder.AppendLine("[JsonSerializable(typeof(OutputStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(MonitorStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(PropertyItemStub))]"); + _ = builder.AppendLine("[JsonSerializable(typeof(InputVolumeMeterStub))]"); + _ = builder.AppendLine("[JsonSerializable(typeof(SceneItemOrderStub))]"); + _ = builder.AppendLine("[JsonSerializable(typeof(CanvasStub))]"); + _ = builder.AppendLine("[JsonSerializable(typeof(CanvasFlagsStub))]"); + _ = builder.AppendLine("[JsonSerializable(typeof(CanvasVideoSettingsStub))]"); // Common collection payload helpers. _ = builder.AppendLine("[JsonSerializable(typeof(List))]"); diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs index 35bb10f..f1d00f5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record InputVolumeMetersPayload /// [JsonPropertyName("inputs")] [Key("inputs")] - public required System.Collections.Generic.List Inputs { get; init; } + public required System.Collections.Generic.List Inputs { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -40,7 +40,7 @@ public InputVolumeMetersPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public InputVolumeMetersPayload(System.Collections.Generic.List inputs) + public InputVolumeMetersPayload(System.Collections.Generic.List inputs) { this.Inputs = inputs; } diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs index 51570c5..5055121 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record SceneItemListReindexedPayload /// [JsonPropertyName("sceneItems")] [Key("sceneItems")] - public required System.Collections.Generic.List SceneItems { get; init; } + public required System.Collections.Generic.List SceneItems { get; init; } /// /// Name of the scene @@ -54,7 +54,7 @@ public SceneItemListReindexedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemListReindexedPayload(string sceneName, string sceneUuid, System.Collections.Generic.List sceneItems) + public SceneItemListReindexedPayload(string sceneName, string sceneUuid, System.Collections.Generic.List sceneItems) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs index 4c86d8c..c65492c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs @@ -29,7 +29,7 @@ public sealed partial record GetCanvasListResponseData /// [JsonPropertyName("canvases")] [Key("canvases")] - public required System.Collections.Generic.List Canvases { get; init; } + public required System.Collections.Generic.List Canvases { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -40,7 +40,7 @@ public GetCanvasListResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetCanvasListResponseData(System.Collections.Generic.List canvases) + public GetCanvasListResponseData(System.Collections.Generic.List canvases) { this.Canvases = canvases; } diff --git a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs index 8abd111..dfbe5e7 100644 --- a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs +++ b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs @@ -36,6 +36,11 @@ namespace ObsWebSocket.Core.Serialization; [JsonSerializable(typeof(OutputStub))] [JsonSerializable(typeof(MonitorStub))] [JsonSerializable(typeof(PropertyItemStub))] +[JsonSerializable(typeof(InputVolumeMeterStub))] +[JsonSerializable(typeof(SceneItemOrderStub))] +[JsonSerializable(typeof(CanvasStub))] +[JsonSerializable(typeof(CanvasFlagsStub))] +[JsonSerializable(typeof(CanvasVideoSettingsStub))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] diff --git a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index c2fadd3..f0644d7 100644 --- a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs +++ b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs @@ -36,6 +36,205 @@ public sealed class SceneStub public SceneStub() { } } +/// +/// A scene item's identity and position, as carried by the SceneItemListReindexed event. +/// +/// +/// Not a : the reindex event asks OBS for the list in its basic form, +/// which carries only the id and the index. Reading it as a full scene item fails on the source +/// and transform fields it never sends. +/// +[MessagePackObject] +public sealed class SceneItemOrderStub +{ + /// Numeric ID of the scene item. + [JsonPropertyName("sceneItemId")] + [Key("sceneItemId")] + public required int SceneItemId { get; init; } + + /// Index of the scene item, counted from the bottom of the list. + [JsonPropertyName("sceneItemIndex")] + [Key("sceneItemIndex")] + public required int SceneItemIndex { get; init; } + + /// Captures any extra fields not explicitly defined in the stub. + [IgnoreMember] + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// Initializes a new instance for deserialization via . + [JsonConstructor] + public SceneItemOrderStub() { } +} + +/// +/// One input's audio levels, as carried by the InputVolumeMeters event. +/// +/// +/// Not an : the meter payload carries only the name, the uuid and the +/// levels, so reading it as one fails on the input kind it never sends. +/// +[MessagePackObject] +public sealed class InputVolumeMeterStub +{ + /// Input name. + [JsonPropertyName("inputName")] + [Key("inputName")] + public required string InputName { get; init; } + + /// Input UUID. + [JsonPropertyName("inputUuid")] + [Key("inputUuid")] + public required string InputUuid { get; init; } + + /// + /// Per channel levels as multipliers, each entry being magnitude, peak and input peak. + /// + [JsonPropertyName("inputLevelsMul")] + [Key("inputLevelsMul")] + public required List> InputLevelsMul { get; init; } + + /// Captures any extra fields not explicitly defined in the stub. + [IgnoreMember] + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// Initializes a new instance for deserialization via . + [JsonConstructor] + public InputVolumeMeterStub() { } +} + +/// +/// A canvas, as returned by GetCanvasList. Resilient to missing fields. +/// +/// +/// The protocol definition types the array as Array<Object> and says no more, so the +/// shape is taken from the request handler: name, uuid, flags and video settings. +/// +[MessagePackObject] +public sealed class CanvasStub +{ + /// Canvas name. No request accepts it; it is for display and for looking up a uuid. + [JsonPropertyName("canvasName")] + [Key("canvasName")] + public required string CanvasName { get; init; } + + /// Canvas UUID. This is what every canvas-scoped request takes. + [JsonPropertyName("canvasUuid")] + [Key("canvasUuid")] + public required string CanvasUuid { get; init; } + + /// Canvas capability flags. + [JsonPropertyName("canvasFlags")] + [Key("canvasFlags")] + public required CanvasFlagsStub CanvasFlags { get; init; } + + /// Video settings for this canvas. + [JsonPropertyName("canvasVideoSettings")] + [Key("canvasVideoSettings")] + public required CanvasVideoSettingsStub CanvasVideoSettings { get; init; } + + /// Captures any extra fields not explicitly defined in the stub. + [IgnoreMember] + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// Initializes a new instance for deserialization via . + [JsonConstructor] + public CanvasStub() { } +} + +/// +/// The capability flags reported for a canvas. +/// +[MessagePackObject] +public sealed class CanvasFlagsStub +{ + /// The main canvas, the one every request addresses when canvasUuid is omitted. + [JsonPropertyName("MAIN")] + [Key("MAIN")] + public required bool Main { get; init; } + + /// Sources on this canvas are activated. + [JsonPropertyName("ACTIVATE")] + [Key("ACTIVATE")] + public required bool Activate { get; init; } + + /// Audio from this canvas is mixed into the main output. + [JsonPropertyName("MIX_AUDIO")] + [Key("MIX_AUDIO")] + public required bool MixAudio { get; init; } + + /// The canvas holds references to its scenes. + [JsonPropertyName("SCENE_REF")] + [Key("SCENE_REF")] + public required bool SceneRef { get; init; } + + /// The canvas is not saved with the scene collection. + [JsonPropertyName("EPHEMERAL")] + [Key("EPHEMERAL")] + public required bool Ephemeral { get; init; } + + /// Captures any extra fields not explicitly defined in the stub. + [IgnoreMember] + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// Initializes a new instance for deserialization via . + [JsonConstructor] + public CanvasFlagsStub() { } +} + +/// +/// Video settings for a canvas. +/// +/// +/// Every field is nullable because OBS sends the whole object as nulls when it cannot read the +/// canvas video info, rather than omitting it. +/// +[MessagePackObject] +public sealed class CanvasVideoSettingsStub +{ + /// Numerator of the frame rate. + [JsonPropertyName("fpsNumerator")] + [Key("fpsNumerator")] + public int? FpsNumerator { get; init; } + + /// Denominator of the frame rate. + [JsonPropertyName("fpsDenominator")] + [Key("fpsDenominator")] + public int? FpsDenominator { get; init; } + + /// Base (canvas) width, in pixels. + [JsonPropertyName("baseWidth")] + [Key("baseWidth")] + public int? BaseWidth { get; init; } + + /// Base (canvas) height, in pixels. + [JsonPropertyName("baseHeight")] + [Key("baseHeight")] + public int? BaseHeight { get; init; } + + /// Output (scaled) width, in pixels. + [JsonPropertyName("outputWidth")] + [Key("outputWidth")] + public int? OutputWidth { get; init; } + + /// Output (scaled) height, in pixels. + [JsonPropertyName("outputHeight")] + [Key("outputHeight")] + public int? OutputHeight { get; init; } + + /// Captures any extra fields not explicitly defined in the stub. + [IgnoreMember] + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// Initializes a new instance for deserialization via . + [JsonConstructor] + public CanvasVideoSettingsStub() { } +} + /// /// Represents a common structure for scene item transform data. Resilient to missing fields. /// diff --git a/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs b/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs index d718fee..8da4a9a 100644 --- a/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs +++ b/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs @@ -13,74 +13,48 @@ internal sealed class MsgPackStubExtensionDataResolver : IFormatterResolver private MsgPackStubExtensionDataResolver() { } - public IMessagePackFormatter? GetFormatter() - { - Type type = typeof(T); - if (type == typeof(SceneStub)) - { - return (IMessagePackFormatter)(object)new MsgPackJsonBridgeFormatter(); - } - - if (type == typeof(SceneItemTransformStub)) - { - return (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter(); - } - - if (type == typeof(SceneItemStub)) - { - return (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter(); - } - - if (type == typeof(FilterStub)) - { - return (IMessagePackFormatter)(object)new MsgPackJsonBridgeFormatter(); - } - - if (type == typeof(InputStub)) - { - return (IMessagePackFormatter)(object)new MsgPackJsonBridgeFormatter(); - } + /// + /// Every hand-written stub, in both its bare and its list form. + /// + /// + /// A table rather than the typeof(T) == chain the other resolvers use, because here the + /// two forms of each stub have to be registered together: registering the bare stub and + /// forgetting the list is what made a response unreadable over MessagePack while JSON read it + /// fine. One call cannot express half a stub. Every instantiation is + /// written out, so nothing here needs reflection. + /// + private static readonly Dictionary s_formatters = BuildFormatters(); + + public IMessagePackFormatter? GetFormatter() => + s_formatters.TryGetValue(typeof(T), out object? formatter) + ? (IMessagePackFormatter)formatter + : null; - if (type == typeof(TransitionStub)) - { - return (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter(); - } + private static Dictionary BuildFormatters() + { + Dictionary map = []; + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + Register(map); + return map; + } - return type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(List) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter>() - : type == typeof(OutputStub) - ? (IMessagePackFormatter)(object)new MsgPackJsonBridgeFormatter() - : type == typeof(MonitorStub) - ? (IMessagePackFormatter)(object)new MsgPackJsonBridgeFormatter() - : type == typeof(PropertyItemStub) - ? (IMessagePackFormatter) - (object)new MsgPackJsonBridgeFormatter() - : null; + private static void Register(Dictionary map) + where T : class + { + map[typeof(T)] = new MsgPackJsonBridgeFormatter(); + map[typeof(List)] = new MsgPackJsonBridgeFormatter>(); } private sealed class MsgPackJsonBridgeFormatter : IMessagePackFormatter diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 1786d2a..bd954c1 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -2743,20 +2743,166 @@ await TrySettingsCheckAsync( .ConfigureAwait(false) ); + results.Add( + await TrySettingsCheckAsync( + "Scene item list reindexed", + async () => + { + // Reindexing asks OBS for the basic scene item list, a different shape + // from every other sceneItems array, so the event needs its own stub. + GetSceneItemListResponseData items = await client + .SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: sceneName), + cancellationToken + ) + .ConfigureAwait(false); + if (items.SceneItems.Count == 0) + { + return (false, "no scene items to reindex"); + } + + int id = items.SceneItems[0].SceneItemId; + int index = items.SceneItems[0].SceneItemIndex; + + Task reindexed = + client.WaitForEventAsync( + timeout: TimeSpan.FromSeconds(5), + cancellationToken: cancellationToken + ); + + await client + .SceneItems.SetSceneItemIndexAsync( + new SetSceneItemIndexRequestData( + sceneItemId: id, + sceneItemIndex: index, + sceneName: sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + SceneItemListReindexedEventArgs args = await reindexed.ConfigureAwait( + false + ); + + SceneItemOrderStub? moved = args.EventData.SceneItems.Find(i => + i.SceneItemId == id + ); + + return ( + moved is not null + && string.Equals( + args.EventData.SceneName, + sceneName, + StringComparison.Ordinal + ), + $"{args.EventData.SceneItems.Count} item(s) reindexed, " + + $"item {id} at index {moved?.SceneItemIndex}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Input volume meters", + async () => + { + // High rate event with its own stub. It was read as an InputStub and + // failed on the kind fields it never sends, so it never fired at all. + EventSubscription? before = client.CurrentEventSubscriptions; + await client + .ReidentifyAsync( + (uint)( + (before ?? EventSubscription.All) + | EventSubscription.InputVolumeMeters + ), + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + try + { + InputVolumeMetersEventArgs meters = await client + .WaitForEventAsync( + timeout: TimeSpan.FromSeconds(5), + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + InputVolumeMeterStub? first = + meters.EventData.Inputs.Count > 0 + ? meters.EventData.Inputs[0] + : null; + + // An input with no audio channels reports an empty level list, so + // the levels are checked where they exist rather than required. + bool levelsWellFormed = meters.EventData.Inputs.TrueForAll(i => + i.InputLevelsMul.TrueForAll(channel => channel.Count == 3) + ); + int channels = meters.EventData.Inputs.Sum(i => + i.InputLevelsMul.Count + ); + + bool ok = + first is not null + && !string.IsNullOrEmpty(first.InputName) + && Guid.TryParse(first.InputUuid, out _) + && levelsWellFormed; + + return ( + ok, + $"{meters.EventData.Inputs.Count} input(s), first '{first?.InputName}', " + + $"{channels} channel(s) total, three levels each = " + + $"{levelsWellFormed}" + ); + } + finally + { + if (before is not null) + { + await client + .ReidentifyAsync( + (uint)before.Value, + cancellationToken: CancellationToken.None + ) + .ConfigureAwait(false); + } + } + } + ) + .ConfigureAwait(false) + ); + results.Add( await TrySettingsCheckAsync( "Canvases category", async () => { - // The only request in its category, and the one stub type nothing - // else reaches. + // The only request in its category. Its array carries no item type in + // the protocol definition, so the stub is taken from the request + // handler and has to be checked against a real OBS on both transports. GetCanvasListResponseData canvases = await client .Canvases.GetCanvasListAsync(cancellationToken) .ConfigureAwait(false); + CanvasStub? main = canvases.Canvases.Find(c => c.CanvasFlags.Main); + bool ok = + canvases.Canvases.Count > 0 + && main is not null + && !string.IsNullOrEmpty(main.CanvasName) + && Guid.TryParse(main.CanvasUuid, out _) + && main.CanvasVideoSettings.BaseWidth > 0 + && main.CanvasVideoSettings.BaseHeight > 0 + && main.CanvasVideoSettings.FpsNumerator > 0; + return ( - canvases.Canvases.Count > 0, - $"{canvases.Canvases.Count} canvas(es)" + ok, + $"{canvases.Canvases.Count} canvas(es), main '{main?.CanvasName}' " + + $"{main?.CanvasVideoSettings.BaseWidth}x{main?.CanvasVideoSettings.BaseHeight} " + + $"@ {main?.CanvasVideoSettings.FpsNumerator}/{main?.CanvasVideoSettings.FpsDenominator}, " + + $"flags MAIN={main?.CanvasFlags.Main} MIX_AUDIO={main?.CanvasFlags.MixAudio}" ); } ) diff --git a/ObsWebSocket.Tests/JsonElementListTests.cs b/ObsWebSocket.Tests/JsonElementListTests.cs deleted file mode 100644 index 0207a73..0000000 --- a/ObsWebSocket.Tests/JsonElementListTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Text.Json; -using MessagePack; -using ObsWebSocket.Core.Protocol.Responses; -using ObsWebSocket.Core.Serialization; - -namespace ObsWebSocket.Tests; - -/// -/// An array whose item type the protocol does not state is generated as a list of -/// . Nothing in the MessagePack resolver chain could build a formatter for -/// one, so GetCanvasList could not be read at all on that transport while JSON read it fine. -/// -[TestClass] -public sealed class JsonElementListTests -{ - [TestMethod] - public void MsgPack_RoundTripsAListOfJsonElement() - { - using JsonDocument doc = JsonDocument.Parse( - """{"canvasName":"Main","canvasVideoSettings":{"baseWidth":1920,"fpsNumerator":30}}""" - ); - GetCanvasListResponseData original = new() { Canvases = [doc.RootElement.Clone()] }; - - byte[] packed = MessagePackSerializer.Serialize( - original, - MsgPackMessageSerializer.s_msgPackOptions - ); - GetCanvasListResponseData read = - MessagePackSerializer.Deserialize( - packed, - MsgPackMessageSerializer.s_msgPackOptions - ); - - Assert.AreEqual(1, read.Canvases.Count); - Assert.AreEqual("Main", read.Canvases[0].GetProperty("canvasName").GetString()); - Assert.AreEqual( - 1920, - read.Canvases[0].GetProperty("canvasVideoSettings").GetProperty("baseWidth").GetInt32(), - "a nested object inside the element has to survive too" - ); - } - - [TestMethod] - public void MsgPack_EmptyListRoundTrips() - { - GetCanvasListResponseData original = new() { Canvases = [] }; - - byte[] packed = MessagePackSerializer.Serialize( - original, - MsgPackMessageSerializer.s_msgPackOptions - ); - - Assert.AreEqual( - 0, - MessagePackSerializer - .Deserialize( - packed, - MsgPackMessageSerializer.s_msgPackOptions - ) - .Canvases.Count - ); - } -} diff --git a/ObsWebSocket.Tests/StubArrayTests.cs b/ObsWebSocket.Tests/StubArrayTests.cs new file mode 100644 index 0000000..7092e6e --- /dev/null +++ b/ObsWebSocket.Tests/StubArrayTests.cs @@ -0,0 +1,283 @@ +using System.Text.Json; +using MessagePack; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Common; +using ObsWebSocket.Core.Protocol.Events; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// An Array<Object> the protocol does not describe has to be mapped onto a stub by the +/// generator. Left unmapped it became a list of , which MessagePack had no +/// formatter for; mapped onto the wrong stub it fails on required members the payload never sends. +/// +[TestClass] +public sealed class StubArrayTests +{ + [TestMethod] + public void Canvases_RoundTripOverMsgPack_KeepsFlagsAndVideoSettings() + { + GetCanvasListResponseData original = new() + { + Canvases = + [ + new CanvasStub + { + CanvasName = "Main", + CanvasUuid = "0e57ad4c-2b2d-4f5b-9d05-3f4b0f4f1f10", + CanvasFlags = new CanvasFlagsStub + { + Main = true, + Activate = true, + MixAudio = true, + SceneRef = true, + Ephemeral = false, + }, + CanvasVideoSettings = new CanvasVideoSettingsStub + { + FpsNumerator = 60, + FpsDenominator = 1, + BaseWidth = 1920, + BaseHeight = 1080, + OutputWidth = 1280, + OutputHeight = 720, + }, + }, + ], + }; + + byte[] packed = MessagePackSerializer.Serialize( + original, + MsgPackMessageSerializer.s_msgPackOptions + ); + GetCanvasListResponseData read = + MessagePackSerializer.Deserialize( + packed, + MsgPackMessageSerializer.s_msgPackOptions + ); + + Assert.AreEqual(1, read.Canvases.Count); + Assert.AreEqual("Main", read.Canvases[0].CanvasName); + Assert.IsTrue(read.Canvases[0].CanvasFlags.Main); + Assert.IsFalse(read.Canvases[0].CanvasFlags.Ephemeral); + Assert.AreEqual(1920, read.Canvases[0].CanvasVideoSettings.BaseWidth); + Assert.AreEqual(720, read.Canvases[0].CanvasVideoSettings.OutputHeight); + } + + /// + /// OBS sends the whole video settings object as nulls when it cannot read the canvas video + /// info, rather than omitting it. + /// + [TestMethod] + public void Canvases_NullVideoSettings_ReadOverJson() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "canvases": [ + { + "canvasName": "Vertical", + "canvasUuid": "3d0e6c1a-9a5e-4a1a-9df0-2f0b1c9d7a22", + "canvasFlags": { + "MAIN": false, "ACTIVATE": true, "MIX_AUDIO": false, + "SCENE_REF": true, "EPHEMERAL": false + }, + "canvasVideoSettings": { + "fpsNumerator": null, "fpsDenominator": null, + "baseWidth": null, "baseHeight": null, + "outputWidth": null, "outputHeight": null + } + } + ] + } + """ + ) + .RootElement.Clone(); + + GetCanvasListResponseData? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual("Vertical", read.Canvases[0].CanvasName); + Assert.IsFalse(read.Canvases[0].CanvasFlags.Main); + Assert.IsNull(read.Canvases[0].CanvasVideoSettings.BaseWidth); + } + + /// + /// The meter payload carries only the name, the uuid and the levels. Reading it as an + /// failed on the input kind it never sends. + /// + [TestMethod] + public void InputVolumeMeters_ReadOverJson_KeepsPerChannelLevels() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "inputs": [ + { + "inputName": "Mic/Aux", + "inputUuid": "8b7a1f2e-1c3d-4e5f-8a9b-0c1d2e3f4a5b", + "inputLevelsMul": [[0.25, 0.5, 0.75], [0.2, 0.45, 0.7]] + } + ] + } + """ + ) + .RootElement.Clone(); + + InputVolumeMetersPayload? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual(1, read.Inputs.Count); + Assert.AreEqual("Mic/Aux", read.Inputs[0].InputName); + Assert.AreEqual(2, read.Inputs[0].InputLevelsMul.Count); + Assert.AreEqual(0.75, read.Inputs[0].InputLevelsMul[0][2]); + } + + [TestMethod] + public void InputVolumeMeters_RoundTripsOverMsgPack() + { + InputVolumeMetersPayload original = new() + { + Inputs = + [ + new InputVolumeMeterStub + { + InputName = "Desktop Audio", + InputUuid = "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9", + InputLevelsMul = + [ + [0.1, 0.2, 0.3], + ], + }, + ], + }; + + byte[] packed = MessagePackSerializer.Serialize( + original, + MsgPackMessageSerializer.s_msgPackOptions + ); + InputVolumeMetersPayload read = MessagePackSerializer.Deserialize( + packed, + MsgPackMessageSerializer.s_msgPackOptions + ); + + Assert.AreEqual("Desktop Audio", read.Inputs[0].InputName); + Assert.AreEqual(0.3, read.Inputs[0].InputLevelsMul[0][2]); + } + + /// + /// The reindex event asks OBS for the basic scene item list, which carries the id and the + /// index and nothing else. + /// + [TestMethod] + public void SceneItemListReindexed_ReadOverJson_KeepsIdAndIndex() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "sceneName": "Intro", + "sceneUuid": "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + "sceneItems": [ + { "sceneItemId": 1, "sceneItemIndex": 0 }, + { "sceneItemId": 4, "sceneItemIndex": 1 } + ] + } + """ + ) + .RootElement.Clone(); + + SceneItemListReindexedPayload? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual(2, read.SceneItems.Count); + Assert.AreEqual(4, read.SceneItems[1].SceneItemId); + Assert.AreEqual(1, read.SceneItems[1].SceneItemIndex); + } + + [TestMethod] + public void SceneItemListReindexed_RoundTripsOverMsgPack() + { + SceneItemListReindexedPayload original = new() + { + SceneName = "Intro", + SceneUuid = "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + SceneItems = [new SceneItemOrderStub { SceneItemId = 7, SceneItemIndex = 2 }], + }; + + byte[] packed = MessagePackSerializer.Serialize( + original, + MsgPackMessageSerializer.s_msgPackOptions + ); + SceneItemListReindexedPayload read = + MessagePackSerializer.Deserialize( + packed, + MsgPackMessageSerializer.s_msgPackOptions + ); + + Assert.AreEqual(7, read.SceneItems[0].SceneItemId); + Assert.AreEqual(2, read.SceneItems[0].SceneItemIndex); + } + + /// + /// Why the meter payload needs its own stub, stated as a test so the mapping cannot quietly go + /// back to : a real meter item has none of the kind fields that stub + /// requires, so reading one as an input fails outright. + /// + [TestMethod] + public void MeterItem_ReadAsInputStub_FailsOnTheKindFieldsItNeverSends() + { + JsonElement item = JsonDocument + .Parse( + """ + { + "inputName": "Mic/Aux", + "inputUuid": "8b7a1f2e-1c3d-4e5f-8a9b-0c1d2e3f4a5b", + "inputLevelsMul": [[0.25, 0.5, 0.75]] + } + """ + ) + .RootElement.Clone(); + + ObsWebSocketSerializationException ex = + Assert.ThrowsExactly(() => + CreateJsonSerializer().DeserializePayload(item) + ); + + StringAssert.Contains(ex.InnerException!.Message, "inputKind"); + } + + /// + /// The formatter that made an unmapped array readable at all. Nothing generated uses it now + /// that every array is mapped, but it is what stands between a future unmapped array and a + /// transport that cannot read the message. + /// + [TestMethod] + public void JsonElementList_HasAMsgPackFormatter() + { + using JsonDocument doc = JsonDocument.Parse("""{"a":1}"""); + List original = [doc.RootElement.Clone()]; + + byte[] packed = MessagePackSerializer.Serialize( + original, + MsgPackMessageSerializer.s_msgPackOptions + ); + List read = MessagePackSerializer.Deserialize>( + packed, + MsgPackMessageSerializer.s_msgPackOptions + ); + + Assert.AreEqual(1, read.Count); + Assert.AreEqual(1, read[0].GetProperty("a").GetInt32()); + } + + private static JsonMessageSerializer CreateJsonSerializer() => + new(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); +} From 839a41a253e3f1333b2cd65d2a1860637e8a117e Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 18:55:17 +0200 Subject: [PATCH 3/6] fix(core): register a MessagePack formatter for Dictionary GetInputAudioTracks could not be read and SetInputAudioTracks could not be sent, on MessagePack only. Found by a new sweep in the example that calls every read request and reports the responses it cannot deserialize, plus a log sink that fails the run on any unreadable payload, since a dropped event is silent by design. Two tests now walk the generated surface for types with no formatter, which is the shape both this and GetCanvasList had. --- .../MsgPackJsonElementResolver.cs | 6 + ObsWebSocket.Example/Program.cs | 4 + .../SerializationFailureSink.cs | 59 +++ ObsWebSocket.Example/Worker.cs | 498 ++++++++++++++++++ ObsWebSocket.Tests/FormatterCoverageTests.cs | 125 +++++ 5 files changed, 692 insertions(+) create mode 100644 ObsWebSocket.Example/SerializationFailureSink.cs create mode 100644 ObsWebSocket.Tests/FormatterCoverageTests.cs diff --git a/ObsWebSocket.Core/Serialization/MsgPackJsonElementResolver.cs b/ObsWebSocket.Core/Serialization/MsgPackJsonElementResolver.cs index 970b990..3066b17 100644 --- a/ObsWebSocket.Core/Serialization/MsgPackJsonElementResolver.cs +++ b/ObsWebSocket.Core/Serialization/MsgPackJsonElementResolver.cs @@ -21,6 +21,12 @@ private MsgPackJsonElementResolver() { } // be read at all over MessagePack. : typeof(T) == typeof(List) ? (IMessagePackFormatter)(object)JsonElementListFormatter.Instance + // An Object field whose values are all booleans becomes Dictionary, which + // the source generated resolver does not build either. GetInputAudioTracks could not be + // read and SetInputAudioTracks could not be sent. + : typeof(T) == typeof(Dictionary) + ? (IMessagePackFormatter) + (object)new MessagePack.Formatters.DictionaryFormatter() : null; internal sealed class JsonElementFormatter : IMessagePackFormatter diff --git a/ObsWebSocket.Example/Program.cs b/ObsWebSocket.Example/Program.cs index 6bfb95e..27e78dd 100644 --- a/ObsWebSocket.Example/Program.cs +++ b/ObsWebSocket.Example/Program.cs @@ -18,6 +18,10 @@ builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); builder.Logging.AddConsole(); +// Watches for payloads the client could not read, so validation fails on a shape mismatch instead +// of leaving it in the log for someone to notice. +builder.Logging.AddProvider(new SerializationFailureSink()); + // Configure OBS WebSocket Client options from "Obs" section in appsettings.json builder.Services.Configure(builder.Configuration.GetSection("Obs")); builder.Services.Configure( diff --git a/ObsWebSocket.Example/SerializationFailureSink.cs b/ObsWebSocket.Example/SerializationFailureSink.cs new file mode 100644 index 0000000..a1e35c1 --- /dev/null +++ b/ObsWebSocket.Example/SerializationFailureSink.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using ObsWebSocket.Core; + +namespace ObsWebSocket.Example; + +/// +/// Records every payload the client could not deserialize. +/// +/// +/// A shape mismatch between a generated record and what OBS actually sends does not fail a check +/// on its own: a request whose response cannot be read throws where the caller can see it, but an +/// event that cannot be read is dropped on purpose so one unmodellable event cannot take the +/// connection down. That is the right behaviour and the wrong thing to be quiet about during +/// validation, so the run collects them and reports them as a failure of its own. Three stub +/// mismatches were found this way, each of which had silently disabled an event for every user. +/// +internal sealed class SerializationFailureSink : ILoggerProvider +{ + private static readonly ConcurrentQueue s_failures = new(); + + /// Failures recorded since the last . + public static IReadOnlyCollection Failures => [.. s_failures]; + + /// Clears the record, so each transport is judged on its own run. + public static void Reset() => s_failures.Clear(); + + public ILogger CreateLogger(string categoryName) => + categoryName.StartsWith("ObsWebSocket.Core.Serialization.", StringComparison.Ordinal) + ? new FailureLogger(categoryName) + : Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + public void Dispose() { } + + private sealed class FailureLogger(string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (logLevel < LogLevel.Error || exception is not ObsWebSocketSerializationException) + { + return; + } + + string reason = exception.InnerException?.Message ?? exception.Message; + s_failures.Enqueue($"{category.Split('.')[^1]}: {reason}"); + } + } +} diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index bd954c1..4cb649b 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -786,6 +786,8 @@ CancellationToken cancellationToken ); await cycleClient.ConnectAsync(cancellationToken).ConfigureAwait(false); + // Each transport is judged on its own run. + SerializationFailureSink.Reset(); try { GetVersionResponseData? version = await cycleClient @@ -992,6 +994,25 @@ await ValidateSettingsModesAsync(cycleClient, inputs, cancellationToken) await ValidateModernApisAsync(cycleClient, healthChecks, cancellationToken) .ConfigureAwait(false); + modernResults.AddRange( + await SweepEveryReadRequestAsync(cycleClient, cancellationToken) + .ConfigureAwait(false) + ); + + // Last, so it covers every check above it. An event whose payload cannot be read is + // dropped rather than raised, which is deliberate and silent; this is what makes it + // loud during validation. + string[] unreadable = [.. SerializationFailureSink.Failures.Distinct()]; + modernResults.Add( + ( + "No unreadable payloads", + unreadable.Length == 0, + unreadable.Length == 0 + ? "every payload the run received deserialized" + : string.Join(" | ", unreadable.Take(3)) + ) + ); + Table summary = new() { Title = new TableTitle($"{format} Validation Summary") }; _ = summary.AddColumn("Check"); _ = summary.AddColumn("Result"); @@ -4120,6 +4141,483 @@ private static void RenderCommandHelp() AnsiConsole.Write(commandTable); } + /// + /// Calls every read-only request in the protocol and reports the ones whose response could not + /// be deserialized. + /// + /// + /// The targeted checks elsewhere cover behaviour; this covers surface. A response record that + /// does not match what OBS sends is invisible until something reads it, and three of them were + /// shipping. Requests OBS declines for the state of the machine (no replay buffer, no group, + /// an input of the wrong kind) are reported as untested rather than as failures, so the count + /// says how much of the surface was actually exercised. + /// + private static async Task< + List<(string Label, bool Pass, string Detail)> + > SweepEveryReadRequestAsync(ObsWebSocketClient client, CancellationToken cancellationToken) + { + List unreadable = []; + List untested = []; + int read = 0; + + async Task Probe(string name, Func call) + { + try + { + await call().ConfigureAwait(false); + read++; + } + catch (ObsWebSocketSerializationException ex) + { + unreadable.Add($"{name}: {ex.InnerException?.Message ?? ex.Message}"); + } + catch (ObsWebSocketRequestException ex) + { + // OBS declined for the state of the machine, so the response shape was never + // exercised. Not a defect, but not coverage either. + untested.Add($"{name} ({ex.StatusCode})"); + } + } + + // Discover targets, so the sweep needs no fixture of its own. + GetSceneListResponseData scenes = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string sceneName = scenes.CurrentProgramSceneName ?? scenes.Scenes[0].SceneName; + + GetInputListResponseData inputs = await client + .Inputs.GetInputListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string inputName = inputs.Inputs[0].InputName; + + GetSceneItemListResponseData items = await client + .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) + .ConfigureAwait(false); + int sceneItemId = items.SceneItems.Count > 0 ? items.SceneItems[0].SceneItemId : -1; + string? itemSourceName = items.SceneItems.Count > 0 ? items.SceneItems[0].SourceName : null; + + GetInputKindListResponseData inputKinds = await client + .Inputs.GetInputKindListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string inputKind = inputKinds.InputKinds[0]; + + GetSourceFilterKindListResponseData filterKinds = await client + .Filters.GetSourceFilterKindListAsync(cancellationToken) + .ConfigureAwait(false); + string filterKind = filterKinds.SourceFilterKinds[0]; + + GetOutputListResponseData outputs = await client + .Outputs.GetOutputListAsync(cancellationToken) + .ConfigureAwait(false); + string outputName = outputs.Outputs[0].OutputName; + + GetGroupListResponseData groups = await client + .Scenes.GetGroupListAsync(cancellationToken) + .ConfigureAwait(false); + string? groupName = groups.Groups.Count > 0 ? groups.Groups[0] : null; + + GetSourceFilterListResponseData sourceFilters = await client + .Filters.GetSourceFilterListAsync(new(sourceName: inputName), cancellationToken) + .ConfigureAwait(false); + string? filterName = + sourceFilters.Filters.Count > 0 ? sourceFilters.Filters[0].FilterName : null; + + // The nine discovery calls above are themselves read requests. + read += 9; + + await Probe("GetCanvasList", () => client.Canvases.GetCanvasListAsync(cancellationToken)) + .ConfigureAwait(false); + + await Probe( + "GetPersistentData", + () => + client.Config.GetPersistentDataAsync( + new(realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", slotName: "__obsws_sweep"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneCollectionList", + () => client.Config.GetSceneCollectionListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe("GetProfileList", () => client.Config.GetProfileListAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "GetProfileParameter", + () => + client.Config.GetProfileParameterAsync( + new(parameterCategory: "General", parameterName: "Name"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetVideoSettings", + () => client.Config.GetVideoSettingsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetStreamServiceSettings", + () => client.Config.GetStreamServiceSettingsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetRecordDirectory", + () => client.Config.GetRecordDirectoryAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetSourceFilterDefaultSettings", + () => + client.Filters.GetSourceFilterDefaultSettingsAsync( + new(filterKind: filterKind), + cancellationToken + ) + ) + .ConfigureAwait(false); + if (filterName is not null) + { + await Probe( + "GetSourceFilter", + () => + client.Filters.GetSourceFilterAsync( + new(filterName: filterName, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + untested.Add("GetSourceFilter (no filter on the first input)"); + } + + await Probe("GetVersion", () => client.General.GetVersionAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("GetStats", () => client.General.GetStatsAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("GetHotkeyList", () => client.General.GetHotkeyListAsync(cancellationToken)) + .ConfigureAwait(false); + + await Probe( + "GetSpecialInputs", + () => client.Inputs.GetSpecialInputsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDefaultSettings", + () => + client.Inputs.GetInputDefaultSettingsAsync( + new(inputKind: inputKind), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputSettings", + () => + client.Inputs.GetInputSettingsAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputMute", + () => client.Inputs.GetInputMuteAsync(new(inputName: inputName), cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetInputVolume", + () => + client.Inputs.GetInputVolumeAsync(new(inputName: inputName), cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioBalance", + () => + client.Inputs.GetInputAudioBalanceAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioSyncOffset", + () => + client.Inputs.GetInputAudioSyncOffsetAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioMonitorType", + () => + client.Inputs.GetInputAudioMonitorTypeAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioTracks", + () => + client.Inputs.GetInputAudioTracksAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDeinterlaceMode", + () => + client.Inputs.GetInputDeinterlaceModeAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDeinterlaceFieldOrder", + () => + client.Inputs.GetInputDeinterlaceFieldOrderAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputPropertiesListPropertyItems", + () => + client.Inputs.GetInputPropertiesListPropertyItemsAsync( + new(propertyName: "monitor", inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetMediaInputStatus", + () => + client.MediaInputs.GetMediaInputStatusAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetVirtualCamStatus", + () => client.Outputs.GetVirtualCamStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetReplayBufferStatus", + () => client.Outputs.GetReplayBufferStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetLastReplayBufferReplay", + () => client.Outputs.GetLastReplayBufferReplayAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetOutputStatus", + () => + client.Outputs.GetOutputStatusAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetOutputSettings", + () => + client.Outputs.GetOutputSettingsAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe("GetRecordStatus", () => client.Record.GetRecordStatusAsync(cancellationToken)) + .ConfigureAwait(false); + + if (groupName is not null) + { + await Probe( + "GetGroupSceneItemList", + () => + client.SceneItems.GetGroupSceneItemListAsync( + new(sceneName: groupName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + untested.Add("GetGroupSceneItemList (no group in the collection)"); + } + + if (sceneItemId >= 0) + { + await Probe( + "GetSceneItemId", + () => + client.SceneItems.GetSceneItemIdAsync( + new(sourceName: itemSourceName!, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemSource", + () => + client.SceneItems.GetSceneItemSourceAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemTransform", + () => + client.SceneItems.GetSceneItemTransformAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemEnabled", + () => + client.SceneItems.GetSceneItemEnabledAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemLocked", + () => + client.SceneItems.GetSceneItemLockedAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemIndex", + () => + client.SceneItems.GetSceneItemIndexAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemBlendMode", + () => + client.SceneItems.GetSceneItemBlendModeAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + untested.Add("7 scene item requests (the program scene has no items)"); + } + + await Probe( + "GetCurrentProgramScene", + () => client.Scenes.GetCurrentProgramSceneAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentPreviewScene", + () => client.Scenes.GetCurrentPreviewSceneAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneSceneTransitionOverride", + () => + client.Scenes.GetSceneSceneTransitionOverrideAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetSourceActive", + () => + client.Sources.GetSourceActiveAsync( + new(sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSourceScreenshot", + () => + client.Sources.GetSourceScreenshotAsync( + new(imageFormat: "png", sourceName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe("GetStreamStatus", () => client.Stream.GetStreamStatusAsync(cancellationToken)) + .ConfigureAwait(false); + + await Probe( + "GetTransitionKindList", + () => client.Transitions.GetTransitionKindListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneTransitionList", + () => client.Transitions.GetSceneTransitionListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentSceneTransition", + () => client.Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentSceneTransitionCursor", + () => client.Transitions.GetCurrentSceneTransitionCursorAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetStudioModeEnabled", + () => client.Ui.GetStudioModeEnabledAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe("GetMonitorList", () => client.Ui.GetMonitorListAsync(cancellationToken)) + .ConfigureAwait(false); + + return + [ + ( + "Every read request deserializes", + unreadable.Count == 0, + unreadable.Count == 0 + ? $"{read} of 60 read; untested: {string.Join(", ", untested)}" + : string.Join(" | ", unreadable.Take(3)) + ), + ]; + } + private static void RenderKeyValueTable( string title, IReadOnlyList<(string Key, string Value)> rows diff --git a/ObsWebSocket.Tests/FormatterCoverageTests.cs b/ObsWebSocket.Tests/FormatterCoverageTests.cs new file mode 100644 index 0000000..9f7e22f --- /dev/null +++ b/ObsWebSocket.Tests/FormatterCoverageTests.cs @@ -0,0 +1,125 @@ +using System.Reflection; +using MessagePack; +using ObsWebSocket.Core.Protocol.Common; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// A generated record can name a collection type that nothing in the MessagePack resolver chain +/// knows how to build. It costs nothing on JSON and makes the message completely unreadable on +/// MessagePack, which is how GetCanvasList and GetInputAudioTracks both shipped +/// broken on one transport only. This walks the generated surface so the next one fails here. +/// +[TestClass] +public sealed class FormatterCoverageTests +{ + private static readonly string[] s_generatedNamespaces = + [ + "ObsWebSocket.Core.Protocol.Requests", + "ObsWebSocket.Core.Protocol.Responses", + "ObsWebSocket.Core.Protocol.Events", + ]; + + [TestMethod] + public void EveryGeneratedProperty_HasAMessagePackFormatter() + { + IFormatterResolver resolver = MsgPackMessageSerializer.s_msgPackOptions.Resolver; + List missing = []; + + foreach (Type generated in GeneratedTypes(s_generatedNamespaces)) + { + foreach ( + PropertyInfo property in generated.GetProperties( + BindingFlags.Public | BindingFlags.Instance + ) + ) + { + // MessagePack never reads an ignored member, so it needs no formatter. + if (property.GetCustomAttribute() is not null) + { + continue; + } + + Type type = + Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + + // Primitives, enums and the generated records themselves are covered by the built + // in and source generated resolvers; the constructed generics are what get missed. + if (!type.IsGenericType || !Resolves(resolver, type)) + { + if (type.IsGenericType) + { + missing.Add($"{generated.Name}.{property.Name} -> {Describe(type)}"); + } + } + } + } + + Assert.IsTrue( + missing.Count == 0, + $"No MessagePack formatter for: {string.Join(", ", missing.Distinct())}" + ); + } + + /// + /// A stub is serialized whole through the JSON bridge, so what has to resolve is the stub and + /// its list, not the members inside it. Registering the bare form and forgetting the list is + /// the specific mistake that made GetCanvasList unreadable. + /// + [TestMethod] + public void EveryStub_ResolvesBothAloneAndInAList() + { + IFormatterResolver resolver = MsgPackMessageSerializer.s_msgPackOptions.Resolver; + List missing = []; + + Type[] stubs = [.. GeneratedTypes(["ObsWebSocket.Core.Protocol.Common"])]; + Assert.IsGreaterThan(10, stubs.Length, "expected the stub types to be discovered"); + + foreach (Type stub in stubs) + { + if (!Resolves(resolver, stub)) + { + missing.Add(stub.Name); + } + + Type list = typeof(List<>).MakeGenericType(stub); + if (!Resolves(resolver, list)) + { + missing.Add($"List<{stub.Name}>"); + } + } + + Assert.IsTrue( + missing.Count == 0, + $"No MessagePack formatter for: {string.Join(", ", missing)}" + ); + } + + private static IEnumerable GeneratedTypes(string[] namespaces) => + typeof(GetVersionResponseData) + .Assembly.GetTypes() + .Where(t => + t.IsClass + && !t.IsAbstract + && t.IsPublic + && t.Namespace is not null + && namespaces.Contains(t.Namespace, StringComparer.Ordinal) + ); + + private static bool Resolves(IFormatterResolver resolver, Type type) + { + try + { + return resolver.GetFormatterDynamic(type) is not null; + } + catch (FormatterNotRegisteredException) + { + return false; + } + } + + private static string Describe(Type type) => + $"{type.Name}<{string.Join(", ", type.GetGenericArguments().Select(a => a.Name))}>"; +} From fd8824f3615e6622875606e63862455bf15ea973 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 19:18:46 +0200 Subject: [PATCH 4/6] fix(core): correct the T-bar type and the transform request shape SetTBarPosition took an int for a 0.0 to 1.0 position, so only the two ends of the T-bar were reachable. The protocol states the range with a decimal point, which the generator now treats as the protocol saying the field is fractional, and refuses to build if the table disagrees. SetSceneItemTransform applies only the fields present, but the request took the full transform with every member required, so a partial one could not be expressed and a transform read back from OBS was refused. Requests take a patch type now; responses keep the full one. A request declaring no response payload no longer tries to read one. OBS sends a payload for some of them and there is no metadata for object, so ToggleRecordPause failed a request that had succeeded. Unmapped arrays, unclassified numbers, and a declared string enum with no field mapped are errors now rather than notes. --- .../Generation/Diagnostics.cs | 36 +- .../Generation/Emitter.Helpers.cs | 30 +- .../Generation/Emitter.JsonContext.cs | 1 + .../Generation/NumericFieldTable.cs | 3 +- .../Generation/ProtocolCodeGenerator.cs | 49 + .../Generation/StringEnumFieldTable.cs | 3 + .../SetSceneItemTransform.Request.g.cs | 4 +- .../Requests/SetTBarPosition.Request.g.cs | 4 +- .../ObsWebSocketJsonContext.g.cs | 1 + ObsWebSocket.Core/ObsWebSocketClient.cs | 7 +- .../Protocol/Common/StubTypes.cs | 148 ++ .../MsgPackStubExtensionDataResolver.cs | 1 + ObsWebSocket.Example/Worker.cs | 1556 +++++++++++++---- .../ObsWebSocketClientRequestTests.cs | 72 +- 14 files changed, 1602 insertions(+), 313 deletions(-) diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Diagnostics.cs b/ObsWebSocket.Codegen.Tasks/Generation/Diagnostics.cs index e48f96e..fcd1afa 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Diagnostics.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Diagnostics.cs @@ -107,9 +107,9 @@ internal static class Diagnostics public static readonly DiagnosticDescriptor ArrayItemTypeUnknownWarning = new( id: "OBSWSGEN010", title: "Array item type unknown", - messageFormat: "Could not determine item type for array field '{0}' in '{1}' from type string '{2}'. Mapping to 'List'.", + messageFormat: "Could not determine item type for array field '{0}' in '{1}' from type string '{2}'. Map it to a stub; List has no MessagePack formatter in most resolver chains and the message becomes unreadable on that transport.", category: Category, - defaultSeverity: DiagnosticSeverity.Warning, // Warning as List is usable + defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); @@ -121,9 +121,37 @@ internal static class Diagnostics public static readonly DiagnosticDescriptor UnclassifiedNumberField = new( id: "OBSWSGEN012", title: "Unclassified Number field", - messageFormat: "Number field '{0}' in '{1}' is not listed in NumericFieldTable. Mapping to 'double'. Add it to the table if it holds whole numbers.", + messageFormat: "Number field '{0}' in '{1}' is not listed in NumericFieldTable. Classify it deliberately: whole numbers reach callers as floating point otherwise.", category: Category, - defaultSeverity: DiagnosticSeverity.Warning, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + /// + /// Reported when a field the numeric table calls a whole number carries a protocol restriction + /// written with a decimal point. The restriction is the protocol stating the field is + /// fractional, so the classification is wrong and the value would be truncated on the wire. + /// + public static readonly DiagnosticDescriptor FractionalFieldClassifiedAsWhole = new( + id: "OBSWSGEN013", + title: "Whole-number field has a fractional restriction", + messageFormat: "Number field '{0}' is listed as a whole number but the protocol restricts it to '{1}', which is fractional. Move it to the double set.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + /// + /// Reported when the protocol declares a string-valued enum that no field is mapped onto. The + /// generated property would be a plain string, which is the state every string enum was in + /// before the mapping table existed. + /// + public static readonly DiagnosticDescriptor UnmappedStringEnum = new( + id: "OBSWSGEN014", + title: "Unmapped string enum", + messageFormat: "The protocol declares string-valued enum '{0}' but no field maps onto it. Add its fields to StringEnumFieldTable, or the properties stay strings.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs index 149be92..24ee3c4 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs @@ -216,6 +216,9 @@ private static StringBuilder BuildSourceHeader(string? fileTypeComment = null) /// The field definition being mapped. /// Name of the DTO this field belongs to (for diagnostics). /// A tuple containing the C# type name (or null if unmappable) and a boolean indicating if it's a value type. + [System.Text.RegularExpressions.GeneratedRegex(@"\d\.\d")] + private static partial System.Text.RegularExpressions.Regex FractionalRestriction(); + private static (string? CSharpType, bool IsValueType) MapProtocolTypeToCSharp( SourceProductionContext context, FieldDefinition field, @@ -237,7 +240,11 @@ string parentDtoName case "sceneItemTransform": // Map specifically named 'Object' field to Stub record // Use the fully qualified name to avoid potential namespace conflicts - return ($"{GeneratedCommonNamespace}.SceneItemTransformStub?", false); + // SetSceneItemTransform applies only the fields present, so a request carries + // a patch. A response carries the whole transform OBS computed. + return parentDtoName.EndsWith("RequestData", StringComparison.Ordinal) + ? ($"{GeneratedCommonNamespace}.SceneItemTransformPatchStub?", false) + : ($"{GeneratedCommonNamespace}.SceneItemTransformStub?", false); // Add other specific 'Object' mappings here if needed in the future } // If not handled above, it falls through to the general 'Object'/'Any' handling below @@ -248,6 +255,27 @@ string parentDtoName if (obsType == "Number") { numberType = NumericFieldTable.MapNumber(fieldName, out bool classified); + + // A restriction written with a decimal point is the protocol saying the field is + // fractional. SetTBarPosition shipped as an int this way, so only the two ends of the + // T-bar could be reached. + if ( + classified + && numberType != "double" + && field.ValueRestrictions is { Length: > 0 } restrictions + && FractionalRestriction().IsMatch(restrictions) + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.FractionalFieldClassifiedAsWhole, + Location.None, + fieldName, + restrictions + ) + ); + } + if (!classified) { context.ReportDiagnostic( diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs index b9c257c..088edd7 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs @@ -65,6 +65,7 @@ ProtocolDefinition protocol _ = builder.AppendLine("[JsonSerializable(typeof(SceneStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(SceneItemStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(SceneItemTransformStub))]"); + _ = builder.AppendLine("[JsonSerializable(typeof(SceneItemTransformPatchStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(FilterStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(InputStub))]"); _ = builder.AppendLine("[JsonSerializable(typeof(TransitionStub))]"); diff --git a/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs index f984c79..1f3dee3 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs @@ -23,7 +23,6 @@ internal static class NumericFieldTable "sceneItemIndex", "filterIndex", "monitorIndex", - "position", "searchOffset", // Resolutions, in pixels. "baseWidth", @@ -75,6 +74,8 @@ internal static class NumericFieldTable "inputVolumeMul", "inputVolumeDb", "inputAudioBalance", + // The T-bar, 0.0 to 1.0. As an int only the two ends were reachable. + "position", "transitionCursor", "outputCongestion", "cpuUsage", diff --git a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs index edd2006..3693ac3 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs @@ -36,6 +36,7 @@ IReadOnlyList Diagnostics return (context.Sources, context.Diagnostics); } + ReportUnmappedStringEnums(context, protocol); Emitter.PreGenerateNestedDtos(context, protocol); Emitter.GenerateEnums(context, protocol); Emitter.GenerateRequestDtos(context, protocol); @@ -53,4 +54,52 @@ IReadOnlyList Diagnostics return (context.Sources, context.Diagnostics); } + + /// + /// Fails the build when the protocol declares a string-valued enum that no field is mapped + /// onto. + /// + /// + /// The definition types these fields as plain String and never says which enum they + /// draw from, so the association is hand written. That table cannot be derived, but it can be + /// checked: a protocol refresh introducing a new string enum has to be noticed, or every field + /// carrying it silently stays a string. + /// + private static void ReportUnmappedStringEnums( + SourceProductionContext context, + ProtocolDefinition protocol + ) + { + if (protocol.Enums is null) + { + return; + } + + HashSet mapped = new(StringEnumFieldTable.MappedEnums, StringComparer.Ordinal); + + foreach (EnumDefinition definition in protocol.Enums) + { + bool stringValued = + definition.EnumIdentifiers.Count > 0 + && definition.EnumIdentifiers.TrueForAll(i => + i.EnumValue.ValueKind == System.Text.Json.JsonValueKind.String + ); + + // The generated C# name drops the protocol's Obs prefix. + string generatedName = definition.EnumType.StartsWith("Obs", StringComparison.Ordinal) + ? definition.EnumType["Obs".Length..] + : definition.EnumType; + + if (stringValued && !mapped.Contains(generatedName)) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.UnmappedStringEnum, + Location.None, + definition.EnumType + ) + ); + } + } + } } diff --git a/ObsWebSocket.Codegen.Tasks/Generation/StringEnumFieldTable.cs b/ObsWebSocket.Codegen.Tasks/Generation/StringEnumFieldTable.cs index 836c6f0..3ac2480 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/StringEnumFieldTable.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/StringEnumFieldTable.cs @@ -22,6 +22,9 @@ internal static class StringEnumFieldTable ["mediaAction"] = "MediaInputAction", }; + /// The enum type names this table maps fields onto. + public static IEnumerable MappedEnums => s_fieldToEnum.Values; + /// /// Returns the enum type name a String field maps to, or when it /// is an ordinary string. diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs index af872c0..431ab4d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs @@ -54,7 +54,7 @@ public sealed partial record SetSceneItemTransformRequestData /// [JsonPropertyName("sceneItemTransform")] [Key("sceneItemTransform")] - public required ObsWebSocket.Core.Protocol.Common.SceneItemTransformStub? SceneItemTransform { get; init; } + public required ObsWebSocket.Core.Protocol.Common.SceneItemTransformPatchStub? SceneItemTransform { get; init; } /// /// Name of the scene the item is in @@ -87,7 +87,7 @@ public SetSceneItemTransformRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetSceneItemTransformRequestData(int sceneItemId, ObsWebSocket.Core.Protocol.Common.SceneItemTransformStub? sceneItemTransform, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public SetSceneItemTransformRequestData(int sceneItemId, ObsWebSocket.Core.Protocol.Common.SceneItemTransformPatchStub? sceneItemTransform, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs index 411dfd4..fffc0c3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs @@ -35,7 +35,7 @@ public sealed partial record SetTBarPositionRequestData /// [JsonPropertyName("position")] [Key("position")] - public required int Position { get; init; } + public required double Position { get; init; } /// /// Whether to release the TBar. Only set `false` if you know that you will be sending another position update @@ -57,7 +57,7 @@ public SetTBarPositionRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetTBarPositionRequestData(int position, bool? release = null) + public SetTBarPositionRequestData(double position, bool? release = null) { this.Position = position; this.Release = release; diff --git a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs index dfbe5e7..6121979 100644 --- a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs +++ b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs @@ -30,6 +30,7 @@ namespace ObsWebSocket.Core.Serialization; [JsonSerializable(typeof(SceneStub))] [JsonSerializable(typeof(SceneItemStub))] [JsonSerializable(typeof(SceneItemTransformStub))] +[JsonSerializable(typeof(SceneItemTransformPatchStub))] [JsonSerializable(typeof(FilterStub))] [JsonSerializable(typeof(InputStub))] [JsonSerializable(typeof(TransitionStub))] diff --git a/ObsWebSocket.Core/ObsWebSocketClient.cs b/ObsWebSocket.Core/ObsWebSocketClient.cs index 892ccc6..9222d1c 100644 --- a/ObsWebSocket.Core/ObsWebSocketClient.cs +++ b/ObsWebSocket.Core/ObsWebSocketClient.cs @@ -453,7 +453,12 @@ await SendMessageAsync( requestTags ); - return _serializer.DeserializePayload(response.ResponseData); + // `object` is what the generated methods ask for when the request declares no response + // payload. There is nothing to deserialize into, and OBS sends one anyway for some of + // them, so attempting it fails on a request that actually succeeded. + return typeof(TResponse) == typeof(object) + ? null + : _serializer.DeserializePayload(response.ResponseData); } catch (Exception ex) when (ex is not OperationCanceledException || cancellationToken.IsCancellationRequested) diff --git a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index f0644d7..a46b78c 100644 --- a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs +++ b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs @@ -378,6 +378,154 @@ public sealed class SceneItemTransformStub public SceneItemTransformStub() { } } +/// +/// The fields of a scene item transform a caller wants to change. +/// +/// +/// Separate from because SetSceneItemTransform +/// reads only the fields that are present and applies those; it is not a whole object write. +/// A transform read back from OBS is refused, because it carries the source dimensions OBS +/// computes for itself. +/// +[MessagePackObject] +public sealed class SceneItemTransformPatchStub +{ + /// + /// Position X value. + /// + [JsonPropertyName("positionX")] + [Key("positionX")] + public double? PositionX { get; init; } + + /// + /// Position X value. + /// + [JsonPropertyName("positionY")] + [Key("positionY")] + public double? PositionY { get; init; } + + /// + /// Rotation value. + /// + [JsonPropertyName("rotation")] + [Key("rotation")] + public double? Rotation { get; init; } + + /// + /// Scale X value. + /// + [JsonPropertyName("scaleX")] + [Key("scaleX")] + public double? ScaleX { get; init; } + + /// + /// Scale Y value. + /// + [JsonPropertyName("scaleY")] + [Key("scaleY")] + public double? ScaleY { get; init; } + + /// + /// Width value. + /// + [JsonPropertyName("width")] + [Key("width")] + public double? Width { get; init; } + + /// + /// Height value. + /// + [JsonPropertyName("height")] + [Key("height")] + public double? Height { get; init; } + + /// + /// Source width value. + /// + [JsonPropertyName("sourceWidth")] + [Key("sourceWidth")] + public double? SourceWidth { get; init; } + + /// + /// Source height value. + /// + [JsonPropertyName("sourceHeight")] + [Key("sourceHeight")] + public double? SourceHeight { get; init; } + + /// + /// Alignment value. + /// + [JsonPropertyName("alignment")] + [Key("alignment")] + public int? Alignment { get; init; } + + /// + /// Bounds type value. + /// + [JsonPropertyName("boundsType")] + [Key("boundsType")] + public string? BoundsType { get; init; } + + /// + /// Bounds alignment value. + /// + [JsonPropertyName("boundsAlignment")] + [Key("boundsAlignment")] + public int? BoundsAlignment { get; init; } + + /// + /// Bounds width value. + /// + [JsonPropertyName("boundsWidth")] + [Key("boundsWidth")] + public double? BoundsWidth { get; init; } + + /// + /// Bounds height value. + /// + [JsonPropertyName("boundsHeight")] + [Key("boundsHeight")] + public double? BoundsHeight { get; init; } + + /// + /// Crop left value. + /// + [JsonPropertyName("cropLeft")] + [Key("cropLeft")] + public int? CropLeft { get; init; } + + /// + /// Crop top value. + /// + [JsonPropertyName("cropTop")] + [Key("cropTop")] + public int? CropTop { get; init; } + + /// + /// Crop right value. + /// + [JsonPropertyName("cropRight")] + [Key("cropRight")] + public int? CropRight { get; init; } + + /// + /// Crop bottom value. + /// + [JsonPropertyName("cropBottom")] + [Key("cropBottom")] + public int? CropBottom { get; init; } + + /// Captures any extra fields not explicitly defined in the stub. + [IgnoreMember] + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// Initializes a new instance for deserialization via . + [JsonConstructor] + public SceneItemTransformPatchStub() { } +} + /// /// Represents a common structure for scene item data, often used in lists. Resilient to missing fields. /// diff --git a/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs b/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs index 8da4a9a..b3208d5 100644 --- a/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs +++ b/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs @@ -36,6 +36,7 @@ private static Dictionary BuildFormatters() Register(map); Register(map); Register(map); + Register(map); Register(map); Register(map); Register(map); diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 4cb649b..0e308fe 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -662,6 +662,54 @@ CurrentProgramSceneChangedEventArgs sceneEvent in _obsClient.Scenes.CurrentProgr await RunTransportValidationSuiteAsync(cancellationToken).ConfigureAwait(false); return false; + case "cleanup-fixtures": + { + // The sweeps name everything they create with a known prefix, so a run that dies + // before its teardown leaves findable litter rather than a puzzle. + GetInputListResponseData allInputs = await _obsClient + .Inputs.GetInputListAsync(new(), cancellationToken) + .ConfigureAwait(false); + int removedInputs = 0; + foreach ( + InputStub leftover in allInputs.Inputs.Where(i => + i.InputName.StartsWith("__obsws", StringComparison.Ordinal) + ) + ) + { + await _obsClient + .Inputs.RemoveInputAsync( + new(inputName: leftover.InputName), + cancellationToken + ) + .ConfigureAwait(false); + removedInputs++; + } + + GetSceneListResponseData allScenes = await _obsClient + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + int removedScenes = 0; + foreach ( + SceneStub leftover in allScenes.Scenes.Where(sc => + sc.SceneName.StartsWith("__obsws", StringComparison.Ordinal) + ) + ) + { + await _obsClient + .Scenes.RemoveSceneAsync( + new(sceneName: leftover.SceneName), + cancellationToken + ) + .ConfigureAwait(false); + removedScenes++; + } + + UiSuccess( + $"Removed {removedInputs} leftover input(s) and {removedScenes} scene(s)." + ); + return false; + } + case "list-subs": RenderKeyValueTable( "Event Subscriptions", @@ -999,6 +1047,11 @@ await SweepEveryReadRequestAsync(cycleClient, cancellationToken) .ConfigureAwait(false) ); + modernResults.AddRange( + await SweepEveryWriteRequestAsync(cycleClient, cancellationToken) + .ConfigureAwait(false) + ); + // Last, so it covers every check above it. An event whose payload cannot be read is // dropped rather than raised, which is deliberate and silent; this is what makes it // loud during validation. @@ -4225,395 +4278,1296 @@ async Task Probe(string name, Func call) // The nine discovery calls above are themselves read requests. read += 9; - await Probe("GetCanvasList", () => client.Canvases.GetCanvasListAsync(cancellationToken)) - .ConfigureAwait(false); + // Four of these requests need OBS to be in a particular state, not just to be asked + // nicely. Without the fixture they answer InvalidResourceState and the response shape is + // never exercised, which is coverage the report would otherwise claim. + string fixtureSuffix = Guid.NewGuid().ToString("N")[..8]; + string readScene = $"__obsws_rsweep_{fixtureSuffix}"; + string audioInput = $"__obsws_rsweep_audio_{fixtureSuffix}"; + string mediaInput = $"__obsws_rsweep_media_{fixtureSuffix}"; - await Probe( - "GetPersistentData", - () => - client.Config.GetPersistentDataAsync( - new(realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", slotName: "__obsws_sweep"), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneCollectionList", - () => client.Config.GetSceneCollectionListAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe("GetProfileList", () => client.Config.GetProfileListAsync(cancellationToken)) + GetStudioModeEnabledResponseData studioBefore = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) .ConfigureAwait(false); - await Probe( - "GetProfileParameter", - () => - client.Config.GetProfileParameterAsync( - new(parameterCategory: "General", parameterName: "Name"), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetVideoSettings", - () => client.Config.GetVideoSettingsAsync(cancellationToken) - ) + + await client + .Scenes.CreateSceneAsync(new(sceneName: readScene), cancellationToken) .ConfigureAwait(false); - await Probe( - "GetStreamServiceSettings", - () => client.Config.GetStreamServiceSettingsAsync(cancellationToken) + await client + .Inputs.CreateInputAsync( + new( + inputName: audioInput, + inputKind: "wasapi_output_capture", + sceneName: readScene + ), + cancellationToken ) .ConfigureAwait(false); - await Probe( - "GetRecordDirectory", - () => client.Config.GetRecordDirectoryAsync(cancellationToken) + await client + .Inputs.CreateInputAsync( + new(inputName: mediaInput, inputKind: "ffmpeg_source", sceneName: readScene), + cancellationToken ) .ConfigureAwait(false); + if (!studioBefore.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + .ConfigureAwait(false); + } - await Probe( - "GetSourceFilterDefaultSettings", - () => - client.Filters.GetSourceFilterDefaultSettingsAsync( - new(filterKind: filterKind), - cancellationToken - ) - ) - .ConfigureAwait(false); - if (filterName is not null) + try { await Probe( - "GetSourceFilter", + "GetCanvasList", + () => client.Canvases.GetCanvasListAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetPersistentData", () => - client.Filters.GetSourceFilterAsync( - new(filterName: filterName, sourceName: inputName), + client.Config.GetPersistentDataAsync( + new( + realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", + slotName: "__obsws_sweep" + ), cancellationToken ) ) .ConfigureAwait(false); - } - else - { - untested.Add("GetSourceFilter (no filter on the first input)"); - } - - await Probe("GetVersion", () => client.General.GetVersionAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe("GetStats", () => client.General.GetStatsAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe("GetHotkeyList", () => client.General.GetHotkeyListAsync(cancellationToken)) - .ConfigureAwait(false); - - await Probe( - "GetSpecialInputs", - () => client.Inputs.GetSpecialInputsAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetInputDefaultSettings", - () => - client.Inputs.GetInputDefaultSettingsAsync( - new(inputKind: inputKind), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputSettings", - () => - client.Inputs.GetInputSettingsAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputMute", - () => client.Inputs.GetInputMuteAsync(new(inputName: inputName), cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetInputVolume", - () => - client.Inputs.GetInputVolumeAsync(new(inputName: inputName), cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioBalance", - () => - client.Inputs.GetInputAudioBalanceAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioSyncOffset", - () => - client.Inputs.GetInputAudioSyncOffsetAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioMonitorType", - () => - client.Inputs.GetInputAudioMonitorTypeAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioTracks", - () => - client.Inputs.GetInputAudioTracksAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputDeinterlaceMode", - () => - client.Inputs.GetInputDeinterlaceModeAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputDeinterlaceFieldOrder", - () => - client.Inputs.GetInputDeinterlaceFieldOrderAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputPropertiesListPropertyItems", - () => - client.Inputs.GetInputPropertiesListPropertyItemsAsync( - new(propertyName: "monitor", inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetMediaInputStatus", - () => - client.MediaInputs.GetMediaInputStatusAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); + await Probe( + "GetSceneCollectionList", + () => client.Config.GetSceneCollectionListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetProfileList", + () => client.Config.GetProfileListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetProfileParameter", + () => + client.Config.GetProfileParameterAsync( + new(parameterCategory: "General", parameterName: "Name"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetVideoSettings", + () => client.Config.GetVideoSettingsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetStreamServiceSettings", + () => client.Config.GetStreamServiceSettingsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetRecordDirectory", + () => client.Config.GetRecordDirectoryAsync(cancellationToken) + ) + .ConfigureAwait(false); - await Probe( - "GetVirtualCamStatus", - () => client.Outputs.GetVirtualCamStatusAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetReplayBufferStatus", - () => client.Outputs.GetReplayBufferStatusAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetLastReplayBufferReplay", - () => client.Outputs.GetLastReplayBufferReplayAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetOutputStatus", - () => - client.Outputs.GetOutputStatusAsync( - new(outputName: outputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetOutputSettings", - () => - client.Outputs.GetOutputSettingsAsync( - new(outputName: outputName), - cancellationToken + await Probe( + "GetSourceFilterDefaultSettings", + () => + client.Filters.GetSourceFilterDefaultSettingsAsync( + new(filterKind: filterKind), + cancellationToken + ) + ) + .ConfigureAwait(false); + if (filterName is not null) + { + await Probe( + "GetSourceFilter", + () => + client.Filters.GetSourceFilterAsync( + new(filterName: filterName, sourceName: inputName), + cancellationToken + ) ) - ) - .ConfigureAwait(false); + .ConfigureAwait(false); + } + else + { + untested.Add("GetSourceFilter (no filter on the first input)"); + } - await Probe("GetRecordStatus", () => client.Record.GetRecordStatusAsync(cancellationToken)) - .ConfigureAwait(false); + await Probe("GetVersion", () => client.General.GetVersionAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("GetStats", () => client.General.GetStatsAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("GetHotkeyList", () => client.General.GetHotkeyListAsync(cancellationToken)) + .ConfigureAwait(false); - if (groupName is not null) - { await Probe( - "GetGroupSceneItemList", + "GetSpecialInputs", + () => client.Inputs.GetSpecialInputsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDefaultSettings", () => - client.SceneItems.GetGroupSceneItemListAsync( - new(sceneName: groupName), + client.Inputs.GetInputDefaultSettingsAsync( + new(inputKind: inputKind), cancellationToken ) ) .ConfigureAwait(false); - } - else - { - untested.Add("GetGroupSceneItemList (no group in the collection)"); - } - - if (sceneItemId >= 0) - { await Probe( - "GetSceneItemId", + "GetInputSettings", () => - client.SceneItems.GetSceneItemIdAsync( - new(sourceName: itemSourceName!, sceneName: sceneName), + client.Inputs.GetInputSettingsAsync( + new(inputName: inputName), cancellationToken ) ) .ConfigureAwait(false); await Probe( - "GetSceneItemSource", + "GetInputMute", () => - client.SceneItems.GetSceneItemSourceAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), + client.Inputs.GetInputMuteAsync( + new(inputName: inputName), cancellationToken ) ) .ConfigureAwait(false); await Probe( - "GetSceneItemTransform", + "GetInputVolume", () => - client.SceneItems.GetSceneItemTransformAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), + client.Inputs.GetInputVolumeAsync( + new(inputName: inputName), cancellationToken ) ) .ConfigureAwait(false); await Probe( - "GetSceneItemEnabled", + "GetInputAudioBalance", () => - client.SceneItems.GetSceneItemEnabledAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), + client.Inputs.GetInputAudioBalanceAsync( + new(inputName: inputName), cancellationToken ) ) .ConfigureAwait(false); await Probe( - "GetSceneItemLocked", + "GetInputAudioSyncOffset", () => - client.SceneItems.GetSceneItemLockedAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), + client.Inputs.GetInputAudioSyncOffsetAsync( + new(inputName: inputName), cancellationToken ) ) .ConfigureAwait(false); await Probe( - "GetSceneItemIndex", + "GetInputAudioMonitorType", () => - client.SceneItems.GetSceneItemIndexAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), + client.Inputs.GetInputAudioMonitorTypeAsync( + new(inputName: inputName), cancellationToken ) ) .ConfigureAwait(false); await Probe( - "GetSceneItemBlendMode", + "GetInputAudioTracks", () => - client.SceneItems.GetSceneItemBlendModeAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), + client.Inputs.GetInputAudioTracksAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDeinterlaceMode", + () => + client.Inputs.GetInputDeinterlaceModeAsync( + new(inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDeinterlaceFieldOrder", + () => + client.Inputs.GetInputDeinterlaceFieldOrderAsync( + new(inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputPropertiesListPropertyItems", + () => + client.Inputs.GetInputPropertiesListPropertyItemsAsync( + new(propertyName: "device_id", inputName: audioInput), cancellationToken ) ) .ConfigureAwait(false); - } - else - { - untested.Add("7 scene item requests (the program scene has no items)"); - } - await Probe( - "GetCurrentProgramScene", - () => client.Scenes.GetCurrentProgramSceneAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetCurrentPreviewScene", - () => client.Scenes.GetCurrentPreviewSceneAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneSceneTransitionOverride", - () => - client.Scenes.GetSceneSceneTransitionOverrideAsync( - new(sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); + await Probe( + "GetMediaInputStatus", + () => + client.MediaInputs.GetMediaInputStatusAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); - await Probe( - "GetSourceActive", - () => - client.Sources.GetSourceActiveAsync( - new(sourceName: inputName), - cancellationToken + await Probe( + "GetVirtualCamStatus", + () => client.Outputs.GetVirtualCamStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetReplayBufferStatus", + () => client.Outputs.GetReplayBufferStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetLastReplayBufferReplay", + () => client.Outputs.GetLastReplayBufferReplayAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetOutputStatus", + () => + client.Outputs.GetOutputStatusAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetOutputSettings", + () => + client.Outputs.GetOutputSettingsAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetRecordStatus", + () => client.Record.GetRecordStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + + if (groupName is not null) + { + await Probe( + "GetGroupSceneItemList", + () => + client.SceneItems.GetGroupSceneItemListAsync( + new(sceneName: groupName), + cancellationToken + ) ) - ) - .ConfigureAwait(false); - await Probe( - "GetSourceScreenshot", - () => - client.Sources.GetSourceScreenshotAsync( - new(imageFormat: "png", sourceName: sceneName), - cancellationToken + .ConfigureAwait(false); + } + else + { + // There is no CreateGroup request in the protocol, so a group can only come from a + // scene collection that already has one. + untested.Add("GetGroupSceneItemList (no group; the protocol cannot create one)"); + } + + if (sceneItemId >= 0) + { + await Probe( + "GetSceneItemId", + () => + client.SceneItems.GetSceneItemIdAsync( + new(sourceName: itemSourceName!, sceneName: sceneName), + cancellationToken + ) ) - ) - .ConfigureAwait(false); + .ConfigureAwait(false); + await Probe( + "GetSceneItemSource", + () => + client.SceneItems.GetSceneItemSourceAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemTransform", + () => + client.SceneItems.GetSceneItemTransformAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemEnabled", + () => + client.SceneItems.GetSceneItemEnabledAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemLocked", + () => + client.SceneItems.GetSceneItemLockedAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemIndex", + () => + client.SceneItems.GetSceneItemIndexAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemBlendMode", + () => + client.SceneItems.GetSceneItemBlendModeAsync( + new(sceneItemId: sceneItemId, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + untested.Add("7 scene item requests (the program scene has no items)"); + } + + await Probe( + "GetCurrentProgramScene", + () => client.Scenes.GetCurrentProgramSceneAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentPreviewScene", + () => client.Scenes.GetCurrentPreviewSceneAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneSceneTransitionOverride", + () => + client.Scenes.GetSceneSceneTransitionOverrideAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetSourceActive", + () => + client.Sources.GetSourceActiveAsync( + new(sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSourceScreenshot", + () => + client.Sources.GetSourceScreenshotAsync( + new(imageFormat: "png", sourceName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetStreamStatus", + () => client.Stream.GetStreamStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetTransitionKindList", + () => client.Transitions.GetTransitionKindListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneTransitionList", + () => client.Transitions.GetSceneTransitionListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentSceneTransition", + () => client.Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentSceneTransitionCursor", + () => client.Transitions.GetCurrentSceneTransitionCursorAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetStudioModeEnabled", + () => client.Ui.GetStudioModeEnabledAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe("GetMonitorList", () => client.Ui.GetMonitorListAsync(cancellationToken)) + .ConfigureAwait(false); + } + finally + { + if (!studioBefore.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(false), CancellationToken.None) + .ConfigureAwait(false); + } + + foreach (string fixture in new[] { audioInput, mediaInput }) + { + await client + .Inputs.RemoveInputAsync(new(inputName: fixture), CancellationToken.None) + .ConfigureAwait(false); + } + + await client + .Scenes.RemoveSceneAsync(new(sceneName: readScene), CancellationToken.None) + .ConfigureAwait(false); + } + + return + [ + ( + "Every read request deserializes", + unreadable.Count == 0, + unreadable.Count == 0 + ? $"{read} of 60 read; untested: {string.Join(", ", untested)}" + : string.Join(" | ", unreadable.Take(3)) + ), + ]; + } + + /// + /// Sends every write request that can be exercised without taking the machine somewhere it + /// cannot come back from, and reports the ones that could not be serialized. + /// + /// + /// Most write requests answer with no payload, so what this covers is the request side. That + /// is not a formality: SetInputAudioTracks could not be sent over MessagePack at all, + /// for the same missing formatter that made GetInputAudioTracks unreadable. + /// + /// Everything runs against a scene, an input and a filter this method creates and removes. + /// Requests that can only touch global state read the current value and write it back, so the + /// call is real and the setting is unchanged. + /// + /// + /// Deliberately not sent, because the cost of running them is not a shape bug: anything that + /// starts a stream, a recording, the replay buffer or the virtual camera; profile and scene + /// collection switching, which reloads OBS underneath the run; the dialog and projector + /// requests, which open windows; TriggerHotkeyByName and + /// PressInputPropertiesButton, which do whatever the target happens to do; + /// CallVendorRequest, which needs a plugin; and Sleep, which is batch only. + /// + /// + private static async Task< + List<(string Label, bool Pass, string Detail)> + > SweepEveryWriteRequestAsync(ObsWebSocketClient client, CancellationToken cancellationToken) + { + List unsendable = []; + List declined = []; + int sent = 0; + + async Task Probe(string name, Func call) + { + try + { + await call().ConfigureAwait(false); + sent++; + } + catch (ObsWebSocketSerializationException ex) + { + unsendable.Add($"{name}: {ex.InnerException?.Message ?? ex.Message}"); + } + catch (ObsWebSocketRequestException ex) + { + declined.Add($"{name} ({ex.StatusCode})"); + } + } - await Probe("GetStreamStatus", () => client.Stream.GetStreamStatusAsync(cancellationToken)) + string suffix = Guid.NewGuid().ToString("N")[..8]; + string sceneName = $"__obsws_wsweep_{suffix}"; + string renamedScene = $"{sceneName}_r"; + string inputName = $"__obsws_wsweep_in_{suffix}"; + string renamedInput = $"{inputName}_r"; + string filterName = "__obsws_wsweep_filter"; + string renamedFilter = $"{filterName}_r"; + + GetSceneListResponseData scenesBefore = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) .ConfigureAwait(false); + string originalProgramScene = scenesBefore.CurrentProgramSceneName!; + // ── Fixture ────────────────────────────────────────────────────────── await Probe( - "GetTransitionKindList", - () => client.Transitions.GetTransitionKindListAsync(cancellationToken) + "CreateScene", + () => client.Scenes.CreateSceneAsync(new(sceneName: sceneName), cancellationToken) ) .ConfigureAwait(false); await Probe( - "GetSceneTransitionList", - () => client.Transitions.GetSceneTransitionListAsync(cancellationToken) + "CreateInput", + () => + client.Inputs.CreateInputAsync( + new( + inputName: inputName, + inputKind: "color_source_v3", + sceneName: sceneName + ), + cancellationToken + ) ) .ConfigureAwait(false); + + // A colour source has no audio and cannot be deinterlaced. Pointing every audio and media + // request at one is how six read requests came back declined rather than exercised. + string audioInput = $"__obsws_wsweep_audio_{suffix}"; + string mediaInput = $"__obsws_wsweep_media_{suffix}"; await Probe( - "GetCurrentSceneTransition", - () => client.Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + "CreateInput (audio)", + () => + client.Inputs.CreateInputAsync( + new( + inputName: audioInput, + inputKind: "wasapi_output_capture", + sceneName: sceneName + ), + cancellationToken + ) ) .ConfigureAwait(false); await Probe( - "GetCurrentSceneTransitionCursor", - () => client.Transitions.GetCurrentSceneTransitionCursorAsync(cancellationToken) + "CreateInput (media)", + () => + client.Inputs.CreateInputAsync( + new( + inputName: mediaInput, + inputKind: "ffmpeg_source", + sceneName: sceneName + ), + cancellationToken + ) ) .ConfigureAwait(false); - await Probe( - "GetStudioModeEnabled", - () => client.Ui.GetStudioModeEnabledAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe("GetMonitorList", () => client.Ui.GetMonitorListAsync(cancellationToken)) - .ConfigureAwait(false); + try + { + GetSceneItemListResponseData fixtureItems = await client + .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) + .ConfigureAwait(false); + int itemId = fixtureItems.SceneItems[0].SceneItemId; - return - [ - ( - "Every read request deserializes", - unreadable.Count == 0, - unreadable.Count == 0 - ? $"{read} of 60 read; untested: {string.Join(", ", untested)}" - : string.Join(" | ", unreadable.Take(3)) + // ── Scenes ─────────────────────────────────────────────────────── + await Probe( + "SetCurrentProgramScene", + () => + client.Scenes.SetCurrentProgramSceneAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneSceneTransitionOverride", + () => + client.Scenes.SetSceneSceneTransitionOverrideAsync( + new(sceneName: sceneName, transitionName: "Fade"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneName", + () => + client.Scenes.SetSceneNameAsync( + new(newSceneName: renamedScene, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + sceneName = renamedScene; + + // ── Scene items ────────────────────────────────────────────────── + await Probe( + "SetSceneItemEnabled", + () => + client.SceneItems.SetSceneItemEnabledAsync( + new(sceneItemId: itemId, sceneItemEnabled: true, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneItemLocked", + () => + client.SceneItems.SetSceneItemLockedAsync( + new(sceneItemId: itemId, sceneItemLocked: false, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneItemIndex", + () => + client.SceneItems.SetSceneItemIndexAsync( + new(sceneItemId: itemId, sceneItemIndex: 0, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneItemBlendMode", + () => + client.SceneItems.SetSceneItemBlendModeAsync( + new( + sceneItemId: itemId, + sceneItemBlendMode: "OBS_BLEND_NORMAL", + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // A whole transform read back from OBS is refused: it carries the source dimensions + // OBS computes and will not accept back. A caller sets the fields they mean to. + SceneItemTransformPatchStub transformPatch = new() + { + PositionX = 0.0, + PositionY = 0.0, + Rotation = 0.0, + }; + await Probe( + "SetSceneItemTransform", + () => + client.SceneItems.SetSceneItemTransformAsync( + new( + sceneItemId: itemId, + sceneItemTransform: transformPatch, + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + int? addedItemId = null; + try + { + CreateSceneItemResponseData added = await client + .SceneItems.CreateSceneItemAsync( + new(sceneName: sceneName, sourceName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + addedItemId = added.SceneItemId; + sent++; + } + catch (ObsWebSocketRequestException ex) + { + declined.Add($"CreateSceneItem ({ex.StatusCode})"); + } + + int? duplicatedItemId = null; + try + { + DuplicateSceneItemResponseData duplicated = await client + .SceneItems.DuplicateSceneItemAsync( + new(sceneItemId: itemId, sceneName: sceneName), + cancellationToken + ) + .ConfigureAwait(false); + duplicatedItemId = duplicated.SceneItemId; + sent++; + } + catch (ObsWebSocketRequestException ex) + { + declined.Add($"DuplicateSceneItem ({ex.StatusCode})"); + } + + // Only the two this sweep added. Removing the last scene item that references an input + // destroys the input, which took the audio and media fixtures with it. + foreach (int extra in new[] { addedItemId, duplicatedItemId }.OfType()) + { + await Probe( + "RemoveSceneItem", + () => + client.SceneItems.RemoveSceneItemAsync( + new(sceneItemId: extra, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + + // ── Inputs ─────────────────────────────────────────────────────── + await Probe( + "SetInputSettings", + () => + client.Inputs.SetInputSettingsAsync( + inputName, + new ColorSourceSettings { Width = 320, Height = 180 }, + cancellationToken: cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputMute", + () => + client.Inputs.SetInputMuteAsync( + new(inputMuted: false, inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "ToggleInputMute", + () => + client.Inputs.ToggleInputMuteAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputVolume", + () => + client.Inputs.SetInputVolumeAsync( + new(inputName: audioInput, inputVolumeMul: 1.0), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputAudioBalance", + () => + client.Inputs.SetInputAudioBalanceAsync( + new(inputAudioBalance: 0.5, inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputAudioSyncOffset", + () => + client.Inputs.SetInputAudioSyncOffsetAsync( + new(inputAudioSyncOffset: 0, inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputAudioMonitorType", + () => + client.Inputs.SetInputAudioMonitorTypeAsync( + new(monitorType: "OBS_MONITORING_TYPE_NONE", inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // The request that could not be sent over MessagePack at all. + GetInputAudioTracksResponseData? tracks = null; + try + { + tracks = await client + .Inputs.GetInputAudioTracksAsync(new(inputName: audioInput), cancellationToken) + .ConfigureAwait(false); + } + catch (ObsWebSocketRequestException) + { + // Deliberately not logged: the write below is probed either way and reports what + // OBS said about it. + } + await Probe( + "SetInputAudioTracks", + () => + client.Inputs.SetInputAudioTracksAsync( + new( + inputAudioTracks: tracks?.InputAudioTracks + ?? new Dictionary { ["1"] = true }, + inputName: audioInput + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "SetInputDeinterlaceMode", + () => + client.Inputs.SetInputDeinterlaceModeAsync( + new( + inputDeinterlaceMode: "OBS_DEINTERLACE_MODE_DISABLE", + inputName: mediaInput + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputDeinterlaceFieldOrder", + () => + client.Inputs.SetInputDeinterlaceFieldOrderAsync( + new( + inputDeinterlaceFieldOrder: "OBS_DEINTERLACE_FIELD_ORDER_TOP", + inputName: mediaInput + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputName", + () => + client.Inputs.SetInputNameAsync( + new(newInputName: renamedInput, inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + inputName = renamedInput; + + // ── Media inputs, on an input that is not one ──────────────────── + await Probe( + "SetMediaInputCursor", + () => + client.MediaInputs.SetMediaInputCursorAsync( + new(mediaCursor: 0, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "OffsetMediaInputCursor", + () => + client.MediaInputs.OffsetMediaInputCursorAsync( + new(mediaCursorOffset: 0, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "TriggerMediaInputAction", + () => + client.MediaInputs.TriggerMediaInputActionAsync( + new(mediaAction: MediaInputAction.Stop, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "PressInputPropertiesButton", + () => + client.Inputs.PressInputPropertiesButtonAsync( + new(propertyName: "__obsws_no_such_button", inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── Filters ────────────────────────────────────────────────────── + await Probe( + "CreateSourceFilter", + () => + client.Filters.CreateSourceFilterAsync( + new( + filterName: filterName, + filterKind: "color_filter_v2", + sourceName: inputName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterEnabled", + () => + client.Filters.SetSourceFilterEnabledAsync( + new(filterName: filterName, filterEnabled: true, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterIndex", + () => + client.Filters.SetSourceFilterIndexAsync( + new(filterName: filterName, filterIndex: 0, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterSettings", + () => + client.Filters.SetSourceFilterSettingsAsync( + inputName, + filterName, + new ColorCorrectionFilterSettings { Opacity = 1.0 }, + cancellationToken: cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterName", + () => + client.Filters.SetSourceFilterNameAsync( + new( + filterName: filterName, + newFilterName: renamedFilter, + sourceName: inputName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "RemoveSourceFilter", + () => + client.Filters.RemoveSourceFilterAsync( + new(filterName: renamedFilter, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── General ────────────────────────────────────────────────────── + using JsonDocument custom = JsonDocument.Parse("""{"obswsSweep":true}"""); + await Probe( + "BroadcastCustomEvent", + () => + client.General.BroadcastCustomEventAsync( + new(eventData: custom.RootElement.Clone()), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "TriggerHotkeyByKeySequence", + () => + client.General.TriggerHotkeyByKeySequenceAsync( + new( + keyId: "OBS_KEY_F13", + keyModifiers: new TriggerHotkeyByKeySequenceRequestData_KeyModifiers( + shift: false, + control: false, + alt: false, + command: false + ) + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── Config, every one a read then a write of the same value ────── + await Probe( + "SetPersistentData", + () => + client.Config.SetPersistentDataAsync( + new( + realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", + slotName: "__obsws_sweep", + slotValue: JsonDocument.Parse("\"probe\"").RootElement.Clone() + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetProfileParameterResponseData profileParameter = await client + .Config.GetProfileParameterAsync( + new(parameterCategory: "Output", parameterName: "Mode"), + cancellationToken + ) + .ConfigureAwait(false); + await Probe( + "SetProfileParameter", + () => + client.Config.SetProfileParameterAsync( + new( + parameterCategory: "Output", + parameterName: "Mode", + parameterValue: profileParameter.ParameterValue + ?? profileParameter.DefaultParameterValue + ?? "Simple" + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetVideoSettingsResponseData video = await client + .Config.GetVideoSettingsAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetVideoSettings", + () => + client.Config.SetVideoSettingsAsync( + new( + fpsNumerator: video.FpsNumerator, + fpsDenominator: video.FpsDenominator, + baseWidth: video.BaseWidth, + baseHeight: video.BaseHeight, + outputWidth: video.OutputWidth, + outputHeight: video.OutputHeight + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetRecordDirectoryResponseData recordDirectory = await client + .Config.GetRecordDirectoryAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetRecordDirectory", + () => + client.Config.SetRecordDirectoryAsync( + new(recordDirectory: recordDirectory.RecordDirectory), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetStreamServiceSettingsResponseData streamService = await client + .Config.GetStreamServiceSettingsAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetStreamServiceSettings", + () => + client.Config.SetStreamServiceSettingsAsync( + new( + streamServiceType: streamService.StreamServiceType, + streamServiceSettings: streamService.StreamServiceSettings + ?? JsonDocument.Parse("{}").RootElement.Clone() + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── Transitions, same read then write ──────────────────────────── + GetCurrentSceneTransitionResponseData transition = await client + .Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetCurrentSceneTransition", + () => + client.Transitions.SetCurrentSceneTransitionAsync( + new(transitionName: transition.TransitionName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetCurrentSceneTransitionDuration", + () => + client.Transitions.SetCurrentSceneTransitionDurationAsync( + new(transitionDuration: transition.TransitionDuration ?? 300), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetCurrentSceneTransitionSettings", + () => + client.Transitions.SetCurrentSceneTransitionSettingsAsync( + new( + transitionSettings: transition.TransitionSettings + ?? JsonDocument.Parse("{}").RootElement.Clone() + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── UI and studio mode, restored below ─────────────────────────── + GetStudioModeEnabledResponseData studio = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetStudioModeEnabled", + () => client.Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "SetCurrentPreviewScene", + () => + client.Scenes.SetCurrentPreviewSceneAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetTBarPosition", + () => + client.Transitions.SetTBarPositionAsync( + new(position: 0.0, release: true), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "TriggerStudioModeTransition", + () => client.Transitions.TriggerStudioModeTransitionAsync(cancellationToken) + ) + .ConfigureAwait(false); + if (!studio.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(false), CancellationToken.None) + .ConfigureAwait(false); + } + + // ── Requests OBS should decline in this state, sent anyway so the + // request side is still exercised ─────────────────────────────── + await Probe("StopRecord", () => client.Record.StopRecordAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "ToggleRecordPause", + () => client.Record.ToggleRecordPauseAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe("PauseRecord", () => client.Record.PauseRecordAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("ResumeRecord", () => client.Record.ResumeRecordAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "SplitRecordFile", + () => client.Record.SplitRecordFileAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "CreateRecordChapter", + () => + client.Record.CreateRecordChapterAsync( + new(chapterName: "__obsws_sweep"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe("StopStream", () => client.Stream.StopStreamAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "SendStreamCaption", + () => + client.Stream.SendStreamCaptionAsync( + new(captionText: "__obsws_sweep"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "StopReplayBuffer", + () => client.Outputs.StopReplayBufferAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "SaveReplayBuffer", + () => client.Outputs.SaveReplayBufferAsync(cancellationToken) + ) + .ConfigureAwait(false); + + GetOutputListResponseData outputs = await client + .Outputs.GetOutputListAsync(cancellationToken) + .ConfigureAwait(false); + string outputName = outputs.Outputs[0].OutputName; + await Probe( + "StopOutput", + () => + client.Outputs.StopOutputAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // SetOutputSettings is deliberately not sent. Writing settings back to a real + // output wedged the output subsystem: GetOutputList then timed out for the rest of + // the session, in checks that had nothing to do with the sweep. + declined.Add("SetOutputSettings (not sent: wedges the output subsystem)"); + + // ── Sources ────────────────────────────────────────────────────── + string screenshotPath = Path.Combine(Path.GetTempPath(), $"obsws_sweep_{suffix}.png"); + await Probe( + "SaveSourceScreenshot", + () => + client.Sources.SaveSourceScreenshotAsync( + new( + imageFormat: "png", + imageFilePath: screenshotPath, + sourceName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + if (File.Exists(screenshotPath)) + { + File.Delete(screenshotPath); + } + } + finally + { + await client + .Scenes.SetCurrentProgramSceneAsync( + new(sceneName: originalProgramScene), + CancellationToken.None + ) + .ConfigureAwait(false); + + foreach (string fixture in new[] { inputName, audioInput, mediaInput }) + { + await Probe( + "RemoveInput", + () => + client.Inputs.RemoveInputAsync( + new(inputName: fixture), + CancellationToken.None + ) + ) + .ConfigureAwait(false); + } + await Probe( + "RemoveScene", + () => + client.Scenes.RemoveSceneAsync( + new(sceneName: sceneName), + CancellationToken.None + ) + ) + .ConfigureAwait(false); + } + + return + [ + ( + "Every write request serializes", + unsendable.Count == 0, + unsendable.Count == 0 + ? $"{sent} sent and accepted, {declined.Count} declined: " + + string.Join(", ", declined) + : string.Join(" | ", unsendable.Take(3)) ), ]; } diff --git a/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs index b0b525f..65a4656 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs @@ -235,6 +235,75 @@ await client.General.GetVersionAsync() Assert.AreEqual(0, pendingRequests.Count, "Pending request should have been removed."); } + /// + /// A request the protocol says has no response payload is generated as + /// CallAsync<object>. OBS sends a payload for some of them anyway, and there is no + /// metadata for object, so attempting the read failed a request that had succeeded. + /// + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallAsyncOfObject_ResponseCarriesAPayload_DoesNotAttemptToReadIt() + { + // Arrange + ( + ObsWebSocketClient? client, + Mock? mockSerializer, + Mock? mockWebSocket + ) = TestUtils.SetupConnectedClientForceState(); + + JsonElement? rawResponseData = TestUtils.ToJsonElement(new { outputPaused = true }); + Assert.IsNotNull(rawResponseData); + + _ = mockWebSocket + .Setup(ws => + ws.SendAsync( + It.IsAny>(), + It.IsAny(), + true, + It.IsAny() + ) + ) + .Callback( + ( + ReadOnlyMemory buffer, + WebSocketMessageType msgType, + bool endOfMsg, + CancellationToken ct + ) => + { + OutgoingMessage? requestMsg = JsonSerializer.Deserialize< + OutgoingMessage + >(buffer.Span, TestUtils.s_jsonSerializerOptions); + if (requestMsg?.D?.RequestType != "ToggleRecordPause") + { + return; + } + + RequestResponsePayload response = new( + RequestType: "ToggleRecordPause", + RequestId: requestMsg.D.RequestId, + RequestStatus: new RequestStatus( + Result: true, + Code: (int)Core.Protocol.Generated.RequestStatusCode.Success + ), + ResponseData: rawResponseData.Value + ); + _ = TestUtils.SimulateIncomingResponse( + client, + requestMsg.D.RequestId, + response + ); + } + ) + .Returns(ValueTask.CompletedTask); + + // Act + await client.Record.ToggleRecordPauseAsync(); + + // Assert + mockSerializer.Verify(s => s.DeserializePayload(It.IsAny()), Times.Never); + } + // --- Test Request WITH Request Data and NO Response Data --- /// @@ -324,7 +393,8 @@ CancellationToken ct ); // Verify the base object deserialization *was* attempted (even with null data) - mockSerializer.Verify(s => s.DeserializePayload(It.IsAny()), Times.Once()); + // A request with no response payload never reads one, whatever OBS sends back. + mockSerializer.Verify(s => s.DeserializePayload(It.IsAny()), Times.Never); // Verify the pending request was removed ConcurrentDictionary>? pendingRequests = From cf7c06ef287e1b80cf07f70423ce089c5bdeff83 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 19:29:55 +0200 Subject: [PATCH 5/6] test(example): make the sweeps build the state they need The read sweep ran against whatever input happened to be first, so the audio, media and property requests were declined rather than exercised, and a scene collection made fresh in the UI has no inputs at all and failed the suite outright. Both sweeps now create what they need: a scene, an audio input, a media input and a filter, plus studio mode. Read coverage goes from 52 to 59 of 60. Volume meters seeds its input into the program scene, since OBS meters only the inputs it considers active. SetOutputSettings is no longer sent: writing settings to a real output wedged the output subsystem for the rest of the session. --- ObsWebSocket.Example/Worker.cs | 121 +++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 29 deletions(-) diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 0e308fe..fdf4045 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -880,6 +880,30 @@ version is null GetInputListResponseData? inputs = await cycleClient .Inputs.GetInputListAsync(new(), cancellationToken) .ConfigureAwait(false); + if (inputs?.Inputs is null || inputs.Inputs.Count == 0) + { + // A scene collection made fresh in the UI has no inputs at all. The suite supplies + // one rather than refusing to run, since every fixture it needs it creates anyway. + _logger.LogInformation( + "[{Format}] No inputs in this collection; creating one to validate against.", + format + ); + await cycleClient + .Inputs.CreateInputAsync( + new( + inputName: $"__obsws_seed_{Guid.NewGuid():N}"[..24], + inputKind: "color_source_v3", + sceneName: scenes!.Scenes[0].SceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + inputs = await cycleClient + .Inputs.GetInputListAsync(new(), cancellationToken) + .ConfigureAwait(false); + } + if (inputs?.Inputs is null || inputs.Inputs.Count == 0) { throw new InvalidOperationException($"[{format}] GetInputList returned no inputs."); @@ -2885,6 +2909,36 @@ await TrySettingsCheckAsync( { // High rate event with its own stub. It was read as an InputStub and // failed on the kind fields it never sends, so it never fired at all. + // A collection with no audio input reports no meters at all, which + // says nothing about whether the payload reads correctly. + GetInputListResponseData present = await client + .Inputs.GetInputListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string? seeded = null; + if ( + !present.Inputs.Exists(i => + i.InputKind.Contains("wasapi", StringComparison.Ordinal) + ) + ) + { + // OBS meters only the inputs it considers active, which means + // the ones in the program scene. Anywhere else reports nothing. + GetSceneListResponseData live = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + seeded = $"__obsws_meter_{Guid.NewGuid():N}"[..22]; + await client + .Inputs.CreateInputAsync( + new( + inputName: seeded, + inputKind: "wasapi_output_capture", + sceneName: live.CurrentProgramSceneName ?? sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + } + EventSubscription? before = client.CurrentEventSubscriptions; await client .ReidentifyAsync( @@ -2943,6 +2997,16 @@ await client ) .ConfigureAwait(false); } + + if (seeded is not null) + { + await client + .Inputs.RemoveInputAsync( + new(inputName: seeded), + CancellationToken.None + ) + .ConfigureAwait(false); + } } } ) @@ -4205,6 +4269,8 @@ private static void RenderCommandHelp() /// an input of the wrong kind) are reported as untested rather than as failures, so the count /// says how much of the surface was actually exercised. /// + private const string FixtureFilterName = "__obsws_rsweep_filter"; + private static async Task< List<(string Label, bool Pass, string Detail)> > SweepEveryReadRequestAsync(ObsWebSocketClient client, CancellationToken cancellationToken) @@ -4269,12 +4335,6 @@ async Task Probe(string name, Func call) .ConfigureAwait(false); string? groupName = groups.Groups.Count > 0 ? groups.Groups[0] : null; - GetSourceFilterListResponseData sourceFilters = await client - .Filters.GetSourceFilterListAsync(new(sourceName: inputName), cancellationToken) - .ConfigureAwait(false); - string? filterName = - sourceFilters.Filters.Count > 0 ? sourceFilters.Filters[0].FilterName : null; - // The nine discovery calls above are themselves read requests. read += 9; @@ -4309,6 +4369,16 @@ await client cancellationToken ) .ConfigureAwait(false); + await client + .Filters.CreateSourceFilterAsync( + new( + filterName: FixtureFilterName, + filterKind: "color_filter_v2", + sourceName: mediaInput + ), + cancellationToken + ) + .ConfigureAwait(false); if (!studioBefore.StudioModeEnabled) { await client @@ -4380,22 +4450,15 @@ await Probe( ) ) .ConfigureAwait(false); - if (filterName is not null) - { - await Probe( - "GetSourceFilter", - () => - client.Filters.GetSourceFilterAsync( - new(filterName: filterName, sourceName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - } - else - { - untested.Add("GetSourceFilter (no filter on the first input)"); - } + await Probe( + "GetSourceFilter", + () => + client.Filters.GetSourceFilterAsync( + new(filterName: FixtureFilterName, sourceName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); await Probe("GetVersion", () => client.General.GetVersionAsync(cancellationToken)) .ConfigureAwait(false); @@ -4431,7 +4494,7 @@ await Probe( "GetInputMute", () => client.Inputs.GetInputMuteAsync( - new(inputName: inputName), + new(inputName: audioInput), cancellationToken ) ) @@ -4440,7 +4503,7 @@ await Probe( "GetInputVolume", () => client.Inputs.GetInputVolumeAsync( - new(inputName: inputName), + new(inputName: audioInput), cancellationToken ) ) @@ -4449,7 +4512,7 @@ await Probe( "GetInputAudioBalance", () => client.Inputs.GetInputAudioBalanceAsync( - new(inputName: inputName), + new(inputName: audioInput), cancellationToken ) ) @@ -4458,7 +4521,7 @@ await Probe( "GetInputAudioSyncOffset", () => client.Inputs.GetInputAudioSyncOffsetAsync( - new(inputName: inputName), + new(inputName: audioInput), cancellationToken ) ) @@ -4467,7 +4530,7 @@ await Probe( "GetInputAudioMonitorType", () => client.Inputs.GetInputAudioMonitorTypeAsync( - new(inputName: inputName), + new(inputName: audioInput), cancellationToken ) ) @@ -4476,7 +4539,7 @@ await Probe( "GetInputAudioTracks", () => client.Inputs.GetInputAudioTracksAsync( - new(inputName: inputName), + new(inputName: audioInput), cancellationToken ) ) @@ -4513,7 +4576,7 @@ await Probe( "GetMediaInputStatus", () => client.MediaInputs.GetMediaInputStatusAsync( - new(inputName: inputName), + new(inputName: mediaInput), cancellationToken ) ) From 9f20000db59d093f1b72e07ac5449684ea7ded8c Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 19:38:51 +0200 Subject: [PATCH 6/6] test(example): cover every read request, and count coverage honestly GetSourceFilterList had been dropped from the sweep when the filter discovery was replaced by a fixture, and the count did not notice because it was a running total against a hardcoded 60. It counts distinct request types now and fails if fewer than 60 are accounted for, so a probe cannot go missing quietly again. The write sweep reports what it actually covers: a request OBS declines still proves it serialized, which is the thing under test, so the total is stable across transports even when machine state is not. --- ObsWebSocket.Example/Worker.cs | 49 +++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index fdf4045..18fce6c 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -4277,14 +4277,14 @@ private static async Task< { List unreadable = []; List untested = []; - int read = 0; + HashSet read = new(StringComparer.Ordinal); async Task Probe(string name, Func call) { try { await call().ConfigureAwait(false); - read++; + _ = read.Add(name); } catch (ObsWebSocketSerializationException ex) { @@ -4335,8 +4335,18 @@ async Task Probe(string name, Func call) .ConfigureAwait(false); string? groupName = groups.Groups.Count > 0 ? groups.Groups[0] : null; - // The nine discovery calls above are themselves read requests. - read += 9; + // The discovery calls above are read requests too, and naming them here rather than + // counting them keeps the total honest when the discovery changes. + read.UnionWith([ + "GetSceneList", + "GetInputList", + "GetSceneItemList", + "GetInputKindList", + "GetSourceFilterKindList", + "GetOutputList", + "GetGroupList", + "GetStudioModeEnabled", + ]); // Four of these requests need OBS to be in a particular state, not just to be asked // nicely. Without the fixture they answer InvalidResourceState and the response shape is @@ -4450,6 +4460,15 @@ await Probe( ) ) .ConfigureAwait(false); + await Probe( + "GetSourceFilterList", + () => + client.Filters.GetSourceFilterListAsync( + new(sourceName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); await Probe( "GetSourceFilter", () => @@ -4592,11 +4611,17 @@ await Probe( () => client.Outputs.GetReplayBufferStatusAsync(cancellationToken) ) .ConfigureAwait(false); + // GetLastReplayBufferReplay is left untested rather than prepared for. Starting and + // stopping the replay buffer to save a clip crashed OBS: the dump lands in + // GetOutputList, on obs_encoder_get_width against the encoder the buffer had just + // freed. Enumerating outputs while one is being torn down is not something a client + // can make safe, and this sweep is not worth an OBS restart per run. await Probe( "GetLastReplayBufferReplay", () => client.Outputs.GetLastReplayBufferReplayAsync(cancellationToken) ) .ConfigureAwait(false); + await Probe( "GetOutputStatus", () => @@ -4811,10 +4836,12 @@ await client [ ( "Every read request deserializes", - unreadable.Count == 0, - unreadable.Count == 0 - ? $"{read} of 60 read; untested: {string.Join(", ", untested)}" - : string.Join(" | ", unreadable.Take(3)) + unreadable.Count == 0 && read.Count + untested.Count >= 60, + unreadable.Count > 0 ? string.Join(" | ", unreadable.Take(3)) + : read.Count + untested.Count < 60 + ? $"only {read.Count + untested.Count} of 60 accounted for; a probe is missing" + : untested.Count == 0 ? $"all {read.Count} of 60 read" + : $"{read.Count} of 60 read; untested: {string.Join(", ", untested)}" ), ]; } @@ -5628,8 +5655,10 @@ await Probe( "Every write request serializes", unsendable.Count == 0, unsendable.Count == 0 - ? $"{sent} sent and accepted, {declined.Count} declined: " - + string.Join(", ", declined) + // A decline still proves the request serialized and reached OBS, which is + // what this sweep covers; only unsendable is a defect. + ? $"{sent + declined.Count} serialized ({sent} accepted, {declined.Count} " + + $"declined for machine state): {string.Join(", ", declined)}" : string.Join(" | ", unsendable.Take(3)) ), ];