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 c373b9a..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( @@ -324,14 +352,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 +376,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..088edd7 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs @@ -65,12 +65,18 @@ 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))]"); _ = 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.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/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/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/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..6121979 100644 --- a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs +++ b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketJsonContext.g.cs @@ -30,12 +30,18 @@ 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))] [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/ObsWebSocketClient.cs b/ObsWebSocket.Core/ObsWebSocketClient.cs index b07c92e..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) @@ -1577,10 +1582,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 +1633,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 +1682,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 +1744,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 +1772,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/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index c2fadd3..a46b78c 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. /// @@ -179,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/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/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.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.Core/Serialization/MsgPackStubExtensionDataResolver.cs b/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs index d718fee..b3208d5 100644 --- a/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs +++ b/ObsWebSocket.Core/Serialization/MsgPackStubExtensionDataResolver.cs @@ -13,74 +13,49 @@ 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); + 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/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 1786d2a..18fce6c 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", @@ -786,6 +834,8 @@ CancellationToken cancellationToken ); await cycleClient.ConnectAsync(cancellationToken).ConfigureAwait(false); + // Each transport is judged on its own run. + SerializationFailureSink.Reset(); try { GetVersionResponseData? version = await cycleClient @@ -830,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."); @@ -992,6 +1066,30 @@ await ValidateSettingsModesAsync(cycleClient, inputs, cancellationToken) await ValidateModernApisAsync(cycleClient, healthChecks, cancellationToken) .ConfigureAwait(false); + modernResults.AddRange( + 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. + 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"); @@ -2743,20 +2841,206 @@ 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. + // 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( + (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); + } + + if (seeded is not null) + { + await client + .Inputs.RemoveInputAsync( + new(inputName: seeded), + 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}" ); } ) @@ -3974,6 +4258,1412 @@ 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 const string FixtureFilterName = "__obsws_rsweep_filter"; + + private static async Task< + List<(string Label, bool Pass, string Detail)> + > SweepEveryReadRequestAsync(ObsWebSocketClient client, CancellationToken cancellationToken) + { + List unreadable = []; + List untested = []; + HashSet read = new(StringComparer.Ordinal); + + async Task Probe(string name, Func call) + { + try + { + await call().ConfigureAwait(false); + _ = read.Add(name); + } + 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; + + // 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 + // 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}"; + + GetStudioModeEnabledResponseData studioBefore = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) + .ConfigureAwait(false); + + await client + .Scenes.CreateSceneAsync(new(sceneName: readScene), cancellationToken) + .ConfigureAwait(false); + await client + .Inputs.CreateInputAsync( + new( + inputName: audioInput, + inputKind: "wasapi_output_capture", + sceneName: readScene + ), + cancellationToken + ) + .ConfigureAwait(false); + await client + .Inputs.CreateInputAsync( + new(inputName: mediaInput, inputKind: "ffmpeg_source", sceneName: readScene), + cancellationToken + ) + .ConfigureAwait(false); + await client + .Filters.CreateSourceFilterAsync( + new( + filterName: FixtureFilterName, + filterKind: "color_filter_v2", + sourceName: mediaInput + ), + cancellationToken + ) + .ConfigureAwait(false); + if (!studioBefore.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + .ConfigureAwait(false); + } + + try + { + 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); + await Probe( + "GetSourceFilterList", + () => + client.Filters.GetSourceFilterListAsync( + new(sourceName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSourceFilter", + () => + client.Filters.GetSourceFilterAsync( + new(filterName: FixtureFilterName, sourceName: mediaInput), + 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); + + 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: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputVolume", + () => + client.Inputs.GetInputVolumeAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioBalance", + () => + client.Inputs.GetInputAudioBalanceAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioSyncOffset", + () => + client.Inputs.GetInputAudioSyncOffsetAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioMonitorType", + () => + client.Inputs.GetInputAudioMonitorTypeAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioTracks", + () => + client.Inputs.GetInputAudioTracksAsync( + new(inputName: audioInput), + 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); + + await Probe( + "GetMediaInputStatus", + () => + client.MediaInputs.GetMediaInputStatusAsync( + new(inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetVirtualCamStatus", + () => client.Outputs.GetVirtualCamStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetReplayBufferStatus", + () => 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", + () => + 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 + { + // 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); + 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 && 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)}" + ), + ]; + } + + /// + /// 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})"); + } + } + + 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( + "CreateScene", + () => client.Scenes.CreateSceneAsync(new(sceneName: sceneName), cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "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( + "CreateInput (audio)", + () => + client.Inputs.CreateInputAsync( + new( + inputName: audioInput, + inputKind: "wasapi_output_capture", + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "CreateInput (media)", + () => + client.Inputs.CreateInputAsync( + new( + inputName: mediaInput, + inputKind: "ffmpeg_source", + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + try + { + GetSceneItemListResponseData fixtureItems = await client + .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) + .ConfigureAwait(false); + int itemId = fixtureItems.SceneItems[0].SceneItemId; + + // ── 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 + // 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)) + ), + ]; + } + 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))}>"; +} 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/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..65a4656 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientRequestTests.cs @@ -152,6 +152,158 @@ 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."); + } + + /// + /// 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 --- /// @@ -241,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 = 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() { 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); +}