Files
2026-10-24 11:38:40 +01:00

481 lines
20 KiB
C#

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<byte>();
}
[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<byte>());
}
}
[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<int, List<Action<SteamCallbackData>>> _handlers = new Dictionary<int, List<Action<SteamCallbackData>>>();
private readonly Dictionary<ulong, List<Action<SteamApiCallResult>>> _pendingCallResults = new Dictionary<ulong, List<Action<SteamApiCallResult>>>();
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<SteamOverlayActivated> 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<SteamCallbackData> handler)
{
if (handler == null)
{
throw new ArgumentNullException(nameof(handler));
}
lock (_sync)
{
if (!_handlers.TryGetValue(callbackId, out var list))
{
list = new List<Action<SteamCallbackData>>();
_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<SteamCallbackData> 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<SteamApiCallResult> 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<Action<SteamApiCallResult>>();
_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<SteamApiCallResult> WaitForCallResultAsync(ulong apiCall, CancellationToken cancellationToken = default)
{
if (apiCall == 0)
{
return Task.FromException<SteamApiCallResult>(new ArgumentException("The Steam API call handle is invalid.", nameof(apiCall)));
}
var completion = new TaskCompletionSource<SteamApiCallResult>(TaskCreationOptions.RunContinuationsAsynchronously);
Action<SteamApiCallResult> handler = result => completion.TrySetResult(result);
if (!RegisterCallResult(apiCall, handler))
{
return Task.FromException<SteamApiCallResult>(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<KeyValuePair<ulong, List<Action<SteamApiCallResult>>>> pending;
lock (_sync)
{
pending = new List<KeyValuePair<ulong, List<Action<SteamApiCallResult>>>>(_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<SteamCallbackData>[] 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<Action<SteamApiCallResult>> 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<byte>());
}
finally
{
Marshal.FreeHGlobal(buffer);
}
InvokeCallResultHandlers(handlers, result);
}
private void RemoveCallResult(ulong apiCall, Action<SteamApiCallResult> handler)
{
lock (_sync)
{
if (_pendingCallResults.TryGetValue(apiCall, out var list) && list.Remove(handler) && list.Count == 0)
{
_pendingCallResults.Remove(apiCall);
}
}
}
private static void InvokeCallResultHandlers(List<Action<SteamApiCallResult>> 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<byte>();
}
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();
}
}
}
}