diff --git a/OmegaLeo.HelperLib.Steamworks/README.md b/OmegaLeo.HelperLib.Steamworks/README.md index f679e78..3f79699 100644 --- a/OmegaLeo.HelperLib.Steamworks/README.md +++ b/OmegaLeo.HelperLib.Steamworks/README.md @@ -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: _Data/Plugins/x86_64 (some Linux layouts use _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"`. diff --git a/OmegaLeo.HelperLib.Steamworks/SteamCallbackDispatcher.cs b/OmegaLeo.HelperLib.Steamworks/SteamCallbackDispatcher.cs new file mode 100644 index 0000000..2da397f --- /dev/null +++ b/OmegaLeo.HelperLib.Steamworks/SteamCallbackDispatcher.cs @@ -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(); + } + + [Documentation(nameof(Payload), "Raw bytes of the native callback struct, copied before Steam frees it.", null, null)] + public byte[] Payload { get; } + + [Documentation(nameof(ReadByte), "Reads a byte at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] + public byte ReadByte(int offset) + { + return offset >= 0 && offset < Payload.Length ? Payload[offset] : (byte)0; + } + + [Documentation(nameof(ReadBoolean), "Reads a one-byte C++ bool at the given offset.", new[] { "offset: Byte offset in the payload." }, null)] + public bool ReadBoolean(int offset) + { + return ReadByte(offset) != 0; + } + + [Documentation(nameof(ReadInt32), "Reads a 32-bit signed integer at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] + public int ReadInt32(int offset) + { + return HasBytes(offset, 4) ? BitConverter.ToInt32(Payload, offset) : 0; + } + + [Documentation(nameof(ReadUInt32), "Reads a 32-bit unsigned integer at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] + public uint ReadUInt32(int offset) + { + return HasBytes(offset, 4) ? BitConverter.ToUInt32(Payload, offset) : 0; + } + + [Documentation(nameof(ReadUInt64), "Reads a 64-bit unsigned integer at the given offset, or 0 when out of range.", new[] { "offset: Byte offset in the payload." }, null)] + public ulong ReadUInt64(int offset) + { + return HasBytes(offset, 8) ? BitConverter.ToUInt64(Payload, offset) : 0; + } + + [Documentation(nameof(ReadString), "Reads a NUL-terminated UTF-8 char array field.", new[] { "offset: Byte offset of the char array.", "maxLength: Size of the char array in the native struct." }, null)] + public string ReadString(int offset, int maxLength) + { + if (offset < 0 || offset >= Payload.Length || maxLength <= 0) + { + return string.Empty; + } + + var end = Math.Min(Payload.Length, offset + maxLength); + var length = 0; + while (offset + length < end && Payload[offset + length] != 0) + { + length++; + } + + return Encoding.UTF8.GetString(Payload, offset, length); + } + + private bool HasBytes(int offset, int size) + { + return offset >= 0 && offset + size <= Payload.Length; + } + } + + [Documentation(nameof(SteamCallbackData), "A Steam callback received through manual dispatch.", null, null)] + public sealed class SteamCallbackData : SteamCallbackPayload + { + internal SteamCallbackData(int callbackId, int steamUser, byte[] payload) : base(payload) + { + CallbackId = callbackId; + SteamUser = steamUser; + } + + [Documentation(nameof(CallbackId), "The callback id (k_iCallback) of the native struct.", null, null)] + public int CallbackId { get; } + + [Documentation(nameof(SteamUser), "The HSteamUser the callback was posted for.", null, null)] + public int SteamUser { get; } + } + + [Documentation(nameof(SteamApiCallResult), "The completed result of an asynchronous Steam API call (SteamAPICall_t).", null, null)] + public sealed class SteamApiCallResult : SteamCallbackPayload + { + internal SteamApiCallResult(ulong apiCall, int callbackId, bool ioFailure, byte[] payload) : base(payload) + { + ApiCall = apiCall; + CallbackId = callbackId; + IoFailure = ioFailure; + } + + [Documentation(nameof(ApiCall), "The SteamAPICall_t handle this result belongs to.", null, null)] + public ulong ApiCall { get; } + + [Documentation(nameof(CallbackId), "The callback id of the result struct (for example SteamCallbackIds.SubmitItemUpdateResult).", null, null)] + public int CallbackId { get; } + + [Documentation(nameof(IoFailure), "True when Steam reported a failure fetching the result, or Steam was shut down before it completed. The payload is then not valid.", null, null)] + public bool IoFailure { get; } + + internal static SteamApiCallResult Failed(ulong apiCall) + { + return new SteamApiCallResult(apiCall, 0, true, Array.Empty()); + } + } + + [Documentation(nameof(SteamOverlayActivated), "Data from GameOverlayActivated_t, raised when the Steam overlay opens or closes.", null, null)] + public readonly struct SteamOverlayActivated + { + internal SteamOverlayActivated(bool active, bool userInitiated, uint appId) + { + Active = active; + UserInitiated = userInitiated; + AppId = appId; + } + + [Documentation(nameof(Active), "True when the overlay was opened, false when it was closed.", null, null)] + public bool Active { get; } + + [Documentation(nameof(UserInitiated), "True when the user opened the overlay (as opposed to the game or Steam).", null, null)] + public bool UserInitiated { get; } + + [Documentation(nameof(AppId), "The AppId the overlay was activated for.", null, null)] + public uint AppId { get; } + } + + [Documentation(nameof(SteamCallbackDispatcher), "Runs the Steam manual callback dispatch loop and delivers callbacks and async call results. Access it through SteamManager.Instance.Callbacks; it is pumped by SteamManager.Update().", null, + @"```csharp +var steam = SteamManager.Instance; +steam.Callbacks.OverlayActivated += overlay => Pause(overlay.Active); +using var sub = steam.Callbacks.Subscribe(SteamCallbackIds.GameOverlayActivated, data => Log(data.ReadBoolean(0))); +var result = await steam.Callbacks.WaitForCallResultAsync(apiCallHandle); +```")] + public sealed class SteamCallbackDispatcher + { + private readonly object _sync = new object(); + private readonly Dictionary>> _handlers = new Dictionary>>(); + private readonly Dictionary>> _pendingCallResults = new Dictionary>>(); + + internal SteamCallbackDispatcher() + { + } + + [Documentation(nameof(UsesLargeCallbackPack), "True when Steam callback structs use 8-byte packing (Windows). Linux, Steam Deck and macOS use 4-byte packing, which moves 64-bit fields in some structs.", null, null)] + public static bool UsesLargeCallbackPack => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + [Documentation(nameof(IsManualDispatchActive), "True when manual dispatch is running, so callbacks and async call results are delivered.", null, null)] + public bool IsManualDispatchActive => SteamNativeApi.IsManualDispatchActive; + + [Documentation(nameof(PendingCallResultCount), "Number of async call handles still waiting for a result.", null, null)] + public int PendingCallResultCount + { + get + { + lock (_sync) + { + return _pendingCallResults.Count; + } + } + } + + [Documentation(nameof(OverlayActivated), "Raised from SteamManager.Update() when the Steam overlay opens or closes (GameOverlayActivated_t). Use it to pause the game.", null, null)] + public event Action OverlayActivated; + + [Documentation(nameof(Subscribe), "Subscribes to every callback with the given id. The handler receives a copy of the raw payload. Dispose the returned object to unsubscribe.", new[] + { + "callbackId: Callback id (k_iCallback), see SteamCallbackIds.", + "handler: Called from SteamManager.Update() for each matching callback." + }, null)] + public IDisposable Subscribe(int callbackId, Action handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + lock (_sync) + { + if (!_handlers.TryGetValue(callbackId, out var list)) + { + list = new List>(); + _handlers[callbackId] = list; + } + + list.Add(handler); + } + + return new Subscription(() => Unsubscribe(callbackId, handler)); + } + + [Documentation(nameof(Unsubscribe), "Removes a handler added with Subscribe.", new[] { "callbackId: Callback id the handler was registered for.", "handler: The handler to remove." }, null)] + public bool Unsubscribe(int callbackId, Action handler) + { + lock (_sync) + { + if (!_handlers.TryGetValue(callbackId, out var list) || !list.Remove(handler)) + { + return false; + } + + if (list.Count == 0) + { + _handlers.Remove(callbackId); + } + + return true; + } + } + + [Documentation(nameof(RegisterCallResult), "Registers a handler for the result of an async Steam API call. The handler runs once, from SteamManager.Update(). Returns false when the handle is invalid or manual dispatch is not active.", new[] + { + "apiCall: SteamAPICall_t handle returned by an async Steam API method.", + "onCompleted: Called with the result (check IoFailure)." + }, null)] + public bool RegisterCallResult(ulong apiCall, Action onCompleted) + { + if (onCompleted == null) + { + throw new ArgumentNullException(nameof(onCompleted)); + } + + if (apiCall == 0 || !IsManualDispatchActive) + { + return false; + } + + lock (_sync) + { + if (!_pendingCallResults.TryGetValue(apiCall, out var list)) + { + list = new List>(); + _pendingCallResults[apiCall] = list; + } + + list.Add(onCompleted); + } + + return true; + } + + [Documentation(nameof(WaitForCallResultAsync), "Returns a task that completes with the result of an async Steam API call. Keep calling SteamManager.Update() while waiting. Faults with InvalidOperationException when manual dispatch is not active.", new[] + { + "apiCall: SteamAPICall_t handle returned by an async Steam API method.", + "cancellationToken: Cancels the wait (the Steam call itself keeps running)." + }, null)] + public Task WaitForCallResultAsync(ulong apiCall, CancellationToken cancellationToken = default) + { + if (apiCall == 0) + { + return Task.FromException(new ArgumentException("The Steam API call handle is invalid.", nameof(apiCall))); + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Action handler = result => completion.TrySetResult(result); + if (!RegisterCallResult(apiCall, handler)) + { + return Task.FromException(new InvalidOperationException("Steam manual callback dispatch is not active, so async call results cannot be delivered.")); + } + + if (cancellationToken.CanBeCanceled) + { + var registration = cancellationToken.Register(() => + { + RemoveCallResult(apiCall, handler); + completion.TrySetCanceled(cancellationToken); + }); + completion.Task.ContinueWith(_ => registration.Dispose(), TaskScheduler.Default); + } + + return completion.Task; + } + + internal void RunFrame() + { + if (!SteamNativeApi.IsManualDispatchActive) + { + return; + } + + SteamNativeApi.ManualDispatchRunFrame(); + while (SteamNativeApi.ManualDispatchGetNextCallback(out var message)) + { + try + { + HandleCallback(message); + } + catch (Exception e) + { + SteamManager.Logger.LogError(e, "An exception occurred while handling Steam callback {CallbackId}.", message.CallbackId); + } + finally + { + SteamNativeApi.ManualDispatchFreeLastCallback(); + } + } + } + + internal void FailPendingCallResults() + { + List>>> pending; + lock (_sync) + { + pending = new List>>>(_pendingCallResults); + _pendingCallResults.Clear(); + } + + foreach (var entry in pending) + { + InvokeCallResultHandlers(entry.Value, SteamApiCallResult.Failed(entry.Key)); + } + } + + private void HandleCallback(SteamNativeCallbackMessage message) + { + var payload = CopyPayload(message.Param, message.ParamSize); + var data = new SteamCallbackData(message.CallbackId, message.SteamUser, payload); + + if (message.CallbackId == SteamCallbackIds.SteamApiCallCompleted) + { + HandleApiCallCompleted(data); + } + else if (message.CallbackId == SteamCallbackIds.GameOverlayActivated) + { + // GameOverlayActivated_t { uint8 m_bActive; bool m_bUserInitiated; AppId_t m_nAppID; uint32 m_dwOverlayPID; } + try + { + OverlayActivated?.Invoke(new SteamOverlayActivated(data.ReadBoolean(0), data.ReadBoolean(1), data.ReadUInt32(4))); + } + catch (Exception e) + { + SteamManager.Logger.LogError(e, "An OverlayActivated handler threw."); + } + } + + Action[] handlers = null; + lock (_sync) + { + if (_handlers.TryGetValue(message.CallbackId, out var list)) + { + handlers = list.ToArray(); + } + } + + if (handlers == null) + { + return; + } + + foreach (var handler in handlers) + { + try + { + handler(data); + } + catch (Exception e) + { + SteamManager.Logger.LogError(e, "A subscriber threw while handling Steam callback {CallbackId}.", message.CallbackId); + } + } + } + + private void HandleApiCallCompleted(SteamCallbackData data) + { + // SteamAPICallCompleted_t { SteamAPICall_t m_hAsyncCall; int m_iCallback; uint32 m_cubParam; } - same layout under pack 4 and 8. + var apiCall = data.ReadUInt64(0); + var expectedCallback = data.ReadInt32(8); + var resultSize = (int)data.ReadUInt32(12); + + List> handlers; + lock (_sync) + { + if (!_pendingCallResults.TryGetValue(apiCall, out handlers)) + { + return; + } + + _pendingCallResults.Remove(apiCall); + } + + var buffer = Marshal.AllocHGlobal(Math.Max(resultSize, 1)); + SteamApiCallResult result; + try + { + var fetched = SteamNativeApi.ManualDispatchGetApiCallResult(apiCall, buffer, resultSize, expectedCallback, out var failed); + result = fetched + ? new SteamApiCallResult(apiCall, expectedCallback, failed, CopyPayload(buffer, resultSize)) + : new SteamApiCallResult(apiCall, expectedCallback, true, Array.Empty()); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + + InvokeCallResultHandlers(handlers, result); + } + + private void RemoveCallResult(ulong apiCall, Action handler) + { + lock (_sync) + { + if (_pendingCallResults.TryGetValue(apiCall, out var list) && list.Remove(handler) && list.Count == 0) + { + _pendingCallResults.Remove(apiCall); + } + } + } + + private static void InvokeCallResultHandlers(List> handlers, SteamApiCallResult result) + { + foreach (var handler in handlers) + { + try + { + handler(result); + } + catch (Exception e) + { + SteamManager.Logger.LogError(e, "A handler threw while handling the result of Steam API call {ApiCall}.", result.ApiCall); + } + } + } + + private static byte[] CopyPayload(IntPtr source, int size) + { + if (source == IntPtr.Zero || size <= 0) + { + return Array.Empty(); + } + + var payload = new byte[size]; + Marshal.Copy(source, payload, 0, size); + return payload; + } + + private sealed class Subscription : IDisposable + { + private Action _dispose; + + public Subscription(Action dispose) + { + _dispose = dispose; + } + + public void Dispose() + { + Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } + } + } +} diff --git a/OmegaLeo.HelperLib.Steamworks/SteamExtendedManagers.cs b/OmegaLeo.HelperLib.Steamworks/SteamExtendedManagers.cs index a281d66..5532875 100644 --- a/OmegaLeo.HelperLib.Steamworks/SteamExtendedManagers.cs +++ b/OmegaLeo.HelperLib.Steamworks/SteamExtendedManagers.cs @@ -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 { diff --git a/OmegaLeo.HelperLib.Steamworks/SteamManager.cs b/OmegaLeo.HelperLib.Steamworks/SteamManager.cs index 541a04d..1feb531 100644 --- a/OmegaLeo.HelperLib.Steamworks/SteamManager.cs +++ b/OmegaLeo.HelperLib.Steamworks/SteamManager.cs @@ -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 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; } diff --git a/OmegaLeo.HelperLib.Steamworks/SteamNativeApi.cs b/OmegaLeo.HelperLib.Steamworks/SteamNativeApi.cs index f36535c..b4a7d2a 100644 --- a/OmegaLeo.HelperLib.Steamworks/SteamNativeApi.cs +++ b/OmegaLeo.HelperLib.Steamworks/SteamNativeApi.cs @@ -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 CustomLibraryPaths = new List(); + + 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 AdditionalInterfaceAccessors = new Dictionary(); private static readonly Dictionary AdditionalInterfacePointers = new Dictionary(); + 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,7 +582,10 @@ namespace OmegaLeo.HelperLib.Steamworks lock (SyncRoot) { ApplyAppIdEnvironment(appId); - TryWriteSteamAppIdFile(appId); + if (writeSteamAppIdFile) + { + TryWriteSteamAppIdFile(appId); + } } } @@ -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(); 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("SteamAPI_InitFlat"); _steamApiInit = Bind("SteamAPI_Init") ?? Bind("SteamAPI_InitSafe"); + _steamApiRestartAppIfNecessary = Bind("SteamAPI_RestartAppIfNecessary"); _steamApiShutdown = Bind("SteamAPI_Shutdown"); _steamApiRunCallbacks = Bind("SteamAPI_RunCallbacks"); + _steamApiGetHSteamPipe = Bind("SteamAPI_GetHSteamPipe"); + _steamApiManualDispatchInit = Bind("SteamAPI_ManualDispatch_Init"); + _steamApiManualDispatchRunFrame = Bind("SteamAPI_ManualDispatch_RunFrame"); + _steamApiManualDispatchGetNextCallback = Bind("SteamAPI_ManualDispatch_GetNextCallback"); + _steamApiManualDispatchFreeLastCallback = Bind("SteamAPI_ManualDispatch_FreeLastCallback"); + _steamApiManualDispatchGetApiCallResult = Bind("SteamAPI_ManualDispatch_GetAPICallResult"); + _steamAppsIsSubscribed = Bind("SteamAPI_ISteamApps_BIsSubscribed"); + _steamAppsIsSubscribedApp = Bind("SteamAPI_ISteamApps_BIsSubscribedApp"); + _steamAppsIsDlcInstalled = Bind("SteamAPI_ISteamApps_BIsDlcInstalled"); + _steamAppsIsAppInstalled = Bind("SteamAPI_ISteamApps_BIsAppInstalled"); + _steamAppsGetCurrentGameLanguage = Bind("SteamAPI_ISteamApps_GetCurrentGameLanguage"); + _steamAppsGetAvailableGameLanguages = Bind("SteamAPI_ISteamApps_GetAvailableGameLanguages"); + _steamAppsGetAppBuildId = Bind("SteamAPI_ISteamApps_GetAppBuildId"); _steamFriendsAccessor = BindInterfaceAccessor("SteamFriends", "ISteamFriends"); _steamUserStatsAccessor = BindInterfaceAccessor("SteamUserStats", "ISteamUserStats"); _steamUgcAccessor = BindInterfaceAccessor("SteamUGC", "ISteamUGC"); @@ -1597,6 +2002,7 @@ namespace OmegaLeo.HelperLib.Steamworks _steamUserStatsSetStatInt32 = Bind("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(); - 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; } diff --git a/OmegaLeo.HelperLib.Steamworks/SteamUgcCallResults.cs b/OmegaLeo.HelperLib.Steamworks/SteamUgcCallResults.cs new file mode 100644 index 0000000..64fdbde --- /dev/null +++ b/OmegaLeo.HelperLib.Steamworks/SteamUgcCallResults.cs @@ -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) + }; + } + } +} diff --git a/OmegaLeo.HelperLib.Steamworks/SteamUgcItemUpdateSession.cs b/OmegaLeo.HelperLib.Steamworks/SteamUgcItemUpdateSession.cs index 7409fd8..707f5a9 100644 --- a/OmegaLeo.HelperLib.Steamworks/SteamUgcItemUpdateSession.cs +++ b/OmegaLeo.HelperLib.Steamworks/SteamUgcItemUpdateSession.cs @@ -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 onCompleted, out string failedStep) + { + return _ugcManager.UpdateItem(_consumerAppId, _publishedFileId, _options, changeNote, onCompleted, out failedStep); + } } } diff --git a/OmegaLeo.HelperLib.Steamworks/SteamUgcManager.cs b/OmegaLeo.HelperLib.Steamworks/SteamUgcManager.cs index 7deda32..5a3b02a 100644 --- a/OmegaLeo.HelperLib.Steamworks/SteamUgcManager.cs +++ b/OmegaLeo.HelperLib.Steamworks/SteamUgcManager.cs @@ -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 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 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 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 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 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 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 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(ulong apiCall, Action onCompleted, Func 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 AwaitResult(ulong apiCall, Func 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); + } } }