diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.BatchBuilder.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.BatchBuilder.cs
index fd613c1..f2e91df 100644
--- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.BatchBuilder.cs
+++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.BatchBuilder.cs
@@ -6,13 +6,14 @@
namespace ObsWebSocket.Codegen.Tasks.Generation;
///
-/// Emitter logic for the typed batch builder, which pairs each request type string with the
-/// request data record the protocol defines for it.
+/// Emitter logic for the typed batch builder. Requests are grouped by protocol category, and each
+/// returns a reference carrying its response type so a result can be read without restating
+/// either its position or its type.
///
internal static partial class Emitter
{
///
- /// Generates one builder method per protocol request.
+ /// Generates the category groups and their request methods.
///
/// The source production context.
/// The parsed protocol definition.
@@ -26,93 +27,172 @@ ProtocolDefinition protocol
return;
}
- StringBuilder builder = BuildSourceHeader("// Helper: typed batch builder methods");
+ StringBuilder builder = BuildSourceHeader("// Helper: typed batch builder groups");
builder.AppendLine("using System;");
builder.AppendLine($"using {GeneratedRequestsNamespace};");
+ builder.AppendLine($"using {GeneratedResponsesNamespace};");
builder.AppendLine();
builder.AppendLine($"namespace {ExtensionsNamespace};");
builder.AppendLine();
+
+ List<(string Category, string GroupName)> groups = [];
+ foreach (
+ IGrouping group in protocol
+ .Requests.GroupBy(r => r.Category ?? "general", StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.Ordinal)
+ )
+ {
+ string groupName = ToGroupName(group.Key);
+ groups.Add((group.Key, groupName));
+
+ builder.AppendLine("/// ");
+ builder.AppendLine(
+ $"/// Batch requests in the {System.Security.SecurityElement.Escape(group.Key)} category."
+ );
+ builder.AppendLine("/// ");
+ builder.AppendLine("/// The batch being built.");
+ builder.AppendLine(
+ $"public readonly struct {groupName}BatchGroup(ObsBatchBuilder builder)"
+ );
+ builder.AppendLine("{");
+
+ foreach (RequestDefinition request in group)
+ {
+ EmitBatchRequestMethod(context, builder, request);
+ }
+
+ builder.AppendLine("}");
+ builder.AppendLine();
+ }
+
builder.AppendLine("public sealed partial class ObsBatchBuilder");
builder.AppendLine("{");
+ foreach ((string category, string groupName) in groups)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// Requests in the {System.Security.SecurityElement.Escape(category)} category."
+ );
+ builder.AppendLine(" /// ");
+ builder.AppendLine($" public {groupName}BatchGroup {groupName} => new(this);");
+ builder.AppendLine();
+ }
+
+ builder.AppendLine("}");
+
+ context.AddSource(
+ "ObsBatchBuilder.Requests.g.cs",
+ SourceText.From(builder.ToString(), Encoding.UTF8)
+ );
+ }
- foreach (RequestDefinition request in protocol.Requests)
+ ///
+ /// Emits one request method, returning a reference to its eventual result.
+ ///
+ private static void EmitBatchRequestMethod(
+ SourceProductionContext context,
+ StringBuilder builder,
+ RequestDefinition request
+ )
+ {
+ try
{
- try
+ string requestType = request.RequestType;
+ string methodName = SanitizeIdentifier(requestType);
+ if (string.IsNullOrEmpty(methodName))
{
- string requestType = request.RequestType;
- string methodName = SanitizeIdentifier(requestType);
- if (string.IsNullOrEmpty(methodName))
- {
- continue;
- }
+ return;
+ }
- bool hasData = request.RequestFields?.Count > 0;
+ bool hasData = request.RequestFields?.Count > 0;
+ bool hasResponse = request.ResponseFields?.Count > 0;
- builder.AppendLine(" /// ");
+ string returnType = hasResponse
+ ? $"BatchRef<{GeneratedResponsesNamespace}.{methodName}ResponseData>"
+ : "BatchRef";
+
+ builder.AppendLine(" /// ");
+ builder.AppendLine($" /// Adds a {requestType} request to the batch.");
+ if (!string.IsNullOrWhiteSpace(request.Description))
+ {
builder.AppendLine(
- $" /// Appends a {requestType} request to the batch."
+ $" /// {FlattenBatchDescription(request.Description)}"
);
- if (!string.IsNullOrWhiteSpace(request.Description))
- {
- builder.AppendLine(
- $" /// {FlattenDescription(request.Description)}"
- );
- }
-
- builder.AppendLine(" /// ");
- builder.AppendLine(" /// The same builder, for chaining.");
- if (request.Deprecated)
- {
- builder.AppendLine(
- $" [System.Obsolete(\"Deprecated in OBS Websocket version {request.InitialVersion}\")]"
- );
- }
-
- if (hasData)
- {
- string dataTypeName =
- $"{GeneratedRequestsNamespace}.{methodName}RequestData";
- builder.AppendLine(
- $" /// The payload for this request."
- );
- builder.AppendLine(
- $" public ObsBatchBuilder {methodName}({dataTypeName} requestData)"
- );
- builder.AppendLine(" {");
- builder.AppendLine(" ArgumentNullException.ThrowIfNull(requestData);");
- builder.AppendLine(
- $" return Add(\"{requestType}\", requestData);"
- );
- builder.AppendLine(" }");
- }
- else
- {
- builder.AppendLine(
- $" public ObsBatchBuilder {methodName}() => Add(\"{requestType}\", null);"
- );
- }
-
- builder.AppendLine();
}
- catch (Exception ex)
+
+ builder.AppendLine(" /// ");
+ if (hasData)
{
- context.ReportDiagnostic(
- Diagnostic.Create(
- Diagnostics.IdentifierGenerationError,
- Location.None,
- request.RequestType,
- $"Generating batch builder method for {request.RequestType}",
- ex.Message
- )
+ builder.AppendLine(
+ " /// The payload for this request."
);
}
- }
- builder.AppendLine("}");
+ builder.AppendLine(
+ " /// A reference used to read this request's result."
+ );
+ if (request.Deprecated)
+ {
+ builder.AppendLine(
+ $" [System.Obsolete(\"Request '{requestType}' is deprecated since OBS Websocket version {request.InitialVersion}\")]"
+ );
+ }
- context.AddSource(
- "ObsBatchBuilder.Requests.g.cs",
- SourceText.From(builder.ToString(), Encoding.UTF8)
- );
+ if (hasData)
+ {
+ string dataTypeName = $"{GeneratedRequestsNamespace}.{methodName}RequestData";
+ builder.AppendLine(
+ $" public {returnType} {methodName}({dataTypeName} requestData)"
+ );
+ builder.AppendLine(" {");
+ builder.AppendLine(" ArgumentNullException.ThrowIfNull(requestData);");
+ builder.AppendLine(
+ $" return new(builder.AddRequest(\"{requestType}\", requestData));"
+ );
+ builder.AppendLine(" }");
+ }
+ else
+ {
+ builder.AppendLine(
+ $" public {returnType} {methodName}() => new(builder.AddRequest(\"{requestType}\", null));"
+ );
+ }
+
+ builder.AppendLine();
+ }
+ catch (Exception ex)
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.IdentifierGenerationError,
+ Location.None,
+ request.RequestType,
+ $"Generating batch builder method for {request.RequestType}",
+ ex.Message
+ )
+ );
+ }
}
+
+ ///
+ /// Converts a protocol category such as scene items into a PascalCase group name.
+ ///
+ private static string ToGroupName(string category) =>
+ string.Concat(
+ category
+ .Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
+ .Select(part =>
+ part.Length == 0
+ ? part
+ : char.ToUpperInvariant(part[0]) + part.Substring(1).ToLowerInvariant()
+ )
+ );
+
+ ///
+ /// Flattens a protocol description onto a single line for a doc comment.
+ ///
+ private static string FlattenBatchDescription(string description) =>
+ System.Security.SecurityElement.Escape(
+ System.Text.RegularExpressions.Regex.Replace(description, @"\s+", " ").Trim()
+ ) ?? string.Empty;
}
diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.DtoGeneration.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.DtoGeneration.cs
index 6b9d948..03adcbd 100644
--- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.DtoGeneration.cs
+++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.DtoGeneration.cs
@@ -490,6 +490,19 @@ ProtocolDefinition protocol
else // Response, Event, Nested
{
isConsideredRequired = !typeIsInherentlyNullable && !csharpType.EndsWith("?");
+
+ // Some fields are only ever null in a particular state, which the protocol
+ // records in the description rather than in valueOptional. Deserializing
+ // those into a non-nullable value type fails outright when it happens.
+ if (
+ isConsideredRequired
+ && isValueType
+ && DescriptionAllowsNull(associatedFieldDef.ValueDescription)
+ )
+ {
+ isConsideredRequired = false;
+ }
+
if (csharpType.StartsWith("List<") || csharpType.StartsWith("Dictionary<"))
{
isConsideredRequired = false;
@@ -716,4 +729,12 @@ out bool isRootOfNested
|| f.ValueName.EndsWith("." + objectNode.Name)
);
}
+ ///
+ /// Reports whether a field's description says it can be null, which the protocol states in
+ /// prose for fields it does not otherwise mark optional.
+ ///
+ private static bool DescriptionAllowsNull(string? description) =>
+ !string.IsNullOrEmpty(description)
+ && description.IndexOf("null", StringComparison.OrdinalIgnoreCase) >= 0;
+
}
diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs
index 168f8f3..dc4c3a9 100644
--- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs
+++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs
@@ -697,36 +697,73 @@ ProtocolDefinition protocol
builder.AppendLine();
builder.AppendLine($"namespace {ExtensionsNamespace};");
builder.AppendLine();
+ List<(string Category, string GroupName)> groups = [];
+ foreach (
+ IGrouping group in protocol
+ .Requests.GroupBy(r => r.Category ?? "general", StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.Ordinal)
+ )
+ {
+ string groupName = ToGroupName(group.Key);
+ groups.Add((group.Key, groupName));
+
+ builder.AppendLine("/// ");
+ builder.AppendLine(
+ $"/// Requests in the {System.Security.SecurityElement.Escape(group.Key)} category."
+ );
+ builder.AppendLine("/// ");
+ builder.AppendLine("/// The client these requests are sent on.");
+ builder.AppendLine(
+ $"public readonly partial struct {groupName}RequestGroup(ObsWebSocketClient client)"
+ );
+ builder.AppendLine("{");
+
+ foreach (RequestDefinition reqDef in group)
+ {
+ try
+ {
+ GenerateSingleExtensionMethod(builder, reqDef);
+ builder.AppendLine();
+ }
+ catch (Exception ex)
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.IdentifierGenerationError,
+ Location.None,
+ reqDef.RequestType,
+ $"Extension method for {reqDef.RequestType}",
+ ex.Message
+ )
+ );
+ }
+ }
+
+ builder.AppendLine("}");
+ builder.AppendLine();
+ }
+
builder.AppendLine("/// ");
- builder.AppendLine(
- "/// Provides strongly-typed extension methods for the ,"
- );
- builder.AppendLine(
- "/// corresponding to the requests defined in the OBS WebSocket v5 protocol."
- );
+ builder.AppendLine("/// Exposes the request categories defined by the OBS WebSocket protocol.");
builder.AppendLine("/// ");
- builder.AppendLine("public static partial class ObsWebSocketClientExtensions");
+ builder.AppendLine("public static class ObsWebSocketClientExtensions");
builder.AppendLine("{");
- foreach (RequestDefinition reqDef in protocol.Requests)
+ foreach ((string category, string groupName) in groups)
{
- try
- {
- GenerateSingleExtensionMethod(builder, reqDef);
- builder.AppendLine();
- }
- catch (Exception ex)
- {
- context.ReportDiagnostic(
- Diagnostic.Create(
- Diagnostics.IdentifierGenerationError,
- Location.None,
- reqDef.RequestType,
- $"Extension method for {reqDef.RequestType}",
- ex.Message
- )
- );
- }
+ builder.AppendLine(" extension(ObsWebSocketClient client)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// Requests in the {System.Security.SecurityElement.Escape(category)} category."
+ );
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public {groupName}RequestGroup {groupName} => new(client);"
+ );
+ builder.AppendLine(" }");
+ builder.AppendLine();
}
+
builder.AppendLine("}");
context.AddSource(
"ObsWebSocketClient.Extensions.g.cs",
@@ -775,9 +812,6 @@ RequestDefinition reqDef
builder.AppendLine(" /// ");
AppendMultiLineXmlDoc(builder, reqDef.Description, " ///");
builder.AppendLine(" /// ");
- builder.AppendLine(
- $" /// The instance."
- );
if (hasRequestData)
{
builder.AppendLine(
@@ -843,7 +877,7 @@ RequestDefinition reqDef
}
builder.AppendLine(
- $" public static async {returnType} {methodName}(this ObsWebSocketClient client, {parameterList})"
+ $" public async {returnType} {methodName}({parameterList})"
);
builder.AppendLine(" {");
// Method Body
diff --git a/ObsWebSocket.Core/BatchRef.cs b/ObsWebSocket.Core/BatchRef.cs
new file mode 100644
index 0000000..cad2fb4
--- /dev/null
+++ b/ObsWebSocket.Core/BatchRef.cs
@@ -0,0 +1,133 @@
+using ObsWebSocket.Core.Protocol;
+
+namespace ObsWebSocket.Core;
+
+///
+/// Identifies a request within a batch, without a response type.
+///
+/// Position of the request in the batch.
+public readonly record struct BatchRef(int Index);
+
+///
+/// Identifies a request within a batch, carrying the type of its response.
+///
+/// The response record this request produces.
+/// Position of the request in the batch.
+public readonly record struct BatchRef(int Index)
+ where TResponse : class
+{
+ /// Drops the response type, leaving a plain reference.
+ /// The reference to convert.
+ public static implicit operator BatchRef(BatchRef reference) =>
+ new(reference.Index);
+
+ /// Drops the response type, leaving a plain reference.
+ public BatchRef ToBatchRef() => new(Index);
+}
+
+///
+/// The results of a batch call, addressable by the references the builder handed out.
+///
+///
+/// Indexing by a ties a result to the request that produced it
+/// and to that request's response type, so neither the position nor the type has to be restated
+/// at the call site.
+///
+public sealed class BatchResults : IReadOnlyList>
+{
+ private readonly IReadOnlyList> _results;
+
+ private readonly bool _payloadsTrustworthy;
+
+ /// Initializes results from the payloads OBS returned.
+ /// The results, in submission order.
+ ///
+ /// Whether each result's data belongs to the request it is attached to. False for parallel
+ /// execution, where OBS pairs every result with another request's response.
+ ///
+ public BatchResults(
+ IReadOnlyList> results,
+ bool payloadsTrustworthy = true
+ )
+ {
+ ArgumentNullException.ThrowIfNull(results);
+ _results = results;
+ _payloadsTrustworthy = payloadsTrustworthy;
+ }
+
+ /// Number of results returned.
+ ///
+ /// Fewer than the number of requests sent when OBS stopped early on a failure.
+ ///
+ public int Count => _results.Count;
+
+ /// The raw results, in submission order.
+ public IReadOnlyList> Raw => _results;
+
+ /// Gets the result for a request by position.
+ /// Position of the request in the batch.
+ public RequestResponsePayload