diff --git a/MainWindow.xaml b/MainWindow.xaml
index be4f7b6..258c549 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -5,7 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Sigil"
mc:Ignorable="d"
- Title="Sigil" Height="500" Width="700"
+ Title="Sigil" Height="580" Width="700"
WindowStartupLocation="CenterScreen"
Background="#1a1a2e">
@@ -141,6 +141,8 @@
+
+
@@ -186,6 +188,7 @@
+
@@ -204,20 +207,68 @@
Foreground="{StaticResource TextPrimary}"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+ Click="OnSaveSettings" Margin="0,0,8,0"/>
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index e358602..1851352 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
@@ -22,20 +23,32 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly AuthService _authService = new();
private readonly LauncherService _launcherService = new();
private readonly JagexAccountService _jagexAccountService = new();
+ private readonly CharacterCreationQueueService _creationQueue;
private AccountProfile? _selectedAccount;
private GameAccount? _selectedCharacter;
private string _statusText = "Ready";
+ private string _queueStatusText = string.Empty;
+ private bool _isQueueActive;
public MainWindow()
{
InitializeComponent();
DataContext = this;
Loaded += OnLoaded;
+ Closed += OnWindowClosed;
+ LogLines.CollectionChanged += (_, _) => LogScrollViewer.ScrollToBottom();
+
+ _creationQueue = new CharacterCreationQueueService(_jagexAccountService);
+ _creationQueue.CharacterCreated += OnQueueCharacterCreated;
+ _creationQueue.BatchCompleted += OnQueueBatchCompleted;
+ _creationQueue.PendingCountChanged += OnQueueCountChanged;
+ _creationQueue.StatusUpdated += OnQueueStatus;
}
public ObservableCollection Accounts { get; } = new();
public ObservableCollection SelectedCharacters { get; } = new();
+ public ObservableCollection LogLines { get; } = new();
private AppSettings _settings = new();
public AppSettings Settings
@@ -71,9 +84,17 @@ public string StatusText
{
_statusText = value;
OnPropertyChanged();
+ AppendLog(value);
}
}
+ private void AppendLog(string message)
+ {
+ LogLines.Add($"[{DateTime.Now:HH:mm:ss}] {message}");
+ while (LogLines.Count > 100)
+ LogLines.RemoveAt(0);
+ }
+
public GameAccount? SelectedCharacter
{
get => _selectedCharacter;
@@ -85,6 +106,27 @@ public GameAccount? SelectedCharacter
}
}
+ public string CharacterCountText =>
+ $"{_selectedAccount?.GameAccounts.Count ?? 0} / 20";
+
+ public bool CanCreateCharacter =>
+ _selectedAccount != null && (_selectedAccount.GameAccounts.Count < 20);
+
+ public bool CanAutoCreate =>
+ _selectedAccount != null && _selectedAccount.GameAccounts.Count < 20;
+
+ public string QueueStatusText
+ {
+ get => _queueStatusText;
+ private set { _queueStatusText = value; OnPropertyChanged(); }
+ }
+
+ public bool IsQueueActive
+ {
+ get => _isQueueActive;
+ private set { _isQueueActive = value; OnPropertyChanged(); }
+ }
+
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
@@ -151,7 +193,10 @@ private async void OnAddAccount(object sender, RoutedEventArgs e)
await _accountStore.SaveAsync(Accounts);
await _tokenService.SaveAsync(profile.AccountId, token);
SelectedAccount = profile;
- StatusText = "Account added";
+
+ StatusText = token.RuneScapeSessionToken != null
+ ? "Account added (RS session token captured)"
+ : "Account added (RS session token NOT captured — character creation unavailable)";
}
catch (OperationCanceledException)
{
@@ -238,6 +283,7 @@ private async void OnLaunch(object sender, RoutedEventArgs e)
var character = SelectedCharacter ?? SelectedAccount.GameAccounts.FirstOrDefault();
await _launcherService.LaunchAsync(Settings, token, character);
+
StatusText = "Game launched";
}
catch (Exception ex)
@@ -247,6 +293,53 @@ private async void OnLaunch(object sender, RoutedEventArgs e)
}
}
+ private async void OnCreateCharacter(object sender, RoutedEventArgs e)
+ {
+ if (!CanCreateCharacter) return;
+
+ var result = MessageBox.Show(
+ "Create a new character slot?\n\nThe character's name will be set in-game on first login.",
+ "Create Character",
+ MessageBoxButton.YesNo);
+
+ if (result != MessageBoxResult.Yes) return;
+
+ try
+ {
+ StatusText = "Creating character...";
+ var token = await _tokenService.LoadAsync(SelectedAccount!.AccountId);
+
+ if (token == null)
+ {
+ StatusText = "No token. Re-add account.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(token.RuneScapeSessionToken))
+ {
+ MessageBox.Show(
+ "RuneScape session token not found.\nRemove and re-add this account to enable character creation.",
+ "Sigil");
+ StatusText = "Ready";
+ return;
+ }
+
+ var activeAccounts = await _jagexAccountService.CreateGameAccountAsync(
+ token.RuneScapeSessionToken,
+ CancellationToken.None);
+
+ SelectedAccount.GameAccounts = activeAccounts.ToList();
+ await _accountStore.SaveAsync(Accounts);
+ UpdateSelectedCharacters();
+ StatusText = $"Character created. You now have {SelectedAccount.GameAccounts.Count} character(s).";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Failed to create character: {ex.Message}";
+ MessageBox.Show(ex.Message, "Sigil");
+ }
+ }
+
private async void OnBrowseRs3Client(object sender, RoutedEventArgs e)
{
var dialog = new OpenFileDialog
@@ -270,6 +363,37 @@ private async void OnSaveSettings(object sender, RoutedEventArgs e)
StatusText = "Settings saved";
}
+ private async void OnShowTokenInfo(object sender, RoutedEventArgs e)
+ {
+ if (SelectedAccount == null)
+ {
+ MessageBox.Show("No account selected.", "Token Info");
+ return;
+ }
+
+ var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
+ if (token == null)
+ {
+ MessageBox.Show("No token stored for this account.", "Token Info");
+ return;
+ }
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"Account: {SelectedAccount.DisplayName}");
+ sb.AppendLine($"AccountId: {SelectedAccount.AccountId}");
+ sb.AppendLine($"Subject: {token.Subject ?? "(null)"}");
+ sb.AppendLine($"ExpiresAt: {token.ExpiresAt:u} (expired={token.IsExpired()})");
+ sb.AppendLine($"AccessToken: {(string.IsNullOrEmpty(token.AccessToken) ? "(empty)" : token.AccessToken[..Math.Min(16, token.AccessToken.Length)] + "…")}");
+ sb.AppendLine($"RefreshToken: {(string.IsNullOrEmpty(token.RefreshToken) ? "(empty)" : token.RefreshToken[..Math.Min(16, token.RefreshToken.Length)] + "…")}");
+ sb.AppendLine($"IdToken: {(string.IsNullOrEmpty(token.IdToken) ? "(null)" : token.IdToken[..Math.Min(16, token.IdToken.Length)] + "…")}");
+ sb.AppendLine($"SessionId: {(string.IsNullOrEmpty(token.SessionId) ? "(null)" : token.SessionId[..Math.Min(16, token.SessionId.Length)] + "…")}");
+ sb.AppendLine();
+ sb.AppendLine($"RuneScapeSessionToken: {(string.IsNullOrEmpty(token.RuneScapeSessionToken) ? "⚠ NOT SET" : $"✓ SET (length={token.RuneScapeSessionToken.Length}, value={token.RuneScapeSessionToken[..Math.Min(16, token.RuneScapeSessionToken.Length)]}…)")}");
+
+ var debug = new Sigil.Views.CookieDebugWindow(sb.ToString()) { Owner = this };
+ debug.Show();
+ }
+
private async Task UpdateStatusAsync()
{
if (SelectedAccount == null)
@@ -313,6 +437,9 @@ private void UpdateSelectedCharacters()
if (SelectedAccount == null)
{
SelectedCharacter = null;
+ OnPropertyChanged(nameof(CharacterCountText));
+ OnPropertyChanged(nameof(CanCreateCharacter));
+ OnPropertyChanged(nameof(CanAutoCreate));
return;
}
@@ -322,6 +449,82 @@ private void UpdateSelectedCharacters()
}
SelectedCharacter = SelectedCharacters.FirstOrDefault();
+ OnPropertyChanged(nameof(CharacterCountText));
+ OnPropertyChanged(nameof(CanCreateCharacter));
+ OnPropertyChanged(nameof(CanAutoCreate));
+ }
+
+ // ── Queue service event handlers ──────────────────────────────────────
+
+ private void OnQueueCharacterCreated(string accountId, IReadOnlyList updated)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ var acct = Accounts.FirstOrDefault(a => a.AccountId == accountId);
+ if (acct != null)
+ {
+ acct.GameAccounts = updated.ToList();
+ if (SelectedAccount?.AccountId == accountId)
+ UpdateSelectedCharacters();
+ _ = _accountStore.SaveAsync(Accounts.ToList());
+ }
+ });
+ }
+
+ private void OnQueueBatchCompleted(string accountId, int created, int skipped)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ var name = Accounts.FirstOrDefault(a => a.AccountId == accountId)?.DisplayName ?? accountId;
+ StatusText = $"[{name}] Done: {created} created, {skipped} skipped.";
+ OnPropertyChanged(nameof(CanAutoCreate));
+ });
+ }
+
+ private void OnQueueCountChanged(int pending)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ IsQueueActive = pending > 0 || _creationQueue.IsActive;
+ QueueStatusText = pending > 0 ? $"Queue: {pending} account(s) pending" : string.Empty;
+ });
+ }
+
+ private void OnQueueStatus(string msg)
+ {
+ Dispatcher.InvokeAsync(() => StatusText = msg);
+ }
+
+ // ── Button handlers ───────────────────────────────────────────────────
+
+ private async void OnAutoCreateCharacters(object sender, RoutedEventArgs e)
+ {
+ if (SelectedAccount == null) return;
+ var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
+ if (token == null || string.IsNullOrWhiteSpace(token.RuneScapeSessionToken))
+ {
+ MessageBox.Show(
+ "RuneScape session token not found.\nRemove and re-add this account.",
+ "Sigil");
+ return;
+ }
+ StatusText = $"Queuing auto-creation for {SelectedAccount.DisplayName}...";
+ var queued = await _creationQueue.EnqueueAsync(SelectedAccount, token.RuneScapeSessionToken, Settings.CharacterCreationDelaySeconds);
+ if (!queued)
+ StatusText = $"{SelectedAccount.DisplayName} already at max or already queued.";
+ }
+
+ private void OnCancelQueue(object sender, RoutedEventArgs e)
+ {
+ _creationQueue.CancelAll();
+ StatusText = "Queue cancelled.";
+ IsQueueActive = false;
+ QueueStatusText = string.Empty;
+ }
+
+ private void OnWindowClosed(object? sender, EventArgs e)
+ {
+ _creationQueue.Dispose();
}
}
}
diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs
index a342d81..ba483b6 100644
--- a/Models/AppSettings.cs
+++ b/Models/AppSettings.cs
@@ -8,6 +8,9 @@ public sealed class AppSettings
// Path to RS3 client executable - launched DIRECTLY with JX_SESSION_ID
public string? Rs3ClientPath { get; set; } = @"C:\ProgramData\Jagex\launcher\rs2client.exe";
+ // Seconds to wait between character creations (and between retries)
+ public int CharacterCreationDelaySeconds { get; set; } = 60;
+
public string OAuthOrigin { get; set; } = "https://account.jagex.com";
public string OAuthRedirectUri { get; set; } = "https://secure.runescape.com/m=weblogin/launcher-redirect";
public string OAuthClientId { get; set; } = "com_jagex_auth_desktop_launcher";
diff --git a/Models/OAuthToken.cs b/Models/OAuthToken.cs
index d4605bf..0dbc4cb 100644
--- a/Models/OAuthToken.cs
+++ b/Models/OAuthToken.cs
@@ -11,6 +11,7 @@ public sealed class OAuthToken
public string? IdToken { get; set; }
public string? Subject { get; set; }
public string? SessionId { get; set; }
+ public string? RuneScapeSessionToken { get; set; }
public bool IsExpired(TimeSpan? skew = null)
{
diff --git a/README.md b/README.md
index 165f310..d60567d 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,49 @@
# Sigil
-A lightweight account manager for RuneScape 3 that allows you to manage multiple Jagex accounts and launch the game directly with your selected account.
+A lightweight account manager for RuneScape 3 that lets you manage multiple Jagex accounts, create characters, and launch the game directly.
## Features
- Manage multiple Jagex accounts
- Select which character to launch with
- Direct game client launch (bypasses Jagex Launcher UI)
-- Secure token storage using Windows Credential Manager
+- Create new character slots (up to 20 per account)
+- Auto character creation — queues and fills an account to 20 with configurable delay
+- Scrollable activity log with timestamps
+- Secure token storage via Windows Credential Manager
- Modern dark-themed UI
## How It Works
-Sigil authenticates with Jagex's OAuth system and stores your session tokens securely. When you launch the game, it passes your credentials directly to the RS3 client via environment variables (`JX_SESSION_ID`, `JX_CHARACTER_ID`, `JX_DISPLAY_NAME`), allowing you to skip the Jagex Launcher's account selection.
+Sigil authenticates with Jagex's OAuth system and stores your session tokens securely. When you launch the game, it passes your credentials directly to the RS3 client via environment variables (`JX_SESSION_ID`, `JX_CHARACTER_ID`, `JX_DISPLAY_NAME`), skipping the Jagex Launcher entirely.
## Requirements
- Windows 10/11
- .NET 7.0 or later
+- WebView2 Runtime
- RS3 client installed (default path: `C:\ProgramData\Jagex\launcher\rs2client.exe`)
## Usage
-1. Click **+ Add** to add a Jagex account
-2. Log in via the browser window
-3. Give the account a display name
-4. Select your account and character
-5. Click **Launch Game**
+1. Click **+ Add** to add a Jagex account and log in via the browser window
+2. Select your account and character from the lists
+3. Click **Launch Game**
+
+**Character creation:**
+- **+ Create** — creates one new character slot
+- **Auto** — queues automatic creation until the account reaches 20 characters (respects the configured delay between creations)
+
+## Configuration
+
+Expand **Advanced Settings** at the bottom of the window:
+
+| Setting | Description |
+|---|---|
+| RS3 Client Path | Path to the RS3 game executable |
+| Character creation delay | Seconds to wait between character creations (default 60) |
+| Refresh Token | Manually refresh the authentication token |
+| Save Settings | Persist configuration changes |
## Building
@@ -34,17 +51,10 @@ Sigil authenticates with Jagex's OAuth system and stores your session tokens sec
dotnet build
```
-## Configuration
-
-Advanced settings are available in the expander at the bottom of the window:
-- **RS3 Client Path**: Path to the game executable
-- **Refresh Token**: Manually refresh your authentication token
-- **Save Settings**: Persist configuration changes
-
## Credits
Inspired by [Bolt Launcher](https://codeberg.org/Adamcake/Bolt).
## License
-GNU Affero General Public License v3.0 - see [LICENSE](LICENSE) for details.
+GNU Affero General Public License v3.0 — see [LICENSE](LICENSE) for details.
diff --git a/Services/AgentDownloadService.cs b/Services/AgentDownloadService.cs
new file mode 100644
index 0000000..70f636d
--- /dev/null
+++ b/Services/AgentDownloadService.cs
@@ -0,0 +1,158 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Sigil.Services;
+
+///
+/// Reads the loader's token.bin and downloads the agent from the BWU API
+/// so we can inspect what the loader gets (~29.6 MB). Saves to a file for analysis.
+///
+public sealed class AgentDownloadService
+{
+ private const string AgentDownloadUrl = "https://botwithus.com/api/download/agent";
+
+ private readonly HttpClient _httpClient = new()
+ {
+ Timeout = TimeSpan.FromMinutes(2)
+ };
+
+ ///
+ /// Reads the Bearer token from the loader's token.bin.
+ /// Tries: raw UTF-8 string (trimmed), then JSON with access_token / token / bearer.
+ ///
+ /// Full path to token.bin (e.g. loader folder).
+ /// The token string to use as Authorization: Bearer <token>.
+ public string ReadTokenFromFile(string tokenBinPath)
+ {
+ if (string.IsNullOrWhiteSpace(tokenBinPath) || !File.Exists(tokenBinPath))
+ throw new FileNotFoundException("token.bin not found.", tokenBinPath);
+
+ var bytes = File.ReadAllBytes(tokenBinPath);
+ if (bytes.Length == 0)
+ throw new InvalidOperationException("token.bin is empty.");
+
+ // Try UTF-8 string first (raw token)
+ var asUtf8 = Encoding.UTF8.GetString(bytes).Trim();
+ if (!string.IsNullOrWhiteSpace(asUtf8))
+ {
+ // If it looks like JSON, parse for token fields
+ if (asUtf8.StartsWith('{'))
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(asUtf8);
+ var root = doc.RootElement;
+ foreach (var key in new[] { "access_token", "token", "bearer", "accessToken" })
+ {
+ if (root.TryGetProperty(key, out var prop))
+ {
+ var value = prop.GetString();
+ if (!string.IsNullOrWhiteSpace(value))
+ return value;
+ }
+ }
+ }
+ catch
+ {
+ // Not valid JSON or no token field; use raw string if it looks like a token
+ }
+ }
+ // Use as raw Bearer token if it's not empty and doesn't look like binary
+ if (asUtf8.Length < 10_000 && asUtf8.IndexOf('\0') < 0)
+ return asUtf8;
+ }
+
+ throw new InvalidOperationException(
+ "Could not read a token from token.bin. Expected UTF-8 text (raw token or JSON with access_token/token).");
+ }
+
+ ///
+ /// Downloads the agent from the BWU API using the given Bearer token
+ /// and saves it to for inspection.
+ ///
+ /// Token from token.bin (Authorization: Bearer).
+ /// Where to save the response (e.g. agent_download.bin).
+ /// Cancellation.
+ /// Number of bytes written and whether response looks like a PE (MZ header).
+ public async Task DownloadAgentAsync(
+ string bearerToken,
+ string saveFilePath,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(bearerToken))
+ throw new ArgumentException("Bearer token is required.", nameof(bearerToken));
+
+ // API returns a presigned S3 URL (body is the URL string). We GET that URL to download the actual DLL.
+ using var request = new HttpRequestMessage(HttpMethod.Get, AgentDownloadUrl);
+ request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + bearerToken);
+ request.Headers.TryAddWithoutValidation("Accept", "*/*");
+
+ using var response = await _httpClient.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ throw new InvalidOperationException(
+ $"Agent download failed: {response.StatusCode}. {body}");
+ }
+
+ var downloadUrl = (await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Trim();
+ if (string.IsNullOrEmpty(downloadUrl) || !downloadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("API did not return a download URL.");
+
+ using var downloadRequest = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
+ using var downloadResponse = await _httpClient.SendAsync(
+ downloadRequest,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken).ConfigureAwait(false);
+ downloadResponse.EnsureSuccessStatusCode();
+
+ await using (var stream = await downloadResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
+ using (var file = File.Create(saveFilePath))
+ {
+ await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false);
+ }
+
+ var length = new FileInfo(saveFilePath).Length;
+ byte[] header = new byte[2];
+ using (var fs = File.OpenRead(saveFilePath))
+ _ = await fs.ReadAsync(header.AsMemory(0, 2), cancellationToken).ConfigureAwait(false);
+
+ var isPe = header.Length >= 2 && header[0] == 0x4D && header[1] == 0x5A; // MZ
+
+ return new AgentDownloadResult(saveFilePath, length, isPe);
+ }
+
+ ///
+ /// Reads token from token.bin and downloads the agent to a file.
+ ///
+ /// Path to loader's token.bin.
+ /// Where to save (default: temp folder, agent_download_YYYYMMDD_HHmmss.bin).
+ /// Cancellation.
+ /// Path, size, and whether the file starts with MZ (PE).
+ public async Task DownloadAgentToFileAsync(
+ string tokenBinPath,
+ string? saveFilePath = null,
+ CancellationToken cancellationToken = default)
+ {
+ var token = ReadTokenFromFile(tokenBinPath);
+ saveFilePath ??= Path.Combine(
+ Path.GetTempPath(),
+ $"agent_download_{DateTime.Now:yyyyMMdd_HHmmss}.dll");
+ return await DownloadAgentAsync(token, saveFilePath, cancellationToken).ConfigureAwait(false);
+ }
+}
+
+/// Result of downloading the agent for inspection.
+/// Path where the response was saved.
+/// Number of bytes written.
+/// True if the file starts with MZ (PE image).
+public readonly record struct AgentDownloadResult(string FilePath, long ByteCount, bool StartsWithMz);
diff --git a/Services/AuthService.cs b/Services/AuthService.cs
index aeb7d18..c93a0a2 100644
--- a/Services/AuthService.cs
+++ b/Services/AuthService.cs
@@ -94,6 +94,8 @@ public async Task RefreshAsync(AppSettings settings, OAuthToken toke
var refreshed = ParseToken(json, token.RefreshToken);
refreshed.SessionId = token.SessionId;
refreshed.Subject ??= token.Subject;
+ refreshed.IdToken ??= token.IdToken;
+ refreshed.RuneScapeSessionToken = token.RuneScapeSessionToken;
return refreshed;
}
diff --git a/Services/CharacterCreationQueueService.cs b/Services/CharacterCreationQueueService.cs
new file mode 100644
index 0000000..0cc49c6
--- /dev/null
+++ b/Services/CharacterCreationQueueService.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Sigil.Models;
+
+namespace Sigil.Services;
+
+internal sealed record AccountCreationBatch(
+ string AccountId,
+ string DisplayName,
+ string RsSessionToken,
+ int ToCreate,
+ int DelaySeconds);
+
+public sealed class CharacterCreationQueueService : IDisposable
+{
+ private readonly JagexAccountService _jagexService;
+ private readonly Channel _channel =
+ Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true });
+ private readonly HashSet _queuedAccountIds = new();
+ private CancellationTokenSource _cts = new();
+ private Task? _worker;
+
+ // UI notification events (subscribers must dispatch to UI thread themselves)
+ public event Action>? CharacterCreated; // accountId, updatedList
+ public event Action? BatchCompleted; // accountId, created, skipped
+ public event Action? PendingCountChanged; // remaining batch count
+ public event Action? StatusUpdated; // status message text
+
+ public int PendingCount { get; private set; }
+ public bool IsActive => _worker != null && !_worker.IsCompleted;
+
+ public CharacterCreationQueueService(JagexAccountService jagexService)
+ {
+ _jagexService = jagexService;
+ }
+
+ ///
+ /// Queues auto-creation for the given account. Returns false if already queued or already at max.
+ ///
+ public Task EnqueueAsync(AccountProfile account, string rsSessionToken, int delaySeconds = 60, CancellationToken ct = default)
+ {
+ if (_queuedAccountIds.Contains(account.AccountId))
+ {
+ StatusUpdated?.Invoke($"{account.DisplayName} is already in the queue.");
+ return Task.FromResult(false);
+ }
+
+ int toCreate = 20 - account.GameAccounts.Count;
+ if (toCreate <= 0)
+ {
+ StatusUpdated?.Invoke($"{account.DisplayName} already has 20 characters.");
+ return Task.FromResult(false);
+ }
+
+ _queuedAccountIds.Add(account.AccountId);
+ PendingCount++;
+ PendingCountChanged?.Invoke(PendingCount);
+
+ _channel.Writer.TryWrite(
+ new AccountCreationBatch(account.AccountId, account.DisplayName, rsSessionToken, toCreate, delaySeconds));
+
+ EnsureWorker();
+ return Task.FromResult(true);
+ }
+
+ private void EnsureWorker()
+ {
+ if (_worker == null || _worker.IsCompleted)
+ {
+ _cts = new CancellationTokenSource();
+ _worker = Task.Run(() => ProcessQueueAsync(_cts.Token));
+ }
+ }
+
+ private async Task ProcessQueueAsync(CancellationToken ct)
+ {
+ await foreach (var batch in _channel.Reader.ReadAllAsync(ct))
+ {
+ await ProcessBatchAsync(batch, ct);
+ PendingCount--;
+ PendingCountChanged?.Invoke(PendingCount);
+ _queuedAccountIds.Remove(batch.AccountId);
+ }
+ }
+
+ private async Task ProcessBatchAsync(AccountCreationBatch batch, CancellationToken ct)
+ {
+ int created = 0, skipped = 0;
+ for (int i = 0; i < batch.ToCreate && !ct.IsCancellationRequested; i++)
+ {
+ bool success = false;
+ for (int attempt = 1; attempt <= 5 && !success && !ct.IsCancellationRequested; attempt++)
+ {
+ StatusUpdated?.Invoke(
+ $"[{batch.DisplayName}] Creating character {i + 1}/{batch.ToCreate}" +
+ (attempt > 1 ? $" (retry {attempt}/5)" : ""));
+ try
+ {
+ var updated = await _jagexService.CreateGameAccountAsync(batch.RsSessionToken, ct);
+ CharacterCreated?.Invoke(batch.AccountId, updated);
+ created++;
+ success = true;
+ }
+ catch (Exception ex) when (!ct.IsCancellationRequested)
+ {
+ bool isRateLimit = ex.Message.Contains("409") ||
+ ex.Message.Contains("TOO_MANY_ACCOUNTS", StringComparison.OrdinalIgnoreCase);
+ if (attempt < 5)
+ {
+ var wait = isRateLimit ? batch.DelaySeconds * 2 : batch.DelaySeconds;
+ StatusUpdated?.Invoke(
+ $"[{batch.DisplayName}] {(isRateLimit ? "Rate limited" : "Failed")} (attempt {attempt}/5), retrying in {wait}s...");
+ await Task.Delay(TimeSpan.FromSeconds(wait), ct).ConfigureAwait(false);
+ }
+ else
+ {
+ skipped++;
+ StatusUpdated?.Invoke(
+ $"[{batch.DisplayName}] Skipped after 5 failures: {ex.Message}");
+ }
+ }
+ }
+
+ // Cool-down after each successful creation before the next one
+ if (success && (i < batch.ToCreate - 1 || PendingCount > 0))
+ await Task.Delay(TimeSpan.FromSeconds(batch.DelaySeconds), ct).ConfigureAwait(false);
+ }
+ BatchCompleted?.Invoke(batch.AccountId, created, skipped);
+ }
+
+ ///
+ /// Cancels all pending and in-progress work and drains the channel.
+ ///
+ public void CancelAll()
+ {
+ _cts.Cancel();
+ // Drain remaining items from the channel
+ while (_channel.Reader.TryRead(out _)) { }
+ _queuedAccountIds.Clear();
+ PendingCount = 0;
+ PendingCountChanged?.Invoke(0);
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ }
+}
diff --git a/Services/EmbeddedDllExtractor.cs b/Services/EmbeddedDllExtractor.cs
new file mode 100644
index 0000000..5b7086f
--- /dev/null
+++ b/Services/EmbeddedDllExtractor.cs
@@ -0,0 +1,105 @@
+using System;
+using System.IO;
+
+namespace Sigil.Services;
+
+///
+/// Extracts an embedded PE (DLL or EXE) from an executable.
+/// Use on loader.exe to see if the "agent" payload is embedded (second MZ/PE with DLL characteristics).
+///
+public sealed class EmbeddedDllExtractor
+{
+ private const ushort ImageFileDll = 0x2000;
+ private const uint PeSignature = 0x00004550; // "PE\0\0"
+
+ ///
+ /// Finds an embedded DLL in the exe and extracts it to a temp file.
+ /// Skips the main exe at offset 0; looks for a second MZ that is a valid DLL PE.
+ ///
+ /// Full path to the loader exe (or any exe with an embedded DLL).
+ /// Path to the extracted DLL file, or null if no embedded DLL found.
+ public string? ExtractToTempFile(string exePath)
+ {
+ if (string.IsNullOrWhiteSpace(exePath))
+ throw new ArgumentException("Exe path is required.", nameof(exePath));
+
+ var fullPath = Path.GetFullPath(exePath);
+ if (!File.Exists(fullPath))
+ throw new FileNotFoundException($"Exe not found: {fullPath}");
+
+ var bytes = File.ReadAllBytes(fullPath);
+ if (bytes.Length < 64)
+ return null;
+
+ var dllStart = FindEmbeddedDllOffset(bytes);
+ if (dllStart < 0)
+ return null;
+
+ var dllSize = GetPeSize(bytes, dllStart);
+ if (dllStart + dllSize > bytes.Length)
+ return null;
+
+ var tempPath = Path.Combine(Path.GetTempPath(), $"Sigil_extracted_{Guid.NewGuid():N}.dll");
+ using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.Read))
+ {
+ fs.Write(bytes, dllStart, dllSize);
+ }
+
+ return tempPath;
+ }
+
+ ///
+ /// Returns the file offset of an embedded DLL (second MZ that is a valid DLL PE), or -1.
+ ///
+ private static int FindEmbeddedDllOffset(byte[] data)
+ {
+ for (var i = 0; i < data.Length - 64; i++)
+ {
+ if (data[i] != 0x4D || data[i + 1] != 0x5A) // "MZ"
+ continue;
+
+ var eLfanew = BitConverter.ToInt32(data, i + 0x3C);
+ if (eLfanew <= 0 || i + eLfanew + 6 > data.Length)
+ continue;
+
+ var peSig = BitConverter.ToUInt32(data, i + eLfanew);
+ if (peSig != PeSignature)
+ continue;
+
+ var coff = i + eLfanew + 4;
+ var characteristics = BitConverter.ToUInt16(data, coff + 18);
+
+ if ((characteristics & ImageFileDll) == 0)
+ continue;
+
+ return i;
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Returns the size in bytes of the PE image (from start of MZ to end of last section).
+ ///
+ private static int GetPeSize(byte[] data, int start)
+ {
+ var eLfanew = BitConverter.ToInt32(data, start + 0x3C);
+ var coff = start + eLfanew + 4;
+ var numberOfSections = BitConverter.ToUInt16(data, coff + 2);
+ var sizeOfOptionalHeader = BitConverter.ToUInt16(data, coff + 16);
+
+ var sectionTable = coff + 20 + sizeOfOptionalHeader;
+ var maxEnd = 0;
+
+ for (var s = 0; s < numberOfSections && sectionTable + 40 <= data.Length; s++, sectionTable += 40)
+ {
+ var pointerToRawData = BitConverter.ToInt32(data, sectionTable + 20);
+ var sizeOfRawData = BitConverter.ToInt32(data, sectionTable + 16);
+ var end = pointerToRawData + sizeOfRawData;
+ if (end > maxEnd)
+ maxEnd = end;
+ }
+
+ return maxEnd > 0 ? maxEnd : data.Length - start;
+ }
+}
diff --git a/Services/InjectionService.cs b/Services/InjectionService.cs
new file mode 100644
index 0000000..e57d4dc
--- /dev/null
+++ b/Services/InjectionService.cs
@@ -0,0 +1,170 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Sigil.Services;
+
+///
+/// Injects the agent DLL into a target process. Two options:
+/// 1. LoadLibrary inject: write DLL to temp file, then CreateRemoteThread(LoadLibraryA, path). Simple; works if the DLL has a normal DllMain.
+/// 2. Manual map: allocate in target, copy PE, relocate, resolve imports, TLS, SEH, call entry. Required if the loader uses manual map only (no file on disk).
+/// This class implements (1). For (2) see docs/LOADER_EXTRACTED_STRINGS.md and existing C++ manual mappers.
+///
+public sealed class InjectionService
+{
+ private const uint ProcessAllAccess = 0x001F0FFF;
+ private const uint MemCommit = 0x1000;
+ private const uint MemReserve = 0x2000;
+ private const uint PageReadWrite = 0x04;
+ private const uint MemRelease = 0x8000;
+ private const uint Th32csSnapModule = 0x00000008;
+
+ ///
+ /// Injects a DLL into the target process using LoadLibrary. The DLL must be on disk (e.g. temp file).
+ ///
+ /// Target process ID (e.g. game client).
+ /// Full path to the DLL file. Must be accessible from the target process (e.g. under ProgramData or temp).
+ public void InjectDllByLoadLibrary(int processId, string dllPath)
+ {
+ if (processId <= 0)
+ throw new ArgumentException("Invalid process ID.", nameof(processId));
+ if (string.IsNullOrWhiteSpace(dllPath) || !File.Exists(dllPath))
+ throw new FileNotFoundException("DLL file not found.", dllPath);
+
+ var dllPathBytes = Encoding.ASCII.GetBytes(dllPath + "\0");
+ IntPtr hProcess = IntPtr.Zero;
+ IntPtr pathAlloc = IntPtr.Zero;
+ IntPtr hThread = IntPtr.Zero;
+
+ try
+ {
+ hProcess = OpenProcess(ProcessAllAccess, false, processId);
+ if (hProcess == IntPtr.Zero)
+ throw new InvalidOperationException($"OpenProcess failed for PID {processId}. Error: {Marshal.GetLastWin32Error()}");
+
+ pathAlloc = VirtualAllocEx(hProcess, IntPtr.Zero, (uint)dllPathBytes.Length, MemCommit | MemReserve, PageReadWrite);
+ if (pathAlloc == IntPtr.Zero)
+ throw new InvalidOperationException("VirtualAllocEx failed. Error: " + Marshal.GetLastWin32Error());
+
+ if (!WriteProcessMemory(hProcess, pathAlloc, dllPathBytes, (uint)dllPathBytes.Length, out _))
+ throw new InvalidOperationException("WriteProcessMemory failed. Error: " + Marshal.GetLastWin32Error());
+
+ IntPtr loadLibraryAddr = GetRemoteProcAddress(processId, "kernel32.dll", "LoadLibraryA");
+ if (loadLibraryAddr == IntPtr.Zero)
+ throw new InvalidOperationException("Could not get LoadLibraryA address in target process.");
+
+ hThread = CreateRemoteThread(hProcess, IntPtr.Zero, 0, loadLibraryAddr, pathAlloc, 0, out _);
+ if (hThread == IntPtr.Zero)
+ throw new InvalidOperationException("CreateRemoteThread failed. Error: " + Marshal.GetLastWin32Error());
+
+ if (WaitForSingleObject(hThread, 15000) != 0) // WAIT_OBJECT_0 = 0
+ throw new InvalidOperationException("Remote thread did not complete in time.");
+ }
+ finally
+ {
+ if (pathAlloc != IntPtr.Zero && hProcess != IntPtr.Zero)
+ VirtualFreeEx(hProcess, pathAlloc, UIntPtr.Zero, MemRelease);
+ if (hThread != IntPtr.Zero)
+ CloseHandle(hThread);
+ if (hProcess != IntPtr.Zero)
+ CloseHandle(hProcess);
+ }
+ }
+
+ ///
+ /// Gets the address of a function in the target process (e.g. LoadLibraryA in kernel32).
+ ///
+ private static IntPtr GetRemoteProcAddress(int processId, string moduleName, string procName)
+ {
+ IntPtr localModule = GetModuleHandle(moduleName);
+ if (localModule == IntPtr.Zero)
+ return IntPtr.Zero;
+ IntPtr localProc = GetProcAddress(localModule, procName);
+ if (localProc == IntPtr.Zero)
+ return IntPtr.Zero;
+ long offset = localProc.ToInt64() - localModule.ToInt64();
+
+ IntPtr remoteModuleBase = GetRemoteModuleBase(processId, moduleName);
+ if (remoteModuleBase == IntPtr.Zero)
+ return IntPtr.Zero;
+ return new IntPtr(remoteModuleBase.ToInt64() + offset);
+ }
+
+ private static IntPtr GetRemoteModuleBase(int processId, string moduleName)
+ {
+ IntPtr snapshot = CreateToolhelp32Snapshot(Th32csSnapModule, (uint)processId);
+ if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1))
+ return IntPtr.Zero;
+ try
+ {
+ var me = new MODULEENTRY32W { dwSize = (uint)Marshal.SizeOf() };
+ if (!Module32First(snapshot, ref me))
+ return IntPtr.Zero;
+ do
+ {
+ var name = me.szModule.TrimEnd('\0');
+ if (string.Equals(name, moduleName, StringComparison.OrdinalIgnoreCase))
+ return me.modBaseAddr;
+ } while (Module32Next(snapshot, ref me));
+ return IntPtr.Zero;
+ }
+ finally
+ {
+ CloseHandle(snapshot);
+ }
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, int processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool CloseHandle(IntPtr hObject);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint dwFreeType);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out uint lpNumberOfBytesWritten);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetModuleHandle(string lpModuleName);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr CreateRemoteThread(IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, out uint lpThreadId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessId);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern bool Module32First(IntPtr hSnapshot, ref MODULEENTRY32W lpme);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern bool Module32Next(IntPtr hSnapshot, ref MODULEENTRY32W lpme);
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct MODULEENTRY32W
+ {
+ public uint dwSize;
+ public uint th32ModuleID;
+ public uint th32ProcessID;
+ public uint GlcntUsage;
+ public uint ProccntUsage;
+ public IntPtr modBaseAddr;
+ public uint modBaseSize;
+ public IntPtr hModule;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
+ public string szModule;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+ public string szExePath;
+ }
+}
diff --git a/Services/JagexAccountService.cs b/Services/JagexAccountService.cs
index 46aee78..b1e3c23 100644
--- a/Services/JagexAccountService.cs
+++ b/Services/JagexAccountService.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
+using System.Text;
using System.Text.Json;
+using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Sigil.Models;
@@ -10,7 +12,7 @@ namespace Sigil.Services;
public sealed class JagexAccountService
{
- private readonly HttpClient _httpClient = new();
+ private readonly HttpClient _httpClient = new(new HttpClientHandler { UseCookies = false });
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
@@ -38,4 +40,104 @@ public async Task> GetGameAccountsAsync(
var accounts = JsonSerializer.Deserialize>(json, JsonOptions) ?? new List();
return accounts;
}
+
+ ///
+ /// Creates a new RS3 character slot on the account. The character's display name is set
+ /// in-game on first login. Returns the updated list of active characters.
+ ///
+ public async Task> CreateGameAccountAsync(
+ string rsSessionToken,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(rsSessionToken))
+ {
+ throw new InvalidOperationException(
+ "RuneScape session token is missing. Re-add the account to enable character creation.");
+ }
+
+ const string url = "https://account.runescape.com/api/users/current/accounts/create";
+ var body = new { clientLanguageCode = "en", receiveEmails = false, thirdPartyConsent = false };
+
+ var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Headers.Add("Accept", "application/json, text/plain, */*");
+ request.Headers.Add("Cookie", $"runescape-accounts__session-token={rsSessionToken}");
+ request.Content = new StringContent(
+ JsonSerializer.Serialize(body),
+ Encoding.UTF8,
+ "application/json");
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"Character creation failed: HTTP {(int)response.StatusCode} {response.StatusCode}.\nResponse: {(string.IsNullOrWhiteSpace(json) ? "(empty)" : json[..Math.Min(500, json.Length)])}");
+ }
+
+ // 204 No Content = created successfully; fetch the updated list separately
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return await GetRuneScapeAccountsAsync(rsSessionToken, cancellationToken).ConfigureAwait(false);
+ }
+
+ RuneScapeAccountsResponse? result;
+ try
+ {
+ result = JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return await GetRuneScapeAccountsAsync(rsSessionToken, cancellationToken).ConfigureAwait(false);
+ }
+
+ // If the response parsed but active list is empty, fetch fresh to be safe
+ if (result?.Active == null || result.Active.Count == 0)
+ {
+ return await GetRuneScapeAccountsAsync(rsSessionToken, cancellationToken).ConfigureAwait(false);
+ }
+
+ return result.Active;
+ }
+
+ ///
+ /// Fetches the current character list from the RS account portal.
+ ///
+ public async Task> GetRuneScapeAccountsAsync(
+ string rsSessionToken,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(rsSessionToken))
+ throw new InvalidOperationException("RuneScape session token is missing.");
+
+ var request = new HttpRequestMessage(
+ HttpMethod.Get,
+ "https://account.runescape.com/api/users/current/accounts");
+ request.Headers.Add("Accept", "application/json, text/plain, */*");
+ request.Headers.Add("Cookie", $"runescape-accounts__session-token={rsSessionToken}");
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"Failed to fetch characters: HTTP {(int)response.StatusCode}.\nResponse: {(string.IsNullOrWhiteSpace(json) ? "(empty)" : json[..Math.Min(500, json.Length)])}");
+ }
+
+ if (string.IsNullOrWhiteSpace(json))
+ return new List();
+
+ var result = JsonSerializer.Deserialize(json, JsonOptions);
+ return result?.Active ?? new List();
+ }
+
+ private sealed class RuneScapeAccountsResponse
+ {
+ [JsonPropertyName("active")]
+ public List Active { get; set; } = new();
+
+ [JsonPropertyName("archived")]
+ public List Archived { get; set; } = new();
+ }
}
diff --git a/Services/LauncherService.cs b/Services/LauncherService.cs
index 2411aaa..22b4841 100644
--- a/Services/LauncherService.cs
+++ b/Services/LauncherService.cs
@@ -11,8 +11,9 @@ public sealed class LauncherService
///
/// Launches the RS3 game client directly with session credentials.
/// The game client reads JX_SESSION_ID, JX_CHARACTER_ID, and JX_DISPLAY_NAME from environment variables.
+ /// Returns the started process so the caller can inject into it (e.g. agent DLL).
///
- public Task LaunchAsync(AppSettings settings, OAuthToken? token, GameAccount? selectedCharacter)
+ public Task LaunchAsync(AppSettings settings, OAuthToken? token, GameAccount? selectedCharacter)
{
var exePath = settings.Rs3ClientPath;
@@ -33,7 +34,6 @@ public Task LaunchAsync(AppSettings settings, OAuthToken? token, GameAccount? se
};
// Set the JX_* environment variables that the game client expects
- // These are the standard Jagex environment variables for session authentication
if (token != null && !string.IsNullOrWhiteSpace(token.SessionId))
{
startInfo.Environment["JX_SESSION_ID"] = token.SessionId;
@@ -48,7 +48,9 @@ public Task LaunchAsync(AppSettings settings, OAuthToken? token, GameAccount? se
}
}
- Process.Start(startInfo);
- return Task.CompletedTask;
+ var process = Process.Start(startInfo);
+ if (process == null)
+ throw new InvalidOperationException("Failed to start the game process.");
+ return Task.FromResult(process);
}
}
diff --git a/Services/LoaderAutomationService.cs b/Services/LoaderAutomationService.cs
new file mode 100644
index 0000000..c385182
--- /dev/null
+++ b/Services/LoaderAutomationService.cs
@@ -0,0 +1,322 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Input;
+
+namespace Sigil.Services;
+
+///
+/// Automates the loader UI: find the process table row for the given PID,
+/// right-click that row, then select "inject" from the context menu.
+/// Loader has columns: pid, data, type, state. Context menu: "inject", "force close".
+///
+public sealed class LoaderAutomationService
+{
+ private const int WaitForLoaderSeconds = 15;
+ private const int WaitAfterRightClickMs = 400;
+ private const int WaitForMenuMs = 300;
+
+ ///
+ /// Starts the loader exe (if path given), waits for its window, finds the row
+ /// whose pid column matches , right-clicks that row,
+ /// then selects "inject" from the context menu.
+ ///
+ /// Full path to loader.exe. If null/empty, only inject is attempted (loader must already be running).
+ /// Process ID of the game client – used to find the correct table row.
+ public async Task LaunchLoaderAndInjectAsync(string? loaderExePath, int gameProcessId)
+ {
+ Process? loaderProcess = null;
+
+ if (!string.IsNullOrWhiteSpace(loaderExePath) && File.Exists(loaderExePath))
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = loaderExePath,
+ UseShellExecute = true
+ };
+ loaderProcess = Process.Start(startInfo);
+ if (loaderProcess == null)
+ throw new InvalidOperationException("Failed to start loader.");
+ }
+
+ IntPtr loaderWindowHandle;
+ if (loaderProcess != null)
+ {
+ loaderWindowHandle = await WaitForMainWindowAsync(loaderProcess, TimeSpan.FromSeconds(WaitForLoaderSeconds)).ConfigureAwait(false);
+ if (loaderWindowHandle == IntPtr.Zero)
+ throw new InvalidOperationException("Loader window did not appear in time. Open the loader manually and use right-click → inject.");
+ await Task.Delay(2000).ConfigureAwait(false); // let loader populate process list
+ }
+ else
+ {
+ loaderWindowHandle = FindLoaderWindow();
+ if (loaderWindowHandle == IntPtr.Zero)
+ throw new InvalidOperationException("Loader window not found. Start the loader first.");
+ }
+
+ await Task.Run(() =>
+ {
+ try
+ {
+ RightClickPidRowAndSelectInject(loaderWindowHandle, gameProcessId);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Could not trigger inject in loader UI: {ex.Message}. Use right-click → inject manually.", ex);
+ }
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ /// Finds the table row for the given PID, right-clicks it, then selects "inject".
+ /// Tries UI Automation first (table + row by PID); falls back to right-click at window offset + key I.
+ ///
+ public void RightClickPidRowAndSelectInject(IntPtr loaderWindowHandle, int gameProcessId)
+ {
+ var pidStr = gameProcessId.ToString();
+
+ if (TryFindRowByPidAndInject(loaderWindowHandle, pidStr))
+ return;
+
+ // Fallback: right-click near center-left (where first pid column often is), then press I for "inject"
+ FallbackRightClickAndInject(loaderWindowHandle);
+ }
+
+ private static bool TryFindRowByPidAndInject(IntPtr loaderWindowHandle, string pidStr)
+ {
+ AutomationElement? root = null;
+ try
+ {
+ root = AutomationElement.FromHandle(loaderWindowHandle);
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (root == null) return false;
+
+ // Find table/list (columns: pid, data, type, state)
+ var tableCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Table);
+ var listCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.List);
+ var dataGridCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.DataGrid);
+
+ var tableOrList = root.FindFirst(TreeScope.Descendants, tableCondition)
+ ?? root.FindFirst(TreeScope.Descendants, dataGridCondition)
+ ?? root.FindFirst(TreeScope.Descendants, listCondition);
+
+ if (tableOrList == null) return false;
+
+ // Find row (DataItem, TableRow, or ListItem) whose text/name contains our PID
+ var rowConditions = new[]
+ {
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.DataItem),
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem)
+ };
+
+ AutomationElement? targetRow = null;
+ foreach (var rowCond in rowConditions)
+ {
+ var rows = tableOrList.FindAll(TreeScope.Children, rowCond);
+ foreach (AutomationElement row in rows)
+ {
+ var name = row.Current.Name ?? "";
+ // Row might show "34356" or "34356 Unknown Unknown Unmanaged" etc.
+ if (name.IndexOf(pidStr, StringComparison.Ordinal) >= 0)
+ {
+ targetRow = row;
+ break;
+ }
+ }
+ if (targetRow != null) break;
+ }
+
+ if (targetRow == null) return false;
+
+ var rect = targetRow.Current.BoundingRectangle;
+ if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) return false;
+
+ // Right-click center of the pid row
+ var x = rect.Left + rect.Width / 2;
+ var y = rect.Top + rect.Height / 2;
+ RightClickAt(x, y);
+ Thread.Sleep(WaitAfterRightClickMs);
+
+ // Select "inject" – try UIA menu first, then keyboard I
+ if (ClickMenuItemContaining("inject"))
+ return true;
+
+ SendKey(Key.I);
+ return true;
+ }
+
+ private static bool ClickMenuItemContaining(string text)
+ {
+ var menuCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Menu);
+ var root = AutomationElement.RootElement;
+ var menus = root.FindAll(TreeScope.Children, menuCondition);
+ foreach (AutomationElement menu in menus)
+ {
+ var itemCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.MenuItem);
+ var items = menu.FindAll(TreeScope.Descendants, itemCondition);
+ foreach (AutomationElement item in items)
+ {
+ if ((item.Current.Name ?? "").IndexOf(text, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ var rect = item.Current.BoundingRectangle;
+ if (!rect.IsEmpty)
+ {
+ ClickAt(rect.Left + rect.Width / 2, rect.Top + rect.Height / 2);
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private static void FallbackRightClickAndInject(IntPtr windowHandle)
+ {
+ if (!GetWindowRect(windowHandle, out var r))
+ return;
+ // Right-click near left quarter (pid column) and vertical center of content area
+ var x = r.Left + (r.Right - r.Left) / 4;
+ var y = r.Top + (r.Bottom - r.Top) / 2;
+ RightClickAt(x, y);
+ Thread.Sleep(WaitForMenuMs);
+ SendKey(Key.I);
+ }
+
+ private static IntPtr FindLoaderWindow()
+ {
+ IntPtr found = IntPtr.Zero;
+ EnumWindows((hWnd, _) =>
+ {
+ var length = GetWindowTextLength(hWnd) + 1;
+ if (length <= 1) return true;
+ var buf = new char[length];
+ if (GetWindowText(hWnd, buf, length) == 0) return true;
+ var title = new string(buf).TrimEnd('\0');
+ // Title like "coaeasy | 9101 Days Left | 1.0.0" or "BWU Loader | ..."
+ if (title.IndexOf("Loader", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ title.IndexOf("coaeasy", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ title.IndexOf("Days Left", StringComparison.Ordinal) >= 0)
+ {
+ found = hWnd;
+ return false; // stop enum
+ }
+ return true;
+ }, IntPtr.Zero);
+ return found;
+ }
+
+ private static async Task WaitForMainWindowAsync(Process process, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ process.Refresh();
+ if (process.MainWindowHandle != IntPtr.Zero)
+ return process.MainWindowHandle;
+ await Task.Delay(300).ConfigureAwait(false);
+ }
+ return IntPtr.Zero;
+ }
+
+ private static void RightClickAt(double x, double y)
+ {
+ SetCursorPos((int)x, (int)y);
+ Thread.Sleep(50);
+ var inputDown = new INPUT { type = InputTypeMouse, mi = new MOUSEINPUT { dwFlags = MouseEventFRightDown } };
+ var inputUp = new INPUT { type = InputTypeMouse, mi = new MOUSEINPUT { dwFlags = MouseEventFRightUp } };
+ SendInput(1, new[] { inputDown }, Marshal.SizeOf());
+ SendInput(1, new[] { inputUp }, Marshal.SizeOf());
+ }
+
+ private static void ClickAt(double x, double y)
+ {
+ SetCursorPos((int)x, (int)y);
+ Thread.Sleep(50);
+ var inputDown = new INPUT { type = InputTypeMouse, mi = new MOUSEINPUT { dwFlags = MouseEventFLeftDown } };
+ var inputUp = new INPUT { type = InputTypeMouse, mi = new MOUSEINPUT { dwFlags = MouseEventFLeftUp } };
+ SendInput(1, new[] { inputDown }, Marshal.SizeOf());
+ SendInput(1, new[] { inputUp }, Marshal.SizeOf());
+ }
+
+ private static void SendKey(Key key)
+ {
+ var vk = KeyInterop.VirtualKeyFromKey(key);
+ var inputDown = new INPUT { type = InputTypeKeyboard, ki = new KEYBDINPUT { wVk = (ushort)vk } };
+ var inputUp = new INPUT { type = InputTypeKeyboard, ki = new KEYBDINPUT { wVk = (ushort)vk, dwFlags = KeyEventFKeyUp } };
+ SendInput(1, new[] { inputDown }, Marshal.SizeOf());
+ SendInput(1, new[] { inputUp }, Marshal.SizeOf());
+ }
+
+ private const int InputTypeMouse = 0;
+ private const int InputTypeKeyboard = 1;
+ private const uint MouseEventFLeftDown = 0x0002;
+ private const uint MouseEventFLeftUp = 0x0004;
+ private const uint MouseEventFRightDown = 0x0008;
+ private const uint MouseEventFRightUp = 0x0010;
+ private const uint KeyEventFKeyUp = 0x0002;
+
+ private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
+
+ [DllImport("user32.dll")]
+ private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetWindowText(IntPtr hWnd, char[] lpString, int nMaxCount);
+
+ [DllImport("user32.dll")]
+ private static extern int GetWindowTextLength(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetCursorPos(int x, int y);
+
+ [DllImport("user32.dll")]
+ private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT
+ {
+ public int Left, Top, Right, Bottom;
+ }
+
+ [StructLayout(LayoutKind.Explicit)]
+ private struct INPUT
+ {
+ [FieldOffset(0)] public int type;
+ [FieldOffset(8)] public MOUSEINPUT mi;
+ [FieldOffset(8)] public KEYBDINPUT ki;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MOUSEINPUT
+ {
+ public int dx;
+ public int dy;
+ public uint mouseData;
+ public uint dwFlags;
+ public uint time;
+ public IntPtr dwExtraInfo;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct KEYBDINPUT
+ {
+ public ushort wVk;
+ public ushort wScan;
+ public uint dwFlags;
+ public uint time;
+ public IntPtr dwExtraInfo;
+ }
+}
diff --git a/Services/TokenService.cs b/Services/TokenService.cs
index 3ca61f7..5ab4913 100644
--- a/Services/TokenService.cs
+++ b/Services/TokenService.cs
@@ -26,7 +26,8 @@ public async Task SaveAsync(string accountId, OAuthToken token)
IdToken = token.IdToken,
ExpiresAt = token.ExpiresAt,
Subject = token.Subject,
- SessionId = token.SessionId
+ SessionId = token.SessionId,
+ RuneScapeSessionToken = token.RuneScapeSessionToken
};
await JsonFileStore.SaveAsync(
@@ -56,7 +57,8 @@ await JsonFileStore.SaveAsync(
ExpiresAt = cache.ExpiresAt == default ? DateTimeOffset.UtcNow : cache.ExpiresAt,
IdToken = cache.IdToken,
Subject = cache.Subject,
- SessionId = cache.SessionId
+ SessionId = cache.SessionId,
+ RuneScapeSessionToken = cache.RuneScapeSessionToken
};
}
@@ -81,5 +83,6 @@ private sealed class TokenCache
public DateTimeOffset ExpiresAt { get; set; }
public string? Subject { get; set; }
public string? SessionId { get; set; }
+ public string? RuneScapeSessionToken { get; set; }
}
}
diff --git a/Views/AuthWindow.xaml.cs b/Views/AuthWindow.xaml.cs
index 01141b9..aaa8bb2 100644
--- a/Views/AuthWindow.xaml.cs
+++ b/Views/AuthWindow.xaml.cs
@@ -271,6 +271,8 @@ private async Task HandleConsentAsync(string? idToken, string? consentCode)
_pendingToken.SessionId = sessionId;
_pendingToken.Subject ??= JwtHelper.TryGetSubject(idToken);
_log?.Invoke($"Session created. SessionId={sessionId}");
+ StatusText.Text = "Finalizing login...";
+ await TryExtractRuneScapeSessionCookieAsync(_pendingToken);
Complete(_pendingToken);
}
catch (Exception ex)
@@ -377,6 +379,124 @@ private static string[] ParseWebViewJsonArray(string json)
return Array.Empty();
}
+ public string? CookieDebugLog { get; private set; }
+
+ private async Task TryExtractRuneScapeSessionCookieAsync(OAuthToken token)
+ {
+ const string cookieName = "runescape-accounts__session-token";
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("=== RuneScape Cookie Extraction Debug ===");
+ sb.AppendLine($"Time: {DateTimeOffset.UtcNow:u}");
+ sb.AppendLine();
+
+ try
+ {
+ // Dump cookies across all relevant domains before navigation
+ string[] probeUrls =
+ {
+ "https://account.runescape.com",
+ "https://secure.runescape.com",
+ "https://runescape.com",
+ "https://account.jagex.com",
+ };
+
+ sb.AppendLine("--- Cookies BEFORE navigation ---");
+ foreach (var url in probeUrls)
+ {
+ var allCookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync(url);
+ sb.AppendLine($"[{url}] ({allCookies.Count} cookie(s))");
+ foreach (var c in allCookies)
+ sb.AppendLine($" {c.Name}={TruncateValue(c.Value)} domain={c.Domain} path={c.Path} httpOnly={c.IsHttpOnly} secure={c.IsSecure}");
+ }
+ sb.AppendLine();
+
+ // Check if the target cookie is already present
+ var rsCookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://account.runescape.com");
+ var match = FindCookie(rsCookies, cookieName);
+
+ if (match != null)
+ {
+ sb.AppendLine($"✓ '{cookieName}' already present BEFORE navigation.");
+ }
+ else
+ {
+ sb.AppendLine($"✗ '{cookieName}' not present. Navigating to account.runescape.com/en-GB/game ...");
+
+ var navDone = new TaskCompletionSource();
+ string? finalUrl = null;
+
+ void OnNavCompleted(object? s, CoreWebView2NavigationCompletedEventArgs e)
+ {
+ finalUrl = Browser.CoreWebView2?.Source;
+ navDone.TrySetResult(e.IsSuccess);
+ }
+
+ Browser.CoreWebView2.NavigationCompleted += OnNavCompleted;
+ Browser.CoreWebView2.Navigate("https://account.runescape.com/en-GB/game");
+
+ var completed = await Task.WhenAny(navDone.Task, Task.Delay(TimeSpan.FromSeconds(12)));
+ Browser.CoreWebView2.NavigationCompleted -= OnNavCompleted;
+
+ bool navSuccess = completed == navDone.Task && navDone.Task.Result;
+ sb.AppendLine($"Navigation result: success={navSuccess} finalUrl={finalUrl ?? "(unknown)"}");
+ sb.AppendLine();
+
+ // Dump cookies after navigation
+ sb.AppendLine("--- Cookies AFTER navigation ---");
+ foreach (var url in probeUrls)
+ {
+ var allCookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync(url);
+ sb.AppendLine($"[{url}] ({allCookies.Count} cookie(s))");
+ foreach (var c in allCookies)
+ sb.AppendLine($" {c.Name}={TruncateValue(c.Value)} domain={c.Domain} path={c.Path} httpOnly={c.IsHttpOnly} secure={c.IsSecure}");
+ }
+ sb.AppendLine();
+
+ rsCookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://account.runescape.com");
+ match = FindCookie(rsCookies, cookieName);
+ }
+
+ if (match != null)
+ {
+ token.RuneScapeSessionToken = match.Value;
+ sb.AppendLine($"✓ SUCCESS: '{cookieName}' extracted (length={match.Value.Length}).");
+ _log?.Invoke("RuneScape session cookie extracted.");
+ }
+ else
+ {
+ sb.AppendLine($"✗ FAILED: '{cookieName}' not found after navigation.");
+ _log?.Invoke("RuneScape session cookie not found.");
+ }
+ }
+ catch (Exception ex)
+ {
+ sb.AppendLine($"EXCEPTION: {ex}");
+ _log?.Invoke($"Cookie extraction error: {ex.Message}");
+ }
+
+ CookieDebugLog = sb.ToString();
+ _log?.Invoke(CookieDebugLog);
+ }
+
+ private static string TruncateValue(string value)
+ {
+ if (value.Length <= 20) return value;
+ return value[..12] + "…(" + value.Length + " chars)";
+ }
+
+ private static CoreWebView2Cookie? FindCookie(
+ IReadOnlyList cookies,
+ string name)
+ {
+ foreach (var c in cookies)
+ {
+ if (string.Equals(c.Name, name, StringComparison.Ordinal))
+ return c;
+ }
+ return null;
+ }
+
private void OnCancel(object sender, RoutedEventArgs e)
{
Fail("Login canceled.");
diff --git a/Views/CookieDebugWindow.xaml b/Views/CookieDebugWindow.xaml
new file mode 100644
index 0000000..5fb245d
--- /dev/null
+++ b/Views/CookieDebugWindow.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/CookieDebugWindow.xaml.cs b/Views/CookieDebugWindow.xaml.cs
new file mode 100644
index 0000000..7a4201e
--- /dev/null
+++ b/Views/CookieDebugWindow.xaml.cs
@@ -0,0 +1,22 @@
+using System.Windows;
+
+namespace Sigil.Views;
+
+public partial class CookieDebugWindow : Window
+{
+ public CookieDebugWindow(string log)
+ {
+ InitializeComponent();
+ LogBox.Text = log;
+ }
+
+ private void OnCopy(object sender, RoutedEventArgs e)
+ {
+ Clipboard.SetText(LogBox.Text);
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+}