481 lines
20 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|
|
}
|