updated SteamWorks library
This commit is contained in:
@@ -4,8 +4,11 @@ Lightweight Steamworks helper library for C# game projects.
|
||||
This package provides a simple Steam API wrapper for common tasks like initialization, callback processing, player name lookup, and achievements.
|
||||
|
||||
## Features
|
||||
- Simple Steam initialization via `SteamManager.Instance`
|
||||
- Per-frame callback runner with `SteamManager.Update()`
|
||||
- Simple Steam initialization via `SteamManager.Instance` (uses `SteamAPI_InitFlat` on SDK 1.58+ and reports the error message in `InitErrorMessage`)
|
||||
- Configurable native library location with `SteamManager.ConfigureLibraryPath()` (Unity `Plugins/x86_64`, custom folders)
|
||||
- `SteamManager.RestartAppIfNecessary()` for launching through Steam
|
||||
- Per-frame manual callback dispatch with `SteamManager.Update()`: callback subscriptions, `OverlayActivated` event and async call results (`SteamManager.Instance.Callbacks`)
|
||||
- Ownership, DLC and language queries via `SteamManager.Instance.Apps` (ISteamApps)
|
||||
- Safe shutdown with `SteamManager.Shutdown()`
|
||||
- Player persona name access with `SteamManager.GetSteamName()`
|
||||
- Achievement helpers via `AchievementManager`
|
||||
@@ -89,11 +92,119 @@ var session = steam.Ugc.BeginUpdateSession(480, 123456789012345678UL)
|
||||
.WithContentFolder("/absolute/path/to/content")
|
||||
.WithPreviewFile("/absolute/path/to/preview.png");
|
||||
var submitCallHandle2 = session.Submit("Updated content", out var failedStep2);
|
||||
|
||||
// Ownership / language (ISteamApps)
|
||||
bool ownsDemo = steam.Apps.BIsSubscribedApp(480);
|
||||
bool hasDlc = steam.Apps.BIsDlcInstalled(123456);
|
||||
string language = steam.Apps.GetCurrentGameLanguage(); // "english", "portuguese", ...
|
||||
```
|
||||
|
||||
## Callbacks and async call results
|
||||
`SteamManager.Update()` runs Steam's manual dispatch loop (`SteamAPI_ManualDispatch_*`). Callbacks and async call results are delivered from `Update()`, on the thread that calls it. If the loaded `steam_api` has no manual dispatch exports, or you call `SteamManager.ConfigureManualDispatch(false)` before `Instance`, `Update()` falls back to `SteamAPI_RunCallbacks` and no events or results are delivered. The library never runs both.
|
||||
|
||||
```cs
|
||||
var steam = SteamManager.Instance;
|
||||
|
||||
// Typed overlay event (GameOverlayActivated_t)
|
||||
steam.OverlayActivated += overlay => Console.WriteLine($"Overlay open: {overlay.Active}");
|
||||
|
||||
// Any callback by id, with the raw payload
|
||||
IDisposable sub = steam.Callbacks.Subscribe(SteamCallbackIds.GameOverlayActivated, data =>
|
||||
{
|
||||
bool active = data.ReadBoolean(0);
|
||||
});
|
||||
sub.Dispose(); // unsubscribe
|
||||
|
||||
// Workshop results: callback style...
|
||||
steam.Ugc.SubmitItemUpdate(updateHandle, "Patch notes", result =>
|
||||
{
|
||||
if (result.IsSuccess) Console.WriteLine($"Uploaded {result.PublishedFileId}");
|
||||
if (result.UserNeedsToAcceptWorkshopLegalAgreement) { /* show the agreement link */ }
|
||||
});
|
||||
steam.Ugc.CreateItem(480, 0 /* k_EWorkshopFileTypeCommunity */, created => { /* created.PublishedFileId */ });
|
||||
steam.Ugc.SendQueryUgcRequest(queryHandle, query => { /* query.NumResultsReturned */ });
|
||||
|
||||
// ...or awaitable (keep calling Update() while waiting)
|
||||
var query = await steam.Ugc.SendQueryUgcRequestAsync(queryHandle);
|
||||
|
||||
// Any other SteamAPICall_t handle
|
||||
var raw = await steam.Callbacks.WaitForCallResultAsync(apiCallHandle);
|
||||
if (!raw.IoFailure) { int eResult = raw.ReadInt32(0); }
|
||||
```
|
||||
|
||||
Callback structs use 8-byte packing on Windows and 4-byte packing on Linux, Steam Deck and macOS, which moves 64-bit fields in some structs. The typed results handle this. When you parse a raw payload yourself, check `SteamCallbackDispatcher.UsesLargeCallbackPack`.
|
||||
|
||||
## Using in Unity
|
||||
The library has no Unity references; add the DLLs and the Steam native libraries (`steam_api64.dll` for Windows, `libsteam_api.so` for Linux) to `Assets/Plugins/x86_64/` and set their platforms in the import settings. Keep all Steam code behind your Steam-off define (for example `DISABLESTEAMWORKS`) so itch / Microsoft Store builds don't touch it.
|
||||
|
||||
```cs
|
||||
#if !DISABLESTEAMWORKS
|
||||
using System.IO;
|
||||
using OmegaLeo.HelperLib.Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
public class SteamBootstrap : MonoBehaviour
|
||||
{
|
||||
private const uint AppId = 480; // your AppId (full game and demo have different ids)
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
DontDestroyOnLoad(gameObject);
|
||||
|
||||
// Player: <Game>_Data/Plugins/x86_64 (some Linux layouts use <Game>_Data/Plugins).
|
||||
// Editor: Application.dataPath is Assets/, so this finds Assets/Plugins/x86_64.
|
||||
SteamManager.ConfigureLibraryPath(Path.Combine(Application.dataPath, "Plugins", "x86_64"));
|
||||
SteamManager.ConfigureLibraryPath(Path.Combine(Application.dataPath, "Plugins"));
|
||||
|
||||
#if !UNITY_EDITOR
|
||||
// Relaunch through Steam when started from the exe. Call before ConfigureAppId.
|
||||
if (SteamManager.RestartAppIfNecessary(AppId))
|
||||
{
|
||||
Application.Quit();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
// Editor/dev only: writes steam_appid.txt. Shipped builds get the AppId from Steam.
|
||||
SteamManager.ConfigureAppId(AppId, writeSteamAppIdFile: true);
|
||||
#endif
|
||||
|
||||
var steam = SteamManager.Instance;
|
||||
if (!steam.IsSteamworksInitialized)
|
||||
{
|
||||
Debug.LogWarning($"Steam not available: {steam.InitErrorMessage}");
|
||||
return;
|
||||
}
|
||||
|
||||
steam.OverlayActivated += OnOverlayActivated;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
SteamManager.Instance.Update(); // every frame, main thread
|
||||
}
|
||||
|
||||
private void OnOverlayActivated(SteamOverlayActivated overlay)
|
||||
{
|
||||
Time.timeScale = overlay.Active ? 0f : 1f; // or your own pause logic
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
SteamManager.Instance.OverlayActivated -= OnOverlayActivated;
|
||||
SteamManager.Instance.Shutdown();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
Pass an `ILogger` to `SteamManager.ConfigureLogger` (a small adapter around `Debug.Log`) to see which library path loaded and why init failed.
|
||||
|
||||
## Notes
|
||||
- Steam must be running and your app must be launched in a valid Steam context.
|
||||
- Configure your AppId before first access to `SteamManager.Instance`.
|
||||
- Configure your AppId, library path and dispatch mode before first access to `SteamManager.Instance`.
|
||||
- `ConfigureAppId(appId)` only sets the `SteamAppId` / `SteamGameId` environment variables. Use `ConfigureAppId(appId, writeSteamAppIdFile: true)` to also write `steam_appid.txt` (development only; a shipped build with that file skips `RestartAppIfNecessary`).
|
||||
- `SteamManager.Update()` should be called continuously during runtime.
|
||||
- If Steam is unavailable, `GetSteamName()` falls back to `"Player"`.
|
||||
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,59 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
}
|
||||
}
|
||||
|
||||
[Documentation(nameof(SteamAppsManager), "Provides ownership, DLC and language queries from ISteamApps.", null,
|
||||
@"```csharp
|
||||
var apps = SteamManager.Instance.Apps;
|
||||
if (apps.BIsSubscribedApp(5498630)) { /* user owns the demo */ }
|
||||
var language = apps.GetCurrentGameLanguage(); // e.g. ""english""
|
||||
```")]
|
||||
public class SteamAppsManager
|
||||
{
|
||||
public bool IsAvailable => SteamNativeApi.IsSteamAppsAvailable();
|
||||
|
||||
[Documentation(nameof(BIsSubscribed), "Checks whether the current user owns (is subscribed to) the running app.", null, null)]
|
||||
public bool BIsSubscribed()
|
||||
{
|
||||
return SteamNativeApi.AppsIsSubscribed();
|
||||
}
|
||||
|
||||
[Documentation(nameof(BIsSubscribedApp), "Checks whether the current user owns the given app, for example a related demo or another of your games.",new[] { "appId: AppId to check." }, null)]
|
||||
public bool BIsSubscribedApp(uint appId)
|
||||
{
|
||||
return SteamNativeApi.AppsIsSubscribedApp(appId);
|
||||
}
|
||||
|
||||
[Documentation(nameof(BIsDlcInstalled), "Checks whether the user owns the given DLC and it is installed.", new[] { "appId: DLC AppId." }, null)]
|
||||
public bool BIsDlcInstalled(uint appId)
|
||||
{
|
||||
return SteamNativeApi.AppsIsDlcInstalled(appId);
|
||||
}
|
||||
|
||||
[Documentation(nameof(BIsAppInstalled), "Checks whether the given app is installed on this machine.", new[] { "appId: AppId to check." }, null)]
|
||||
public bool BIsAppInstalled(uint appId)
|
||||
{
|
||||
return SteamNativeApi.AppsIsAppInstalled(appId);
|
||||
}
|
||||
|
||||
[Documentation(nameof(GetCurrentGameLanguage), "Gets the language the user selected for this game in Steam (API language code such as \"english\"), or an empty string when unavailable.", null, null)]
|
||||
public string GetCurrentGameLanguage()
|
||||
{
|
||||
return SteamNativeApi.AppsGetCurrentGameLanguage();
|
||||
}
|
||||
|
||||
[Documentation(nameof(GetAvailableGameLanguages), "Gets a comma-separated list of the languages the game supports.", null, null)]
|
||||
public string GetAvailableGameLanguages()
|
||||
{
|
||||
return SteamNativeApi.AppsGetAvailableGameLanguages();
|
||||
}
|
||||
|
||||
[Documentation(nameof(GetAppBuildId), "Gets the Steam build id of the installed depot.", null, null)]
|
||||
public int GetAppBuildId()
|
||||
{
|
||||
return SteamNativeApi.AppsGetAppBuildId();
|
||||
}
|
||||
}
|
||||
|
||||
[Documentation(nameof(SteamUtilsManager), "Provides access checks for ISteamUtils.", null, null)]
|
||||
public class SteamUtilsManager
|
||||
{
|
||||
|
||||
@@ -5,11 +5,13 @@ using OmegaLeo.HelperLib.Shared.Attributes;
|
||||
|
||||
namespace OmegaLeo.HelperLib.Steamworks
|
||||
{
|
||||
[Documentation(nameof(SteamManager), "Manages Steamworks initialization, callback polling, and shutdown for applications using the Steam API.", null,
|
||||
[Documentation(nameof(SteamManager), "Manages Steamworks initialization, callback dispatch, and shutdown for applications using the Steam API.", null,
|
||||
@"```csharp
|
||||
SteamManager.ConfigureLogger(logger);
|
||||
SteamManager.ConfigureAppId(480);
|
||||
SteamManager.ConfigureLibraryPath(pluginFolder);
|
||||
if (SteamManager.RestartAppIfNecessary(480)) { Quit(); return; }
|
||||
var steam = SteamManager.Instance;
|
||||
steam.OverlayActivated += overlay => Pause(overlay.Active);
|
||||
steam.Update();
|
||||
```")]
|
||||
public class SteamManager
|
||||
@@ -17,6 +19,8 @@ steam.Update();
|
||||
private static SteamManager _instance;
|
||||
private static uint? _configuredAppId;
|
||||
private static ILogger _logger = NullLogger.Instance;
|
||||
private static readonly SteamCallbackDispatcher CallbackDispatcher = new SteamCallbackDispatcher();
|
||||
private static readonly SteamAppsManager AppsManager = new SteamAppsManager();
|
||||
private static readonly SteamUgcManager UgcManager = new SteamUgcManager();
|
||||
private static readonly SteamRemotePlayManager RemotePlayManager = new SteamRemotePlayManager();
|
||||
private static readonly SteamControllerManager ControllerManager = new SteamControllerManager();
|
||||
@@ -41,14 +45,76 @@ steam.Update();
|
||||
|
||||
internal static ILogger Logger => _logger;
|
||||
|
||||
internal static SteamCallbackDispatcher Dispatcher => CallbackDispatcher;
|
||||
|
||||
[Documentation(nameof(ConfigureLogger), "Configures the logger used by Steamworks helper classes.", new[] { "logger: ILogger instance to receive Steamworks logs." }, null)]
|
||||
public static void ConfigureLogger(ILogger logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
[Documentation(nameof(ConfigureAppId), "Configures the Steam AppId used during initialization. Must be called before accessing the singleton instance.", new[] { "appId: Steam application identifier." }, null)]
|
||||
[Documentation(nameof(ConfigureLibraryPath), "Adds a folder (or full file path) to search first for the Steam API native library (steam_api64.dll / libsteam_api.so / libsteam_api.dylib). Can be called several times; paths are tried in order, then the default locations. Must be called before Instance or RestartAppIfNecessary. Returns false when the library is already loaded.", new[] { "directoryOrFile: Folder containing the native library, or the full path to it." },
|
||||
@"```csharp
|
||||
SteamManager.ConfigureLibraryPath(Path.Combine(gameDataFolder, ""Plugins"", ""x86_64""));
|
||||
```")]
|
||||
public static bool ConfigureLibraryPath(string directoryOrFile)
|
||||
{
|
||||
if (!SteamNativeApi.AddLibrarySearchPath(directoryOrFile))
|
||||
{
|
||||
_logger.LogWarning("ConfigureLibraryPath ignored for '{SteamLibraryPath}': the path is empty or the Steam API library is already loaded.", directoryOrFile);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[Documentation(nameof(ConfigureManualDispatch), "Enables or disables manual callback dispatch (enabled by default). When disabled, Update() uses SteamAPI_RunCallbacks and no callback events or async call results are delivered. Must be called before Instance.", new[] { "enabled: True to use manual dispatch." }, null)]
|
||||
public static bool ConfigureManualDispatch(bool enabled)
|
||||
{
|
||||
return _instance == null && SteamNativeApi.ConfigureManualDispatch(enabled);
|
||||
}
|
||||
|
||||
[Documentation(nameof(RestartAppIfNecessary), "Calls SteamAPI_RestartAppIfNecessary. Returns true when the game was not launched through Steam and Steam is relaunching it: quit immediately in that case. Call it before Instance and before ConfigureAppId (steam_appid.txt, and possibly the SteamAppId environment variable, suppress the relaunch). Returns false when the native library cannot be loaded.", new[] { "appId: Your Steam AppId." },
|
||||
@"```csharp
|
||||
if (SteamManager.RestartAppIfNecessary(480))
|
||||
{
|
||||
Quit();
|
||||
return;
|
||||
}
|
||||
```")]
|
||||
public static bool RestartAppIfNecessary(uint appId)
|
||||
{
|
||||
if (appId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_configuredAppId.HasValue)
|
||||
{
|
||||
_logger.LogWarning("RestartAppIfNecessary was called after ConfigureAppId; the AppId environment variables or steam_appid.txt may stop Steam from relaunching the game.");
|
||||
}
|
||||
|
||||
var restarting = SteamNativeApi.RestartAppIfNecessary(appId);
|
||||
if (restarting)
|
||||
{
|
||||
_logger.LogInformation("Steam is relaunching the game through the Steam client; the current process should exit.");
|
||||
}
|
||||
|
||||
return restarting;
|
||||
}
|
||||
|
||||
[Documentation(nameof(ConfigureAppId), "Configures the Steam AppId used during initialization by setting the SteamAppId/SteamGameId environment variables. Does not write steam_appid.txt. Must be called before accessing the singleton instance.", new[] { "appId: Steam application identifier." }, null)]
|
||||
public static bool ConfigureAppId(uint appId)
|
||||
{
|
||||
return ConfigureAppId(appId, false);
|
||||
}
|
||||
|
||||
[Documentation(nameof(ConfigureAppId), "Configures the Steam AppId used during initialization. Optionally writes steam_appid.txt next to the executable and in the working directory (useful in editors and development builds; leave false for shipped builds). Must be called before accessing the singleton instance.", new[]
|
||||
{
|
||||
"appId: Steam application identifier.",
|
||||
"writeSteamAppIdFile: True to also write steam_appid.txt."
|
||||
}, null)]
|
||||
public static bool ConfigureAppId(uint appId, bool writeSteamAppIdFile)
|
||||
{
|
||||
if (appId == 0)
|
||||
{
|
||||
@@ -62,13 +128,29 @@ steam.Update();
|
||||
}
|
||||
|
||||
_configuredAppId = appId;
|
||||
SteamNativeApi.ConfigureAppId(appId);
|
||||
SteamNativeApi.ConfigureAppId(appId, writeSteamAppIdFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
[Documentation(nameof(IsSteamworksInitialized), "Indicates whether Steamworks is initialized and ready for API calls.", null, null)]
|
||||
public bool IsSteamworksInitialized { get; private set; }
|
||||
|
||||
[Documentation(nameof(InitErrorMessage), "The reason initialization failed, as reported by SteamAPI_InitFlat when available. Empty after a successful init.", null, null)]
|
||||
public string InitErrorMessage { get; private set; } = string.Empty;
|
||||
|
||||
[Documentation(nameof(Callbacks), "Provides callback subscriptions and async call results through manual dispatch.", null, null)]
|
||||
public SteamCallbackDispatcher Callbacks => CallbackDispatcher;
|
||||
|
||||
[Documentation(nameof(OverlayActivated), "Raised from Update() when the Steam overlay opens or closes. Same as Callbacks.OverlayActivated.", null, null)]
|
||||
public event Action<SteamOverlayActivated> OverlayActivated
|
||||
{
|
||||
add => CallbackDispatcher.OverlayActivated += value;
|
||||
remove => CallbackDispatcher.OverlayActivated -= value;
|
||||
}
|
||||
|
||||
[Documentation(nameof(Apps), "Provides access to ISteamApps bindings (ownership, DLC, language).", null, null)]
|
||||
public SteamAppsManager Apps => AppsManager;
|
||||
|
||||
[Documentation(nameof(Ugc), "Provides access to Steam Workshop (UGC) helper operations.", null, null)]
|
||||
public SteamUgcManager Ugc => UgcManager;
|
||||
|
||||
@@ -134,11 +216,12 @@ steam.Update();
|
||||
if (SteamNativeApi.Initialize(_configuredAppId))
|
||||
{
|
||||
IsSteamworksInitialized = true;
|
||||
_logger.LogInformation("Steamworks initialized successfully.");
|
||||
_logger.LogInformation("Steamworks initialized successfully (manual callback dispatch: {ManualDispatchActive}).", SteamNativeApi.IsManualDispatchActive);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Steamworks initialization failed. Ensure Steam is running and AppId context is valid.");
|
||||
InitErrorMessage = SteamNativeApi.LastInitErrorMessage;
|
||||
_logger.LogWarning("Steamworks initialization failed: {InitErrorMessage} Ensure Steam is running and AppId context is valid.", InitErrorMessage);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -148,20 +231,30 @@ steam.Update();
|
||||
}
|
||||
|
||||
// This must be called from your game's main loop (e.g., Update in Unity/Godot)
|
||||
[Documentation(nameof(Update), "Executes Steamworks callbacks. Call every frame from your game loop.", null, null)]
|
||||
[Documentation(nameof(Update), "Executes Steamworks callbacks (manual dispatch, or SteamAPI_RunCallbacks when manual dispatch is unavailable) and delivers events and async call results. Call every frame from your game loop's main thread.", null, null)]
|
||||
public void Update()
|
||||
{
|
||||
if (IsSteamworksInitialized)
|
||||
if (!IsSteamworksInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (SteamNativeApi.IsManualDispatchActive)
|
||||
{
|
||||
CallbackDispatcher.RunFrame();
|
||||
}
|
||||
else
|
||||
{
|
||||
SteamNativeApi.RunCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
[Documentation(nameof(Shutdown), "Shuts down Steamworks and clears the initialized state.", null, null)]
|
||||
[Documentation(nameof(Shutdown), "Shuts down Steamworks and clears the initialized state. Pending async call results complete with IoFailure = true.", null, null)]
|
||||
public void Shutdown()
|
||||
{
|
||||
if (IsSteamworksInitialized)
|
||||
{
|
||||
CallbackDispatcher.FailPendingCallResults();
|
||||
SteamNativeApi.Shutdown();
|
||||
IsSteamworksInitialized = false;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,70 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OmegaLeo.HelperLib.Steamworks
|
||||
{
|
||||
internal readonly struct SteamNativeCallbackMessage
|
||||
{
|
||||
public SteamNativeCallbackMessage(int steamUser, int callbackId, IntPtr param, int paramSize)
|
||||
{
|
||||
SteamUser = steamUser;
|
||||
CallbackId = callbackId;
|
||||
Param = param;
|
||||
ParamSize = paramSize;
|
||||
}
|
||||
|
||||
public int SteamUser { get; }
|
||||
public int CallbackId { get; }
|
||||
public IntPtr Param { get; }
|
||||
public int ParamSize { get; }
|
||||
}
|
||||
|
||||
internal static class SteamNativeApi
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
private delegate bool SteamApiInitDelegate();
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int SteamApiInitFlatDelegate(IntPtr errorMessage);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
private delegate bool SteamApiRestartAppIfNecessaryDelegate(uint appId);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void SteamApiVoidDelegate();
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int SteamApiGetHSteamPipeDelegate();
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void SteamApiManualDispatchPipeDelegate(int steamPipe);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
private delegate bool SteamApiManualDispatchGetNextCallbackDelegate(int steamPipe, IntPtr callbackMessage);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
private delegate bool SteamApiManualDispatchGetApiCallResultDelegate(int steamPipe, ulong apiCall, IntPtr callbackBuffer, int callbackSize, int callbackExpected, [MarshalAs(UnmanagedType.I1)] out bool failed);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
private delegate bool SteamAppsBoolDelegate(IntPtr steamApps);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
private delegate bool SteamAppsAppIdBoolDelegate(IntPtr steamApps, uint appId);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate IntPtr SteamAppsStringDelegate(IntPtr steamApps);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int SteamAppsGetAppBuildIdDelegate(IntPtr steamApps);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate IntPtr SteamFriendsAccessorDelegate();
|
||||
|
||||
@@ -289,13 +341,41 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
|
||||
private static readonly object SyncRoot = new object();
|
||||
|
||||
// SteamErrMsg is a char[1024] buffer in steam_api_common.h.
|
||||
private const int SteamErrMsgSize = 1024;
|
||||
|
||||
// Large enough for CallbackMsg_t on 32-bit and 64-bit (24 bytes on x64).
|
||||
private const int CallbackMessageBufferSize = 64;
|
||||
|
||||
private static IntPtr _libraryHandle;
|
||||
private static bool _libraryLoaded;
|
||||
private static bool _loadFailureLogged;
|
||||
private static bool _initialized;
|
||||
private static bool _manualDispatchEnabled = true;
|
||||
private static bool _manualDispatchActive;
|
||||
private static int _steamPipe;
|
||||
private static IntPtr _callbackMessageBuffer;
|
||||
|
||||
private static readonly List<string> CustomLibraryPaths = new List<string>();
|
||||
|
||||
private static SteamApiInitFlatDelegate _steamApiInitFlat;
|
||||
private static SteamApiInitDelegate _steamApiInit;
|
||||
private static SteamApiRestartAppIfNecessaryDelegate _steamApiRestartAppIfNecessary;
|
||||
private static SteamApiVoidDelegate _steamApiShutdown;
|
||||
private static SteamApiVoidDelegate _steamApiRunCallbacks;
|
||||
private static SteamApiGetHSteamPipeDelegate _steamApiGetHSteamPipe;
|
||||
private static SteamApiVoidDelegate _steamApiManualDispatchInit;
|
||||
private static SteamApiManualDispatchPipeDelegate _steamApiManualDispatchRunFrame;
|
||||
private static SteamApiManualDispatchGetNextCallbackDelegate _steamApiManualDispatchGetNextCallback;
|
||||
private static SteamApiManualDispatchPipeDelegate _steamApiManualDispatchFreeLastCallback;
|
||||
private static SteamApiManualDispatchGetApiCallResultDelegate _steamApiManualDispatchGetApiCallResult;
|
||||
private static SteamAppsBoolDelegate _steamAppsIsSubscribed;
|
||||
private static SteamAppsAppIdBoolDelegate _steamAppsIsSubscribedApp;
|
||||
private static SteamAppsAppIdBoolDelegate _steamAppsIsDlcInstalled;
|
||||
private static SteamAppsAppIdBoolDelegate _steamAppsIsAppInstalled;
|
||||
private static SteamAppsStringDelegate _steamAppsGetCurrentGameLanguage;
|
||||
private static SteamAppsStringDelegate _steamAppsGetAvailableGameLanguages;
|
||||
private static SteamAppsGetAppBuildIdDelegate _steamAppsGetAppBuildId;
|
||||
private static SteamFriendsAccessorDelegate _steamFriendsAccessor;
|
||||
private static SteamUserStatsAccessorDelegate _steamUserStatsAccessor;
|
||||
private static SteamUgcAccessorDelegate _steamUgcAccessor;
|
||||
@@ -385,6 +465,7 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
private static readonly Dictionary<string, SteamInterfaceAccessorDelegate> AdditionalInterfaceAccessors = new Dictionary<string, SteamInterfaceAccessorDelegate>();
|
||||
private static readonly Dictionary<string, IntPtr> AdditionalInterfacePointers = new Dictionary<string, IntPtr>();
|
||||
|
||||
private const string InterfaceSteamApps = "SteamApps";
|
||||
private const string InterfaceSteamController = "SteamController";
|
||||
private const string InterfaceSteamGameCoordinator = "SteamGameCoordinator";
|
||||
private const string InterfaceSteamGameServer = "SteamGameServer";
|
||||
@@ -403,13 +484,53 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
|
||||
public static bool IsAvailable => EnsureLoaded();
|
||||
|
||||
public static bool IsManualDispatchActive => _manualDispatchActive;
|
||||
|
||||
public static string LastInitErrorMessage { get; private set; } = string.Empty;
|
||||
|
||||
private static ILogger Logger => SteamManager.Logger;
|
||||
|
||||
public static bool AddLibrarySearchPath(string directoryOrFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directoryOrFile))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (SyncRoot)
|
||||
{
|
||||
if (_libraryLoaded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CustomLibraryPaths.Add(directoryOrFile);
|
||||
_loadFailureLogged = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ConfigureManualDispatch(bool enabled)
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_manualDispatchEnabled = enabled;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Initialize(uint? appId = null)
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
if (appId.HasValue && appId.Value > 0)
|
||||
{
|
||||
ConfigureAppId(appId.Value);
|
||||
ApplyAppIdEnvironment(appId.Value);
|
||||
}
|
||||
|
||||
if (_initialized)
|
||||
@@ -419,25 +540,39 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
|
||||
if (!EnsureLoaded())
|
||||
{
|
||||
LastInitErrorMessage = "The Steam API native library could not be loaded.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_steamApiInit == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_initialized = _steamApiInit();
|
||||
_initialized = InvokeSteamApiInit();
|
||||
if (_initialized)
|
||||
{
|
||||
CacheInterfaces();
|
||||
TryEnableManualDispatch();
|
||||
}
|
||||
|
||||
return _initialized;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ConfigureAppId(uint appId)
|
||||
public static bool RestartAppIfNecessary(uint appId)
|
||||
{
|
||||
if (!EnsureLoaded())
|
||||
{
|
||||
Logger.LogError("SteamAPI_RestartAppIfNecessary skipped: the Steam API native library could not be loaded.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_steamApiRestartAppIfNecessary == null)
|
||||
{
|
||||
Logger.LogWarning("SteamAPI_RestartAppIfNecessary export not found in the loaded Steam API library.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return _steamApiRestartAppIfNecessary(appId);
|
||||
}
|
||||
|
||||
public static void ConfigureAppId(uint appId, bool writeSteamAppIdFile)
|
||||
{
|
||||
if (appId == 0)
|
||||
{
|
||||
@@ -447,9 +582,12 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
lock (SyncRoot)
|
||||
{
|
||||
ApplyAppIdEnvironment(appId);
|
||||
if (writeSteamAppIdFile)
|
||||
{
|
||||
TryWriteSteamAppIdFile(appId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Shutdown()
|
||||
{
|
||||
@@ -462,6 +600,14 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
|
||||
_steamApiShutdown?.Invoke();
|
||||
_initialized = false;
|
||||
_manualDispatchActive = false;
|
||||
_steamPipe = 0;
|
||||
if (_callbackMessageBuffer != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(_callbackMessageBuffer);
|
||||
_callbackMessageBuffer = IntPtr.Zero;
|
||||
}
|
||||
|
||||
_steamFriends = IntPtr.Zero;
|
||||
_steamUserStats = IntPtr.Zero;
|
||||
_steamUgc = IntPtr.Zero;
|
||||
@@ -472,7 +618,8 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
|
||||
public static void RunCallbacks()
|
||||
{
|
||||
if (!_initialized)
|
||||
// SteamAPI_RunCallbacks must never run once manual dispatch is in use.
|
||||
if (!_initialized || _manualDispatchActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -480,6 +627,152 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
_steamApiRunCallbacks?.Invoke();
|
||||
}
|
||||
|
||||
public static void ManualDispatchRunFrame()
|
||||
{
|
||||
if (!_manualDispatchActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_steamApiManualDispatchRunFrame(_steamPipe);
|
||||
}
|
||||
|
||||
public static bool ManualDispatchGetNextCallback(out SteamNativeCallbackMessage message)
|
||||
{
|
||||
message = default;
|
||||
|
||||
if (!_manualDispatchActive || !_steamApiManualDispatchGetNextCallback(_steamPipe, _callbackMessageBuffer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// CallbackMsg_t { HSteamUser m_hSteamUser; int m_iCallback; uint8 *m_pubParam; int m_cubParam; }
|
||||
// The pointer lands on offset 8 under both pack 4 and pack 8, so the layout is the same on every platform.
|
||||
message = new SteamNativeCallbackMessage(
|
||||
Marshal.ReadInt32(_callbackMessageBuffer, 0),
|
||||
Marshal.ReadInt32(_callbackMessageBuffer, 4),
|
||||
Marshal.ReadIntPtr(_callbackMessageBuffer, 8),
|
||||
Marshal.ReadInt32(_callbackMessageBuffer, 8 + IntPtr.Size));
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ManualDispatchFreeLastCallback()
|
||||
{
|
||||
if (!_manualDispatchActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_steamApiManualDispatchFreeLastCallback(_steamPipe);
|
||||
}
|
||||
|
||||
public static bool ManualDispatchGetApiCallResult(ulong apiCall, IntPtr callbackBuffer, int callbackSize, int callbackExpected, out bool failed)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
if (!_manualDispatchActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _steamApiManualDispatchGetApiCallResult(_steamPipe, apiCall, callbackBuffer, callbackSize, callbackExpected, out failed);
|
||||
}
|
||||
|
||||
private static bool InvokeSteamApiInit()
|
||||
{
|
||||
LastInitErrorMessage = string.Empty;
|
||||
|
||||
if (_steamApiInitFlat != null)
|
||||
{
|
||||
var errorBuffer = Marshal.AllocHGlobal(SteamErrMsgSize);
|
||||
try
|
||||
{
|
||||
Marshal.WriteByte(errorBuffer, 0, 0);
|
||||
var result = _steamApiInitFlat(errorBuffer);
|
||||
if (result == 0)
|
||||
{
|
||||
Logger.LogDebug("Steamworks initialized through SteamAPI_InitFlat.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = Marshal.PtrToStringAnsi(errorBuffer) ?? string.Empty;
|
||||
LastInitErrorMessage = string.IsNullOrWhiteSpace(message) ? DescribeInitResult(result) : message;
|
||||
Logger.LogWarning("SteamAPI_InitFlat failed with {InitResult} ({InitResultName}): {InitErrorMessage}", result, DescribeInitResult(result), LastInitErrorMessage);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(errorBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (_steamApiInit != null)
|
||||
{
|
||||
if (_steamApiInit())
|
||||
{
|
||||
Logger.LogDebug("Steamworks initialized through the legacy SteamAPI_Init export.");
|
||||
return true;
|
||||
}
|
||||
|
||||
LastInitErrorMessage = "SteamAPI_Init returned false (this SDK version does not report a reason).";
|
||||
return false;
|
||||
}
|
||||
|
||||
LastInitErrorMessage = "No SteamAPI_InitFlat, SteamAPI_Init or SteamAPI_InitSafe export was found.";
|
||||
Logger.LogError(LastInitErrorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DescribeInitResult(int result)
|
||||
{
|
||||
// ESteamAPIInitResult
|
||||
switch (result)
|
||||
{
|
||||
case 0: return "OK";
|
||||
case 1: return "FailedGeneric";
|
||||
case 2: return "NoSteamClient";
|
||||
case 3: return "VersionMismatch";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryEnableManualDispatch()
|
||||
{
|
||||
if (!_manualDispatchEnabled)
|
||||
{
|
||||
Logger.LogInformation("Manual callback dispatch disabled; using SteamAPI_RunCallbacks.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_steamApiGetHSteamPipe == null ||
|
||||
_steamApiManualDispatchInit == null ||
|
||||
_steamApiManualDispatchRunFrame == null ||
|
||||
_steamApiManualDispatchGetNextCallback == null ||
|
||||
_steamApiManualDispatchFreeLastCallback == null ||
|
||||
_steamApiManualDispatchGetApiCallResult == null)
|
||||
{
|
||||
Logger.LogWarning("Steam manual dispatch exports not found; falling back to SteamAPI_RunCallbacks. Callback events and async call results will not be delivered.");
|
||||
return;
|
||||
}
|
||||
|
||||
var steamPipe = _steamApiGetHSteamPipe();
|
||||
if (steamPipe == 0)
|
||||
{
|
||||
Logger.LogWarning("SteamAPI_GetHSteamPipe returned an invalid pipe; falling back to SteamAPI_RunCallbacks.");
|
||||
return;
|
||||
}
|
||||
|
||||
_steamApiManualDispatchInit();
|
||||
_steamPipe = steamPipe;
|
||||
if (_callbackMessageBuffer == IntPtr.Zero)
|
||||
{
|
||||
_callbackMessageBuffer = Marshal.AllocHGlobal(CallbackMessageBufferSize);
|
||||
}
|
||||
|
||||
_manualDispatchActive = true;
|
||||
Logger.LogDebug("Steam manual callback dispatch enabled.");
|
||||
}
|
||||
|
||||
public static string GetPersonaName()
|
||||
{
|
||||
if (!EnsureReady())
|
||||
@@ -784,6 +1077,8 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
return _steamRemotePlayGetSessionClientResolution?.Invoke(_steamRemotePlay, sessionId, out resolutionX, out resolutionY) ?? false;
|
||||
}
|
||||
|
||||
public static bool IsSteamAppsAvailable() => EnsureAdditionalInterfaceReady(InterfaceSteamApps);
|
||||
|
||||
public static bool IsSteamControllerAvailable() => EnsureAdditionalInterfaceReady(InterfaceSteamController);
|
||||
|
||||
public static bool IsSteamFriendsAvailable() => EnsureFriendsReady();
|
||||
@@ -928,6 +1223,85 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
return _steamUtilsIsOverlayEnabled?.Invoke(steamUtils) ?? false;
|
||||
}
|
||||
|
||||
public static bool AppsIsSubscribed()
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
return _steamAppsIsSubscribed?.Invoke(steamApps) ?? false;
|
||||
}
|
||||
|
||||
public static bool AppsIsSubscribedApp(uint appId)
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
return _steamAppsIsSubscribedApp?.Invoke(steamApps, appId) ?? false;
|
||||
}
|
||||
|
||||
public static bool AppsIsDlcInstalled(uint appId)
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
return _steamAppsIsDlcInstalled?.Invoke(steamApps, appId) ?? false;
|
||||
}
|
||||
|
||||
public static bool AppsIsAppInstalled(uint appId)
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
return _steamAppsIsAppInstalled?.Invoke(steamApps, appId) ?? false;
|
||||
}
|
||||
|
||||
public static string AppsGetCurrentGameLanguage()
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
var languagePtr = _steamAppsGetCurrentGameLanguage?.Invoke(steamApps) ?? IntPtr.Zero;
|
||||
return languagePtr == IntPtr.Zero ? string.Empty : Marshal.PtrToStringAnsi(languagePtr) ?? string.Empty;
|
||||
}
|
||||
|
||||
public static string AppsGetAvailableGameLanguages()
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
var languagesPtr = _steamAppsGetAvailableGameLanguages?.Invoke(steamApps) ?? IntPtr.Zero;
|
||||
return languagesPtr == IntPtr.Zero ? string.Empty : Marshal.PtrToStringAnsi(languagesPtr) ?? string.Empty;
|
||||
}
|
||||
|
||||
public static int AppsGetAppBuildId()
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamApps))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var steamApps = GetAdditionalInterfacePointer(InterfaceSteamApps);
|
||||
return _steamAppsGetAppBuildId?.Invoke(steamApps) ?? 0;
|
||||
}
|
||||
|
||||
public static bool RemoteStorageFileExists(string fileName)
|
||||
{
|
||||
if (!EnsureAdditionalInterfaceReady(InterfaceSteamRemoteStorage))
|
||||
@@ -1486,35 +1860,66 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
return true;
|
||||
}
|
||||
|
||||
var failures = new List<string>();
|
||||
foreach (var candidate in GetLibraryCandidates())
|
||||
{
|
||||
try
|
||||
{
|
||||
_libraryHandle = LoadUnmanagedLibrary(candidate);
|
||||
_libraryLoaded = _libraryHandle != IntPtr.Zero;
|
||||
if (_libraryLoaded)
|
||||
var handle = LoadUnmanagedLibrary(candidate);
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
_libraryHandle = handle;
|
||||
_libraryLoaded = true;
|
||||
BindDelegates();
|
||||
Logger.LogInformation("Loaded Steam API native library from '{SteamLibraryPath}'.", candidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
failures.Add($"{candidate} ({GetLastLoadError()})");
|
||||
}
|
||||
catch
|
||||
catch (Exception e)
|
||||
{
|
||||
// Try the next candidate.
|
||||
failures.Add($"{candidate} ({e.GetType().Name}: {e.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
if (!_loadFailureLogged)
|
||||
{
|
||||
_loadFailureLogged = true;
|
||||
Logger.LogError(
|
||||
"Could not load the Steam API native library '{SteamLibraryName}'. Call SteamManager.ConfigureLibraryPath with the folder that contains it. Tried: {SteamLibraryCandidates}",
|
||||
GetLibraryFileName(),
|
||||
string.Join("; ", failures));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BindDelegates()
|
||||
{
|
||||
// SDK 1.58+ exposes SteamAPI_InitFlat (with an error message); older SDKs only have SteamAPI_Init/InitSafe.
|
||||
_steamApiInitFlat = Bind<SteamApiInitFlatDelegate>("SteamAPI_InitFlat");
|
||||
_steamApiInit =
|
||||
Bind<SteamApiInitDelegate>("SteamAPI_Init")
|
||||
?? Bind<SteamApiInitDelegate>("SteamAPI_InitSafe");
|
||||
_steamApiRestartAppIfNecessary = Bind<SteamApiRestartAppIfNecessaryDelegate>("SteamAPI_RestartAppIfNecessary");
|
||||
_steamApiShutdown = Bind<SteamApiVoidDelegate>("SteamAPI_Shutdown");
|
||||
_steamApiRunCallbacks = Bind<SteamApiVoidDelegate>("SteamAPI_RunCallbacks");
|
||||
_steamApiGetHSteamPipe = Bind<SteamApiGetHSteamPipeDelegate>("SteamAPI_GetHSteamPipe");
|
||||
_steamApiManualDispatchInit = Bind<SteamApiVoidDelegate>("SteamAPI_ManualDispatch_Init");
|
||||
_steamApiManualDispatchRunFrame = Bind<SteamApiManualDispatchPipeDelegate>("SteamAPI_ManualDispatch_RunFrame");
|
||||
_steamApiManualDispatchGetNextCallback = Bind<SteamApiManualDispatchGetNextCallbackDelegate>("SteamAPI_ManualDispatch_GetNextCallback");
|
||||
_steamApiManualDispatchFreeLastCallback = Bind<SteamApiManualDispatchPipeDelegate>("SteamAPI_ManualDispatch_FreeLastCallback");
|
||||
_steamApiManualDispatchGetApiCallResult = Bind<SteamApiManualDispatchGetApiCallResultDelegate>("SteamAPI_ManualDispatch_GetAPICallResult");
|
||||
_steamAppsIsSubscribed = Bind<SteamAppsBoolDelegate>("SteamAPI_ISteamApps_BIsSubscribed");
|
||||
_steamAppsIsSubscribedApp = Bind<SteamAppsAppIdBoolDelegate>("SteamAPI_ISteamApps_BIsSubscribedApp");
|
||||
_steamAppsIsDlcInstalled = Bind<SteamAppsAppIdBoolDelegate>("SteamAPI_ISteamApps_BIsDlcInstalled");
|
||||
_steamAppsIsAppInstalled = Bind<SteamAppsAppIdBoolDelegate>("SteamAPI_ISteamApps_BIsAppInstalled");
|
||||
_steamAppsGetCurrentGameLanguage = Bind<SteamAppsStringDelegate>("SteamAPI_ISteamApps_GetCurrentGameLanguage");
|
||||
_steamAppsGetAvailableGameLanguages = Bind<SteamAppsStringDelegate>("SteamAPI_ISteamApps_GetAvailableGameLanguages");
|
||||
_steamAppsGetAppBuildId = Bind<SteamAppsGetAppBuildIdDelegate>("SteamAPI_ISteamApps_GetAppBuildId");
|
||||
_steamFriendsAccessor = BindInterfaceAccessor<SteamFriendsAccessorDelegate>("SteamFriends", "ISteamFriends");
|
||||
_steamUserStatsAccessor = BindInterfaceAccessor<SteamUserStatsAccessorDelegate>("SteamUserStats", "ISteamUserStats");
|
||||
_steamUgcAccessor = BindInterfaceAccessor<SteamUgcAccessorDelegate>("SteamUGC", "ISteamUGC");
|
||||
@@ -1597,6 +2002,7 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
_steamUserStatsSetStatInt32 = Bind<SteamUserStatsSetStatInt32Delegate>("SteamAPI_ISteamUserStats_SetStatInt32");
|
||||
|
||||
// Additional interface accessors requested for broad API coverage.
|
||||
BindAdditionalInterfaceAccessor(InterfaceSteamApps, "SteamApps", "ISteamApps");
|
||||
BindAdditionalInterfaceAccessor(InterfaceSteamController, "SteamController", "ISteamController");
|
||||
BindAdditionalInterfaceAccessor(InterfaceSteamGameCoordinator, "SteamGameCoordinator", "ISteamGameCoordinator");
|
||||
BindAdditionalInterfaceAccessor(InterfaceSteamGameServer, "SteamGameServer", "ISteamGameServer");
|
||||
@@ -1735,13 +2141,38 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
{
|
||||
var libraryName = GetLibraryFileName();
|
||||
var runtimeIdentifier = GetRuntimeIdentifier();
|
||||
var candidates = new List<string>();
|
||||
|
||||
return new[]
|
||||
// Paths from SteamManager.ConfigureLibraryPath are tried first, in the order they were added.
|
||||
foreach (var customPath in CustomLibraryPaths)
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "runtimes", runtimeIdentifier, "native", libraryName),
|
||||
Path.Combine(AppContext.BaseDirectory, libraryName),
|
||||
libraryName
|
||||
};
|
||||
var isFile = File.Exists(customPath) ||
|
||||
string.Equals(Path.GetFileName(customPath), libraryName, StringComparison.OrdinalIgnoreCase);
|
||||
candidates.Add(isFile ? customPath : Path.Combine(customPath, libraryName));
|
||||
}
|
||||
|
||||
candidates.Add(Path.Combine(AppContext.BaseDirectory, "runtimes", runtimeIdentifier, "native", libraryName));
|
||||
candidates.Add(Path.Combine(AppContext.BaseDirectory, libraryName));
|
||||
candidates.Add(libraryName);
|
||||
return candidates.ToArray();
|
||||
}
|
||||
|
||||
private static string GetLastLoadError()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()).Message;
|
||||
}
|
||||
|
||||
var errorPtr = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? DlerrorMac() : DlerrorLinux();
|
||||
return errorPtr == IntPtr.Zero ? "unknown error" : Marshal.PtrToStringAnsi(errorPtr) ?? "unknown error";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return e.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRuntimeIdentifier()
|
||||
@@ -1779,6 +2210,31 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
var appIdValue = appId.ToString();
|
||||
Environment.SetEnvironmentVariable("SteamAppId", appIdValue);
|
||||
Environment.SetEnvironmentVariable("SteamGameId", appIdValue);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Some runtimes keep Environment.SetEnvironmentVariable managed-only on Unix,
|
||||
// so mirror the values into the native environment that steam_api reads.
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
SetEnvMac("SteamAppId", appIdValue, 1);
|
||||
SetEnvMac("SteamGameId", appIdValue, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetEnvLinux("SteamAppId", appIdValue, 1);
|
||||
SetEnvLinux("SteamGameId", appIdValue, 1);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogDebug(e, "Could not set the native SteamAppId environment variables.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryWriteSteamAppIdFile(uint appId)
|
||||
@@ -1856,6 +2312,18 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
[DllImport("libSystem.B.dylib", EntryPoint = "dlsym")]
|
||||
private static extern IntPtr DlsymMac(IntPtr handle, string symbol);
|
||||
|
||||
[DllImport("libdl.so.2", EntryPoint = "dlerror")]
|
||||
private static extern IntPtr DlerrorLinux();
|
||||
|
||||
[DllImport("libSystem.B.dylib", EntryPoint = "dlerror")]
|
||||
private static extern IntPtr DlerrorMac();
|
||||
|
||||
[DllImport("libc.so.6", EntryPoint = "setenv")]
|
||||
private static extern int SetEnvLinux(string name, string value, int overwrite);
|
||||
|
||||
[DllImport("libSystem.B.dylib", EntryPoint = "setenv")]
|
||||
private static extern int SetEnvMac(string name, string value, int overwrite);
|
||||
|
||||
private const int RtldNow = 2;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using OmegaLeo.HelperLib.Shared.Attributes;
|
||||
|
||||
namespace OmegaLeo.HelperLib.Steamworks
|
||||
{
|
||||
[Documentation(nameof(SteamUgcSubmitItemUpdateResult), "Result of ISteamUGC::SubmitItemUpdate (SubmitItemUpdateResult_t).", null, null)]
|
||||
public sealed class SteamUgcSubmitItemUpdateResult
|
||||
{
|
||||
[Documentation(nameof(Result), "Numeric EResult value (1 = OK).", null, null)]
|
||||
public int Result { get; private set; }
|
||||
|
||||
[Documentation(nameof(UserNeedsToAcceptWorkshopLegalAgreement), "True when the user must accept the Workshop legal agreement before the item becomes visible.", null, null)]
|
||||
public bool UserNeedsToAcceptWorkshopLegalAgreement { get; private set; }
|
||||
|
||||
[Documentation(nameof(PublishedFileId), "The updated Workshop item ID.", null, null)]
|
||||
public ulong PublishedFileId { get; private set; }
|
||||
|
||||
[Documentation(nameof(IoFailure), "True when the result could not be retrieved; the other fields are then not valid.", null, null)]
|
||||
public bool IoFailure { get; private set; }
|
||||
|
||||
[Documentation(nameof(IsSuccess), "True when the result was retrieved and EResult is OK.", null, null)]
|
||||
public bool IsSuccess => !IoFailure && Result == 1;
|
||||
|
||||
internal static SteamUgcSubmitItemUpdateResult FromCallResult(SteamApiCallResult callResult)
|
||||
{
|
||||
// SubmitItemUpdateResult_t { EResult m_eResult; bool m_bUserNeedsToAcceptWorkshopLegalAgreement; PublishedFileId_t m_nPublishedFileId; }
|
||||
// Offsets 0/4/8 under both pack 4 and pack 8.
|
||||
return new SteamUgcSubmitItemUpdateResult
|
||||
{
|
||||
IoFailure = IsFailure(callResult, SteamCallbackIds.SubmitItemUpdateResult, 16),
|
||||
Result = callResult.ReadInt32(0),
|
||||
UserNeedsToAcceptWorkshopLegalAgreement = callResult.ReadBoolean(4),
|
||||
PublishedFileId = callResult.ReadUInt64(8)
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool IsFailure(SteamApiCallResult callResult, int expectedCallbackId, int minimumSize)
|
||||
{
|
||||
return callResult.IoFailure || callResult.CallbackId != expectedCallbackId || callResult.Payload.Length < minimumSize;
|
||||
}
|
||||
}
|
||||
|
||||
[Documentation(nameof(SteamUgcCreateItemResult), "Result of ISteamUGC::CreateItem (CreateItemResult_t).", null, null)]
|
||||
public sealed class SteamUgcCreateItemResult
|
||||
{
|
||||
[Documentation(nameof(Result), "Numeric EResult value (1 = OK).", null, null)]
|
||||
public int Result { get; private set; }
|
||||
|
||||
[Documentation(nameof(PublishedFileId), "The new Workshop item ID. Pass it to StartItemUpdate to upload content.", null, null)]
|
||||
public ulong PublishedFileId { get; private set; }
|
||||
|
||||
[Documentation(nameof(UserNeedsToAcceptWorkshopLegalAgreement), "True when the user must accept the Workshop legal agreement before the item becomes visible.", null, null)]
|
||||
public bool UserNeedsToAcceptWorkshopLegalAgreement { get; private set; }
|
||||
|
||||
[Documentation(nameof(IoFailure), "True when the result could not be retrieved; the other fields are then not valid.", null, null)]
|
||||
public bool IoFailure { get; private set; }
|
||||
|
||||
[Documentation(nameof(IsSuccess), "True when the result was retrieved and EResult is OK.", null, null)]
|
||||
public bool IsSuccess => !IoFailure && Result == 1;
|
||||
|
||||
internal static SteamUgcCreateItemResult FromCallResult(SteamApiCallResult callResult)
|
||||
{
|
||||
// CreateItemResult_t { EResult m_eResult; PublishedFileId_t m_nPublishedFileId; bool m_bUserNeedsToAcceptWorkshopLegalAgreement; }
|
||||
// Pack 8 (Windows): offsets 0/8/16, size 24. Pack 4 (Linux/macOS): offsets 0/4/12, size 16.
|
||||
var large = SteamCallbackDispatcher.UsesLargeCallbackPack;
|
||||
var idOffset = large ? 8 : 4;
|
||||
var legalOffset = large ? 16 : 12;
|
||||
return new SteamUgcCreateItemResult
|
||||
{
|
||||
IoFailure = SteamUgcSubmitItemUpdateResult.IsFailure(callResult, SteamCallbackIds.CreateItemResult, legalOffset + 1),
|
||||
Result = callResult.ReadInt32(0),
|
||||
PublishedFileId = callResult.ReadUInt64(idOffset),
|
||||
UserNeedsToAcceptWorkshopLegalAgreement = callResult.ReadBoolean(legalOffset)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Documentation(nameof(SteamUgcQueryCompletedResult), "Result of ISteamUGC::SendQueryUGCRequest (SteamUGCQueryCompleted_t). Release the query handle with ReleaseQueryUgcRequest when done.", null, null)]
|
||||
public sealed class SteamUgcQueryCompletedResult
|
||||
{
|
||||
[Documentation(nameof(QueryHandle), "The UGC query handle the results belong to.", null, null)]
|
||||
public ulong QueryHandle { get; private set; }
|
||||
|
||||
[Documentation(nameof(Result), "Numeric EResult value (1 = OK).", null, null)]
|
||||
public int Result { get; private set; }
|
||||
|
||||
[Documentation(nameof(NumResultsReturned), "Number of items returned in this page.", null, null)]
|
||||
public uint NumResultsReturned { get; private set; }
|
||||
|
||||
[Documentation(nameof(TotalMatchingResults), "Total number of items matching the query.", null, null)]
|
||||
public uint TotalMatchingResults { get; private set; }
|
||||
|
||||
[Documentation(nameof(CachedData), "True when the results came from the local cache.", null, null)]
|
||||
public bool CachedData { get; private set; }
|
||||
|
||||
[Documentation(nameof(NextCursor), "Cursor for the next page (cursor-based queries only).", null, null)]
|
||||
public string NextCursor { get; private set; } = string.Empty;
|
||||
|
||||
[Documentation(nameof(IoFailure), "True when the result could not be retrieved; the other fields are then not valid.", null, null)]
|
||||
public bool IoFailure { get; private set; }
|
||||
|
||||
[Documentation(nameof(IsSuccess), "True when the result was retrieved and EResult is OK.", null, null)]
|
||||
public bool IsSuccess => !IoFailure && Result == 1;
|
||||
|
||||
internal static SteamUgcQueryCompletedResult FromCallResult(SteamApiCallResult callResult)
|
||||
{
|
||||
// SteamUGCQueryCompleted_t { UGCQueryHandle_t m_handle; EResult m_eResult; uint32 m_unNumResultsReturned;
|
||||
// uint32 m_unTotalMatchingResults; bool m_bCachedData; char m_rgchNextCursor[256]; }
|
||||
// Offsets 0/8/12/16/20/21 under both pack 4 and pack 8.
|
||||
return new SteamUgcQueryCompletedResult
|
||||
{
|
||||
IoFailure = SteamUgcSubmitItemUpdateResult.IsFailure(callResult, SteamCallbackIds.SteamUgcQueryCompleted, 21),
|
||||
QueryHandle = callResult.ReadUInt64(0),
|
||||
Result = callResult.ReadInt32(8),
|
||||
NumResultsReturned = callResult.ReadUInt32(12),
|
||||
TotalMatchingResults = callResult.ReadUInt32(16),
|
||||
CachedData = callResult.ReadBoolean(20),
|
||||
NextCursor = callResult.ReadString(21, 256)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using OmegaLeo.HelperLib.Shared.Attributes;
|
||||
|
||||
namespace OmegaLeo.HelperLib.Steamworks
|
||||
@@ -68,6 +69,17 @@ namespace OmegaLeo.HelperLib.Steamworks
|
||||
{
|
||||
return _ugcManager.UpdateItem(_consumerAppId, _publishedFileId, _options, changeNote, out failedStep);
|
||||
}
|
||||
|
||||
[Documentation(nameof(Submit), "Submits the fluent update session and calls onCompleted with the upload result from SteamManager.Update().", new[]
|
||||
{
|
||||
"changeNote: Changelog note for this update.",
|
||||
"onCompleted: Called once with the upload result.",
|
||||
"failedStep: Name of the failed step if submission returns 0."
|
||||
}, null)]
|
||||
public ulong Submit(string changeNote, Action<SteamUgcSubmitItemUpdateResult> onCompleted, out string failedStep)
|
||||
{
|
||||
return _ugcManager.UpdateItem(_consumerAppId, _publishedFileId, _options, changeNote, onCompleted, out failedStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OmegaLeo.HelperLib.Shared.Attributes;
|
||||
|
||||
namespace OmegaLeo.HelperLib.Steamworks
|
||||
@@ -113,6 +117,22 @@ var items = ugc.GetSubscribedItems();
|
||||
return submitHandle;
|
||||
}
|
||||
|
||||
[Documentation(nameof(UpdateItem), "Starts an item update, applies option fields, submits it, and calls onCompleted with the upload result from SteamManager.Update().", new[]
|
||||
{
|
||||
"consumerAppId: Your game AppId.",
|
||||
"publishedFileId: Existing workshop item ID.",
|
||||
"options: Options object containing fields to update.",
|
||||
"changeNote: Changelog note for submission.",
|
||||
"onCompleted: Called once with the upload result.",
|
||||
"failedStep: Name of the failed step when the call returns 0."
|
||||
}, null)]
|
||||
public ulong UpdateItem(uint consumerAppId, ulong publishedFileId, SteamUgcItemUpdateOptions options, string changeNote, Action<SteamUgcSubmitItemUpdateResult> onCompleted, out string failedStep)
|
||||
{
|
||||
var submitHandle = UpdateItem(consumerAppId, publishedFileId, options, changeNote, out failedStep);
|
||||
RegisterResult(submitHandle, onCompleted, SteamUgcSubmitItemUpdateResult.FromCallResult, nameof(SubmitItemUpdate));
|
||||
return submitHandle;
|
||||
}
|
||||
|
||||
[Documentation(nameof(CreateItem), "Creates a new Workshop item and returns a Steam API call handle.", new[]
|
||||
{
|
||||
"consumerAppId: Your game AppId.",
|
||||
@@ -123,6 +143,30 @@ var items = ugc.GetSubscribedItems();
|
||||
return SteamNativeApi.CreateItem(consumerAppId, fileType);
|
||||
}
|
||||
|
||||
[Documentation(nameof(CreateItem), "Creates a new Workshop item and calls onCompleted with the CreateItemResult_t from SteamManager.Update(). Returns the Steam API call handle, or 0 on failure.", new[]
|
||||
{
|
||||
"consumerAppId: Your game AppId.",
|
||||
"fileType: Numeric EWorkshopFileType value.",
|
||||
"onCompleted: Called once with the new item ID and result."
|
||||
}, null)]
|
||||
public ulong CreateItem(uint consumerAppId, uint fileType, Action<SteamUgcCreateItemResult> onCompleted)
|
||||
{
|
||||
var apiCall = CreateItem(consumerAppId, fileType);
|
||||
RegisterResult(apiCall, onCompleted, SteamUgcCreateItemResult.FromCallResult, nameof(CreateItem));
|
||||
return apiCall;
|
||||
}
|
||||
|
||||
[Documentation(nameof(CreateItemAsync), "Creates a new Workshop item and returns a task with the CreateItemResult_t. Keep calling SteamManager.Update() while waiting.", new[]
|
||||
{
|
||||
"consumerAppId: Your game AppId.",
|
||||
"fileType: Numeric EWorkshopFileType value.",
|
||||
"cancellationToken: Cancels the wait (the Steam call keeps running)."
|
||||
}, null)]
|
||||
public Task<SteamUgcCreateItemResult> CreateItemAsync(uint consumerAppId, uint fileType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AwaitResult(CreateItem(consumerAppId, fileType), SteamUgcCreateItemResult.FromCallResult, nameof(CreateItem), cancellationToken);
|
||||
}
|
||||
|
||||
[Documentation(nameof(StartItemUpdate), "Starts an update transaction for an existing Workshop item.", new[]
|
||||
{
|
||||
"consumerAppId: Your game AppId.",
|
||||
@@ -181,7 +225,31 @@ var items = ugc.GetSubscribedItems();
|
||||
return SteamNativeApi.SubmitItemUpdate(updateHandle, changeNote);
|
||||
}
|
||||
|
||||
[Documentation(nameof(GetItemUpdateProgress), "Gets progress state for a pending item update upload.", new[] { "updateHandle: Handle from StartItemUpdate.", "bytesProcessed: Uploaded bytes output.", "bytesTotal: Total bytes output." }, null)]
|
||||
[Documentation(nameof(SubmitItemUpdate), "Submits a pending workshop item update and calls onCompleted with the SubmitItemUpdateResult_t from SteamManager.Update(). Returns the Steam API call handle, or 0 when submission failed.", new[]
|
||||
{
|
||||
"updateHandle: Handle from StartItemUpdate.",
|
||||
"changeNote: Changelog note for the update.",
|
||||
"onCompleted: Called once with the upload result."
|
||||
}, null)]
|
||||
public ulong SubmitItemUpdate(ulong updateHandle, string changeNote, Action<SteamUgcSubmitItemUpdateResult> onCompleted)
|
||||
{
|
||||
var apiCall = SubmitItemUpdate(updateHandle, changeNote);
|
||||
RegisterResult(apiCall, onCompleted, SteamUgcSubmitItemUpdateResult.FromCallResult, nameof(SubmitItemUpdate));
|
||||
return apiCall;
|
||||
}
|
||||
|
||||
[Documentation(nameof(SubmitItemUpdateAsync), "Submits a pending workshop item update and returns a task with the SubmitItemUpdateResult_t. Keep calling SteamManager.Update() while waiting.", new[]
|
||||
{
|
||||
"updateHandle: Handle from StartItemUpdate.",
|
||||
"changeNote: Changelog note for the update.",
|
||||
"cancellationToken: Cancels the wait (the upload keeps running)."
|
||||
}, null)]
|
||||
public Task<SteamUgcSubmitItemUpdateResult> SubmitItemUpdateAsync(ulong updateHandle, string changeNote, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AwaitResult(SubmitItemUpdate(updateHandle, changeNote), SteamUgcSubmitItemUpdateResult.FromCallResult, nameof(SubmitItemUpdate), cancellationToken);
|
||||
}
|
||||
|
||||
[Documentation(nameof(GetItemUpdateProgress),"Gets progress state for a pending item update upload.", new[] { "updateHandle: Handle from StartItemUpdate.", "bytesProcessed: Uploaded bytes output.", "bytesTotal: Total bytes output." }, null)]
|
||||
public uint GetItemUpdateProgress(ulong updateHandle, out ulong bytesProcessed, out ulong bytesTotal)
|
||||
{
|
||||
return SteamNativeApi.GetItemUpdateProgress(updateHandle, out bytesProcessed, out bytesTotal);
|
||||
@@ -206,7 +274,29 @@ var items = ugc.GetSubscribedItems();
|
||||
return SteamNativeApi.SendQueryUgcRequest(queryHandle);
|
||||
}
|
||||
|
||||
[Documentation(nameof(ReleaseQueryUgcRequest), "Releases a UGC query handle after you are done with query results.", new[] { "queryHandle: UGC query handle to release." }, null)]
|
||||
[Documentation(nameof(SendQueryUgcRequest), "Submits a UGC query and calls onCompleted with the SteamUGCQueryCompleted_t from SteamManager.Update(). Returns the Steam API call handle, or 0 on failure.", new[]
|
||||
{
|
||||
"queryHandle: UGC query handle returned from CreateQueryAllUgcRequest.",
|
||||
"onCompleted: Called once with the query result."
|
||||
}, null)]
|
||||
public ulong SendQueryUgcRequest(ulong queryHandle, Action<SteamUgcQueryCompletedResult> onCompleted)
|
||||
{
|
||||
var apiCall = SendQueryUgcRequest(queryHandle);
|
||||
RegisterResult(apiCall, onCompleted, SteamUgcQueryCompletedResult.FromCallResult, nameof(SendQueryUgcRequest));
|
||||
return apiCall;
|
||||
}
|
||||
|
||||
[Documentation(nameof(SendQueryUgcRequestAsync), "Submits a UGC query and returns a task with the SteamUGCQueryCompleted_t. Keep calling SteamManager.Update() while waiting.", new[]
|
||||
{
|
||||
"queryHandle: UGC query handle returned from CreateQueryAllUgcRequest.",
|
||||
"cancellationToken: Cancels the wait (the query keeps running)."
|
||||
}, null)]
|
||||
public Task<SteamUgcQueryCompletedResult> SendQueryUgcRequestAsync(ulong queryHandle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AwaitResult(SendQueryUgcRequest(queryHandle), SteamUgcQueryCompletedResult.FromCallResult, nameof(SendQueryUgcRequest), cancellationToken);
|
||||
}
|
||||
|
||||
[Documentation(nameof(ReleaseQueryUgcRequest),"Releases a UGC query handle after you are done with query results.", new[] { "queryHandle: UGC query handle to release." }, null)]
|
||||
public bool ReleaseQueryUgcRequest(ulong queryHandle)
|
||||
{
|
||||
return SteamNativeApi.ReleaseQueryUgcRequest(queryHandle);
|
||||
@@ -235,6 +325,30 @@ var items = ugc.GetSubscribedItems();
|
||||
{
|
||||
return SteamNativeApi.UnsubscribeItem(publishedFileId);
|
||||
}
|
||||
|
||||
private static void RegisterResult<T>(ulong apiCall, Action<T> onCompleted, Func<SteamApiCallResult, T> convert, string operation)
|
||||
{
|
||||
if (apiCall == 0 || onCompleted == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SteamManager.Dispatcher.RegisterCallResult(apiCall, result => onCompleted(convert(result))))
|
||||
{
|
||||
SteamManager.Logger.LogWarning("{Operation} was submitted but its result will not be delivered because Steam manual callback dispatch is not active.", operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<T> AwaitResult<T>(ulong apiCall, Func<SteamApiCallResult, T> convert, string operation, CancellationToken cancellationToken)
|
||||
{
|
||||
if (apiCall == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{operation} did not return a Steam API call handle. Ensure Steamworks is initialized.");
|
||||
}
|
||||
|
||||
var result = await SteamManager.Dispatcher.WaitForCallResultAsync(apiCall, cancellationToken).ConfigureAwait(false);
|
||||
return convert(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user