diff --git a/scripts/build-setup.ps1 b/scripts/build-setup.ps1 new file mode 100644 index 0000000..e0f868d --- /dev/null +++ b/scripts/build-setup.ps1 @@ -0,0 +1,141 @@ +<# +.SYNOPSIS + Builds and packages LuaTools into an installer/setup executable and portable package using Velopack. + +.PARAMETER Configuration + The build configuration (default: "Release"). + +.PARAMETER Version + The release version (default: parsed from LuaToolsGui.csproj). + +.PARAMETER OutputDir + The directory where release artifacts (Setup.exe, portable zip, nupkg, etc.) will be placed (default: "releases"). + +.PARAMETER PackId + The Velopack package ID (default: "LuaTools"). + +.PARAMETER Runtime + The target runtime framework to bundle/check (default: "net8-x64-desktop"). +#> + +param( + [string]$Configuration = "Release", + [string]$Version = "", + [string]$OutputDir = "releases", + [string]$PackId = "LuaTools", + [string]$PackAuthors = "LuaTools", + [string]$PackTitle = "LuaTools", + [string]$Runtime = "net8-x64-desktop" +) + +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location $RootDir + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " LuaTools - Build & Package Setup" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +# 1. Resolve Project and Version +$CsprojPath = Join-Path $RootDir "src\LuaToolsGui\LuaToolsGui.csproj" +if (-not (Test-Path $CsprojPath)) { + Write-Error "Could not find project file at $CsprojPath" +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + $CsprojContent = Get-Content $CsprojPath -Raw + if ($CsprojContent -match "([^<]+)") { + $Version = $Matches[1].Trim() + Write-Host "Detected version from csproj: $Version" -ForegroundColor Green + } else { + $Version = "1.0.0" + Write-Warning "Could not detect version in csproj, defaulting to $Version" + } +} else { + Write-Host "Using specified version: $Version" -ForegroundColor Green +} + +# 2. Ensure Output Directory +$FullOutputDir = Join-Path $RootDir $OutputDir +if (-not (Test-Path $FullOutputDir)) { + New-Item -ItemType Directory -Path $FullOutputDir -Force | Out-Null +} + +# 3. Publish Project (Framework-dependent Win-x64) +$PublishDir = Join-Path $RootDir "publish" +Write-Host "`nPublishing project ($Configuration)..." -ForegroundColor Yellow + +dotnet publish $CsprojPath ` + -c $Configuration ` + -r win-x64 ` + --self-contained false ` + -o $PublishDir + +if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet publish failed with exit code $LASTEXITCODE" +} +Write-Host "Publish succeeded: $PublishDir" -ForegroundColor Green + +# 4. Locate or Install Velopack CLI (vpk) +$VpkExe = $null +$ToolsDir = Join-Path $RootDir "tools" +$LocalVpk = Join-Path $ToolsDir "vpk.exe" + +if (Test-Path $LocalVpk) { + $VpkExe = $LocalVpk +} elseif (Get-Command vpk -ErrorAction SilentlyContinue) { + $VpkExe = "vpk" +} else { + Write-Host "`nVelopack CLI (vpk) not found in PATH. Checking/installing to ./tools..." -ForegroundColor Yellow + if (-not (Test-Path $ToolsDir)) { + New-Item -ItemType Directory -Path $ToolsDir -Force | Out-Null + } + dotnet tool install --tool-path $ToolsDir vpk + if (Test-Path $LocalVpk) { + $VpkExe = $LocalVpk + } else { + Write-Error "Failed to locate or install vpk tool." + } +} + +Write-Host "Using Velopack CLI: $VpkExe" -ForegroundColor Green + +# 5. Pack with Velopack +$IconPath = Join-Path $RootDir "src\LuaToolsGui\icon.ico" +$MainExe = "LuaTools.exe" + +Write-Host "`nPackaging with Velopack..." -ForegroundColor Yellow + +$VpkArgs = @( + "pack", + "--packId", $PackId, + "--packVersion", $Version, + "--packDir", $PublishDir, + "--packAuthors", $PackAuthors, + "--packTitle", $PackTitle, + "--mainExe", $MainExe, + "--outputDir", $FullOutputDir +) + +if (Test-Path $IconPath) { + $VpkArgs += @("--icon", $IconPath) +} + +if (-not [string]::IsNullOrWhiteSpace($Runtime)) { + $VpkArgs += @("--framework", $Runtime) +} + +Write-Host "Running: $VpkExe $($VpkArgs -join ' ')" -ForegroundColor Gray +& $VpkExe @VpkArgs + +if ($LASTEXITCODE -ne 0) { + Write-Error "Velopack packaging failed with exit code $LASTEXITCODE" +} + +Write-Host "`n========================================" -ForegroundColor Green +Write-Host " Setup creation complete!" -ForegroundColor Green +Write-Host " Artifacts created in: $FullOutputDir" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green + +Get-ChildItem $FullOutputDir | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize diff --git a/src/LuaToolsGui/App.xaml b/src/LuaToolsGui/App.xaml index 655d858..58229ae 100644 --- a/src/LuaToolsGui/App.xaml +++ b/src/LuaToolsGui/App.xaml @@ -1,9 +1,8 @@ - @@ -12,14 +11,16 @@ - - - - - - - - + + + + + + + + + + diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index aa7f5ec..5fa211e 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -12,16 +12,31 @@ namespace LuaToolsGui; public partial class App : Application { private readonly IHost _host; - // True when the app was cold-started solely to run a silent install AND MinimizeToTray is off, // which means we auto-exit after the balloon so we don't leave a ghost tray icon behind. private bool _exitAfterSilentInstall; public App() { + // Subscribe to unhandled exceptions for diagnostic purposes + this.DispatcherUnhandledException += App_DispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += (s, ev) => + { + var ex = ev.ExceptionObject as Exception; + System.IO.File.AppendAllText("crash.log", $"[AppDomain Unhandled] {ex}\n"); + MessageBox.Show(ex?.ToString() ?? "Unknown AppDomain error", "AppDomain Crash", MessageBoxButton.OK, MessageBoxImage.Error); + }; + TaskScheduler.UnobservedTaskException += (s, ev) => + { + System.IO.File.AppendAllText("crash.log", $"[TaskScheduler Unobserved] {ev.Exception}\n"); + MessageBox.Show(ev.Exception.ToString(), "TaskScheduler Crash", MessageBoxButton.OK, MessageBoxImage.Error); + }; + _host = Host.CreateDefaultBuilder() .ConfigureServices(services => { + // Enable WPF data binding error tracing (Critical and Error levels) + System.Diagnostics.PresentationTraceSources.DataBindingSource.Switch.Level = System.Diagnostics.SourceLevels.Critical | System.Diagnostics.SourceLevels.Error; services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -43,6 +58,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); // one per page (Home, Add) services.AddSingleton(); services.AddSingleton(); @@ -60,6 +76,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); // one per dialog services.AddSingleton(); services.AddSingleton(); @@ -72,6 +89,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -87,27 +105,14 @@ public App() // never run it concurrently. A second caller drops out immediately. private readonly System.Threading.SemaphoreSlim _updateFlowGate = new(1, 1); - /// - /// Warn when Steam has overwritten launch options we'd applied, and offer to put them back. - /// - /// - /// Steam rebuilds appinfo.vdf from PICS on login, app updates and store browsing. It did so twice - /// while this feature was being written, so an applied edit is not permanent. Re-applying is offered - /// but never automatic: it closes Steam, which is not something to do behind the user's back at - /// startup. Costs nothing when no launch options have been edited (the store short-circuits on empty). - /// - /// private async Task CheckLaunchOptionDriftAsync() { try { var launch = _host.Services.GetRequiredService(); if (launch.Store.IsEmpty) return; - - // Indexing the ~373 MB cache takes a couple of seconds, never on the UI thread. var drifted = await Task.Run(launch.FindDrifted); if (drifted.Count == 0) return; - var toast = _host.Services.GetRequiredService(); Dispatcher.Invoke(() => toast.ShowAction( LuaToolsGui.Resources.Strings.Launch_Drift_Title, @@ -121,29 +126,15 @@ private async Task CheckLaunchOptionDriftAsync() } } - /// - /// The drift notice's "Re-apply" button: confirm, then write the staged edits back into appinfo. - /// - /// - /// The write runs OFF the UI thread. Unlike the launch-options dialog (which is modal, so its own - /// synchronous apply merely blocks a window that's already blocking), this fires with the main window - /// live, and Apply indexes a ~373 MB file, copies a backup and rewrites it. On the UI thread - /// that's a multi-second freeze of the whole app. - /// - /// private static async Task ReapplyDriftedAsync( Services.AppInfo.LaunchOptionsService launch, IReadOnlyList drifted, ToastService toast) { - // Same wording as the dialog's own prompt: closing Steam should never read as a different - // decision depending on where it was triggered from. if (MessageBox.Show( LuaToolsGui.Resources.Strings.Launch_ApplyNow_Body, LuaToolsGui.Resources.Strings.Launch_ApplyNow_Title, MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK) return; - var result = await Task.Run(() => launch.Reapply(drifted)); - if (result.Ok) toast.Show(LuaToolsGui.Resources.Strings.Launch_Title, result.SteamWasRunning @@ -154,57 +145,30 @@ private static async Task ReapplyDriftedAsync( string.Format(LuaToolsGui.Resources.Strings.Launch_ApplyFailed, result.Error), error: true); } - /// Set by OnStartup to so non-UI callers (e.g. the - /// /check-updates HTTP handler) can run the exact same update flow instead of a divergent one. internal static Func? RunUpdateFlow; - /// The Steam-open update flow (fully silent): update the APP first, unconditionally, before - /// ever touching the plugin. Then, once the running app is guaranteed current, check/apply a plugin - /// update against it. Called on a loader (--tray-locked) launch and on the Steam-open re-check poke; - /// safe to call repeatedly. - /// - /// App-before-plugin is load-bearing, not just tidy ordering: the app and plugin are NOT independently - /// safe to update out of order whenever a plugin release changes something the app's own compiled code - /// depends on (e.g. 's CDP port is a compile-time constant. - /// An old app build talking to a freshly-updated plugin that moved the port simply can't connect, and - /// won't self-heal until the app itself happens to update, which is not guaranteed to land in the same - /// pass: the app and plugin ship from separate repos on separate cadences, so one can succeed while the - /// other fails/lags). Restarting into the latest app FIRST, before it goes anywhere near a plugin - /// update, means whatever the plugin changes is always applied by a process that already understands - /// it. - /// private async Task RunUpdateFlowAsync() { if (!_updateFlowGate.Wait(0)) return; // another run already in progress try { - // 1) Stage + immediately apply any app update, before touching the plugin at all. - // ApplyAndRestart() terminates this process; the relaunched instance (launched with - // --tray-locked) re-enters this same flow via OnStartup once it's already current, so this - // run's job ends here. There is nothing safe left for THIS process to do. try { await Updates.CheckAndStageAsync(); } catch { /* offline / not installed */ } if (Updates.HasStagedUpdate) { Dispatcher.Invoke(() => Updates.ApplyAndRestart(new[] { "--minimized", "--tray-locked" })); return; } - - // 2) No app update pending: safe to check/apply a plugin update against this (already-current) app. - try + var installer = _host.Services.GetRequiredService(); + var st = await installer.GetStatusAsync(force: true); + if (st.UpdateAvailable) { - var installer = _host.Services.GetRequiredService(); - var st = await installer.GetStatusAsync(force: true); - if (st.UpdateAvailable) + if (!st.DllMatches) { - if (!st.DllMatches) - { - var t = _host.Services.GetRequiredService(); - Dispatcher.Invoke(() => t.Show("LuaTools", "Updating plugin. Steam will restart.")); - } - await installer.InstallAsync(progress: null); + var t = _host.Services.GetRequiredService(); + Dispatcher.Invoke(() => t.Show("LuaTools", "Updating plugin. Steam will restart.")); } + await installer.InstallAsync(progress: null); } - catch { /* offline / install error. Retry next Steam-open */ } } finally { _updateFlowGate.Release(); } } @@ -212,9 +176,7 @@ private async Task RunUpdateFlowAsync() protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); - - // Legacy cleanup: older builds staged downloads in ~/Downloads/LuaTools (they now stage in - // %TEMP% and self-delete). Remove any leftovers from that user-visible folder, best-effort. + // Legacy cleanup: older builds staged downloads in ~/Downloads/LuaTools (they now stage in %TEMP% and self-delete). _ = System.Threading.Tasks.Task.Run(() => { try @@ -225,108 +187,64 @@ protected override async void OnStartup(StartupEventArgs e) } catch { /* best effort, never block startup on cleanup */ } }); - await _host.StartAsync(); - - // Rewrite any pre-3-mode SelectedMode BEFORE anything reads it. UnlockerService.SelectedMode - // would otherwise parse a legacy value to null and quietly present an unconfigured app. Users - // whose mode was retired outright (SteamTools, the CloudRedirect fix) have no mode now, so - // onboarding is forced back open: OnboardingComplete is a permanent flag that every existing - // user already has set, and clearing SelectedMode alone would leave them with no mode AND no - // overlay explaining why. + // Rewrite any pre-3-mode SelectedMode BEFORE anything reads it. if (ModeMigration.Apply(_host.Services.GetRequiredService())) _host.Services.GetRequiredService().OnboardingComplete = false; - var main = _host.Services.GetRequiredService(); var settingsVm = _host.Services.GetRequiredService(); - - // Changing the language needs a relaunch (x:Static resources resolve at parse time). settingsVm.RequestRestart = RelaunchApp; - var window = _host.Services.GetRequiredService(); - - // Turning off "Minimize to tray" while hidden in the tray → bring the window back. settingsVm.RequestShowWindow = () => Dispatcher.Invoke(window.RestoreFromTray); - - // Relaunching the app (single-instance) signals this event → surface the existing window and - // check for any protocol URL a second instance wrote. AutoReset + executeOnlyOnce:false so it - // keeps firing for every relaunch. if (Program.ShowWindowSignal is not null) System.Threading.ThreadPool.RegisterWaitForSingleObject( Program.ShowWindowSignal, (_, _) => Dispatcher.Invoke(() => { - // A silent install relaunch stays headless: don't surface the window for it. string? pending = ProtocolService.TryReadPending(); bool silent = pending is not null && ProtocolService.Parse(pending).Silent; - if (!silent) - window.RestoreFromTray(); - if (pending is not null) - HandleProtocolUrl(pending); + if (!silent) window.RestoreFromTray(); + if (pending is not null) HandleProtocolUrl(pending); }), null, System.Threading.Timeout.Infinite, executeOnlyOnce: false); - - // A --tray-locked relaunch (the loader) signals this → enable close-to-tray for the session even if - // this instance was started without the flag. Idempotent; keeps firing for every relaunch. if (Program.EnableTrayLockSignal is not null) System.Threading.ThreadPool.RegisterWaitForSingleObject( Program.EnableTrayLockSignal, (_, _) => Program.SessionTrayLock = true, null, System.Threading.Timeout.Infinite, executeOnlyOnce: false); - - // A --tray-locked relaunch (the loader on Steam-open) signals this → re-run the update flow so an - // already-running app still updates when the user opens Steam. Guarded internally against overlap. if (Program.RecheckUpdatesSignal is not null) System.Threading.ThreadPool.RegisterWaitForSingleObject( Program.RecheckUpdatesSignal, (_, _) => _ = RunUpdateFlowAsync(), null, System.Threading.Timeout.Infinite, executeOnlyOnce: false); - - // Expose the same flow to non-UI callers (the /check-updates HTTP handler). RunUpdateFlow = RunUpdateFlowAsync; - - // Settings' own "Sign in with Discord" button → browser OAuth (unchanged). settingsVm.RequestSignIn = () => main.SignInCommand.ExecuteAsync(null); - - // Guests hitting a protected action on other pages → navigate to Settings with context banner. - Func navigateToSignIn = () => - { - Dispatcher.Invoke(() => - { - settingsVm.LoginRequiredMessage = LuaToolsGui.Resources.Strings.Settings_LoginRequired; - window.NavigateToSettings(); - }); - return Task.CompletedTask; - }; - _host.Services.GetRequiredService().RequestSignIn = navigateToSignIn; - _host.Services.GetRequiredService().RequestSignIn = navigateToSignIn; var toast = _host.Services.GetRequiredService(); - toast.Attach(window.RootSnackbar); // wire the presenter before anything can raise a toast - - // Language changed → persistent toast offering an immediate relaunch. + toast.Attach(window.RootSnackbar); settingsVm.RequestRestartPrompt = () => Dispatcher.Invoke(() => toast.ShowAction( LuaToolsGui.Resources.Strings.Lang_Changed_Title, LuaToolsGui.Resources.Strings.Lang_Changed_Body, LuaToolsGui.Resources.Strings.Lang_Changed_Restart, () => settingsVm.RequestRestart?.Invoke())); - - // App updates now apply silently via RunUpdateFlowAsync (restart-on-Steam-open, unconditionally - // and before any plugin update), so no "Restart" prompt toast. var download = _host.Services.GetRequiredService(); - var manage = _host.Services.GetRequiredService(); - - // Manage page "Update" → go to the Add page pre-seeded with that appid. - manage.NavigateToAdd = appId => - Dispatcher.Invoke(() => { window.NavigateToAdd(); download.SeedSearch(appId); }); - - // Manage flyout "Manage Build" → go to the Builds page with that game selected. var builds = _host.Services.GetRequiredService(); - manage.NavigateToBuilds = appId => - Dispatcher.Invoke(() => { window.NavigateToBuilds(); _ = builds.SelectAppAsync(appId); }); - - // Manage flyout "Launch options…" → modal editor over Steam's appinfo cache. + var achievements = _host.Services.GetRequiredService(); + // Manage page hooks + manage.NavigateToAdd = appId => Dispatcher.Invoke(() => { window.NavigateToAdd(); download.SeedSearch(appId); }); + manage.NavigateToBuilds = appId => Dispatcher.Invoke(() => { window.NavigateToBuilds(); _ = builds.SelectAppAsync(appId); }); + manage.NavigateToAchievements = appId => Dispatcher.Invoke(() => + { + window.NavigateToAchievements(); + _ = achievements.SelectGameAsync(new Models.SamGameInfo + { + Id = (uint)appId, + Name = $"App {appId}", + Type = "normal", + DisplayCoverUrl = _host.Services.GetRequiredService().GetCoverPathOrUrl(appId), + }); + }); manage.OpenLaunchOptions = (appId, name) => Dispatcher.Invoke(() => { var dialog = new LaunchOptionsDialog( @@ -334,23 +252,14 @@ protected override async void OnStartup(StartupEventArgs e) { Owner = window }; dialog.ShowDialog(); }); - - // Steam regenerates appinfo.vdf from PICS, wiping launch edits. Check once at startup and - // OFFER to re-apply, never silently, since applying closes Steam. _ = CheckLaunchOptionDriftAsync(); - - // Home "recently added" + Add install banner "Reveal" → go to Manage and open that game's detail. - Action openInManage = appId => - Dispatcher.Invoke(() => { window.NavigateToManage(); _ = manage.OpenDetailForAppIdAsync(appId); }); + // Home navigation var home = _host.Services.GetRequiredService(); + Action openInManage = appId => Dispatcher.Invoke(() => { window.NavigateToManage(); _ = manage.OpenDetailForAppIdAsync(appId); }); home.NavigateToGame = openInManage; download.NavigateToGame = openInManage; - builds.NavigateToManage = openInManage; // Builds "Manage" button: the reverse of "Manage Build" - - // Dragging a SteamDB / Steam store link onto either drop box installs that appid. Routed through - // HandleProtocolUrl rather than calling ProtocolInstall directly, so a dropped link and - // luatools://install/ are literally the same path and can't drift apart later. - // DropInstallViewModel is transient, so Home and Add each hold their own instance. + builds.NavigateToManage = openInManage; + // Drag‑and‑drop install Func installByAppId = appId => { Dispatcher.Invoke(() => HandleProtocolUrl($"luatools://install/{appId}")); @@ -358,50 +267,26 @@ protected override async void OnStartup(StartupEventArgs e) }; home.Drop.InstallByAppId = installByAppId; download.Drop.InstallByAppId = installByAppId; - - // Home dashboard cells → section navigation. + // Dashboard navigation home.NavigateToPlugin = () => Dispatcher.Invoke(window.NavigateToPlugin); home.NavigateToManage = () => Dispatcher.Invoke(window.NavigateToManage); home.NavigateToSettings = () => Dispatcher.Invoke(window.NavigateToSettings); home.NavigateToMode = () => Dispatcher.Invoke(window.NavigateToMode); - - // Onboarding finished applying its actions → refresh the Home dashboard tiles (mode + plugin status). main.Onboarding.RefreshHome = () => Dispatcher.Invoke(() => home.LoadAsync()); - - // Any game added (plugin store-page button, drag-drop, Add page, Fixes) → refresh the library views - // live. LuaInstaller.Installed can fire on a background thread (plugin install), so marshal to UI. + // Refresh library on install var luaInstaller = _host.Services.GetRequiredService(); var appInfo = _host.Services.GetRequiredService(); luaInstaller.Installed += appId => Dispatcher.InvokeAsync(async () => { - _ = manage.LoadAsync(); // re-scan so Manage updates too if it's the visible page - _ = builds.LoadAsync(); // a newly installed lua is a new variant in the vault - await home.RefreshLibraryAsync(); // game appears (its cover may lag for newer titles) - - // Newer titles have no guessable header URL: the classic CDN path 404s and the real header is - // a content-hashed store_item_assets URL that only comes from appdetails. Warm that game's - // details at interactive priority (retries past throttling), then refresh again so its cover - // fills in instead of staying blank until an app restart. + _ = manage.LoadAsync(); + _ = builds.LoadAsync(); + await home.RefreshLibraryAsync(); if (await appInfo.EnsureFullDetailsAsync(appId)) await home.RefreshLibraryAsync(); }); - - // Handle a protocol URL from the command line (first launch) or from a temp file left by a - // second instance that exited before the signal listener was wired up. string? url = Program.StartupUrl ?? ProtocolService.TryReadPending(); - - // A silent install launch (luatools://install/silent/) runs headless: stay in the tray and - // never surface the window. The window's Loaded handler (which restores auth) won't fire when we - // skip Show(), so restore the session explicitly before the install runs. bool silentStartup = (url is not null && ProtocolService.Parse(url).Silent) || Program.StartMinimized; - - // Auto-exit after a silent install only when this was a COLD launch for it (StartupUrl came on the - // command line, not from an already-running second instance) AND the user doesn't keep a tray app - // around. Otherwise the app was already living somewhere and must stay. - _exitAfterSilentInstall = silentStartup - && Program.StartupUrl is not null - && !settingsVm.MinimizeToTray; - + _exitAfterSilentInstall = silentStartup && Program.StartupUrl is not null && !settingsVm.MinimizeToTray; if (silentStartup) { window.StartSilent(); @@ -410,60 +295,30 @@ protected override async void OnStartup(StartupEventArgs e) else { window.Show(); - - // First-run onboarding: show the welcome overlay on a fresh install. Skip it (and mark done) - // when the user is already set up (a managed mode selected AND the plugin installed), so - // existing users / dev machines aren't nagged. Marking done here is permanent, so switching - // mode later never re-triggers onboarding (only ModeMigration ever clears it again). var cache = _host.Services.GetRequiredService(); if (!cache.OnboardingComplete) { var unlocker = _host.Services.GetRequiredService(); var installer = _host.Services.GetRequiredService(); - // Custom deliberately doesn't count: a first-run user can't meaningfully choose "I'll - // manage it myself" before they've been shown what the options are. - bool configured = - unlocker.SelectedMode is (UnlockerMode.Ost or UnlockerMode.Bst) - && installer.IsInstalledLocally(); - if (configured) cache.OnboardingComplete = true; - else main.Onboarding.IsOpen = true; + bool configured = unlocker.SelectedMode is (UnlockerMode.Ost or UnlockerMode.Bst) && installer.IsInstalledLocally(); + if (configured) cache.OnboardingComplete = true; else main.Onboarding.IsOpen = true; } } - - if (url is not null) - HandleProtocolUrl(url); - - // Background, non-blocking Steam-open update flow (app + plugin), but ONLY in the loader context - // (--tray-locked). A manual / protocol / silent-install launch skips it, so the app never - // auto-updates or restarts mid-manual-use. It only happens when Steam launches us. (Velopack only - // updates to a STRICTLY HIGHER version, so every release must bump --packVersion.) - if (Program.SessionTrayLock) - _ = RunUpdateFlowAsync(); - - // Background, non-blocking key donation (runs only when the setting is on; silent + deduped). + if (url is not null) HandleProtocolUrl(url); + if (Program.SessionTrayLock) _ = RunUpdateFlowAsync(); _ = _host.Services.GetRequiredService().SendPendingKeysIfEnabledAsync(); - - // Anonymous app-launch ping (Umami). Fire-and-forget; never blocks. _ = _host.Services.GetRequiredService().TrackAppLaunchAsync(); - - // Warm the hardware-appid blacklist (refreshes from GitHub if the cache is stale). Fire-and-forget. _ = _host.Services.GetRequiredService().EnsureFreshAsync(); } protected override async void OnExit(ExitEventArgs e) { - // If an update was downloaded but not yet applied, stage it for after exit. - if (Updates.HasStagedUpdate) - Updates.ApplyOnExit(); - + if (Updates.HasStagedUpdate) Updates.ApplyOnExit(); await _host.StopAsync(); _host.Dispose(); base.OnExit(e); } - /// Relaunch the app (used after a language change). The single-instance mutex is released - /// only when THIS process exits, so we start the new instance via a short delayed shell command. By - /// the time it launches the exe, our mutex is free and the new instance won't bow out. private void RelaunchApp() { try @@ -471,7 +326,6 @@ private void RelaunchApp() string? exe = Environment.ProcessPath; if (exe is not null) { - // cmd: wait ~1.2s for this process's mutex to release, then start the exe detached. var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", $"/c timeout /t 2 /nobreak >nul & start \"\" \"{exe}\"") { @@ -488,17 +342,14 @@ private void RelaunchApp() } } - /// Route a luatools:// protocol URL to the appropriate page and action. private void HandleProtocolUrl(string url) { var (action, appId, silent) = ProtocolService.Parse(url); if (action is null || appId is null) return; - var window = _host.Services.GetRequiredService(); var download = _host.Services.GetRequiredService(); var manage = _host.Services.GetRequiredService(); var fixes = _host.Services.GetRequiredService(); - switch (action) { case "game": @@ -508,12 +359,10 @@ private void HandleProtocolUrl(string url) case "install": if (silent) { - // Headless: don't navigate or surface; install in the background, then a tray balloon. _ = download.ProtocolInstall(appId.Value, (msg, error) => Dispatcher.Invoke(() => { window.ShowInstallNotification(msg, error); - // Cold launch + no tray app wanted → exit once the balloon has had time to show. if (_exitAfterSilentInstall) _ = Task.Delay(6000).ContinueWith(_ => Dispatcher.Invoke(Shutdown)); })); @@ -534,4 +383,10 @@ private void HandleProtocolUrl(string url) break; } } + + private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + MessageBox.Show(e.Exception.ToString(), "Unhandled Exception", MessageBoxButton.OK, MessageBoxImage.Error); + e.Handled = true; + } } diff --git a/src/LuaToolsGui/AppConfig.cs b/src/LuaToolsGui/AppConfig.cs index 68d06f0..d05279e 100644 --- a/src/LuaToolsGui/AppConfig.cs +++ b/src/LuaToolsGui/AppConfig.cs @@ -70,10 +70,7 @@ public static class AppConfig /// public static readonly string[] GithubReleasesRepos = [ - "https://github.com/madoiscool/LuaTools", // primary - "https://github.com/mendy-tools/LuaTools", // backup. Create this repo + re-upload the Velopack - // assets ONLY if the primary goes down (404s harmlessly - // until then; UpdateService just falls through past it). + "https://github.com/not1cyyy/LuaTools", ]; /// The primary releases repo (first in ). diff --git a/src/LuaToolsGui/Converters.cs b/src/LuaToolsGui/Converters.cs index 5480b48..cf0a4b9 100644 --- a/src/LuaToolsGui/Converters.cs +++ b/src/LuaToolsGui/Converters.cs @@ -36,10 +36,12 @@ public object ConvertBack(object? value, Type targetType, object? parameter, Cul throw new NotSupportedException(); } -public class InverseBoolToVisibilityConverter : IValueConverter + + +public class BooleanToVisibilityConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value is true ? Visibility.Collapsed : Visibility.Visible; + value is true ? Visibility.Visible : Visibility.Collapsed; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); @@ -140,3 +142,21 @@ public class StatusToBrushConverter : IValueConverter public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); } + +public class EnumEqualsConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value == null || parameter == null) return false; + return string.Equals(value.ToString(), parameter.ToString(), StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is true && parameter is string paramStr) + { + return Enum.Parse(targetType, paramStr); + } + return Binding.DoNothing; + } +} diff --git a/src/LuaToolsGui/Converters/BoolToVisibilityConverter.cs b/src/LuaToolsGui/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..2eebf24 --- /dev/null +++ b/src/LuaToolsGui/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace LuaToolsGui +{ + /// + /// Converts true → Visible, false → Collapsed. + /// + public sealed class BoolToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value is bool b && b) ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is Visibility v && v == Visibility.Visible; + } + } +} diff --git a/src/LuaToolsGui/Converters/InverseBoolToVisibilityConverter.cs b/src/LuaToolsGui/Converters/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000..f21b5b0 --- /dev/null +++ b/src/LuaToolsGui/Converters/InverseBoolToVisibilityConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace LuaToolsGui +{ + /// + /// Inverts a boolean to Visibility: true → Collapsed, false → Visible. + /// + public sealed class InverseBoolToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value is bool b && b) ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return !(value is Visibility v && v == Visibility.Visible); + } + } +} diff --git a/src/LuaToolsGui/LuaToolsGui.csproj b/src/LuaToolsGui/LuaToolsGui.csproj index 55583b6..b760879 100644 --- a/src/LuaToolsGui/LuaToolsGui.csproj +++ b/src/LuaToolsGui/LuaToolsGui.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -10,7 +10,7 @@ LuaTools LuaTools LuaTools - 1.1.3 + 1.1.6 icon.ico LuaToolsGui.Program diff --git a/src/LuaToolsGui/MainWindow.xaml b/src/LuaToolsGui/MainWindow.xaml index 4cb4b36..56fe094 100644 --- a/src/LuaToolsGui/MainWindow.xaml +++ b/src/LuaToolsGui/MainWindow.xaml @@ -77,6 +77,11 @@ + + + + + diff --git a/src/LuaToolsGui/MainWindow.xaml.cs b/src/LuaToolsGui/MainWindow.xaml.cs index 2adf975..35afc83 100644 --- a/src/LuaToolsGui/MainWindow.xaml.cs +++ b/src/LuaToolsGui/MainWindow.xaml.cs @@ -124,6 +124,9 @@ public void ShowInstallNotification(string message, bool error) /// Switch to Builds (used by the Manage flyout's "Manage Build"). Caller selects the game. public void NavigateToBuilds() => RootNavigation.Navigate(typeof(BuildsView)); + /// Switch to Achievements (Steam Achievement Manager). + public void NavigateToAchievements() => RootNavigation.Navigate(typeof(AchievementsView)); + /// Switch to Settings (used when a guest hits a protected action). public void NavigateToSettings() => RootNavigation.Navigate(typeof(SettingsView)); diff --git a/src/LuaToolsGui/Models/SamModels.cs b/src/LuaToolsGui/Models/SamModels.cs new file mode 100644 index 0000000..f7e6fea --- /dev/null +++ b/src/LuaToolsGui/Models/SamModels.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; +using LuaToolsGui.Services.SAM.Native.Types; + +namespace LuaToolsGui.Models; + +public partial class SamGameInfo : ObservableObject +{ + public uint Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = "normal"; // "normal", "demo", "mod", "junk" + public string? ImageUrl { get; set; } + public bool IsInstalledInLuaTools { get; set; } + + [ObservableProperty] + private string? _displayCoverUrl; +} + +public partial class SamAchievement : ObservableObject +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? IconNormal { get; set; } + public string? IconLocked { get; set; } + public bool IsHidden { get; set; } + public int Permission { get; set; } + + public bool OriginalIsAchieved { get; set; } + public DateTime? UnlockTime { get; set; } + + [ObservableProperty] + private bool _isAchieved; + + [ObservableProperty] + private string? _iconUrl; + + [ObservableProperty] + private string? _lockedIconUrl; + + public bool IsModified => IsAchieved != OriginalIsAchieved; + + public string DisplayUnlockTime => UnlockTime.HasValue + ? UnlockTime.Value.ToString("g") + : string.Empty; + + public string EffectiveIconUrl => IsAchieved + ? (IconUrl ?? LockedIconUrl ?? string.Empty) + : (LockedIconUrl ?? IconUrl ?? string.Empty); + + partial void OnIsAchievedChanged(bool value) + { + OnPropertyChanged(nameof(IsModified)); + OnPropertyChanged(nameof(EffectiveIconUrl)); + } +} + +public partial class SamStat : ObservableObject +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public UserStatType StatType { get; set; } = UserStatType.Integer; + + public int MinInt { get; set; } = int.MinValue; + public int MaxInt { get; set; } = int.MaxValue; + public float MinFloat { get; set; } = float.MinValue; + public float MaxFloat { get; set; } = float.MaxValue; + public bool IncrementOnly { get; set; } + public int Permission { get; set; } + + public int OriginalIntValue { get; set; } + public float OriginalFloatValue { get; set; } + + [ObservableProperty] + private int _intValue; + + [ObservableProperty] + private float _floatValue; + + [ObservableProperty] + private string _valueString = string.Empty; + + public bool IsFloat => StatType is UserStatType.Float or UserStatType.AverageRate; + + public bool IsModified + { + get + { + if (IsFloat) + { + return Math.Abs(FloatValue - OriginalFloatValue) > 0.0001f; + } + return IntValue != OriginalIntValue; + } + } + + public string DisplayType => StatType switch + { + UserStatType.Integer => "Integer", + UserStatType.Float => "Float", + UserStatType.AverageRate => "Average Rate", + _ => StatType.ToString(), + }; + + public string RangeDescription + { + get + { + if (IsFloat) + { + if (MinFloat > float.MinValue && MaxFloat < float.MaxValue) + return $"[{MinFloat} .. {MaxFloat}]"; + if (MinFloat > float.MinValue) + return $">= {MinFloat}"; + if (MaxFloat < float.MaxValue) + return $"<= {MaxFloat}"; + return "Any float"; + } + + if (MinInt > int.MinValue && MaxInt < int.MaxValue) + return $"[{MinInt} .. {MaxInt}]"; + if (MinInt > int.MinValue) + return $">= {MinInt}"; + if (MaxInt < int.MaxValue) + return $"<= {MaxInt}"; + return "Any integer"; + } + } + + partial void OnIntValueChanged(int value) + { + _valueString = value.ToString(); + OnPropertyChanged(nameof(ValueString)); + OnPropertyChanged(nameof(IsModified)); + } + + partial void OnFloatValueChanged(float value) + { + _valueString = value.ToString("G"); + OnPropertyChanged(nameof(ValueString)); + OnPropertyChanged(nameof(IsModified)); + } + + partial void OnValueStringChanged(string value) + { + if (IsFloat) + { + if (float.TryParse(value, out float f)) + { + _floatValue = f; + OnPropertyChanged(nameof(FloatValue)); + OnPropertyChanged(nameof(IsModified)); + } + } + else + { + if (int.TryParse(value, out int i)) + { + _intValue = i; + OnPropertyChanged(nameof(IntValue)); + OnPropertyChanged(nameof(IsModified)); + } + } + } +} + +public class SamGameStatsData +{ + public uint AppId { get; set; } + public string GameName { get; set; } = string.Empty; + public List Achievements { get; set; } = []; + public List Stats { get; set; } = []; + public string? ErrorMessage { get; set; } +} + +public class SamStoreRequest +{ + public uint AppId { get; set; } + public Dictionary Achievements { get; set; } = []; + public Dictionary IntStats { get; set; } = []; + public Dictionary FloatStats { get; set; } = []; + public bool ResetAll { get; set; } + public bool ResetAchievementsToo { get; set; } +} + +public class SamStoreResult +{ + public bool Success { get; set; } + public int AchievementsStored { get; set; } + public int StatsStored { get; set; } + public string? ErrorMessage { get; set; } +} diff --git a/src/LuaToolsGui/Program.cs b/src/LuaToolsGui/Program.cs index eaba064..d46d435 100644 --- a/src/LuaToolsGui/Program.cs +++ b/src/LuaToolsGui/Program.cs @@ -13,6 +13,14 @@ public static class Program [STAThread] public static void Main(string[] args) { + // Handle SAM worker CLI invocation before any WPF or single-instance mutex + if (args is { Length: > 0 } && args[0].Equals("--sam-worker", StringComparison.OrdinalIgnoreCase)) + { + int exitCode = Services.SAM.SamWorker.RunAsync(args).GetAwaiter().GetResult(); + Environment.Exit(exitCode); + return; + } + // MUST run before any WPF/UI work: handles Velopack install/update hooks, // then no-ops on a normal launch. VelopackApp.Build().Run(); diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 226b9b5..000b64a 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -24,6 +24,7 @@ public static class Strings public static string Nav_Add => Get(nameof(Nav_Add)); public static string Nav_Manage => Get(nameof(Nav_Manage)); public static string Nav_Builds => Get(nameof(Nav_Builds)); + public static string Nav_Achievements => Get(nameof(Nav_Achievements)); public static string Nav_Mode => Get(nameof(Nav_Mode)); public static string Nav_Fixes => Get(nameof(Nav_Fixes)); public static string Nav_RestartSteam => Get(nameof(Nav_RestartSteam)); diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 453cf27..01f586e 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -63,6 +63,7 @@ Home Add Manage + Achievements Mode Fixes Restart Steam diff --git a/src/LuaToolsGui/Services/CoverCache.cs b/src/LuaToolsGui/Services/CoverCache.cs index 098366a..9cca87f 100644 --- a/src/LuaToolsGui/Services/CoverCache.cs +++ b/src/LuaToolsGui/Services/CoverCache.cs @@ -68,6 +68,10 @@ private static bool IsHeaderCapsulePlaceholder(byte[] b) return p; } + /// Returns the cached local file path if available, or the standard Steam CDN header URL. + public string GetCoverPathOrUrl(long appid) => + GetLocalPath(appid) ?? $"https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{appid}/header.jpg"; + /// True if we already determined this appid has no usable cover (don't keep retrying). public bool IsKnownMissing(long appid) => _noCover.ContainsKey(appid); diff --git a/src/LuaToolsGui/Services/SAM/Native/CallHandle.cs b/src/LuaToolsGui/Services/SAM/Native/CallHandle.cs new file mode 100644 index 0000000..f393129 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/CallHandle.cs @@ -0,0 +1,26 @@ +using System; + +namespace LuaToolsGui.Services.SAM.Native; + +public struct CallHandle : IEquatable +{ + public static readonly CallHandle Invalid = new(0); + + private readonly ulong _value; + + public CallHandle(ulong value) + { + _value = value; + } + + public static implicit operator ulong(CallHandle handle) => handle._value; + public static implicit operator CallHandle(ulong value) => new(value); + + public override bool Equals(object? obj) => obj is CallHandle handle && Equals(handle); + public bool Equals(CallHandle other) => _value == other._value; + public override int GetHashCode() => _value.GetHashCode(); + public override string ToString() => _value.ToString(); + + public static bool operator ==(CallHandle left, CallHandle right) => left.Equals(right); + public static bool operator !=(CallHandle left, CallHandle right) => !left.Equals(right); +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Callback.cs b/src/LuaToolsGui/Services/SAM/Native/Callback.cs new file mode 100644 index 0000000..0a48e3d --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Callback.cs @@ -0,0 +1,20 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native; + +public class Callback : ICallback +{ + public delegate void CallbackFunction(TParameter parameter); + + public int Id { get; protected set; } + public bool IsServer { get; protected set; } + public event CallbackFunction? OnRun; + + public void Run(IntPtr param) + { + if (OnRun is null) return; + var parameter = (TParameter)Marshal.PtrToStructure(param, typeof(TParameter))!; + OnRun(parameter); + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Callbacks/AppDataChanged.cs b/src/LuaToolsGui/Services/SAM/Native/Callbacks/AppDataChanged.cs new file mode 100644 index 0000000..7f08d68 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Callbacks/AppDataChanged.cs @@ -0,0 +1,12 @@ +using LuaToolsGui.Services.SAM.Native.Types; + +namespace LuaToolsGui.Services.SAM.Native.Callbacks; + +public class AppDataChanged : Callback +{ + public AppDataChanged() + { + Id = 1005; + IsServer = false; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Callbacks/UserStatsReceived.cs b/src/LuaToolsGui/Services/SAM/Native/Callbacks/UserStatsReceived.cs new file mode 100644 index 0000000..5a8ee23 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Callbacks/UserStatsReceived.cs @@ -0,0 +1,12 @@ +using LuaToolsGui.Services.SAM.Native.Types; + +namespace LuaToolsGui.Services.SAM.Native.Callbacks; + +public class UserStatsReceived : Callback +{ + public UserStatsReceived() + { + Id = 1101; + IsServer = false; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Callbacks/UserStatsStored.cs b/src/LuaToolsGui/Services/SAM/Native/Callbacks/UserStatsStored.cs new file mode 100644 index 0000000..6bfebb9 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Callbacks/UserStatsStored.cs @@ -0,0 +1,12 @@ +using LuaToolsGui.Services.SAM.Native.Types; + +namespace LuaToolsGui.Services.SAM.Native.Callbacks; + +public class UserStatsStored : Callback +{ + public UserStatsStored() + { + Id = 1102; + IsServer = false; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Client.cs b/src/LuaToolsGui/Services/SAM/Native/Client.cs new file mode 100644 index 0000000..30c282f --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Client.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace LuaToolsGui.Services.SAM.Native; + +public class Client : IDisposable +{ + public Wrappers.SteamClient018? SteamClient { get; private set; } + public Wrappers.SteamUser012? SteamUser { get; private set; } + public Wrappers.SteamUserStats013? SteamUserStats { get; private set; } + public Wrappers.SteamUtils005? SteamUtils { get; private set; } + public Wrappers.SteamApps001? SteamApps001 { get; private set; } + public Wrappers.SteamApps008? SteamApps008 { get; private set; } + + private bool _isDisposed; + private int _pipe; + private int _user; + + private readonly List _callbacks = []; + + public void Initialize(long appId) + { + if (string.IsNullOrEmpty(Steam.GetInstallPath())) + { + throw new ClientInitializeException(ClientInitializeFailure.GetInstallPath, "failed to get Steam install path"); + } + + if (appId != 0) + { + Environment.SetEnvironmentVariable("SteamAppId", appId.ToString(CultureInfo.InvariantCulture)); + } + + if (!Steam.Load()) + { + throw new ClientInitializeException(ClientInitializeFailure.Load, "failed to load SteamClient"); + } + + SteamClient = Steam.CreateInterface("SteamClient018"); + if (SteamClient == null) + { + throw new ClientInitializeException(ClientInitializeFailure.CreateSteamClient, "failed to create ISteamClient018"); + } + + _pipe = SteamClient.CreateSteamPipe(); + if (_pipe == 0) + { + throw new ClientInitializeException(ClientInitializeFailure.CreateSteamPipe, "failed to create pipe"); + } + + _user = SteamClient.ConnectToGlobalUser(_pipe); + if (_user == 0) + { + throw new ClientInitializeException(ClientInitializeFailure.ConnectToGlobalUser, "failed to connect to global user"); + } + + SteamUtils = SteamClient.GetSteamUtils004(_pipe); + if (appId > 0 && SteamUtils.GetAppId() != (uint)appId) + { + throw new ClientInitializeException(ClientInitializeFailure.AppIdMismatch, "appID mismatch"); + } + + SteamUser = SteamClient.GetSteamUser012(_user, _pipe); + SteamUserStats = SteamClient.GetSteamUserStats013(_user, _pipe); + SteamApps001 = SteamClient.GetSteamApps001(_user, _pipe); + SteamApps008 = SteamClient.GetSteamApps008(_user, _pipe); + } + + ~Client() + { + Dispose(false); + } + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) return; + + if (SteamClient != null && _pipe > 0) + { + if (_user > 0) + { + SteamClient.ReleaseUser(_pipe, _user); + _user = 0; + } + + SteamClient.ReleaseSteamPipe(_pipe); + _pipe = 0; + } + + _isDisposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public TCallback CreateAndRegisterCallback() + where TCallback : ICallback, new() + { + TCallback callback = new(); + _callbacks.Add(callback); + return callback; + } + + private bool _runningCallbacks; + + public void RunCallbacks(bool server) + { + if (_runningCallbacks) return; + + _runningCallbacks = true; + + while (Steam.GetCallback(_pipe, out var message, out _)) + { + var callbackId = message.Id; + foreach (var callback in _callbacks.Where( + candidate => candidate.Id == callbackId && + candidate.IsServer == server)) + { + callback.Run(message.ParamPointer); + } + Steam.FreeLastCallback(_pipe); + } + + _runningCallbacks = false; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/ClientInitializeException.cs b/src/LuaToolsGui/Services/SAM/Native/ClientInitializeException.cs new file mode 100644 index 0000000..64a660c --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/ClientInitializeException.cs @@ -0,0 +1,25 @@ +using System; + +namespace LuaToolsGui.Services.SAM.Native; + +public class ClientInitializeException : Exception +{ + public ClientInitializeFailure Failure { get; } + + public ClientInitializeException(ClientInitializeFailure failure) + { + Failure = failure; + } + + public ClientInitializeException(ClientInitializeFailure failure, string message) + : base(message) + { + Failure = failure; + } + + public ClientInitializeException(ClientInitializeFailure failure, string message, Exception innerException) + : base(message, innerException) + { + Failure = failure; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/ClientInitializeFailure.cs b/src/LuaToolsGui/Services/SAM/Native/ClientInitializeFailure.cs new file mode 100644 index 0000000..10eec96 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/ClientInitializeFailure.cs @@ -0,0 +1,12 @@ +namespace LuaToolsGui.Services.SAM.Native; + +public enum ClientInitializeFailure +{ + Unknown = 0, + GetInstallPath, + Load, + CreateSteamClient, + CreateSteamPipe, + ConnectToGlobalUser, + AppIdMismatch, +} diff --git a/src/LuaToolsGui/Services/SAM/Native/ICallback.cs b/src/LuaToolsGui/Services/SAM/Native/ICallback.cs new file mode 100644 index 0000000..9d340ab --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/ICallback.cs @@ -0,0 +1,10 @@ +using System; + +namespace LuaToolsGui.Services.SAM.Native; + +public interface ICallback +{ + int Id { get; } + bool IsServer { get; } + void Run(IntPtr param); +} diff --git a/src/LuaToolsGui/Services/SAM/Native/INativeWrapper.cs b/src/LuaToolsGui/Services/SAM/Native/INativeWrapper.cs new file mode 100644 index 0000000..cd79282 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/INativeWrapper.cs @@ -0,0 +1,8 @@ +using System; + +namespace LuaToolsGui.Services.SAM.Native; + +public interface INativeWrapper +{ + void SetupFunctions(IntPtr objectAddress); +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamApps001.cs b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamApps001.cs new file mode 100644 index 0000000..dee8474 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamApps001.cs @@ -0,0 +1,10 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Interfaces; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ISteamApps001 +{ + public IntPtr GetAppData; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamApps008.cs b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamApps008.cs new file mode 100644 index 0000000..0b3dfa8 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamApps008.cs @@ -0,0 +1,34 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Interfaces; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ISteamApps008 +{ + public IntPtr IsSubscribed; + public IntPtr IsLowViolence; + public IntPtr IsCybercafe; + public IntPtr IsVACBanned; + public IntPtr GetCurrentGameLanguage; + public IntPtr GetAvailableGameLanguages; + public IntPtr IsSubscribedApp; + public IntPtr IsDlcInstalled; + public IntPtr GetEarliestPurchaseUnixTime; + public IntPtr IsSubscribedFromFreeWeekend; + public IntPtr GetDLCCount; + public IntPtr BGetDLCDataByIndex; + public IntPtr InstallDLC; + public IntPtr UninstallDLC; + public IntPtr RequestAppProofOfPurchaseKey; + public IntPtr GetCurrentBetaName; + public IntPtr MarkContentCorrupt; + public IntPtr GetInstalledDepots; + public IntPtr GetAppInstallDir; + public IntPtr BIsAppInstalled; + public IntPtr GetAppOwner; + public IntPtr GetLaunchQueryParam; + public IntPtr GetDlcDownloadProgress; + public IntPtr GetAppBuildId; + public IntPtr RegisterActivationCode; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamClient018.cs b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamClient018.cs new file mode 100644 index 0000000..7e4f3ce --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamClient018.cs @@ -0,0 +1,49 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Interfaces; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ISteamClient018 +{ + public IntPtr CreateSteamPipe; + public IntPtr ReleaseSteamPipe; + public IntPtr ConnectToGlobalUser; + public IntPtr CreateLocalUser; + public IntPtr ReleaseUser; + public IntPtr GetISteamUser; + public IntPtr GetISteamGameServer; + public IntPtr SetLocalIPBinding; + public IntPtr GetISteamFriends; + public IntPtr GetISteamUtils; + public IntPtr GetISteamMatchmaking; + public IntPtr GetISteamMatchmakingServers; + public IntPtr GetISteamGenericInterface; + public IntPtr GetISteamUserStats; + public IntPtr GetISteamGameServerStats; + public IntPtr GetISteamApps; + public IntPtr GetISteamNetworking; + public IntPtr GetISteamRemoteStorage; + public IntPtr GetISteamScreenshots; + public IntPtr GetISteamGameSearch; + public IntPtr RunFrame; + public IntPtr GetIPCCallCount; + public IntPtr SetWarningMessageHook; + public IntPtr ShutdownIfAllPipesClosed; + public IntPtr GetISteamHTTP; + public IntPtr DEPRECATED_GetISteamUnifiedMessages; + public IntPtr GetISteamController; + public IntPtr GetISteamUGC; + public IntPtr GetISteamAppList; + public IntPtr GetISteamMusic; + public IntPtr GetISteamMusicRemote; + public IntPtr GetISteamHTMLSurface; + public IntPtr DEPRECATED_Set_SteamAPI_CPostAPIResultInProcess; + public IntPtr DEPRECATED_Remove_SteamAPI_CPostAPIResultInProcess; + public IntPtr Set_SteamAPI_CCheckCallbackRegisteredInProcess; + public IntPtr GetISteamInventory; + public IntPtr GetISteamVideo; + public IntPtr GetISteamParentalSettings; + public IntPtr GetISteamInput; + public IntPtr GetISteamParties; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUser012.cs b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUser012.cs new file mode 100644 index 0000000..98b7b16 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUser012.cs @@ -0,0 +1,26 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Interfaces; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ISteamUser012 +{ + public IntPtr GetHSteamUser; + public IntPtr LoggedOn; + public IntPtr GetSteamID; + public IntPtr InitiateGameConnection; + public IntPtr TerminateGameConnection; + public IntPtr TrackAppUsageEvent; + public IntPtr GetUserDataFolder; + public IntPtr StartVoiceRecording; + public IntPtr StopVoiceRecording; + public IntPtr GetCompressedVoice; + public IntPtr DecompressVoice; + public IntPtr GetAuthSessionTicket; + public IntPtr BeginAuthSession; + public IntPtr EndAuthSession; + public IntPtr CancelAuthTicket; + public IntPtr IsBehindNAT; + public IntPtr AdvertiseGame; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUserStats013.cs b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUserStats013.cs new file mode 100644 index 0000000..3f0969d --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUserStats013.cs @@ -0,0 +1,53 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Interfaces; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ISteamUserStats013 +{ + public IntPtr GetStatFloat; + public IntPtr GetStatInteger; + public IntPtr SetStatFloat; + public IntPtr SetStatInteger; + public IntPtr UpdateAvgRateStat; + public IntPtr GetAchievement; + public IntPtr SetAchievement; + public IntPtr ClearAchievement; + public IntPtr GetAchievementAndUnlockTime; + public IntPtr StoreStats; + public IntPtr GetAchievementIcon; + public IntPtr GetAchievementDisplayAttribute; + public IntPtr IndicateAchievementProgress; + public IntPtr GetNumAchievements; + public IntPtr GetAchievementName; + public IntPtr RequestUserStats; + public IntPtr GetUserStatFloat; + public IntPtr GetUserStatInt; + public IntPtr GetUserAchievement; + public IntPtr GetUserAchievementAndUnlockTime; + public IntPtr ResetAllStats; + public IntPtr FindOrCreateLeaderboard; + public IntPtr FindLeaderboard; + public IntPtr GetLeaderboardName; + public IntPtr GetLeaderboardEntryCount; + public IntPtr GetLeaderboardSortMethod; + public IntPtr GetLeaderboardDisplayType; + public IntPtr DownloadLeaderboardEntries; + public IntPtr DownloadLeaderboardEntriesForUsers; + public IntPtr GetDownloadedLeaderboardEntry; + public IntPtr UploadLeaderboardScore; + public IntPtr AttachLeaderboardUGC; + public IntPtr GetNumberOfCurrentPlayers; + public IntPtr RequestGlobalAchievementPercentages; + public IntPtr GetMostAchievedAchievementInfo; + public IntPtr GetNextMostAchievedAchievementInfo; + public IntPtr GetAchievementAchievedPercent; + public IntPtr RequestGlobalStats; + public IntPtr GetGlobalStatFloat; + public IntPtr GetGlobalStatInteger; + public IntPtr GetGlobalStatHistoryFloat; + public IntPtr GetGlobalStatHistoryInteger; + public IntPtr GetAchievementProgressLimitsFloat; + public IntPtr GetAchievementProgressLimitsInteger; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUtils005.cs b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUtils005.cs new file mode 100644 index 0000000..8e8f1a1 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Interfaces/ISteamUtils005.cs @@ -0,0 +1,33 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Interfaces; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ISteamUtils005 +{ + public IntPtr GetSecondsSinceAppActive; + public IntPtr GetSecondsSinceComputerActive; + public IntPtr GetConnectedUniverse; + public IntPtr GetServerRealTime; + public IntPtr GetIPCountry; + public IntPtr GetImageSize; + public IntPtr GetImageRGBA; + public IntPtr GetCSERIPPort; + public IntPtr GetCurrentBatteryPower; + public IntPtr GetAppID; + public IntPtr SetOverlayNotificationPosition; + public IntPtr IsAPICallCompleted; + public IntPtr GetAPICallFailureReason; + public IntPtr GetAPICallResult; + public IntPtr RunFrame; + public IntPtr GetIPCCallCount; + public IntPtr SetWarningMessageHook; + public IntPtr IsOverlayEnabled; + public IntPtr BOverlayNeedsPresent; + public IntPtr CheckFileSignature; + public IntPtr ShowGamepadTextInput; + public IntPtr GetEnteredGamepadTextLength; + public IntPtr GetEnteredGamepadTextInput; + public IntPtr GetGameLauncherMode; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/NativeClass.cs b/src/LuaToolsGui/Services/SAM/Native/NativeClass.cs new file mode 100644 index 0000000..55c140f --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/NativeClass.cs @@ -0,0 +1,10 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native; + +[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)] +internal struct NativeClass +{ + public IntPtr VirtualTable; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/NativeStrings.cs b/src/LuaToolsGui/Services/SAM/Native/NativeStrings.cs new file mode 100644 index 0000000..8681f64 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/NativeStrings.cs @@ -0,0 +1,82 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace LuaToolsGui.Services.SAM.Native; + +internal class NativeStrings +{ + public sealed class StringHandle : IDisposable + { + private bool _isDisposed; + public IntPtr Handle { get; private set; } + + public StringHandle(IntPtr handle) + { + Handle = handle; + } + + ~StringHandle() + { + Dispose(false); + } + + private void Dispose(bool disposing) + { + if (_isDisposed) return; + if (Handle != IntPtr.Zero) + { + Marshal.FreeHGlobal(Handle); + Handle = IntPtr.Zero; + } + _isDisposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } + + public static StringHandle StringToStringHandle(string? value) + { + if (value is null) + { + return new StringHandle(IntPtr.Zero); + } + + byte[] bytes = Encoding.UTF8.GetBytes(value); + IntPtr pointer = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, pointer, bytes.Length); + Marshal.WriteByte(pointer, bytes.Length, 0); + return new StringHandle(pointer); + } + + public static string? PointerToString(IntPtr nativeData) + { + if (nativeData == IntPtr.Zero) return null; + int length = 0; + while (Marshal.ReadByte(nativeData, length) != 0) + { + length++; + } + if (length == 0) return string.Empty; + byte[] buffer = new byte[length]; + Marshal.Copy(nativeData, buffer, 0, buffer.Length); + return Encoding.UTF8.GetString(buffer); + } + + public static string? PointerToString(IntPtr nativeData, int length) + { + if (nativeData == IntPtr.Zero) return null; + byte[] buffer = new byte[length]; + Marshal.Copy(nativeData, buffer, 0, buffer.Length); + int realLength = Array.IndexOf(buffer, (byte)0); + if (realLength >= 0) + { + length = realLength; + } + return length == 0 ? string.Empty : Encoding.UTF8.GetString(buffer, 0, length); + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/NativeWrapper.cs b/src/LuaToolsGui/Services/SAM/Native/NativeWrapper.cs new file mode 100644 index 0000000..6eada23 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/NativeWrapper.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native; + +public abstract class NativeWrapper : INativeWrapper + where TNativeFunctions : struct +{ + protected IntPtr ObjectAddress; + protected TNativeFunctions Functions; + + public override string ToString() + { + return $"Steam Interface<{typeof(TNativeFunctions)}> #{ObjectAddress.ToInt64():X8}"; + } + + public void SetupFunctions(IntPtr objectAddress) + { + ObjectAddress = objectAddress; + + var iface = (NativeClass)Marshal.PtrToStructure( + ObjectAddress, + typeof(NativeClass))!; + + Functions = (TNativeFunctions)Marshal.PtrToStructure( + iface.VirtualTable, + typeof(TNativeFunctions))!; + } + + private readonly Dictionary _functionCache = new(); + + protected Delegate GetDelegate(IntPtr pointer) + { + if (!_functionCache.TryGetValue(pointer, out var function)) + { + function = Marshal.GetDelegateForFunctionPointer(pointer, typeof(TDelegate)); + _functionCache[pointer] = function; + } + return function; + } + + protected TDelegate GetFunction(IntPtr pointer) + where TDelegate : class + { + return (TDelegate)(object)GetDelegate(pointer); + } + + protected void Call(IntPtr pointer, params object[] args) + { + GetDelegate(pointer).DynamicInvoke(args); + } + + protected TReturn Call(IntPtr pointer, params object[] args) + { + return (TReturn)GetDelegate(pointer).DynamicInvoke(args)!; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Steam.cs b/src/LuaToolsGui/Services/SAM/Native/Steam.cs new file mode 100644 index 0000000..a5b4e5d --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Steam.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32; + +namespace LuaToolsGui.Services.SAM.Native; + +public static class Steam +{ + private struct NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)] + internal static extern IntPtr GetProcAddress(IntPtr module, string name); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetDllDirectory(string path); + + internal const uint LoadWithAlteredSearchPath = 8; + } + + private static Delegate? GetExportDelegate(IntPtr module, string name) + { + IntPtr address = NativeMethods.GetProcAddress(module, name); + return address == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(address, typeof(TDelegate)); + } + + private static TDelegate? GetExportFunction(IntPtr module, string name) + where TDelegate : class + { + var del = GetExportDelegate(module, name); + return del is null ? null : (TDelegate)(object)del; + } + + private static IntPtr _handle = IntPtr.Zero; + private static string? _customSteamPath; + + public static void SetCustomInstallPath(string? path) + { + _customSteamPath = path; + } + + public static string? GetInstallPath() + { + if (!string.IsNullOrWhiteSpace(_customSteamPath) && Directory.Exists(_customSteamPath)) + { + return _customSteamPath; + } + + // Try standard registry locations + var hkcuPath = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam", "SteamPath", null) as string; + if (!string.IsNullOrWhiteSpace(hkcuPath) && Directory.Exists(hkcuPath)) + { + return Path.GetFullPath(hkcuPath.Replace('/', '\\')); + } + + var hklm32Path = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath", null) as string; + if (!string.IsNullOrWhiteSpace(hklm32Path) && Directory.Exists(hklm32Path)) + { + return Path.GetFullPath(hklm32Path.Replace('/', '\\')); + } + + var hklmPath = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam", "InstallPath", null) as string; + if (!string.IsNullOrWhiteSpace(hklmPath) && Directory.Exists(hklmPath)) + { + return Path.GetFullPath(hklmPath.Replace('/', '\\')); + } + + return null; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + private delegate IntPtr NativeCreateInterface(string version, IntPtr returnCode); + + private static NativeCreateInterface? _callCreateInterface; + + public static TClass? CreateInterface(string version) + where TClass : INativeWrapper, new() + { + if (_callCreateInterface is null) return default; + IntPtr address = _callCreateInterface(version, IntPtr.Zero); + if (address == IntPtr.Zero) return default; + + TClass instance = new(); + instance.SetupFunctions(address); + return instance; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeSteamGetCallback(int pipe, out Types.CallbackMessage message, out int call); + + private static NativeSteamGetCallback? _callSteamBGetCallback; + + public static bool GetCallback(int pipe, out Types.CallbackMessage message, out int call) + { + if (_callSteamBGetCallback is null) + { + message = default; + call = 0; + return false; + } + return _callSteamBGetCallback(pipe, out message, out call); + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeSteamFreeLastCallback(int pipe); + + private static NativeSteamFreeLastCallback? _callSteamFreeLastCallback; + + public static bool FreeLastCallback(int pipe) + { + if (_callSteamFreeLastCallback is null) return false; + return _callSteamFreeLastCallback(pipe); + } + + public static bool Load() + { + if (_handle != IntPtr.Zero) + { + return true; + } + + string? path = GetInstallPath(); + if (string.IsNullOrEmpty(path)) + { + return false; + } + + NativeMethods.SetDllDirectory(path + ";" + Path.Combine(path, "bin")); + + string dllName = Environment.Is64BitProcess ? "steamclient64.dll" : "steamclient.dll"; + string dllPath = Path.Combine(path, dllName); + + if (!File.Exists(dllPath)) + { + // Fallback check in bin/ if present + string altPath = Path.Combine(path, "bin", dllName); + if (File.Exists(altPath)) + { + dllPath = altPath; + } + } + + IntPtr module = NativeMethods.LoadLibraryEx(dllPath, IntPtr.Zero, NativeMethods.LoadWithAlteredSearchPath); + if (module == IntPtr.Zero) + { + return false; + } + + _callCreateInterface = GetExportFunction(module, "CreateInterface"); + if (_callCreateInterface == null) + { + return false; + } + + _callSteamBGetCallback = GetExportFunction(module, "Steam_BGetCallback"); + if (_callSteamBGetCallback == null) + { + return false; + } + + _callSteamFreeLastCallback = GetExportFunction(module, "Steam_FreeLastCallback"); + if (_callSteamFreeLastCallback == null) + { + return false; + } + + _handle = module; + return true; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Types/AccountType.cs b/src/LuaToolsGui/Services/SAM/Native/Types/AccountType.cs new file mode 100644 index 0000000..e90b9d2 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Types/AccountType.cs @@ -0,0 +1,17 @@ +namespace LuaToolsGui.Services.SAM.Native.Types; + +public enum AccountType +{ + Invalid = 0, + Individual = 1, + Multiseat = 2, + GameServer = 3, + AnonGameServer = 4, + Pending = 5, + ContentServer = 6, + Clan = 7, + Chat = 8, + ConsoleUser = 9, + AnonUser = 10, + Max, +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Types/AppDataChanged.cs b/src/LuaToolsGui/Services/SAM/Native/Types/AppDataChanged.cs new file mode 100644 index 0000000..f3102da --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Types/AppDataChanged.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Types; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct AppDataChanged +{ + public uint Id; + [MarshalAs(UnmanagedType.I1)] + public bool Result; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Types/CallbackMessage.cs b/src/LuaToolsGui/Services/SAM/Native/Types/CallbackMessage.cs new file mode 100644 index 0000000..7149700 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Types/CallbackMessage.cs @@ -0,0 +1,13 @@ +using System; +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Types; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct CallbackMessage +{ + public int User; + public int Id; + public IntPtr ParamPointer; + public int ParamSize; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Types/UserStatType.cs b/src/LuaToolsGui/Services/SAM/Native/Types/UserStatType.cs new file mode 100644 index 0000000..c5d363b --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Types/UserStatType.cs @@ -0,0 +1,11 @@ +namespace LuaToolsGui.Services.SAM.Native.Types; + +public enum UserStatType +{ + Invalid = 0, + Integer = 1, + Float = 2, + AverageRate = 3, + Achievements = 4, + GroupAchievements = 5, +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Types/UserStatsReceived.cs b/src/LuaToolsGui/Services/SAM/Native/Types/UserStatsReceived.cs new file mode 100644 index 0000000..984d993 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Types/UserStatsReceived.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Types; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct UserStatsReceived +{ + public ulong GameId; + public int Result; + public ulong SteamIdUser; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Types/UserStatsStored.cs b/src/LuaToolsGui/Services/SAM/Native/Types/UserStatsStored.cs new file mode 100644 index 0000000..00469ab --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Types/UserStatsStored.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace LuaToolsGui.Services.SAM.Native.Types; + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct UserStatsStored +{ + public ulong GameId; + public int Result; +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamApps001.cs b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamApps001.cs new file mode 100644 index 0000000..5c2915e --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamApps001.cs @@ -0,0 +1,40 @@ +using System; +using System.Runtime.InteropServices; +using LuaToolsGui.Services.SAM.Native.Interfaces; + +namespace LuaToolsGui.Services.SAM.Native.Wrappers; + +public class SteamApps001 : NativeWrapper +{ + #region GetAppData + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate int NativeGetAppData( + IntPtr self, + uint appId, + IntPtr key, + IntPtr value, + int valueLength); + + public string? GetAppData(uint appId, string key) + { + using var nativeHandle = NativeStrings.StringToStringHandle(key); + const int valueLength = 1024; + var valuePointer = Marshal.AllocHGlobal(valueLength); + try + { + int result = Call( + Functions.GetAppData, + ObjectAddress, + appId, + nativeHandle.Handle, + valuePointer, + valueLength); + return result == 0 ? null : NativeStrings.PointerToString(valuePointer, valueLength); + } + finally + { + Marshal.FreeHGlobal(valuePointer); + } + } + #endregion +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamApps008.cs b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamApps008.cs new file mode 100644 index 0000000..b099626 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamApps008.cs @@ -0,0 +1,32 @@ +using System; +using System.Runtime.InteropServices; +using LuaToolsGui.Services.SAM.Native.Interfaces; + +namespace LuaToolsGui.Services.SAM.Native.Wrappers; + +public class SteamApps008 : NativeWrapper +{ + #region IsSubscribedApp + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeIsSubscribedApp(IntPtr self, uint gameId); + + public bool IsSubscribedApp(uint gameId) + { + return Call(Functions.IsSubscribedApp, ObjectAddress, gameId); + } + #endregion + + #region GetCurrentGameLanguage + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetCurrentGameLanguage(IntPtr self); + + public string? GetCurrentGameLanguage() + { + var languagePointer = Call( + Functions.GetCurrentGameLanguage, + ObjectAddress); + return NativeStrings.PointerToString(languagePointer); + } + #endregion +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamClient018.cs b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamClient018.cs new file mode 100644 index 0000000..58ca3e7 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamClient018.cs @@ -0,0 +1,187 @@ +using System; +using System.Runtime.InteropServices; +using LuaToolsGui.Services.SAM.Native.Interfaces; + +namespace LuaToolsGui.Services.SAM.Native.Wrappers; + +public class SteamClient018 : NativeWrapper +{ + #region CreateSteamPipe + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate int NativeCreateSteamPipe(IntPtr self); + + public int CreateSteamPipe() + { + return Call(Functions.CreateSteamPipe, ObjectAddress); + } + #endregion + + #region ReleaseSteamPipe + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeReleaseSteamPipe(IntPtr self, int pipe); + + public bool ReleaseSteamPipe(int pipe) + { + return Call(Functions.ReleaseSteamPipe, ObjectAddress, pipe); + } + #endregion + + #region CreateLocalUser + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate int NativeCreateLocalUser(IntPtr self, ref int pipe, Types.AccountType type); + + public int CreateLocalUser(ref int pipe, Types.AccountType type) + { + var call = GetFunction(Functions.CreateLocalUser); + return call(ObjectAddress, ref pipe, type); + } + #endregion + + #region ConnectToGlobalUser + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate int NativeConnectToGlobalUser(IntPtr self, int pipe); + + public int ConnectToGlobalUser(int pipe) + { + return Call( + Functions.ConnectToGlobalUser, + ObjectAddress, + pipe); + } + #endregion + + #region ReleaseUser + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate void NativeReleaseUser(IntPtr self, int pipe, int user); + + public void ReleaseUser(int pipe, int user) + { + Call(Functions.ReleaseUser, ObjectAddress, pipe, user); + } + #endregion + + #region SetLocalIPBinding + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate void NativeSetLocalIPBinding(IntPtr self, uint host, ushort port); + + public void SetLocalIPBinding(uint host, ushort port) + { + Call(Functions.SetLocalIPBinding, ObjectAddress, host, port); + } + #endregion + + #region GetISteamUser + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetISteamUser(IntPtr self, int user, int pipe, IntPtr version); + + private TClass GetISteamUser(int user, int pipe, string version) + where TClass : INativeWrapper, new() + { + using var nativeVersion = NativeStrings.StringToStringHandle(version); + IntPtr address = Call( + Functions.GetISteamUser, + ObjectAddress, + user, + pipe, + nativeVersion.Handle); + TClass result = new(); + result.SetupFunctions(address); + return result; + } + #endregion + + #region GetSteamUser012 + public SteamUser012 GetSteamUser012(int user, int pipe) + { + return GetISteamUser(user, pipe, "SteamUser012"); + } + #endregion + + #region GetISteamUserStats + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetISteamUserStats(IntPtr self, int user, int pipe, IntPtr version); + + private TClass GetISteamUserStats(int user, int pipe, string version) + where TClass : INativeWrapper, new() + { + using var nativeVersion = NativeStrings.StringToStringHandle(version); + IntPtr address = Call( + Functions.GetISteamUserStats, + ObjectAddress, + user, + pipe, + nativeVersion.Handle); + TClass result = new(); + result.SetupFunctions(address); + return result; + } + #endregion + + #region GetSteamUserStats013 + public SteamUserStats013 GetSteamUserStats013(int user, int pipe) + { + return GetISteamUserStats(user, pipe, "STEAMUSERSTATS_INTERFACE_VERSION013"); + } + #endregion + + #region GetISteamUtils + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetISteamUtils(IntPtr self, int pipe, IntPtr version); + + public TClass GetISteamUtils(int pipe, string version) + where TClass : INativeWrapper, new() + { + using var nativeVersion = NativeStrings.StringToStringHandle(version); + IntPtr address = Call( + Functions.GetISteamUtils, + ObjectAddress, + pipe, + nativeVersion.Handle); + TClass result = new(); + result.SetupFunctions(address); + return result; + } + #endregion + + #region GetSteamUtils004 + public SteamUtils005 GetSteamUtils004(int pipe) + { + return GetISteamUtils(pipe, "SteamUtils005"); + } + #endregion + + #region GetISteamApps + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetISteamApps(IntPtr self, int user, int pipe, IntPtr version); + + private TClass GetISteamApps(int user, int pipe, string version) + where TClass : INativeWrapper, new() + { + using var nativeVersion = NativeStrings.StringToStringHandle(version); + IntPtr address = Call( + Functions.GetISteamApps, + ObjectAddress, + user, + pipe, + nativeVersion.Handle); + TClass result = new(); + result.SetupFunctions(address); + return result; + } + #endregion + + #region GetSteamApps001 + public SteamApps001 GetSteamApps001(int user, int pipe) + { + return GetISteamApps(user, pipe, "STEAMAPPS_INTERFACE_VERSION001"); + } + #endregion + + #region GetSteamApps008 + public SteamApps008 GetSteamApps008(int user, int pipe) + { + return GetISteamApps(user, pipe, "STEAMAPPS_INTERFACE_VERSION008"); + } + #endregion +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUser012.cs b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUser012.cs new file mode 100644 index 0000000..5d7f862 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUser012.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.InteropServices; +using LuaToolsGui.Services.SAM.Native.Interfaces; + +namespace LuaToolsGui.Services.SAM.Native.Wrappers; + +public class SteamUser012 : NativeWrapper +{ + #region IsLoggedIn + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeLoggedOn(IntPtr self); + + public bool IsLoggedIn() + { + return Call(Functions.LoggedOn, ObjectAddress); + } + #endregion + + #region GetSteamID + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate void NativeGetSteamId(IntPtr self, out ulong steamId); + + public ulong GetSteamId() + { + var call = GetFunction(Functions.GetSteamID); + call(ObjectAddress, out ulong steamId); + return steamId; + } + #endregion +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUserStats013.cs b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUserStats013.cs new file mode 100644 index 0000000..40247ea --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUserStats013.cs @@ -0,0 +1,192 @@ +using System; +using System.Runtime.InteropServices; +using LuaToolsGui.Services.SAM.Native.Interfaces; + +namespace LuaToolsGui.Services.SAM.Native.Wrappers; + +public class SteamUserStats013 : NativeWrapper +{ + #region GetStatValue (int) + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeGetStatInt(IntPtr self, IntPtr name, out int data); + + public bool GetStatValue(string name, out int value) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + var call = GetFunction(Functions.GetStatInteger); + return call(ObjectAddress, nativeName.Handle, out value); + } + #endregion + + #region GetStatValue (float) + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeGetStatFloat(IntPtr self, IntPtr name, out float data); + + public bool GetStatValue(string name, out float value) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + var call = GetFunction(Functions.GetStatFloat); + return call(ObjectAddress, nativeName.Handle, out value); + } + #endregion + + #region SetStatValue (int) + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeSetStatInt(IntPtr self, IntPtr name, int data); + + public bool SetStatValue(string name, int value) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + return Call( + Functions.SetStatInteger, + ObjectAddress, + nativeName.Handle, + value); + } + #endregion + + #region SetStatValue (float) + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeSetStatFloat(IntPtr self, IntPtr name, float data); + + public bool SetStatValue(string name, float value) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + return Call( + Functions.SetStatFloat, + ObjectAddress, + nativeName.Handle, + value); + } + #endregion + + #region GetAchievement + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeGetAchievement( + IntPtr self, + IntPtr name, + [MarshalAs(UnmanagedType.I1)] out bool isAchieved); + + public bool GetAchievement(string name, out bool isAchieved) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + var call = GetFunction(Functions.GetAchievement); + return call(ObjectAddress, nativeName.Handle, out isAchieved); + } + #endregion + + #region SetAchievement + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeSetAchievement(IntPtr self, IntPtr name); + + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeClearAchievement(IntPtr self, IntPtr name); + + public bool SetAchievement(string name, bool state) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + if (!state) + { + return Call( + Functions.ClearAchievement, + ObjectAddress, + nativeName.Handle); + } + + return Call( + Functions.SetAchievement, + ObjectAddress, + nativeName.Handle); + } + #endregion + + #region GetAchievementAndUnlockTime + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeGetAchievementAndUnlockTime( + IntPtr self, + IntPtr name, + [MarshalAs(UnmanagedType.I1)] out bool isAchieved, + out uint unlockTime); + + public bool GetAchievementAndUnlockTime(string name, out bool isAchieved, out uint unlockTime) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + var call = GetFunction(Functions.GetAchievementAndUnlockTime); + return call(ObjectAddress, nativeName.Handle, out isAchieved, out unlockTime); + } + #endregion + + #region StoreStats + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeStoreStats(IntPtr self); + + public bool StoreStats() + { + return Call(Functions.StoreStats, ObjectAddress); + } + #endregion + + #region GetAchievementIcon + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate int NativeGetAchievementIcon(IntPtr self, IntPtr name); + + public int GetAchievementIcon(string name) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + return Call( + Functions.GetAchievementIcon, + ObjectAddress, + nativeName.Handle); + } + #endregion + + #region GetAchievementDisplayAttribute + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetAchievementDisplayAttribute(IntPtr self, IntPtr name, IntPtr key); + + public string? GetAchievementDisplayAttribute(string name, string key) + { + using var nativeName = NativeStrings.StringToStringHandle(name); + using var nativeKey = NativeStrings.StringToStringHandle(key); + var result = Call( + Functions.GetAchievementDisplayAttribute, + ObjectAddress, + nativeName.Handle, + nativeKey.Handle); + return NativeStrings.PointerToString(result); + } + #endregion + + #region RequestUserStats + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate CallHandle NativeRequestUserStats(IntPtr self, ulong steamIdUser); + + public CallHandle RequestUserStats(ulong steamIdUser) + { + return Call(Functions.RequestUserStats, ObjectAddress, steamIdUser); + } + #endregion + + #region ResetAllStats + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeResetAllStats(IntPtr self, [MarshalAs(UnmanagedType.I1)] bool achievementsToo); + + public bool ResetAllStats(bool achievementsToo) + { + return Call( + Functions.ResetAllStats, + ObjectAddress, + achievementsToo); + } + #endregion +} diff --git a/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUtils005.cs b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUtils005.cs new file mode 100644 index 0000000..340599c --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Native/Wrappers/SteamUtils005.cs @@ -0,0 +1,64 @@ +using System; +using System.Runtime.InteropServices; +using LuaToolsGui.Services.SAM.Native.Interfaces; + +namespace LuaToolsGui.Services.SAM.Native.Wrappers; + +public class SteamUtils005 : NativeWrapper +{ + #region GetConnectedUniverse + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate int NativeGetConnectedUniverse(IntPtr self); + + public int GetConnectedUniverse() + { + return Call(Functions.GetConnectedUniverse, ObjectAddress); + } + #endregion + + #region GetIPCountry + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate IntPtr NativeGetIPCountry(IntPtr self); + + public string? GetIPCountry() + { + var result = Call(Functions.GetIPCountry, ObjectAddress); + return NativeStrings.PointerToString(result); + } + #endregion + + #region GetImageSize + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeGetImageSize(IntPtr self, int index, out int width, out int height); + + public bool GetImageSize(int index, out int width, out int height) + { + var call = GetFunction(Functions.GetImageSize); + return call(ObjectAddress, index, out width, out height); + } + #endregion + + #region GetImageRGBA + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool NativeGetImageRGBA(IntPtr self, int index, byte[] buffer, int length); + + public bool GetImageRGBA(int index, byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + var call = GetFunction(Functions.GetImageRGBA); + return call(ObjectAddress, index, data, data.Length); + } + #endregion + + #region GetAppID + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate uint NativeGetAppId(IntPtr self); + + public uint GetAppId() + { + return Call(Functions.GetAppID, ObjectAddress); + } + #endregion +} diff --git a/src/LuaToolsGui/Services/SAM/SamService.cs b/src/LuaToolsGui/Services/SAM/SamService.cs new file mode 100644 index 0000000..1ff33dd --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/SamService.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using LuaToolsGui.Models; +using LuaToolsGui.Services.SAM.Native; + +namespace LuaToolsGui.Services.SAM; + +public class SamService +{ + private readonly SteamService _steamService; + private readonly SteamAppInfoCache _appInfoCache; + private readonly SteamAppListCache _appListCache; + private readonly CoverCache _coverCache; + private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(10) }; + + private readonly ConcurrentDictionary _statsCache = new(); + private List? _cachedGames; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public SamService( + SteamService steamService, + SteamAppInfoCache appInfoCache, + SteamAppListCache appListCache, + CoverCache coverCache) + { + _steamService = steamService; + _appInfoCache = appInfoCache; + _appListCache = appListCache; + _coverCache = coverCache; + + // Set custom Steam path if configured + if (!string.IsNullOrEmpty(_steamService.EffectivePath)) + { + Steam.SetCustomInstallPath(_steamService.EffectivePath); + } + } + + public async Task> GetGamesAsync(bool forceRefresh = false) + { + if (_cachedGames != null && !forceRefresh) + { + return _cachedGames; + } + + var gamesMap = new Dictionary(); + + // 1. Ensure LuaTools app list cache is loaded for instant friendly names + try + { + await _appListCache.EnsureLoadedAsync(); + } + catch { /* best effort */ } + + // 2. Add LuaTools installed games (.lua files in stplug-in) + try + { + string? plugInDir = _steamService.StPlugInDir; + if (!string.IsNullOrEmpty(plugInDir) && Directory.Exists(plugInDir)) + { + foreach (var f in LuaInstaller.EnumerateInstalled(plugInDir)) + { + uint id = (uint)f.AppId; + string? name = _appListCache.GetName(id) ?? _appInfoCache.GetCached(id)?.Name; + gamesMap[id] = new SamGameInfo + { + Id = id, + Name = name ?? $"App {id}", + Type = "normal", + IsInstalledInLuaTools = true, + }; + } + } + } + catch { /* best effort */ } + + // 3. Discover from Steam installation folders (appcache & steamapps) + string? installPath = _steamService.EffectivePath ?? Steam.GetInstallPath(); + if (!string.IsNullOrEmpty(installPath) && Directory.Exists(installPath)) + { + // Appcache stats schemas + string statsPath = Path.Combine(installPath, "appcache", "stats"); + if (Directory.Exists(statsPath)) + { + foreach (var file in Directory.GetFiles(statsPath, "UserGameStatsSchema_*.bin")) + { + string fn = Path.GetFileNameWithoutExtension(file); + if (fn.StartsWith("UserGameStatsSchema_") && + uint.TryParse(fn["UserGameStatsSchema_".Length..], out uint fileAppId)) + { + if (!gamesMap.ContainsKey(fileAppId)) + { + gamesMap[fileAppId] = new SamGameInfo + { + Id = fileAppId, + Name = _appListCache.GetName(fileAppId) ?? $"App {fileAppId}", + Type = "normal", + }; + } + } + } + } + + // Steamapps manifests + string steamAppsDir = Path.Combine(installPath, "steamapps"); + if (Directory.Exists(steamAppsDir)) + { + foreach (var file in Directory.GetFiles(steamAppsDir, "appmanifest_*.acf")) + { + string fn = Path.GetFileNameWithoutExtension(file); + if (fn.StartsWith("appmanifest_") && + uint.TryParse(fn["appmanifest_".Length..], out uint manifestAppId)) + { + if (!gamesMap.ContainsKey(manifestAppId)) + { + gamesMap[manifestAppId] = new SamGameInfo + { + Id = manifestAppId, + Name = _appListCache.GetName(manifestAppId) ?? $"App {manifestAppId}", + Type = "normal", + }; + } + } + } + } + } + + // 4. Always include Spacewar (480) for testing + if (!gamesMap.ContainsKey(480)) + { + gamesMap[480] = new SamGameInfo + { + Id = 480, + Name = "Spacewar", + Type = "normal", + }; + } + + // 5. Try running worker to discover additional games via Steam Client API + try + { + var workerGames = await RunWorkerGetGamesAsync(); + if (workerGames != null) + { + foreach (var g in workerGames) + { + if (gamesMap.TryGetValue(g.Id, out var existing)) + { + if (!string.IsNullOrWhiteSpace(g.Name) && !g.Name.StartsWith("App ")) + { + existing.Name = g.Name; + } + if (!string.IsNullOrWhiteSpace(g.ImageUrl)) + { + existing.ImageUrl = g.ImageUrl; + } + } + else + { + gamesMap[g.Id] = g; + } + } + } + } + catch { /* Fallback to already discovered games */ } + + // 6. Populate friendly names and covers from LuaTools app list & app info caches + foreach (var game in gamesMap.Values) + { + if (string.IsNullOrWhiteSpace(game.Name) || game.Name.StartsWith("App ")) + { + string? cachedName = _appListCache.GetName(game.Id) ?? _appInfoCache.GetCached(game.Id)?.Name; + if (!string.IsNullOrWhiteSpace(cachedName)) + { + game.Name = cachedName; + } + } + + // Cover URL + game.DisplayCoverUrl = _coverCache.GetCoverPathOrUrl(game.Id); + } + + var resultList = gamesMap.Values + .OrderByDescending(g => g.IsInstalledInLuaTools) + .ThenBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + _cachedGames = resultList; + return resultList; + } + + public async Task GetGameStatsAsync(uint appId, bool forceRefresh = false) + { + if (!forceRefresh && _statsCache.TryGetValue(appId, out var cached)) + { + return cached; + } + + // Try fetching stats with a retry on timeout + const int maxAttempts = 3; // more attempts for stats retrieval + SamGameStatsData? data = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + data = await RunWorkerGetStatsAsync(appId); + // success if data contains achievements + if (data != null && string.IsNullOrEmpty(data.ErrorMessage) && data.Achievements?.Count > 0) + { + break; // got valid achievements + } + // otherwise continue retry + // If timeout, wait a bit before retrying + if (data != null && data.ErrorMessage?.Contains("timed out") == true && attempt < maxAttempts) + { + await Task.Delay(2000); + } + } + + if (data != null && string.IsNullOrWhiteSpace(data.ErrorMessage)) + { + // Populate fallback game name if missing + if (string.IsNullOrWhiteSpace(data.GameName) || data.GameName.StartsWith("App ")) + { + string? name = _appListCache.GetName(appId); + if (!string.IsNullOrWhiteSpace(name)) data.GameName = name; + } + + _statsCache[appId] = data; + return data; + } + + return data ?? new SamGameStatsData + { + AppId = appId, + ErrorMessage = "Failed to communicate with Steam worker", + }; + } + + public async Task StoreStatsAsync(SamStoreRequest request) + { + var result = await RunWorkerStoreStatsAsync(request); + if (result.Success) + { + // Invalidate cached stats so next load re-fetches updated values + _statsCache.TryRemove(request.AppId, out _); + } + return result; + } + + private static string? ExtractJson(string output) + { + if (string.IsNullOrWhiteSpace(output)) return null; + + // The native steam client / pipes library may print debug messages like: + // "src\common\pipes.cpp (537) : !m_bOutstandingCallback" to stdout. + // We find the JSON substring by checking trimmed lines starting with '{' or '['. + using var reader = new StringReader(output); + string? line; + while ((line = reader.ReadLine()) != null) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith('{') || trimmed.StartsWith('[')) + { + return trimmed; + } + } + + // Fallback: search for first { or [ to matching end + int jsonStart = output.IndexOfAny(['{', '[']); + if (jsonStart >= 0) + { + return output[jsonStart..].Trim(); + } + + return null; + } + + // ── Worker Execution Helper ────────────────────────────────────── + + private static async Task?> RunWorkerGetGamesAsync() + { + string? exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) return null; + + var psi = new ProcessStartInfo(exePath) + { + Arguments = "--sam-worker get-games", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // Retry logic for fetching games with increased timeout + const int maxAttempts = 3; // increased retries for robustness + List? games = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); // further increased timeout for slow responses + try + { + using var process = Process.Start(psi); + if (process == null) return null; + + var readOutputTask = process.StandardOutput.ReadToEndAsync(cts.Token); + var waitForExitTask = process.WaitForExitAsync(cts.Token); + + await Task.WhenAll(readOutputTask, waitForExitTask); + + string output = await readOutputTask; + string? json = ExtractJson(output); + if (string.IsNullOrWhiteSpace(json)) + { + games = null; + } + else + { + games = JsonSerializer.Deserialize>(json, JsonOptions); + if (games != null) break; // success + } + } + catch (OperationCanceledException) + { + // timeout, log and retry if attempts remain + Console.WriteLine($"[SamService] GetGames timeout on attempt {attempt}"); + // will retry if attempts remain + } + catch (Exception ex) + { + // other error, log and break + Console.WriteLine($"[SamService] GetGames error on attempt {attempt}: {ex.Message}"); + break; + } + if (attempt < maxAttempts) + { + await Task.Delay(2000); + } + } + return games; + } + + private static async Task RunWorkerGetStatsAsync(uint appId) + { + string? exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) + { + return new SamGameStatsData { AppId = appId, ErrorMessage = "Process path unavailable" }; + } + + var psi = new ProcessStartInfo(exePath) + { + Arguments = $"--sam-worker get-stats {appId}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); // extended timeout for heavy games + try + { + using var process = Process.Start(psi); + if (process == null) return null; + + var readOutputTask = process.StandardOutput.ReadToEndAsync(cts.Token); + var waitForExitTask = process.WaitForExitAsync(cts.Token); + + await Task.WhenAll(readOutputTask, waitForExitTask); + + string output = await readOutputTask; + string? json = ExtractJson(output); + if (string.IsNullOrWhiteSpace(json)) + return null; + + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (OperationCanceledException) + { + // timeout, log + Console.WriteLine($"[SamService] GetStats timeout for AppId {appId}"); + return new SamGameStatsData { AppId = appId, ErrorMessage = "Request timed out connecting to Steam" }; + } + catch (Exception ex) + { + // log unexpected errors + Console.WriteLine($"[SamService] GetStats exception for AppId {appId}: {ex.Message}"); + return new SamGameStatsData { AppId = appId, ErrorMessage = $"JSON parse error: {ex.Message}" }; + } + } + + private static async Task RunWorkerStoreStatsAsync(SamStoreRequest request) + { + string? exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) + { + return new SamStoreResult { Success = false, ErrorMessage = "Process path unavailable" }; + } + + string tempPayloadFile = Path.Combine(Path.GetTempPath(), $"sam_store_{request.AppId}_{Guid.NewGuid():N}.json"); + try + { + string payloadJson = JsonSerializer.Serialize(request, JsonOptions); + await File.WriteAllTextAsync(tempPayloadFile, payloadJson); + + var psi = new ProcessStartInfo(exePath) + { + Arguments = $"--sam-worker store-stats {request.AppId} \"{tempPayloadFile}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + using var process = Process.Start(psi); + if (process == null) + { + return new SamStoreResult { Success = false, ErrorMessage = "Failed to launch Steam worker" }; + } + + var readOutputTask = process.StandardOutput.ReadToEndAsync(cts.Token); + var waitForExitTask = process.WaitForExitAsync(cts.Token); + + await Task.WhenAll(readOutputTask, waitForExitTask); + + string output = await readOutputTask; + string? json = ExtractJson(output); + if (string.IsNullOrWhiteSpace(json)) + { + return new SamStoreResult { Success = false, ErrorMessage = "Worker produced no output" }; + } + + return JsonSerializer.Deserialize(json, JsonOptions) ?? new SamStoreResult + { + Success = false, + ErrorMessage = "Failed to parse worker response", + }; + } + catch (Exception ex) + { + return new SamStoreResult { Success = false, ErrorMessage = $"Store error: {ex.Message}" }; + } + finally + { + try + { + if (File.Exists(tempPayloadFile)) File.Delete(tempPayloadFile); + } + catch { /* clean up temp file */ } + } + } +} diff --git a/src/LuaToolsGui/Services/SAM/SamWorker.cs b/src/LuaToolsGui/Services/SAM/SamWorker.cs new file mode 100644 index 0000000..5a93e86 --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/SamWorker.cs @@ -0,0 +1,604 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using LuaToolsGui.Models; +using LuaToolsGui.Services.SAM.Native; +using LuaToolsGui.Services.SAM.Native.Types; +using LuaToolsGui.Services.SAM.Schema; + +namespace LuaToolsGui.Services.SAM; + +public static class SamWorker +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + public static async Task RunAsync(string[] args) + { + // args: ["--sam-worker", "", ...] + if (args.Length < 2) + { + Console.WriteLine(JsonSerializer.Serialize(new { error = "Missing command" }, JsonOptions)); + return 1; + } + + string command = args[1].ToLowerInvariant(); + + try + { + switch (command) + { + case "get-games": + return await GetGamesAsync(); + + case "get-stats": + if (args.Length < 3 || !uint.TryParse(args[2], out uint appId)) + { + Console.WriteLine(JsonSerializer.Serialize(new { error = "Invalid AppID" }, JsonOptions)); + return 1; + } + return await GetStatsAsync(appId); + + case "store-stats": + if (args.Length < 3 || !uint.TryParse(args[2], out uint storeAppId)) + { + Console.WriteLine(JsonSerializer.Serialize(new { error = "Invalid AppID" }, JsonOptions)); + return 1; + } + string payloadJson; + if (args.Length >= 4 && File.Exists(args[3])) + { + payloadJson = await File.ReadAllTextAsync(args[3]); + } + else if (args.Length >= 4) + { + payloadJson = args[3]; + } + else + { + using var reader = new StreamReader(Console.OpenStandardInput()); + payloadJson = await reader.ReadToEndAsync(); + } + + var request = JsonSerializer.Deserialize(payloadJson, JsonOptions); + if (request == null) + { + Console.WriteLine(JsonSerializer.Serialize(new SamStoreResult + { + Success = false, + ErrorMessage = "Invalid store request payload", + }, JsonOptions)); + return 1; + } + return await StoreStatsAsync(storeAppId, request); + + default: + Console.WriteLine(JsonSerializer.Serialize(new { error = $"Unknown command '{command}'" }, JsonOptions)); + return 1; + } + } + catch (Exception ex) + { + Console.WriteLine(JsonSerializer.Serialize(new + { + success = false, + errorMessage = ex.Message, + }, JsonOptions)); + return 1; + } + } + + public static Task GetGamesAsync() + { + var games = new List(); + + using var client = new Client(); + try + { + client.Initialize(0); + } + catch (Exception ex) + { + Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = ex.Message }, JsonOptions)); + return Task.FromResult(1); + } + + // We also add standard default games (e.g. Spacewar 480) + var knownAppIds = new HashSet { 480 }; + + // Read subscribed games from steam apps + if (client.SteamApps008 != null && client.SteamApps001 != null) + { + // If user has cached games.xml or library, we can check ownership + string? installPath = Steam.GetInstallPath(); + if (!string.IsNullOrEmpty(installPath)) + { + string statsPath = Path.Combine(installPath, "appcache", "stats"); + if (Directory.Exists(statsPath)) + { + foreach (var file in Directory.GetFiles(statsPath, "UserGameStatsSchema_*.bin")) + { + string fn = Path.GetFileNameWithoutExtension(file); + if (fn.StartsWith("UserGameStatsSchema_") && + uint.TryParse(fn["UserGameStatsSchema_".Length..], out uint fileAppId)) + { + knownAppIds.Add(fileAppId); + } + } + } + + // Check steamapps manifests + string steamAppsDir = Path.Combine(installPath, "steamapps"); + if (Directory.Exists(steamAppsDir)) + { + foreach (var file in Directory.GetFiles(steamAppsDir, "appmanifest_*.acf")) + { + string fn = Path.GetFileNameWithoutExtension(file); + if (fn.StartsWith("appmanifest_") && + uint.TryParse(fn["appmanifest_".Length..], out uint manifestAppId)) + { + knownAppIds.Add(manifestAppId); + } + } + } + } + + foreach (uint id in knownAppIds) + { + try + { + string? name = client.SteamApps001.GetAppData(id, "name"); + games.Add(new SamGameInfo + { + Id = id, + Name = string.IsNullOrWhiteSpace(name) ? $"App {id}" : name, + Type = "normal", + ImageUrl = GetGameImageUrl(client, id), + }); + } + catch + { + // Ignore per-game retrieval error + } + } + } + + Console.WriteLine(JsonSerializer.Serialize(games, JsonOptions)); + return Task.FromResult(0); + } + + public static async Task GetStatsAsync(uint appId) + { + var result = new SamGameStatsData { AppId = appId }; + + using var client = new Client(); + try + { + client.Initialize(appId); + } + catch (ClientInitializeException ex) + { + result.ErrorMessage = $"Failed to initialize Steam client: {ex.Failure} ({ex.Message})"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + catch (Exception ex) + { + result.ErrorMessage = $"Steam error: {ex.Message}"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + + if (client.SteamUser == null || client.SteamUserStats == null || client.SteamApps001 == null) + { + result.ErrorMessage = "Steam interfaces not available"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + + string? gameName = client.SteamApps001.GetAppData(appId, "name"); + result.GameName = string.IsNullOrWhiteSpace(gameName) ? $"App {appId}" : gameName; + + var statsReceivedTcs = new TaskCompletionSource(); + var userStatsCallback = client.CreateAndRegisterCallback(); + userStatsCallback.OnRun += param => + { + statsReceivedTcs.TrySetResult(param.Result); + }; + + ulong steamId = client.SteamUser.GetSteamId(); + var callHandle = client.SteamUserStats.RequestUserStats(steamId); + if (callHandle == CallHandle.Invalid) + { + result.ErrorMessage = "Failed to request user stats from Steam"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + + // Run callbacks with timeout + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + _ = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested && !statsReceivedTcs.Task.IsCompleted) + { + client.RunCallbacks(false); + await Task.Delay(20); + } + }); + + int statsResult; + try + { + statsResult = await statsReceivedTcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) + { + statsResult = 1; // Try loading from local schema anyway if available + } + + if (statsResult != 1) + { + // Some games return result 2 if not owned or no stats + // Try fallback to local schema anyway + } + + // Load schema + var (achDefinitions, statDefinitions) = LoadSchema(client, appId); + + string currentLanguage = client.SteamApps008?.GetCurrentGameLanguage() ?? "english"; + + // Fetch Achievements + foreach (var def in achDefinitions) + { + if (string.IsNullOrEmpty(def.Id)) continue; + + bool isAchieved = false; + uint unlockTime = 0; + + try + { + client.SteamUserStats.GetAchievementAndUnlockTime(def.Id, out isAchieved, out unlockTime); + } + catch + { + // Ignored + } + + DateTime? unlockDateTime = isAchieved && unlockTime > 0 + ? DateTimeOffset.FromUnixTimeSeconds(unlockTime).LocalDateTime + : null; + + result.Achievements.Add(new SamAchievement + { + Id = def.Id, + Name = string.IsNullOrWhiteSpace(def.Name) ? def.Id : def.Name, + Description = def.Description ?? string.Empty, + IsAchieved = isAchieved, + OriginalIsAchieved = isAchieved, + UnlockTime = unlockDateTime, + IconNormal = def.IconNormal, + IconLocked = def.IconLocked, + IconUrl = !string.IsNullOrEmpty(def.IconNormal) + ? $"https://cdn.steamstatic.com/steamcommunity/public/images/apps/{appId}/{def.IconNormal}" + : null, + LockedIconUrl = !string.IsNullOrEmpty(def.IconLocked) + ? $"https://cdn.steamstatic.com/steamcommunity/public/images/apps/{appId}/{def.IconLocked}" + : (!string.IsNullOrEmpty(def.IconNormal) ? $"https://cdn.steamstatic.com/steamcommunity/public/images/apps/{appId}/{def.IconNormal}" : null), + IsHidden = def.IsHidden, + Permission = def.Permission, + }); + } + + // Fetch Statistics + foreach (var def in statDefinitions) + { + if (string.IsNullOrEmpty(def.Id)) continue; + + if (def.StatType == UserStatType.Integer) + { + int val = 0; + client.SteamUserStats.GetStatValue(def.Id, out val); + result.Stats.Add(new SamStat + { + Id = def.Id, + DisplayName = string.IsNullOrWhiteSpace(def.DisplayName) ? def.Id : def.DisplayName, + StatType = UserStatType.Integer, + IntValue = val, + OriginalIntValue = val, + MinInt = def.MinInt, + MaxInt = def.MaxInt, + IncrementOnly = def.IncrementOnly, + Permission = def.Permission, + }); + } + else if (def.StatType is UserStatType.Float or UserStatType.AverageRate) + { + float val = 0; + client.SteamUserStats.GetStatValue(def.Id, out val); + result.Stats.Add(new SamStat + { + Id = def.Id, + DisplayName = string.IsNullOrWhiteSpace(def.DisplayName) ? def.Id : def.DisplayName, + StatType = def.StatType, + FloatValue = val, + OriginalFloatValue = val, + MinFloat = def.MinFloat, + MaxFloat = def.MaxFloat, + IncrementOnly = def.IncrementOnly, + Permission = def.Permission, + }); + } + } + + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 0; + } + + public static async Task StoreStatsAsync(uint appId, SamStoreRequest request) + { + var result = new SamStoreResult(); + + using var client = new Client(); + try + { + client.Initialize(appId); + } + catch (Exception ex) + { + result.Success = false; + result.ErrorMessage = $"Failed to initialize Steam: {ex.Message}"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + + if (client.SteamUserStats == null) + { + result.Success = false; + result.ErrorMessage = "SteamUserStats interface is not available"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + + if (request.ResetAll) + { + client.SteamUserStats.ResetAllStats(request.ResetAchievementsToo); + } + + // Apply achievement changes + int achievementsStored = 0; + foreach (var (achId, state) in request.Achievements) + { + if (client.SteamUserStats.SetAchievement(achId, state)) + { + achievementsStored++; + } + else + { + result.Success = false; + result.ErrorMessage = $"Failed to set achievement '{achId}' to {state}"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + } + + // Apply stat changes + int statsStored = 0; + foreach (var (statId, intVal) in request.IntStats) + { + if (client.SteamUserStats.SetStatValue(statId, intVal)) + { + statsStored++; + } + else + { + result.Success = false; + result.ErrorMessage = $"Failed to set integer stat '{statId}' to {intVal}"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + } + + foreach (var (statId, floatVal) in request.FloatStats) + { + if (client.SteamUserStats.SetStatValue(statId, floatVal)) + { + statsStored++; + } + else + { + result.Success = false; + result.ErrorMessage = $"Failed to set float stat '{statId}' to {floatVal}"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + } + + // Commit to Steam + if (!client.SteamUserStats.StoreStats()) + { + result.Success = false; + result.ErrorMessage = "Steam rejected StoreStats() commit call"; + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 1; + } + + // Run callbacks to process confirmation + for (int i = 0; i < 10; i++) + { + client.RunCallbacks(false); + await Task.Delay(25); + } + + result.Success = true; + result.AchievementsStored = achievementsStored; + result.StatsStored = statsStored; + + Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); + return 0; + } + + private static string? GetGameImageUrl(Client client, uint appId) + { + if (client.SteamApps001 == null || client.SteamApps008 == null) return null; + + string currentLanguage = client.SteamApps008.GetCurrentGameLanguage() ?? "english"; + + string? candidate = client.SteamApps001.GetAppData(appId, $"small_capsule/{currentLanguage}"); + if (!string.IsNullOrEmpty(candidate)) + { + return $"https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{appId}/{candidate}"; + } + + if (currentLanguage != "english") + { + candidate = client.SteamApps001.GetAppData(appId, "small_capsule/english"); + if (!string.IsNullOrEmpty(candidate)) + { + return $"https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{appId}/{candidate}"; + } + } + + candidate = client.SteamApps001.GetAppData(appId, "logo"); + if (!string.IsNullOrEmpty(candidate)) + { + return $"https://cdn.steamstatic.com/steamcommunity/public/images/apps/{appId}/{candidate}.jpg"; + } + + return $"https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{appId}/header.jpg"; + } + + private record RawAchDef(string Id, string Name, string Description, string? IconNormal, string? IconLocked, bool IsHidden, int Permission); + private record RawStatDef(string Id, string DisplayName, UserStatType StatType, int MinInt, int MaxInt, float MinFloat, float MaxFloat, bool IncrementOnly, int Permission); + + private static (List Achievements, List Stats) LoadSchema(Client client, uint appId) + { + var achievements = new List(); + var stats = new List(); + + string? installPath = Steam.GetInstallPath(); + if (string.IsNullOrEmpty(installPath)) return (achievements, stats); + + string schemaPath = Path.Combine(installPath, "appcache", "stats", $"UserGameStatsSchema_{appId}.bin"); + if (!File.Exists(schemaPath)) return (achievements, stats); + + var kv = KeyValue.LoadAsBinary(schemaPath); + if (kv == null) return (achievements, stats); + + string currentLanguage = client.SteamApps008?.GetCurrentGameLanguage() ?? "english"; + + var statsNode = kv[appId.ToString(CultureInfo.InvariantCulture)]["stats"]; + if (!statsNode.Valid || statsNode.Children == null) return (achievements, stats); + + foreach (var stat in statsNode.Children) + { + if (!stat.Valid) continue; + + UserStatType type = UserStatType.Invalid; + var typeNode = stat["type"]; + if (typeNode.Valid && typeNode.Type == KeyValueType.String && typeNode.Value is string typeStr) + { + if (!Enum.TryParse(typeStr, true, out type)) + { + type = UserStatType.Invalid; + } + } + + if (type == UserStatType.Invalid) + { + var typeIntNode = stat["type_int"]; + int rawType = typeIntNode.Valid ? typeIntNode.AsInteger(0) : typeNode.AsInteger(0); + type = (UserStatType)rawType; + } + + switch (type) + { + case UserStatType.Integer: + { + string id = stat["name"].AsString(""); + string displayName = GetLocalizedString(stat["display"]["name"], currentLanguage, id); + stats.Add(new RawStatDef( + id, + displayName, + UserStatType.Integer, + stat["min"].AsInteger(int.MinValue), + stat["max"].AsInteger(int.MaxValue), + 0, 0, + stat["incrementonly"].AsBoolean(false), + stat["permission"].AsInteger(0))); + break; + } + case UserStatType.Float: + case UserStatType.AverageRate: + { + string id = stat["name"].AsString(""); + string displayName = GetLocalizedString(stat["display"]["name"], currentLanguage, id); + stats.Add(new RawStatDef( + id, + displayName, + type, + 0, 0, + stat["min"].AsFloat(float.MinValue), + stat["max"].AsFloat(float.MaxValue), + stat["incrementonly"].AsBoolean(false), + stat["permission"].AsInteger(0))); + break; + } + case UserStatType.Achievements: + case UserStatType.GroupAchievements: + { + if (stat.Children != null) + { + foreach (var bits in stat.Children.Where( + b => string.Equals(b.Name, "bits", StringComparison.OrdinalIgnoreCase))) + { + if (!bits.Valid || bits.Children == null) continue; + + foreach (var bit in bits.Children) + { + string id = bit["name"].AsString(""); + string name = GetLocalizedString(bit["display"]["name"], currentLanguage, id); + string desc = GetLocalizedString(bit["display"]["desc"], currentLanguage, ""); + achievements.Add(new RawAchDef( + id, + name, + desc, + bit["display"]["icon"].AsString(string.Empty), + bit["display"]["icon_gray"].AsString(string.Empty), + bit["display"]["hidden"].AsBoolean(false), + bit["permission"].AsInteger(0))); + } + } + } + break; + } + } + } + + return (achievements, stats); + } + + private static string GetLocalizedString(KeyValue kv, string language, string defaultValue) + { + string name = kv[language].AsString(""); + if (!string.IsNullOrEmpty(name)) return name; + + if (language != "english") + { + name = kv["english"].AsString(""); + if (!string.IsNullOrEmpty(name)) return name; + } + + name = kv.AsString(""); + if (!string.IsNullOrEmpty(name)) return name; + + return defaultValue; + } +} diff --git a/src/LuaToolsGui/Services/SAM/Schema/KeyValue.cs b/src/LuaToolsGui/Services/SAM/Schema/KeyValue.cs new file mode 100644 index 0000000..f6ea41c --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Schema/KeyValue.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace LuaToolsGui.Services.SAM.Schema; + +public class KeyValue +{ + private static readonly KeyValue _invalid = new(); + public string Name { get; set; } = ""; + public KeyValueType Type { get; set; } = KeyValueType.None; + public object? Value { get; set; } + public bool Valid { get; set; } + + public List? Children { get; set; } + + public KeyValue this[string key] + { + get + { + if (Children == null) + { + return _invalid; + } + + var child = Children.FirstOrDefault( + c => string.Compare(c.Name, key, StringComparison.OrdinalIgnoreCase) == 0); + + return child ?? _invalid; + } + } + + public string AsString(string defaultValue = "") + { + if (!Valid || Value == null) + { + return defaultValue; + } + + return Value.ToString() ?? defaultValue; + } + + public int AsInteger(int defaultValue = 0) + { + if (!Valid || Value == null) + { + return defaultValue; + } + + switch (Type) + { + case KeyValueType.String: + case KeyValueType.WideString: + { + return int.TryParse((string)Value, out int value) ? value : defaultValue; + } + case KeyValueType.Int32: + { + return (int)Value; + } + case KeyValueType.Float32: + { + return (int)(float)Value; + } + case KeyValueType.UInt64: + { + return (int)((ulong)Value & 0xFFFFFFFF); + } + } + + return defaultValue; + } + + public float AsFloat(float defaultValue = 0.0f) + { + if (!Valid || Value == null) + { + return defaultValue; + } + + switch (Type) + { + case KeyValueType.String: + case KeyValueType.WideString: + { + return float.TryParse((string)Value, out float value) ? value : defaultValue; + } + case KeyValueType.Int32: + { + return (int)Value; + } + case KeyValueType.Float32: + { + return (float)Value; + } + case KeyValueType.UInt64: + { + return (ulong)Value & 0xFFFFFFFF; + } + } + + return defaultValue; + } + + public bool AsBoolean(bool defaultValue = false) + { + if (!Valid || Value == null) + { + return defaultValue; + } + + switch (Type) + { + case KeyValueType.String: + case KeyValueType.WideString: + { + return int.TryParse((string)Value, out int value) ? value != 0 : defaultValue; + } + case KeyValueType.Int32: + { + return (int)Value != 0; + } + case KeyValueType.Float32: + { + return (int)(float)Value != 0; + } + case KeyValueType.UInt64: + { + return (ulong)Value != 0; + } + } + + return defaultValue; + } + + public override string ToString() + { + if (!Valid) return ""; + if (Type == KeyValueType.None) return Name; + return $"{Name} = {Value}"; + } + + public static KeyValue? LoadAsBinary(string path) + { + if (!File.Exists(path)) return null; + + try + { + using var input = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var kv = new KeyValue(); + if (!kv.ReadAsBinary(input)) + { + return null; + } + return kv; + } + catch + { + return null; + } + } + + public bool ReadAsBinary(Stream input) + { + Children = []; + try + { + while (input.Position < input.Length) + { + var type = (KeyValueType)input.ReadValueU8(); + + if (type == KeyValueType.End) + { + break; + } + + var current = new KeyValue + { + Type = type, + Name = input.ReadStringUnicode(), + }; + + switch (type) + { + case KeyValueType.None: + { + current.ReadAsBinary(input); + current.Valid = true; + break; + } + case KeyValueType.String: + { + current.Valid = true; + current.Value = input.ReadStringUnicode(); + break; + } + case KeyValueType.WideString: + { + throw new FormatException("wstring is unsupported"); + } + case KeyValueType.Int32: + { + current.Valid = true; + current.Value = input.ReadValueS32(); + break; + } + case KeyValueType.UInt64: + { + current.Valid = true; + current.Value = input.ReadValueU64(); + break; + } + case KeyValueType.Float32: + { + current.Valid = true; + current.Value = input.ReadValueF32(); + break; + } + case KeyValueType.Color: + case KeyValueType.Pointer: + { + current.Valid = true; + current.Value = input.ReadValueU32(); + break; + } + default: + { + throw new FormatException($"Unknown KeyValue type: {type}"); + } + } + + Children.Add(current); + } + + Valid = true; + return true; + } + catch + { + return false; + } + } +} diff --git a/src/LuaToolsGui/Services/SAM/Schema/KeyValueType.cs b/src/LuaToolsGui/Services/SAM/Schema/KeyValueType.cs new file mode 100644 index 0000000..becc8af --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Schema/KeyValueType.cs @@ -0,0 +1,14 @@ +namespace LuaToolsGui.Services.SAM.Schema; + +public enum KeyValueType : byte +{ + None = 0, + String = 1, + Int32 = 2, + Float32 = 3, + Pointer = 4, + WideString = 5, + Color = 6, + UInt64 = 7, + End = 8, +} diff --git a/src/LuaToolsGui/Services/SAM/Schema/StreamHelpers.cs b/src/LuaToolsGui/Services/SAM/Schema/StreamHelpers.cs new file mode 100644 index 0000000..ac95e1f --- /dev/null +++ b/src/LuaToolsGui/Services/SAM/Schema/StreamHelpers.cs @@ -0,0 +1,96 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; + +namespace LuaToolsGui.Services.SAM.Schema; + +internal static class StreamHelpers +{ + public static byte ReadValueU8(this Stream stream) + { + int b = stream.ReadByte(); + if (b < 0) throw new EndOfStreamException(); + return (byte)b; + } + + public static int ReadValueS32(this Stream stream) + { + Span data = stackalloc byte[4]; + int read = stream.Read(data); + if (read < 4) throw new EndOfStreamException(); + return BitConverter.ToInt32(data); + } + + public static uint ReadValueU32(this Stream stream) + { + Span data = stackalloc byte[4]; + int read = stream.Read(data); + if (read < 4) throw new EndOfStreamException(); + return BitConverter.ToUInt32(data); + } + + public static ulong ReadValueU64(this Stream stream) + { + Span data = stackalloc byte[8]; + int read = stream.Read(data); + if (read < 8) throw new EndOfStreamException(); + return BitConverter.ToUInt64(data); + } + + public static float ReadValueF32(this Stream stream) + { + Span data = stackalloc byte[4]; + int read = stream.Read(data); + if (read < 4) throw new EndOfStreamException(); + return BitConverter.ToSingle(data); + } + + internal static string ReadStringInternalDynamic(this Stream stream, Encoding encoding, char end) + { + int characterSize = encoding.GetByteCount("e"); + string characterEnd = end.ToString(CultureInfo.InvariantCulture); + + int i = 0; + var data = new byte[128 * characterSize]; + + while (true) + { + if (i + characterSize > data.Length) + { + Array.Resize(ref data, data.Length + (128 * characterSize)); + } + + int read = stream.Read(data, i, characterSize); + if (read < characterSize) + { + break; + } + + if (encoding.GetString(data, i, characterSize) == characterEnd) + { + break; + } + + i += characterSize; + } + + if (i == 0) + { + return string.Empty; + } + + return encoding.GetString(data, 0, i); + } + + public static string ReadStringAscii(this Stream stream) + { + return stream.ReadStringInternalDynamic(Encoding.ASCII, '\0'); + } + + public static string ReadStringUnicode(this Stream stream) + { + return stream.ReadStringInternalDynamic(Encoding.UTF8, '\0'); + } +} diff --git a/src/LuaToolsGui/ViewModels/AchievementsViewModel.cs b/src/LuaToolsGui/ViewModels/AchievementsViewModel.cs new file mode 100644 index 0000000..0a08ada --- /dev/null +++ b/src/LuaToolsGui/ViewModels/AchievementsViewModel.cs @@ -0,0 +1,600 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LuaToolsGui.Models; +using LuaToolsGui.Services; +using LuaToolsGui.Services.SAM; + +namespace LuaToolsGui.ViewModels; + +public enum AchievementFilter +{ + All, + Unlocked, + Locked, +} + +public enum GameCategoryFilter +{ + All, + Installed, + Normal, + Demos, + Mods, +} + +public partial class AchievementsViewModel : ObservableObject +{ + private readonly SamService _samService; + private readonly ToastService _toastService; + private readonly CoverCache _coverCache; + + private readonly object _gamesLock = new(); + private readonly object _achsLock = new(); + private readonly object _statsLock = new(); + + public AchievementsViewModel( + SamService samService, + ToastService toastService, + CoverCache coverCache) + { + _samService = samService; + _toastService = toastService; + _coverCache = coverCache; + + BindingOperations.EnableCollectionSynchronization(AllGames, _gamesLock); + BindingOperations.EnableCollectionSynchronization(Achievements, _achsLock); + BindingOperations.EnableCollectionSynchronization(Stats, _statsLock); + + _gamesView = CollectionViewSource.GetDefaultView(AllGames); + _gamesView.Filter = FilterGame; + + _achievementsView = CollectionViewSource.GetDefaultView(Achievements); + _achievementsView.Filter = FilterAchievement; + + _statsView = CollectionViewSource.GetDefaultView(Stats); + _statsView.Filter = FilterStat; + } + + // ── Collections & Views ────────────────────────────────────────── + + [ObservableProperty] + private ObservableCollection _allGames = []; + + [ObservableProperty] + private ObservableCollection _achievements = []; + + [ObservableProperty] + private ObservableCollection _stats = []; + + private ICollectionView _gamesView; + private ICollectionView _achievementsView; + private ICollectionView _statsView; + + public ICollectionView FilteredGames => _gamesView; + public ICollectionView FilteredAchievements => _achievementsView; + public ICollectionView FilteredStats => _statsView; + + // ── Navigation & View State ────────────────────────────────────── + [ObservableProperty] + private bool _isGameSelected; + + [ObservableProperty] + private SamGameInfo? _selectedGame; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private bool _isSaving; + + [ObservableProperty] + private string? _statusMessage; + + [ObservableProperty] + private string? _errorMessage; + + // ── Game Picker Search & Filters ───────────────────────────────── + [ObservableProperty] + private string _gameSearchText = string.Empty; + + [ObservableProperty] + private GameCategoryFilter _selectedCategoryFilter = GameCategoryFilter.All; + + [ObservableProperty] + private string _customAppIdInput = string.Empty; + + // ── Achievement Management State ───────────────────────────────── + [ObservableProperty] + private string _achievementSearchText = string.Empty; + + [ObservableProperty] + private AchievementFilter _selectedAchievementFilter = AchievementFilter.All; + + [ObservableProperty] + private int _selectedTabIndex; // 0 = Achievements, 1 = Statistics + + [ObservableProperty] + private bool _enableStatsEditing; + + [ObservableProperty] + private int _unlockedCount; + + // Message shown when selected game has no achievements + [ObservableProperty] + private string _noAchievementsMessage = string.Empty; + + [ObservableProperty] + private int _totalAchievementsCount; + + [ObservableProperty] + private double _progressPercentage; + + public bool HasPendingChanges => + Achievements.Any(a => a.IsModified) || + Stats.Any(s => s.IsModified); + + // ── Search & Filter Logic ──────────────────────────────────────── + + partial void OnGameSearchTextChanged(string value) => _gamesView.Refresh(); + partial void OnSelectedCategoryFilterChanged(GameCategoryFilter value) => _gamesView.Refresh(); + + private bool FilterGame(object item) + { + if (item is not SamGameInfo game) return false; + + // Category filter + switch (SelectedCategoryFilter) + { + case GameCategoryFilter.Installed: + if (!game.IsInstalledInLuaTools) return false; + break; + case GameCategoryFilter.Normal: + if (!string.Equals(game.Type, "normal", StringComparison.OrdinalIgnoreCase)) return false; + break; + case GameCategoryFilter.Demos: + if (!string.Equals(game.Type, "demo", StringComparison.OrdinalIgnoreCase)) return false; + break; + case GameCategoryFilter.Mods: + if (!string.Equals(game.Type, "mod", StringComparison.OrdinalIgnoreCase)) return false; + break; + } + + // Text search + if (!string.IsNullOrWhiteSpace(GameSearchText)) + { + string q = GameSearchText.Trim(); + if (uint.TryParse(q, out uint searchAppId) && game.Id == searchAppId) + return true; + + return game.Name.Contains(q, StringComparison.OrdinalIgnoreCase); + } + + return true; + } + + partial void OnAchievementSearchTextChanged(string value) => _achievementsView.Refresh(); + partial void OnSelectedAchievementFilterChanged(AchievementFilter value) => _achievementsView.Refresh(); + + private bool FilterAchievement(object item) + { + if (item is not SamAchievement ach) return false; + + // State filter + switch (SelectedAchievementFilter) + { + case AchievementFilter.Unlocked: + if (!ach.IsAchieved) return false; + break; + case AchievementFilter.Locked: + if (ach.IsAchieved) return false; + break; + } + + // Text search + if (!string.IsNullOrWhiteSpace(AchievementSearchText)) + { + string q = AchievementSearchText.Trim(); + return ach.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + ach.Description.Contains(q, StringComparison.OrdinalIgnoreCase) || + ach.Id.Contains(q, StringComparison.OrdinalIgnoreCase); + } + + return true; + } + + private bool FilterStat(object item) + { + if (item is not SamStat stat) return false; + + if (!string.IsNullOrWhiteSpace(AchievementSearchText)) + { + string q = AchievementSearchText.Trim(); + return stat.DisplayName.Contains(q, StringComparison.OrdinalIgnoreCase) || + stat.Id.Contains(q, StringComparison.OrdinalIgnoreCase); + } + + return true; + } + + private bool _isBulkUpdating; + + private void RecalculateProgress() + { + if (_isBulkUpdating) return; + + TotalAchievementsCount = Achievements.Count; + UnlockedCount = Achievements.Count(a => a.IsAchieved); + ProgressPercentage = TotalAchievementsCount > 0 + ? (double)UnlockedCount / TotalAchievementsCount * 100.0 + : 0.0; + + OnPropertyChanged(nameof(HasPendingChanges)); + } + + private static void OnUi(Action action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher is null || dispatcher.CheckAccess()) action(); + else dispatcher.Invoke(action); + } + + // ── Commands & Actions ─────────────────────────────────────────── + + [RelayCommand] + public async Task LoadGamesAsync(bool force = false) + { + if (IsLoading) return; + IsLoading = true; + ErrorMessage = null; + StatusMessage = "Scanning Steam games..."; + + try + { + var games = await _samService.GetGamesAsync(force); + OnUi(() => + { + AllGames = new ObservableCollection(games); + BindingOperations.EnableCollectionSynchronization(AllGames, _gamesLock); + _gamesView = CollectionViewSource.GetDefaultView(AllGames); + _gamesView.Filter = FilterGame; + OnPropertyChanged(nameof(FilteredGames)); + }); + } + catch (Exception ex) + { + ErrorMessage = $"Failed to load games: {ex.Message}"; + } + finally + { + IsLoading = false; + StatusMessage = null; + } + } + + [RelayCommand] + public async Task OpenCustomAppIdAsync() + { + if (uint.TryParse(CustomAppIdInput.Trim(), out uint appId) && appId > 0) + { + CustomAppIdInput = string.Empty; + await SelectGameAsync(new SamGameInfo + { + Id = appId, + Name = $"App {appId}", + Type = "normal", + DisplayCoverUrl = _coverCache.GetCoverPathOrUrl(appId), + }); + } + else + { + _toastService.Show("Achievements", "Please enter a valid numeric Steam App ID.", error: true); + } + } + + [RelayCommand] + public async Task SelectGameAsync(SamGameInfo? game) + { + if (game == null) return; + + SelectedGame = game; + IsGameSelected = true; + IsLoading = true; + ErrorMessage = null; + StatusMessage = $"Loading achievements for {game.Name}..."; + + OnUi(() => + { + Achievements = []; + Stats = []; + }); + + try + { + var data = await _samService.GetGameStatsAsync(game.Id, forceRefresh: true); + + if (!string.IsNullOrEmpty(data.ErrorMessage)) + { + ErrorMessage = data.ErrorMessage; + _toastService.Show("Achievements", data.ErrorMessage, error: true); + // Clear any previous message about missing achievements + NoAchievementsMessage = string.Empty; + return; + } + + if (!string.IsNullOrWhiteSpace(data.GameName)) + { + game.Name = data.GameName; + SelectedGame = game; + } + + // Determine if there are any achievements + bool hasAchievements = data.Achievements != null && data.Achievements.Count > 0; + + OnUi(() => + { + var newAchievements = new ObservableCollection(); + if (hasAchievements) + { + var achList = data.Achievements!; + foreach (var ach in achList) + { + ach.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(SamAchievement.IsAchieved)) + { + RecalculateProgress(); + } + }; + newAchievements.Add(ach); + } + NoAchievementsMessage = string.Empty; // clear any previous message + } + else + { + // Show a friendly message when no achievements are present + NoAchievementsMessage = $"No achievements found for {game.Name}."; + _toastService.Show("Achievements", NoAchievementsMessage, error: false); + } + + var newStats = new ObservableCollection(); + foreach (var stat in data.Stats) + { + stat.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(SamStat.IntValue) or nameof(SamStat.FloatValue) or nameof(SamStat.ValueString)) + { + OnPropertyChanged(nameof(HasPendingChanges)); + } + }; + newStats.Add(stat); + } + + Achievements = newAchievements; + Stats = newStats; + + BindingOperations.EnableCollectionSynchronization(Achievements, _achsLock); + BindingOperations.EnableCollectionSynchronization(Stats, _statsLock); + + _achievementsView = CollectionViewSource.GetDefaultView(Achievements); + _achievementsView.Filter = FilterAchievement; + + _statsView = CollectionViewSource.GetDefaultView(Stats); + _statsView.Filter = FilterStat; + + OnPropertyChanged(nameof(FilteredAchievements)); + OnPropertyChanged(nameof(FilteredStats)); + + RecalculateProgress(); + }); + } + catch (Exception ex) + { + ErrorMessage = $"Error loading stats: {ex.Message}"; + _toastService.Show("Achievements", ErrorMessage, error: true); + } + finally + { + IsLoading = false; + StatusMessage = null; + } + } + + [RelayCommand] + public void BackToPicker() + { + IsGameSelected = false; + SelectedGame = null; + OnUi(() => + { + Achievements = []; + Stats = []; + OnPropertyChanged(nameof(FilteredAchievements)); + OnPropertyChanged(nameof(FilteredStats)); + }); + ErrorMessage = null; + StatusMessage = null; + } + + [RelayCommand] + public void UnlockAll() + { + _isBulkUpdating = true; + try + { + foreach (var ach in Achievements) + { + ach.IsAchieved = true; + } + } + finally + { + _isBulkUpdating = false; + RecalculateProgress(); + _achievementsView.Refresh(); + } + } + + [RelayCommand] + public void LockAll() + { + _isBulkUpdating = true; + try + { + foreach (var ach in Achievements) + { + ach.IsAchieved = false; + } + } + finally + { + _isBulkUpdating = false; + RecalculateProgress(); + _achievementsView.Refresh(); + } + } + + [RelayCommand] + public void InvertSelection() + { + _isBulkUpdating = true; + try + { + foreach (var ach in Achievements) + { + ach.IsAchieved = !ach.IsAchieved; + } + } + finally + { + _isBulkUpdating = false; + RecalculateProgress(); + _achievementsView.Refresh(); + } + } + + [RelayCommand] + public async Task ReloadStatsAsync() + { + if (SelectedGame == null) return; + await SelectGameAsync(SelectedGame); + } + + [RelayCommand] + public async Task CommitChangesAsync() + { + if (SelectedGame == null || IsSaving) return; + + var request = new SamStoreRequest + { + AppId = SelectedGame.Id, + }; + + foreach (var ach in Achievements.Where(a => a.IsModified)) + { + request.Achievements[ach.Id] = ach.IsAchieved; + } + + foreach (var stat in Stats.Where(s => s.IsModified)) + { + if (stat.IsFloat) + { + request.FloatStats[stat.Id] = stat.FloatValue; + } + else + { + request.IntStats[stat.Id] = stat.IntValue; + } + } + + if (request.Achievements.Count == 0 && request.IntStats.Count == 0 && request.FloatStats.Count == 0) + { + _toastService.Show("Achievements", "No modified achievements or stats to store."); + return; + } + + IsSaving = true; + StatusMessage = "Saving changes to Steam Cloud..."; + + try + { + var result = await _samService.StoreStatsAsync(request); + + if (result.Success) + { + // Update original states + foreach (var ach in Achievements) + { + ach.OriginalIsAchieved = ach.IsAchieved; + } + foreach (var stat in Stats) + { + stat.OriginalIntValue = stat.IntValue; + stat.OriginalFloatValue = stat.FloatValue; + } + + OnPropertyChanged(nameof(HasPendingChanges)); + + _toastService.Show( + "Achievements Saved", + $"Successfully updated {result.AchievementsStored} achievements and {result.StatsStored} stats in Steam!"); + } + else + { + string err = result.ErrorMessage ?? "Failed to save stats to Steam."; + ErrorMessage = err; + _toastService.Show("Steam Error", err, error: true); + } + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + _toastService.Show("Error", ex.Message, error: true); + } + finally + { + IsSaving = false; + StatusMessage = null; + } + } + + [RelayCommand] + public async Task ResetAllStatsAsync() + { + if (SelectedGame == null) return; + + var request = new SamStoreRequest + { + AppId = SelectedGame.Id, + ResetAll = true, + ResetAchievementsToo = true, + }; + + IsSaving = true; + StatusMessage = "Resetting all game stats in Steam..."; + + try + { + var result = await _samService.StoreStatsAsync(request); + if (result.Success) + { + _toastService.Show("Stats Reset", "All stats and achievements have been reset."); + await SelectGameAsync(SelectedGame); + } + else + { + _toastService.Show("Reset Failed", result.ErrorMessage ?? "Failed to reset stats.", error: true); + } + } + finally + { + IsSaving = false; + StatusMessage = null; + } + } +} diff --git a/src/LuaToolsGui/ViewModels/ManageViewModel.cs b/src/LuaToolsGui/ViewModels/ManageViewModel.cs index 3100f72..4ca644a 100644 --- a/src/LuaToolsGui/ViewModels/ManageViewModel.cs +++ b/src/LuaToolsGui/ViewModels/ManageViewModel.cs @@ -194,6 +194,9 @@ public partial class ManageViewModel : PagedListViewModel /// Set by App so "Manage Build" can open this game on the Builds page. public Action? NavigateToBuilds { get; set; } + /// Set by App so "Achievements" can open this game in the Achievements view. + public Action? NavigateToAchievements { get; set; } + // Paging (Items/PageSize/CurrentPage/…), the filtered slice, refresh cooldown, IsLoading/EmptyMessage // and the empty-state gating all live in PagedListViewModel. @@ -346,6 +349,10 @@ private void CloseDetail() [RelayCommand] private void ManageBuild(LuaTileViewModel tile) => NavigateToBuilds?.Invoke(tile.AppId); + /// Open this game in the Achievements view. + [RelayCommand] + private void ManageAchievements(LuaTileViewModel tile) => NavigateToAchievements?.Invoke(tile.AppId); + /// Set by App. Opens the launch-option editor for a game (appid, name). public Action? OpenLaunchOptions { get; set; } diff --git a/src/LuaToolsGui/Views/AchievementsView.xaml b/src/LuaToolsGui/Views/AchievementsView.xaml new file mode 100644 index 0000000..5feda54 --- /dev/null +++ b/src/LuaToolsGui/Views/AchievementsView.xaml @@ -0,0 +1,681 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LuaToolsGui/Views/AchievementsView.xaml.cs b/src/LuaToolsGui/Views/AchievementsView.xaml.cs new file mode 100644 index 0000000..edb184f --- /dev/null +++ b/src/LuaToolsGui/Views/AchievementsView.xaml.cs @@ -0,0 +1,27 @@ +using System.Windows.Controls; +using LuaToolsGui.ViewModels; + +namespace LuaToolsGui.Views; + +public partial class AchievementsView : UserControl +{ + private readonly AchievementsViewModel _viewModel; + + public AchievementsView(AchievementsViewModel viewModel) + { + InitializeComponent(); + DataContext = _viewModel = viewModel; + + Loaded += async (_, _) => + { + try + { + if (_viewModel.AllGames.Count == 0 && !_viewModel.IsGameSelected) + { + await _viewModel.LoadGamesCommand.ExecuteAsync(false); + } + } + catch { /* non-fatal */ } + }; + } +} diff --git a/src/LuaToolsGui/Views/ManageView.xaml b/src/LuaToolsGui/Views/ManageView.xaml index ac06535..37b1b9e 100644 --- a/src/LuaToolsGui/Views/ManageView.xaml +++ b/src/LuaToolsGui/Views/ManageView.xaml @@ -739,6 +739,13 @@ CommandParameter="{Binding}" Content="{x:Static res:Strings.Manage_Action_LaunchOptions}" Icon="{ui:SymbolIcon Play24}" /> + (json); + + Assert.NotNull(deserialized); + Assert.Equal(480u, deserialized.AppId); + Assert.True(deserialized.Achievements["ACH_01"]); + Assert.False(deserialized.Achievements["ACH_02"]); + Assert.Equal(1200, deserialized.IntStats["STAT_SCORE"]); + Assert.Equal(2.5f, deserialized.FloatStats["STAT_RATIO"]); + } + + [Fact] + public void SamStoreResult_JsonSerialization() + { + var res = new SamStoreResult + { + Success = true, + AchievementsStored = 2, + StatsStored = 1, + }; + + string json = JsonSerializer.Serialize(res); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Success); + Assert.Equal(2, deserialized.AchievementsStored); + Assert.Equal(1, deserialized.StatsStored); + } + + [Fact] + public void AchievementsViewModel_BulkOperations_And_Progress() + { + // Setup mock service / VM + var settings = new LuaToolsGui.Services.SettingsService(); + var steam = new LuaToolsGui.Services.SteamService(settings); + var cache = new LuaToolsGui.Services.CacheService(); + var appList = new LuaToolsGui.Services.SteamAppListCache(); + var appInfo = new LuaToolsGui.Services.SteamAppInfoCache(cache); + var covers = new LuaToolsGui.Services.CoverCache(); + var toast = new LuaToolsGui.Services.ToastService(); + var samService = new LuaToolsGui.Services.SAM.SamService(steam, appInfo, appList, covers); + + var vm = new AchievementsViewModel(samService, toast, covers); + + // Add 3 achievements + var a1 = new SamAchievement { Id = "ACH_1", Name = "A1", OriginalIsAchieved = false, IsAchieved = false }; + var a2 = new SamAchievement { Id = "ACH_2", Name = "A2", OriginalIsAchieved = true, IsAchieved = true }; + var a3 = new SamAchievement { Id = "ACH_3", Name = "A3", OriginalIsAchieved = false, IsAchieved = false }; + + vm.Achievements.Add(a1); + vm.Achievements.Add(a2); + vm.Achievements.Add(a3); + + // Unlock All + vm.UnlockAllCommand.Execute(null); + Assert.All(vm.Achievements, a => Assert.True(a.IsAchieved)); + Assert.Equal(3, vm.UnlockedCount); + Assert.Equal(3, vm.TotalAchievementsCount); + Assert.Equal(100.0, vm.ProgressPercentage); + Assert.True(vm.HasPendingChanges); // a1 and a3 modified + + // Lock All + vm.LockAllCommand.Execute(null); + Assert.All(vm.Achievements, a => Assert.False(a.IsAchieved)); + Assert.Equal(0, vm.UnlockedCount); + Assert.Equal(0.0, vm.ProgressPercentage); + Assert.True(vm.HasPendingChanges); // a2 modified + + // Invert + vm.InvertSelectionCommand.Execute(null); + Assert.All(vm.Achievements, a => Assert.True(a.IsAchieved)); + Assert.Equal(3, vm.UnlockedCount); + Assert.Equal(100.0, vm.ProgressPercentage); + } + + [Fact] + public async Task SamWorker_InvalidCommand_ReturnsErrorCode() + { + int exitCode = await LuaToolsGui.Services.SAM.SamWorker.RunAsync(["--sam-worker", "unknown-cmd"]); + Assert.Equal(1, exitCode); + } +} + +internal static class BinaryWriterExtensions +{ + public static void WriteNullTerminatedUtf8(this BinaryWriter writer, string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + writer.Write(bytes); + writer.Write((byte)0); + } +}