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);
+ }
+}