using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using OmegaLeo.HelperLib.Shared.Attributes; namespace OmegaLeo.HelperLib.Steamworks { [Documentation(nameof(SteamCallbackIds), "Steamworks callback ids (the k_iCallback value of each callback struct) used by this library.", null, null)] public static class SteamCallbackIds { [Documentation(nameof(GameOverlayActivated), "GameOverlayActivated_t (k_iSteamFriendsCallbacks + 31).", null, null)] public const int GameOverlayActivated = 331; [Documentation(nameof(SteamApiCallCompleted), "SteamAPICallCompleted_t (k_iSteamUtilsCallbacks + 3). Handled internally to deliver async call results.", null, null)] public const int SteamApiCallCompleted = 703; [Documentation(nameof(SteamUgcQueryCompleted), "SteamUGCQueryCompleted_t (k_iSteamUGCCallbacks + 1), the result of SendQueryUGCRequest.", null, null)] public const int SteamUgcQueryCompleted = 3401; [Documentation(nameof(CreateItemResult), "CreateItemResult_t (k_iSteamUGCCallbacks + 3), the result of CreateItem.", null, null)] public const int CreateItemResult = 3403; [Documentation(nameof(SubmitItemUpdateResult), "SubmitItemUpdateResult_t (k_iSteamUGCCallbacks + 4), the result of SubmitItemUpdate.", null, null)] public const int SubmitItemUpdateResult = 3404; } [Documentation(nameof(SteamCallbackPayload), "Base class for a copied Steam callback payload with little-endian field readers. Offsets follow the native struct layout for the current platform (see SteamCallbackDispatcher.UsesLargeCallbackPack).", null, null)] public abstract class SteamCallbackPayload { protected SteamCallbackPayload(byte[] payload) { Payload = payload ?? Array.Empty(); } [Documentation(nameof(Payload), "Raw bytes of the native callback struct, copied before Steam frees it.", null, null)] public byte[] Payload { get; } [Documentation(nameof(ReadByte), "Reads a byte at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] public byte ReadByte(int offset) { return offset >= 0 && offset < Payload.Length ? Payload[offset] : (byte)0; } [Documentation(nameof(ReadBoolean), "Reads a one-byte C++ bool at the given offset.", new[] { "offset: Byte offset in the payload." }, null)] public bool ReadBoolean(int offset) { return ReadByte(offset) != 0; } [Documentation(nameof(ReadInt32), "Reads a 32-bit signed integer at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] public int ReadInt32(int offset) { return HasBytes(offset, 4) ? BitConverter.ToInt32(Payload, offset) : 0; } [Documentation(nameof(ReadUInt32), "Reads a 32-bit unsigned integer at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] public uint ReadUInt32(int offset) { return HasBytes(offset, 4) ? BitConverter.ToUInt32(Payload, offset) : 0; } [Documentation(nameof(ReadUInt64), "Reads a 64-bit unsigned integer at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] public ulong ReadUInt64(int offset) { return HasBytes(offset, 8) ? BitConverter.ToUInt64(Payload, offset) : 0; } [Documentation(nameof(ReadString), "Reads a NUL-terminated UTF-8 char array field.", new[] { "offset: Byte offset of the char array.", "maxLength: Size of the char array in the native struct." }, null)] public string ReadString(int offset, int maxLength) { if (offset < 0 || offset >= Payload.Length || maxLength <= 0) { return string.Empty; } var end = Math.Min(Payload.Length, offset + maxLength); var length = 0; while (offset + length < end && Payload[offset + length] != 0) { length++; } return Encoding.UTF8.GetString(Payload, offset, length); } private bool HasBytes(int offset, int size) { return offset >= 0 && offset + size <= Payload.Length; } } [Documentation(nameof(SteamCallbackData), "A Steam callback received through manual dispatch.", null, null)] public sealed class SteamCallbackData : SteamCallbackPayload { internal SteamCallbackData(int callbackId, int steamUser, byte[] payload) : base(payload) { CallbackId = callbackId; SteamUser = steamUser; } [Documentation(nameof(CallbackId), "The callback id (k_iCallback) of the native struct.", null, null)] public int CallbackId { get; } [Documentation(nameof(SteamUser), "The HSteamUser the callback was posted for.", null, null)] public int SteamUser { get; } } [Documentation(nameof(SteamApiCallResult), "The completed result of an asynchronous Steam API call (SteamAPICall_t).", null, null)] public sealed class SteamApiCallResult : SteamCallbackPayload { internal SteamApiCallResult(ulong apiCall, int callbackId, bool ioFailure, byte[] payload) : base(payload) { ApiCall = apiCall; CallbackId = callbackId; IoFailure = ioFailure; } [Documentation(nameof(ApiCall), "The SteamAPICall_t handle this result belongs to.", null, null)] public ulong ApiCall { get; } [Documentation(nameof(CallbackId), "The callback id of the result struct (for example SteamCallbackIds.SubmitItemUpdateResult).", null, null)] public int CallbackId { get; } [Documentation(nameof(IoFailure), "True when Steam reported a failure fetching the result, or Steam was shut down before it completed. The payload is then not valid.", null, null)] public bool IoFailure { get; } internal static SteamApiCallResult Failed(ulong apiCall) { return new SteamApiCallResult(apiCall, 0, true, Array.Empty()); } } [Documentation(nameof(SteamOverlayActivated), "Data from GameOverlayActivated_t, raised when the Steam overlay opens or closes.", null, null)] public readonly struct SteamOverlayActivated { internal SteamOverlayActivated(bool active, bool userInitiated, uint appId) { Active = active; UserInitiated = userInitiated; AppId = appId; } [Documentation(nameof(Active), "True when the overlay was opened, false when it was closed.", null, null)] public bool Active { get; } [Documentation(nameof(UserInitiated), "True when the user opened the overlay (as opposed to the game or Steam).", null, null)] public bool UserInitiated { get; } [Documentation(nameof(AppId), "The AppId the overlay was activated for.", null, null)] public uint AppId { get; } } [Documentation(nameof(SteamCallbackDispatcher), "Runs the Steam manual callback dispatch loop and delivers callbacks and async call results. Access it through SteamManager.Instance.Callbacks; it is pumped by SteamManager.Update().", null, @"```csharp var steam = SteamManager.Instance; steam.Callbacks.OverlayActivated += overlay => Pause(overlay.Active); using var sub = steam.Callbacks.Subscribe(SteamCallbackIds.GameOverlayActivated, data => Log(data.ReadBoolean(0))); var result = await steam.Callbacks.WaitForCallResultAsync(apiCallHandle); ```")] public sealed class SteamCallbackDispatcher { private readonly object _sync = new object(); private readonly Dictionary>> _handlers = new Dictionary>>(); private readonly Dictionary>> _pendingCallResults = new Dictionary>>(); internal SteamCallbackDispatcher() { } [Documentation(nameof(UsesLargeCallbackPack), "True when Steam callback structs use 8-byte packing (Windows). Linux, Steam Deck and macOS use 4-byte packing, which moves 64-bit fields in some structs.", null, null)] public static bool UsesLargeCallbackPack => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); [Documentation(nameof(IsManualDispatchActive), "True when manual dispatch is running, so callbacks and async call results are delivered.", null, null)] public bool IsManualDispatchActive => SteamNativeApi.IsManualDispatchActive; [Documentation(nameof(PendingCallResultCount), "Number of async call handles still waiting for a result.", null, null)] public int PendingCallResultCount { get { lock (_sync) { return _pendingCallResults.Count; } } } [Documentation(nameof(OverlayActivated), "Raised from SteamManager.Update() when the Steam overlay opens or closes (GameOverlayActivated_t). Use it to pause the game.", null, null)] public event Action OverlayActivated; [Documentation(nameof(Subscribe), "Subscribes to every callback with the given id. The handler receives a copy of the raw payload. Dispose the returned object to unsubscribe.", new[] { "callbackId: Callback id (k_iCallback), see SteamCallbackIds.", "handler: Called from SteamManager.Update() for each matching callback." }, null)] public IDisposable Subscribe(int callbackId, Action handler) { if (handler == null) { throw new ArgumentNullException(nameof(handler)); } lock (_sync) { if (!_handlers.TryGetValue(callbackId, out var list)) { list = new List>(); _handlers[callbackId] = list; } list.Add(handler); } return new Subscription(() => Unsubscribe(callbackId, handler)); } [Documentation(nameof(Unsubscribe), "Removes a handler added with Subscribe.", new[] { "callbackId: Callback id the handler was registered for.", "handler: The handler to remove." }, null)] public bool Unsubscribe(int callbackId, Action handler) { lock (_sync) { if (!_handlers.TryGetValue(callbackId, out var list) || !list.Remove(handler)) { return false; } if (list.Count == 0) { _handlers.Remove(callbackId); } return true; } } [Documentation(nameof(RegisterCallResult), "Registers a handler for the result of an async Steam API call. The handler runs once, from SteamManager.Update(). Returns false when the handle is invalid or manual dispatch is not active.", new[] { "apiCall: SteamAPICall_t handle returned by an async Steam API method.", "onCompleted: Called with the result (check IoFailure)." }, null)] public bool RegisterCallResult(ulong apiCall, Action onCompleted) { if (onCompleted == null) { throw new ArgumentNullException(nameof(onCompleted)); } if (apiCall == 0 || !IsManualDispatchActive) { return false; } lock (_sync) { if (!_pendingCallResults.TryGetValue(apiCall, out var list)) { list = new List>(); _pendingCallResults[apiCall] = list; } list.Add(onCompleted); } return true; } [Documentation(nameof(WaitForCallResultAsync), "Returns a task that completes with the result of an async Steam API call. Keep calling SteamManager.Update() while waiting. Faults with InvalidOperationException when manual dispatch is not active.", new[] { "apiCall: SteamAPICall_t handle returned by an async Steam API method.", "cancellationToken: Cancels the wait (the Steam call itself keeps running)." }, null)] public Task WaitForCallResultAsync(ulong apiCall, CancellationToken cancellationToken = default) { if (apiCall == 0) { return Task.FromException(new ArgumentException("The Steam API call handle is invalid.", nameof(apiCall))); } var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Action handler = result => completion.TrySetResult(result); if (!RegisterCallResult(apiCall, handler)) { return Task.FromException(new InvalidOperationException("Steam manual callback dispatch is not active, so async call results cannot be delivered.")); } if (cancellationToken.CanBeCanceled) { var registration = cancellationToken.Register(() => { RemoveCallResult(apiCall, handler); completion.TrySetCanceled(cancellationToken); }); completion.Task.ContinueWith(_ => registration.Dispose(), TaskScheduler.Default); } return completion.Task; } internal void RunFrame() { if (!SteamNativeApi.IsManualDispatchActive) { return; } SteamNativeApi.ManualDispatchRunFrame(); while (SteamNativeApi.ManualDispatchGetNextCallback(out var message)) { try { HandleCallback(message); } catch (Exception e) { SteamManager.Logger.LogError(e, "An exception occurred while handling Steam callback {CallbackId}.", message.CallbackId); } finally { SteamNativeApi.ManualDispatchFreeLastCallback(); } } } internal void FailPendingCallResults() { List>>> pending; lock (_sync) { pending = new List>>>(_pendingCallResults); _pendingCallResults.Clear(); } foreach (var entry in pending) { InvokeCallResultHandlers(entry.Value, SteamApiCallResult.Failed(entry.Key)); } } private void HandleCallback(SteamNativeCallbackMessage message) { var payload = CopyPayload(message.Param, message.ParamSize); var data = new SteamCallbackData(message.CallbackId, message.SteamUser, payload); if (message.CallbackId == SteamCallbackIds.SteamApiCallCompleted) { HandleApiCallCompleted(data); } else if (message.CallbackId == SteamCallbackIds.GameOverlayActivated) { // GameOverlayActivated_t { uint8 m_bActive; bool m_bUserInitiated; AppId_t m_nAppID; uint32 m_dwOverlayPID; } try { OverlayActivated?.Invoke(new SteamOverlayActivated(data.ReadBoolean(0), data.ReadBoolean(1), data.ReadUInt32(4))); } catch (Exception e) { SteamManager.Logger.LogError(e, "An OverlayActivated handler threw."); } } Action[] handlers = null; lock (_sync) { if (_handlers.TryGetValue(message.CallbackId, out var list)) { handlers = list.ToArray(); } } if (handlers == null) { return; } foreach (var handler in handlers) { try { handler(data); } catch (Exception e) { SteamManager.Logger.LogError(e, "A subscriber threw while handling Steam callback {CallbackId}.", message.CallbackId); } } } private void HandleApiCallCompleted(SteamCallbackData data) { // SteamAPICallCompleted_t { SteamAPICall_t m_hAsyncCall; int m_iCallback; uint32 m_cubParam; } - same layout under pack 4 and 8. var apiCall = data.ReadUInt64(0); var expectedCallback = data.ReadInt32(8); var resultSize = (int)data.ReadUInt32(12); List> handlers; lock (_sync) { if (!_pendingCallResults.TryGetValue(apiCall, out handlers)) { return; } _pendingCallResults.Remove(apiCall); } var buffer = Marshal.AllocHGlobal(Math.Max(resultSize, 1)); SteamApiCallResult result; try { var fetched = SteamNativeApi.ManualDispatchGetApiCallResult(apiCall, buffer, resultSize, expectedCallback, out var failed); result = fetched ? new SteamApiCallResult(apiCall, expectedCallback, failed, CopyPayload(buffer, resultSize)) : new SteamApiCallResult(apiCall, expectedCallback, true, Array.Empty()); } finally { Marshal.FreeHGlobal(buffer); } InvokeCallResultHandlers(handlers, result); } private void RemoveCallResult(ulong apiCall, Action handler) { lock (_sync) { if (_pendingCallResults.TryGetValue(apiCall, out var list) && list.Remove(handler) && list.Count == 0) { _pendingCallResults.Remove(apiCall); } } } private static void InvokeCallResultHandlers(List> handlers, SteamApiCallResult result) { foreach (var handler in handlers) { try { handler(result); } catch (Exception e) { SteamManager.Logger.LogError(e, "A handler threw while handling the result of Steam API call {ApiCall}.", result.ApiCall); } } } private static byte[] CopyPayload(IntPtr source, int size) { if (source == IntPtr.Zero || size <= 0) { return Array.Empty(); } var payload = new byte[size]; Marshal.Copy(source, payload, 0, size); return payload; } private sealed class Subscription : IDisposable { private Action _dispose; public Subscription(Action dispose) { _dispose = dispose; } public void Dispose() { Interlocked.Exchange(ref _dispose, null)?.Invoke(); } } } }