feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity
Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new TurbineChatMembershipGate checks Turbine availability and the player's own Hear*Chat option before sending, raising "Turbine chat is not available." or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter) and headless (DirectGameRuntimeCommandAdapter) send paths so they can't diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md — ACE's TurbineChat implementation is complete and on by default; the real bug was treating Hear*Chat as a display filter instead of room membership. Also: implements SetSingleCharacterOption (0x0005), the only wire message that actually joins/leaves a Turbine room, and wires the five Settings Chat toggles to it (publish on Save, changed bits only) plus seeds ChatSettings from the server's own CharacterOptions2 on every PlayerDescription. Fixes the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally consulted). Routes /a to Turbine unconditionally (retail's @a never falls back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead of discarding it silently. Deletes the malformed, callerless SetCharacterOptions (0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders. Files every AC-specific algorithm change cites the named retail decomp (SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0, GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks. Register rows AP-181 (no client-side spam throttle) and UN-9 (an incidentally-discovered CharacterOptions1.Default literal mismatch, not investigated further) filed per the divergence-register rule. 11,957 passed / 4 skipped / 0 failed (full Release suite, up from the 11,916/4/0 baseline). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fc9590e4fc
commit
614a1e055f
35 changed files with 1453 additions and 229 deletions
|
|
@ -76,6 +76,17 @@ public enum RuntimeChatChannel
|
|||
Roleplay,
|
||||
Society,
|
||||
Olthoi,
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): the legacy
|
||||
/// <c>ChatChannel.AllegianceBroadcast (0x02000000)</c> — the monarch/
|
||||
/// speaker-permission broadcast bound to retail's <c>@ab</c> verb.
|
||||
/// Distinct from <see cref="Allegiance"/>, which is ALWAYS the Turbine
|
||||
/// room now (retail's <c>@a</c> binds unconditionally to
|
||||
/// <c>DoTurbineChat_Allegiance</c> once Turbine chat starts — see the
|
||||
/// research doc §4.3/§6.7).
|
||||
/// </summary>
|
||||
AllegianceBroadcast,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeChatCommand(
|
||||
|
|
@ -227,9 +238,18 @@ public interface IRuntimeCharacterCommands
|
|||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeAdvancementCommand command);
|
||||
|
||||
RuntimeCommandResult SetOptions1(
|
||||
/// <summary>
|
||||
/// Retail <c>SetSingleCharacterOption (GameActionType 0x0005)</c> —
|
||||
/// toggles one character option. Campaign CH slice CH3 (2026-08-09)
|
||||
/// replaced the former <c>SetOptions1</c> (the malformed, callerless
|
||||
/// full-Options1-blob 0x01A1 builder) with this: the only wire message
|
||||
/// that actually changes Turbine room membership for the six
|
||||
/// <c>ListenTo*Chat</c> ids.
|
||||
/// </summary>
|
||||
RuntimeCommandResult SetSingleOption(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint options);
|
||||
uint optionId,
|
||||
bool value);
|
||||
}
|
||||
|
||||
public enum RuntimeFriendCommandKind
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.Core.Player;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Properties;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
|
|
@ -107,6 +108,27 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
public IRuntimeCharacterView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): retail
|
||||
/// <c>PlayerModule::IsOlthoi</c> / ACE <c>Player.IsOlthoiPlayer</c>
|
||||
/// (<c>Player.cs:169</c>, <c>HeritageGroup == Olthoi ||
|
||||
/// HeritageGroup == OlthoiAcid</c>) — the gate
|
||||
/// <c>TurbineChatMembershipGate</c> consults for the Olthoi room instead
|
||||
/// of a <c>Hear*Chat</c> option (there is no such toggle; only heritage
|
||||
/// gates that room). Reads the already-parsed
|
||||
/// <c>PropertyInt.HeritageGroup (188)</c> off the local player's own
|
||||
/// property bundle — 0 (unparsed/unknown) reads as "not Olthoi", matching
|
||||
/// every other heritage-gated check in this codebase.
|
||||
/// </summary>
|
||||
public bool IsOlthoiPlayer
|
||||
{
|
||||
get
|
||||
{
|
||||
int heritage = LocalPlayer.Properties.GetInt((uint)PropertyInt.HeritageGroup, 0);
|
||||
return heritage == 12 || heritage == 13; // HeritageGroup.Olthoi / OlthoiAcid
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>CommandInterpreter::GetAutonomyLevel</c>.</summary>
|
||||
public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel);
|
||||
|
||||
|
|
|
|||
143
src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs
Normal file
143
src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="TurbineChatMembershipGate.Evaluate"/>.
|
||||
/// </summary>
|
||||
public enum TurbineChatGateStatus
|
||||
{
|
||||
/// <summary>The send may proceed to the wire.</summary>
|
||||
Allowed,
|
||||
|
||||
/// <summary>
|
||||
/// Turbine chat is disabled, or this room's id is still 0 (not
|
||||
/// populated by <c>SetTurbineChatChannels</c> — e.g. no allegiance, no
|
||||
/// society). Retail: <c>"Turbine chat is not available."</c>
|
||||
/// (<see cref="ClientTextRefusals.TurbineChatUnavailable"/>), raised
|
||||
/// locally with NOTHING sent.
|
||||
/// </summary>
|
||||
Unavailable,
|
||||
|
||||
/// <summary>
|
||||
/// The room exists and Turbine chat is enabled, but the player's own
|
||||
/// <c>Hear*Chat</c> option (or, for Olthoi, heritage) is off. Retail:
|
||||
/// <c>WeenieErrorWithString 0x0551 YouAreNotListeningTo_Channel</c>,
|
||||
/// raised locally with NOTHING sent.
|
||||
/// </summary>
|
||||
NotListening,
|
||||
}
|
||||
|
||||
/// <summary>One evaluated gate decision, carrying the room/chatType/display
|
||||
/// name a caller needs whether the send proceeds or gets refused.</summary>
|
||||
public readonly record struct TurbineChatGateResult(
|
||||
TurbineChatGateStatus Status,
|
||||
uint RoomId,
|
||||
uint ChatType,
|
||||
string DisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's local pre-send membership gate for a Turbine-room chat channel,
|
||||
/// ported from <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>
|
||||
/// (Sept 2013 EoR build; research doc
|
||||
/// <c>docs/research/2026-08-09-chat-side-channels-vs-ace.md</c> §4.2/§6.2).
|
||||
///
|
||||
/// <para>
|
||||
/// Both the graphical (<c>LiveSessionCommandRouter</c>) and headless
|
||||
/// (<c>DirectGameRuntimeCommandAdapter</c>) outbound chat paths call this
|
||||
/// SAME chokepoint so the two hosts can never diverge on which channels are
|
||||
/// joined and which local refusal a blocked send produces. Retail refuses
|
||||
/// LOCALLY (nothing reaches the wire) in exactly two cases — Turbine chat
|
||||
/// off / room id 0, or the player's own <c>Hear*Chat</c> option off — before
|
||||
/// ever building a <c>TurbineChatBlob</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TurbineChatMembershipGate
|
||||
{
|
||||
public static TurbineChatGateResult Evaluate(
|
||||
ChatChannelKindLite kind,
|
||||
TurbineChatState turbineChat,
|
||||
RuntimeCharacterOptionsState options,
|
||||
bool isOlthoiPlayer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(turbineChat);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
(uint room, uint chatType, string name) = kind switch
|
||||
{
|
||||
ChatChannelKindLite.Allegiance => (
|
||||
turbineChat.AllegianceRoom,
|
||||
(uint)TurbineChat.ChatType.Allegiance,
|
||||
"Allegiance"),
|
||||
ChatChannelKindLite.General => (
|
||||
turbineChat.GeneralRoom,
|
||||
(uint)TurbineChat.ChatType.General,
|
||||
"General"),
|
||||
ChatChannelKindLite.Trade => (
|
||||
turbineChat.TradeRoom,
|
||||
(uint)TurbineChat.ChatType.Trade,
|
||||
"Trade"),
|
||||
ChatChannelKindLite.Lfg => (
|
||||
turbineChat.LfgRoom,
|
||||
(uint)TurbineChat.ChatType.Lfg,
|
||||
"LFG"),
|
||||
ChatChannelKindLite.Roleplay => (
|
||||
turbineChat.RoleplayRoom,
|
||||
(uint)TurbineChat.ChatType.Roleplay,
|
||||
"Roleplay"),
|
||||
ChatChannelKindLite.Society => (
|
||||
turbineChat.SocietyRoom,
|
||||
(uint)TurbineChat.ChatType.Society,
|
||||
"Society"),
|
||||
ChatChannelKindLite.Olthoi => (
|
||||
turbineChat.OlthoiRoom,
|
||||
(uint)TurbineChat.ChatType.Olthoi,
|
||||
"Olthoi"),
|
||||
_ => (0u, 0u, string.Empty),
|
||||
};
|
||||
|
||||
if (!turbineChat.Enabled || room == 0u)
|
||||
{
|
||||
return new TurbineChatGateResult(
|
||||
TurbineChatGateStatus.Unavailable, room, chatType, name);
|
||||
}
|
||||
|
||||
bool hearOption = kind switch
|
||||
{
|
||||
ChatChannelKindLite.Allegiance =>
|
||||
(options.Options1
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.General =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Trade =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Lfg =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Roleplay =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Society =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)
|
||||
!= 0u,
|
||||
// Retail's Olthoi entry point passes CPlayerSystem::IsOlthoi()
|
||||
// as its hearOption argument instead of a PlayerModule flag —
|
||||
// there is no "Hear Olthoi chat" toggle, only heritage.
|
||||
ChatChannelKindLite.Olthoi => isOlthoiPlayer,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
return hearOption
|
||||
? new TurbineChatGateResult(TurbineChatGateStatus.Allowed, room, chatType, name)
|
||||
: new TurbineChatGateResult(TurbineChatGateStatus.NotListening, room, chatType, name);
|
||||
}
|
||||
}
|
||||
|
|
@ -649,15 +649,16 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
command.StatId);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult SetOptions1(
|
||||
public RuntimeCommandResult SetSingleOption(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint options)
|
||||
uint optionId,
|
||||
bool value)
|
||||
{
|
||||
RuntimeCommandStatus gate =
|
||||
Validate(expectedGeneration, out WorldSession? session);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
session!.SendSetCharacterOptions(options);
|
||||
session!.SendSetSingleCharacterOption(optionId, value);
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
operation: 4,
|
||||
|
|
@ -951,31 +952,49 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
RuntimeChatChannel channel,
|
||||
string text)
|
||||
{
|
||||
if (TryMapTurbine(
|
||||
channel,
|
||||
out ChatChannelKindLite turbineKind,
|
||||
out TurbineChat.ChatType chatType))
|
||||
if (TryMapTurbine(channel, out ChatChannelKindLite turbineKind))
|
||||
{
|
||||
TurbineChatState state =
|
||||
_runtime.CommunicationOwner.TurbineChat;
|
||||
uint roomId = state.RoomFor(turbineKind);
|
||||
if (state.Enabled && roomId != 0u)
|
||||
// CH3 (2026-08-09): the SAME membership gate the graphical host
|
||||
// uses (LiveSessionCommandRouter.RouteTurbineChat) — retail
|
||||
// SendTurbineChat @0x0057db10 never falls through to the legacy
|
||||
// ChatChannel bitflag; it refuses locally instead.
|
||||
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
|
||||
turbineKind,
|
||||
_runtime.CommunicationOwner.TurbineChat,
|
||||
_runtime.CharacterOwner.Options,
|
||||
_runtime.CharacterOwner.IsOlthoiPlayer);
|
||||
|
||||
switch (gate.Status)
|
||||
{
|
||||
session.SendTurbineChatTo(
|
||||
roomId,
|
||||
(uint)chatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
_runtime.PlayerIdentity.ServerGuid,
|
||||
text,
|
||||
state.NextContextId());
|
||||
return true;
|
||||
case TurbineChatGateStatus.Unavailable:
|
||||
_runtime.CommunicationOwner.AddText(
|
||||
ClientTextRefusals.TurbineChatUnavailable,
|
||||
RetailLogTextType.Default);
|
||||
return true;
|
||||
case TurbineChatGateStatus.NotListening:
|
||||
{
|
||||
(string? refusal, RetailLogTextType type) =
|
||||
WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName);
|
||||
if (refusal is not null)
|
||||
_runtime.CommunicationOwner.AddText(refusal, type);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
session.SendTurbineChatTo(
|
||||
gate.RoomId,
|
||||
gate.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
_runtime.PlayerIdentity.ServerGuid,
|
||||
text,
|
||||
_runtime.CommunicationOwner.TurbineChat.NextContextId());
|
||||
return true;
|
||||
}
|
||||
|
||||
uint? legacyChannel = channel switch
|
||||
{
|
||||
RuntimeChatChannel.Fellowship => 0x00000800u,
|
||||
RuntimeChatChannel.Allegiance => 0x02000000u,
|
||||
RuntimeChatChannel.AllegianceBroadcast => 0x02000000u,
|
||||
RuntimeChatChannel.Vassals => 0x00001000u,
|
||||
RuntimeChatChannel.Patron => 0x00002000u,
|
||||
RuntimeChatChannel.Monarch => 0x00004000u,
|
||||
|
|
@ -990,32 +1009,17 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
|
||||
private static bool TryMapTurbine(
|
||||
RuntimeChatChannel channel,
|
||||
out ChatChannelKindLite kind,
|
||||
out TurbineChat.ChatType chatType)
|
||||
out ChatChannelKindLite kind)
|
||||
{
|
||||
(kind, chatType) = channel switch
|
||||
kind = channel switch
|
||||
{
|
||||
RuntimeChatChannel.Allegiance => (
|
||||
ChatChannelKindLite.Allegiance,
|
||||
TurbineChat.ChatType.Allegiance),
|
||||
RuntimeChatChannel.General => (
|
||||
ChatChannelKindLite.General,
|
||||
TurbineChat.ChatType.General),
|
||||
RuntimeChatChannel.Trade => (
|
||||
ChatChannelKindLite.Trade,
|
||||
TurbineChat.ChatType.Trade),
|
||||
RuntimeChatChannel.LookingForGroup => (
|
||||
ChatChannelKindLite.Lfg,
|
||||
TurbineChat.ChatType.Lfg),
|
||||
RuntimeChatChannel.Roleplay => (
|
||||
ChatChannelKindLite.Roleplay,
|
||||
TurbineChat.ChatType.Roleplay),
|
||||
RuntimeChatChannel.Society => (
|
||||
ChatChannelKindLite.Society,
|
||||
TurbineChat.ChatType.Society),
|
||||
RuntimeChatChannel.Olthoi => (
|
||||
ChatChannelKindLite.Olthoi,
|
||||
TurbineChat.ChatType.Olthoi),
|
||||
RuntimeChatChannel.Allegiance => ChatChannelKindLite.Allegiance,
|
||||
RuntimeChatChannel.General => ChatChannelKindLite.General,
|
||||
RuntimeChatChannel.Trade => ChatChannelKindLite.Trade,
|
||||
RuntimeChatChannel.LookingForGroup => ChatChannelKindLite.Lfg,
|
||||
RuntimeChatChannel.Roleplay => ChatChannelKindLite.Roleplay,
|
||||
RuntimeChatChannel.Society => ChatChannelKindLite.Society,
|
||||
RuntimeChatChannel.Olthoi => ChatChannelKindLite.Olthoi,
|
||||
_ => default,
|
||||
};
|
||||
return channel is RuntimeChatChannel.Allegiance
|
||||
|
|
|
|||
|
|
@ -57,7 +57,15 @@ public sealed record LiveCharacterSessionBindings(
|
|||
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
|
||||
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
|
||||
// unchanged.
|
||||
Action? OnMovementStatsUpdated = null);
|
||||
Action? OnMovementStatsUpdated = null,
|
||||
// Campaign CH slice CH3 (2026-08-09): fires with the raw
|
||||
// (options1, options2) pair whenever a fresh PlayerDescription lands —
|
||||
// AFTER Character.Options.Replace has already committed them. Lets the
|
||||
// graphical host reseed its Settings "Hear * Chat" draft from server
|
||||
// truth (research doc §5.2/§6.4: the local ChatSettings.Default lies
|
||||
// relative to ACE's CharacterOptions2.Default). Optional/nullable so
|
||||
// every existing caller compiles unchanged.
|
||||
Action<uint, uint>? OnCharacterOptionsChanged = null);
|
||||
|
||||
public sealed record LiveSocialSessionBindings(
|
||||
ChatLog Chat,
|
||||
|
|
@ -197,7 +205,11 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
friends: social.Friends,
|
||||
squelch: social.Squelch,
|
||||
onDesiredComponents: null,
|
||||
onCharacterOptions: character.Character.Options.Replace,
|
||||
onCharacterOptions: (options1, options2) =>
|
||||
{
|
||||
character.Character.Options.Replace(options1, options2);
|
||||
character.OnCharacterOptionsChanged?.Invoke(options1, options2);
|
||||
},
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
vendor: inventory.Vendor,
|
||||
|
|
@ -501,19 +513,40 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
|
||||
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
|
||||
{
|
||||
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
|
||||
return;
|
||||
switch (parsed.Body)
|
||||
{
|
||||
case TurbineChat.Payload.EventSendToRoom message:
|
||||
// message.RoomId is an opaque per-session Turbine room GUID,
|
||||
// not a legacy channel bitflag — ChatLog.OnChannelBroadcast's
|
||||
// default (legacy-bit) LogTextType derivation would
|
||||
// misclassify it, so the room's own ChatType maps to
|
||||
// LogTextType explicitly here instead.
|
||||
chat.OnChannelBroadcast(
|
||||
message.RoomId,
|
||||
message.SenderName,
|
||||
message.Message,
|
||||
logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType),
|
||||
channelName: TurbineChatDisplayNames.Resolve(
|
||||
message.RoomId,
|
||||
message.ChatType));
|
||||
return;
|
||||
|
||||
// message.RoomId is an opaque per-session Turbine room GUID, not a
|
||||
// legacy channel bitflag — ChatLog.OnChannelBroadcast's default
|
||||
// (legacy-bit) LogTextType derivation would misclassify it, so the
|
||||
// room's own ChatType maps to LogTextType explicitly here instead.
|
||||
chat.OnChannelBroadcast(
|
||||
message.RoomId,
|
||||
message.SenderName,
|
||||
message.Message,
|
||||
logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType),
|
||||
channelName: TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType));
|
||||
case TurbineChat.Payload.Response { HResult: not 0 } response:
|
||||
// CH3 (2026-08-09, research doc §2.7/§6.6): previously
|
||||
// discarded unconditionally — a server-side send rejection
|
||||
// was completely invisible. HResult==0 (the overwhelmingly
|
||||
// common case) stays silent, matching retail's own quiet
|
||||
// success ack.
|
||||
chat.OnSystemMessage(
|
||||
"TurbineChat send rejected "
|
||||
+ $"(hresult=0x{unchecked((uint)response.HResult):X8}).",
|
||||
(uint)RetailLogTextType.Default);
|
||||
return;
|
||||
|
||||
default:
|
||||
// Response with HResult==0, or Unknown — nothing to surface.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue