Compare commits

...
15 Commits
95 changed files with 7128 additions and 4054 deletions
+71
View File
@@ -0,0 +1,71 @@
# Copilot instructions for Daybreak
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
Photino.Blazor. Read the
[Architecture Overview](../README.md#architecture-overview) for the project
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
process.
## Workflow
- Branch off the current `release/[version]` branch, never off `master`.
- One feature or fix per branch and per PR.
- Never push to `master` and never edit the version in `Directory.Build.props`.
Both are owned by the release workflows.
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
## Build and test
CI does not run on PRs targeting a release branch, so validate locally. Run the
smallest command that covers the change:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
```
Keep the build warning-free.
## Where code goes
| Change | Project |
| -------------------------------------------------- | ------------------------------------ |
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
| Blazor views and services | `Daybreak.Core` |
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
thin caller.
Add NuGet packages through `Directory.Packages.props`.
## C# conventions
`.editorconfig` is the source of truth. The projects are set up to treat
warnings as errors.
## Verify before claiming
Prefer evidence over reasoning about behaviour, especially for the Wine and
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
the fix against it rather than inferring what a Win32 call does.
## Writing style
Applies to docs, comments, PR descriptions and answers.
- Be short. Cut filler, recap and process narration.
- No AI-isms and no inflated language.
- Do not paste code into documentation. Link to the file so there is only one
copy of it.
- Explain the reason for a change, not a summary of the diff.
## Markdown
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
`img` tag.
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
frontmatter inside the fence is rejected by some renderers.
- Long URLs belong in reference-style link definitions at the bottom of the
file.
+113
View File
@@ -0,0 +1,113 @@
# Contributing to Daybreak
## Getting started
Install the prerequisites listed under
[Build Requirements](README.md#build-requirements), then clone with submodules:
```bash
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
```
Read the [Architecture Overview](README.md#architecture-overview) before making
changes so you put code in the right project.
## Branching model
There is always exactly one active release branch, named `release/[version]`
(for example `release/0.9.10.20`). It is the integration branch: all work
targets it, and `master` only receives finished releases.
```mermaid
flowchart LR
subgraph cycle["Release cycle"]
direction LR
R["release/0.9.10.20<br/><i>active release branch</i>"]
F["feature branch<br/><i>one per change</i>"]
R -->|fork| F
F -->|"PR, squash merge"| R
end
R -->|"PR, rebase merge"| M["master"]
M --> CD["CD pipeline<br/>builds and publishes the release"]
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
```
### Feature work
1. Branch off the current release branch.
2. Implement one feature or fix. Keep the branch scoped to a single change.
3. Open a PR against the release branch.
4. Merge with **squash**, so each feature lands as one commit.
### Releasing
1. Open a PR from the release branch onto `master`.
2. Merge with **rebase**, keeping full history. Each feature stays a single
commit on `master`.
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
builds the Windows and Linux bundles and publishes a GitHub release.
4. On success,
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
bumps the revision and creates the next `release/[version]` branch
automatically. That becomes the new active release branch.
Do not push directly to `master` or create release branches by hand.
## Versions
The version lives in a single place,
[`Directory.Build.props`](Directory.Build.props), and follows
`major.minor.build.revision`. It is bumped by the release branch creation job,
not manually.
Revision bumps happen automatically after every release. For the other
components, run the matching workflow manually before starting the next cycle:
| Workflow | Bumps |
| ------------------------------------------------------------------------------------------ | --------------------- |
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
All four delegate to
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
## Pull requests
Title format follows the existing history: a short description, plus the issue
reference when it closes one.
```text
Setup stable computer name in Wine prefix (Closes #1609)
```
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
`close/fix/resolve #N` from the title and body of PRs merged into a release
branch and labels the linked issues, so use those keywords.
## Checks
[CI](.github/workflows/ci.yaml) and
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
targeting `master`, so a feature PR onto a release branch is not covered by
them. Run the equivalent locally before opening one:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
```
The version check fails a PR to `master` if the version in
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
must come from the release branch job.
## Code style
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
compiler and analyzers; keep the build warning-free.
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
want platform code covered by tests, put the testable part in
[`Daybreak.Shared`](Daybreak.Shared).
File diff suppressed because it is too large Load Diff
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
public readonly uint Size;
public readonly uint Param;
/// <summary>
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
/// rather than a real array.
/// </summary>
private const uint MaxPlausibleCapacity = 0x10000;
/// <summary>
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
/// these fields transiently inconsistent while it (re)allocates an array, and a
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
/// <c>size &lt;= capacity</c>.
/// </summary>
public bool IsValid =>
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
this.Size <= this.Capacity &&
this.Capacity <= MaxPlausibleCapacity;
/// <summary>
/// Number of elements that can safely be read. Zero whenever the array is not backed by
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
/// </summary>
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
public T this[int index]
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
return this.Buffer[index];
}
}
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
@@ -7,6 +7,18 @@ public readonly unsafe struct WrappedPointer<T>(T* pointer)
public bool IsNull => this.Pointer is null;
/// <summary>
/// True when the pointer is non-null and plausibly dereferenceable.
/// </summary>
/// <remarks>
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
/// </remarks>
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
@@ -35,21 +35,28 @@ public sealed class CharacterSelectService(
{
var gameContext = this.gameContextService.GetGameContext();
if (gameContext.IsNull ||
gameContext.Pointer->World is null)
gameContext.Pointer->World is null ||
gameContext.Pointer->Character is null)
{
scopedLogger.LogError("Game context is not initialized");
return default;
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (availableCharsContext.IsNull)
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
{
scopedLogger.LogError("Available characters context is not initialized");
scopedLogger.LogError(
"Available characters context is not initialized (array@0x{Array:X8} buffer=0x{Buffer:X8} capacity={Capacity} size={Size})",
(nuint)availableCharsContext.Pointer,
availableCharsContext.IsValid ? (nuint)availableCharsContext.Pointer->Buffer : 0,
availableCharsContext.IsValid ? availableCharsContext.Pointer->Capacity : 0,
availableCharsContext.IsValid ? availableCharsContext.Pointer->Size : 0);
return default;
}
var currentUuid = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString();
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Size);
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Count);
foreach (var charContext in *availableCharsContext.Pointer)
{
var name = new string(charContext.Name);
@@ -196,7 +203,9 @@ public sealed class CharacterSelectService(
// Find target character index
uint targetIdx = 0xFFFF;
var charCount = ctx.Pointer->Chars.Size;
// Count (not Size) so a not-yet-allocated roster yields zero iterations
// instead of indexing through a null buffer.
var charCount = ctx.Pointer->Chars.Count;
for (uint i = 0; i < charCount; i++)
{
@@ -406,7 +415,8 @@ public sealed class CharacterSelectService(
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (availableCharsContext.IsNull)
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
{
scopedLogger.LogError("Available characters context is not initialized");
return default;
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
using Daybreak.Shared.Attributes;
namespace Daybreak.Configuration.Options;
[OptionsName(Name = "Downloads")]
internal sealed class DownloadOptions
{
[JsonPropertyName(nameof(HeaderTimeout))]
[OptionName(
Name = "Header Timeout",
Description = "Amount of seconds Daybreak will wait for a download to respond before giving up on the attempt"
)]
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
public double HeaderTimeout { get; set; } = 30;
[JsonPropertyName(nameof(ChunkTimeout))]
[OptionName(
Name = "Chunk Timeout",
Description = "Amount of seconds Daybreak will wait for the next chunk of a download before giving up on the attempt"
)]
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
public double ChunkTimeout { get; set; } = 30;
[JsonPropertyName(nameof(Retries))]
[OptionName(
Name = "Retries",
Description = "Amount of times Daybreak will retry a failed download before reporting it as failed"
)]
[OptionRange<int>(MinValue = 0, MaxValue = 10)]
public int Retries { get; set; } = 3;
[JsonPropertyName(nameof(RetryDelay))]
[OptionName(
Name = "Retry Delay",
Description = "Amount of seconds Daybreak will wait before retrying a failed download"
)]
[OptionRange<double>(MinValue = 0, MaxValue = 60)]
public double RetryDelay { get; set; } = 2;
}
@@ -349,6 +349,7 @@ public class ProjectConfiguration : PluginConfigurationBase
optionsProducer.RegisterOptions<SynchronizationOptions>();
optionsProducer.RegisterOptions<FocusViewOptions>();
optionsProducer.RegisterOptions<DaybreakApiOptions>();
optionsProducer.RegisterOptions<DownloadOptions>();
optionsProducer.RegisterOptions<ToolboxOptions>();
optionsProducer.RegisterOptions<UModOptions>();
@@ -433,6 +433,7 @@ internal sealed class DXVKService(
{
Directory.Delete(x32Target, recursive: true);
}
if (Directory.Exists(x64Target))
{
Directory.Delete(x64Target, recursive: true);
@@ -1,20 +1,25 @@
using Daybreak.Shared.Models.Async;
using Daybreak.Configuration.Options;
using Daybreak.Shared.Models.Async;
using Daybreak.Shared.Models.Metrics;
using Daybreak.Shared.Services.Downloads;
using Daybreak.Shared.Services.Metrics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Diagnostics.Metrics;
using System.Extensions.Core;
using System.Logging;
namespace Daybreak.Services.Downloads;
internal sealed class DownloadService(
IMetricsService metricsService,
IHttpClient<DownloadService> httpClient,
IOptionsMonitor<DownloadOptions> options,
ILogger<DownloadService> logger) : IDownloadService
{
private const double StatusUpdateInterval = 50;
private const int BufferSize = 81920;
private const string MetricName = "download.speed";
private const string MetricUnits = "bytes/sec";
private const string MetricDescription = "Average download speed. Specified in bytes per second";
@@ -23,19 +28,79 @@ internal sealed class DownloadService(
private readonly static ProgressUpdate ProgressFailed = new(1, "Download failed");
private readonly static ProgressUpdate ProgressCompleted = new(1, "Download finished");
private static ProgressUpdate ProgressDownload(double progress) => new(progress, "Downloading");
private static ProgressUpdate ProgressRetrying(int attempt, int retries) => new(0, $"Download failed. Retrying ({attempt}/{retries})");
private readonly Histogram<double> averageDownloadSpeed = metricsService.ThrowIfNull().CreateHistogram<double>(MetricName, MetricUnits, MetricDescription, AggregationTypes.NoAggregate);
private readonly IHttpClient<DownloadService> httpClient = httpClient.ThrowIfNull();
private readonly IOptionsMonitor<DownloadOptions> options = options.ThrowIfNull();
private readonly ILogger<DownloadService> logger = logger.ThrowIfNull();
public async Task<bool> DownloadFile(string downloadUri, string destinationPath, IProgress<ProgressUpdate> progress, CancellationToken cancellationToken = default)
{
var scopedLogger = this.logger.CreateScopedLogger();
var currentOptions = this.options.CurrentValue;
var retries = Math.Max(0, currentOptions.Retries);
var attempts = retries + 1;
for (var attempt = 1; attempt <= attempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (await this.TryDownloadFile(downloadUri, destinationPath, progress, currentOptions, cancellationToken))
{
progress.Report(ProgressCompleted);
scopedLogger.LogDebug("Downloaded file");
return true;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller gave up. The partial file is still removed, because leaving it behind
// lets a later run mistake it for a complete download.
DeletePartialDownload(destinationPath, scopedLogger);
throw;
}
catch (OperationCanceledException)
{
scopedLogger.LogError("Download timed out on attempt {Attempt} of {Attempts}", attempt, attempts);
}
catch (Exception e)
{
scopedLogger.LogError(e, "Download failed on attempt {Attempt} of {Attempts}", attempt, attempts);
}
DeletePartialDownload(destinationPath, scopedLogger);
if (attempt < attempts)
{
progress.Report(ProgressRetrying(attempt, retries));
if (currentOptions.RetryDelay > 0)
{
await Task.Delay(TimeSpan.FromSeconds(currentOptions.RetryDelay), cancellationToken);
}
}
}
progress.Report(ProgressFailed);
return false;
}
private async Task<bool> TryDownloadFile(
string downloadUri,
string destinationPath,
IProgress<ProgressUpdate> progress,
DownloadOptions currentOptions,
CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
progress.Report(ProgressInitialize);
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
// A server that accepts the connection but never answers would otherwise hang the launch.
using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
headerCts.CancelAfter(TimeSpan.FromSeconds(currentOptions.HeaderTimeout));
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, headerCts.Token);
if (response.IsSuccessStatusCode is false)
{
progress.Report(ProgressFailed);
scopedLogger.LogError($"Failed to download installer. Status: {response.StatusCode}. Details: {await response.Content.ReadAsStringAsync(cancellationToken)}");
return false;
}
@@ -44,30 +109,39 @@ internal sealed class DownloadService(
this.logger.LogDebug("Beginning download");
var fileInfo = new FileInfo(destinationPath);
fileInfo.Directory?.Create();
using var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write);
var downloadSize = response.Content?.Headers?.ContentLength ?? double.MaxValue;
var buffer = new byte[1024];
var length = 0;
var downloaded = 0d;
var downloadedPerTimeframe = 0d;
var tickTime = DateTime.Now;
var startTime = DateTime.Now;
while (downloadStream.CanRead && (length = await downloadStream.ReadAsync(buffer, cancellationToken)) > 0)
{
downloaded += length;
downloadedPerTimeframe += length;
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
{
tickTime = DateTime.Now;
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
this.averageDownloadSpeed.Record(downloadedInSecond);
var chunkTimeout = TimeSpan.FromSeconds(currentOptions.ChunkTimeout);
var buffer = new byte[BufferSize];
// var avgSpeed = downloaded / (tickTime - startTime).TotalSeconds;
// var remainingSize = downloadSize - downloaded;
// var secondsRemaining = remainingSize / avgSpeed;
downloadedPerTimeframe = 0d;
progress.Report(ProgressDownload(downloaded / downloadSize));
// Scoped so the handle is released before a failed download is deleted.
using (var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write))
{
while (downloadStream.CanRead)
{
// A stalled connection blocks inside ReadAsync indefinitely, so each chunk gets its own deadline.
using var chunkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
chunkCts.CancelAfter(chunkTimeout);
var length = await downloadStream.ReadAsync(buffer, chunkCts.Token);
if (length <= 0)
{
break;
}
downloaded += length;
downloadedPerTimeframe += length;
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
{
tickTime = DateTime.Now;
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
this.averageDownloadSpeed.Record(downloadedInSecond);
downloadedPerTimeframe = 0d;
progress.Report(ProgressDownload(downloaded / downloadSize));
}
}
}
@@ -75,14 +149,24 @@ internal sealed class DownloadService(
if (downloadSize != double.MaxValue && downloaded < downloadSize)
{
scopedLogger.LogError("Download incomplete. Expected {ExpectedSize} bytes but received {ActualSize} bytes", (long)downloadSize, (long)downloaded);
progress.Report(ProgressFailed);
fileStream.Close();
File.Delete(destinationPath);
return false;
}
progress.Report(ProgressCompleted);
scopedLogger.LogDebug("Downloaded file");
return true;
}
private static void DeletePartialDownload(string destinationPath, ScopedLogger<DownloadService> scopedLogger)
{
try
{
if (File.Exists(destinationPath))
{
File.Delete(destinationPath);
}
}
catch (Exception e)
{
scopedLogger.LogError(e, "Failed to delete partial download at {DestinationPath}", destinationPath);
}
}
}
+80 -70
View File
@@ -8,80 +8,90 @@
<div class="option-view-title">
<h3>Accounts</h3>
</div>
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
@if (this.ViewModel.LoginCredentials.Count is 0)
{
<EmptyStateWidget Message="No accounts configured"
ActionText="Add account"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
OnAction="@this.ViewModel.CreateCredential" />
}
else
{
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -0,0 +1,28 @@
<div class="empty-state">
<FluentIcon Value="@this.Icon" Color="Color.Neutral" />
<div class="empty-message">@this.Message</div>
<FluentButton Appearance="Appearance.Accent"
Disabled="@this.Disabled"
IconStart="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())"
Title="@this.ActionText"
OnClick="@this.OnAction">
@this.ActionText
</FluentButton>
</div>
@code {
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string ActionText { get; set; } = "Add";
[Parameter]
public Icon Icon { get; set; } = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Search();
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public EventCallback OnAction { get; set; }
}
@@ -0,0 +1,14 @@
.empty-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--neutral-foreground-hint);
}
.empty-message {
font-size: var(--font-size-large);
text-align: center;
}
@@ -6,6 +6,7 @@ using System.Core.Extensions;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class EventCalendarViewModel(IEventService eventService)
: ViewModelBase<EventCalendarViewModel, EventCalendarView>
{
@@ -92,7 +93,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
{
dates.Add(new DateTime(today.Year, today.Month, day));
}
return dates;
}
@@ -107,6 +108,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
events.Add(eventItem);
}
}
return events;
}
}
+68 -57
View File
@@ -8,65 +8,76 @@
<div class="option-view-title">
<h3>Executables</h3>
</div>
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
@if (this.ViewModel.Executables.Count is 0)
{
<EmptyStateWidget Message="No executables configured"
ActionText="Add executable"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
Disabled="@(!this.ViewModel.AddButtonEnabled)"
OnAction="@this.ViewModel.CreateExecutable" />
}
else
{
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
{
this.Executables.Remove(executable);
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
this.RefreshView();
}
public async Task CreateExecutable()
+151 -141
View File
@@ -9,151 +9,161 @@
<div class="option-view-title">
<h3>Launch Configurations</h3>
</div>
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
@if (this.ViewModel.LaunchConfigurations.Count is 0)
{
<EmptyStateWidget Message="No launch configurations yet"
ActionText="Add launch configuration"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
}
else
{
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
</div>
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
+33 -12
View File
@@ -178,6 +178,7 @@ internal static class CppHeaderParser
{
inMultiLineConstexpr = false;
}
continue;
}
@@ -189,6 +190,7 @@ internal static class CppHeaderParser
{
inSkipBlock = false;
}
continue;
}
@@ -211,6 +213,7 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(trimmed.Substring(0, closeBraceIdx), currentEnum);
}
inEnum = false;
var node = namespaceStack.Peek();
node.Enums.Add(currentEnum);
@@ -231,11 +234,11 @@ internal static class CppHeaderParser
int closeBraces = CountChar(lineWithoutComments, '}');
int prevDepth = structBraceDepth;
structBraceDepth += openBraces - closeBraces;
// Debug: Track brace changes for AgentLiving
if (currentStruct.Name == "AgentLiving" && (openBraces > 0 || closeBraces > 0))
{
DebugLines.Add($"[BRACE] line {i+1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
DebugLines.Add($"[BRACE] line {i + 1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
}
// If we hit the closing brace of the struct
@@ -252,8 +255,8 @@ internal static class CppHeaderParser
pathParts.Add(n.Name);
pathParts.Reverse();
currentStruct.DebugNamespacePath = string.Join(".", pathParts);
DebugLines.Add($"[STRUCT-END] line {i+1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
DebugLines.Add($"[STRUCT-END] line {i + 1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
node.Structs.Add(currentStruct);
currentStruct = null;
inStruct = false;
@@ -278,6 +281,7 @@ internal static class CppHeaderParser
if (field is not null)
currentStruct.Fields.Add(field);
}
continue;
}
@@ -295,7 +299,7 @@ internal static class CppHeaderParser
var trailingCommentIdx = trimmed.IndexOf("//");
if (trailingCommentIdx > 0)
trimmedWithoutTrailingComment = trimmed.Substring(0, trailingCommentIdx).TrimEnd();
if (trimmedWithoutTrailingComment.Contains("(") && (trimmedWithoutTrailingComment.Contains(")") || trimmedWithoutTrailingComment.Contains("{")))
continue;
@@ -339,6 +343,7 @@ internal static class CppHeaderParser
};
currentStruct.Fields.Add(field);
}
continue;
}
@@ -354,6 +359,7 @@ internal static class CppHeaderParser
var child = current.GetOrCreateChild(part);
namespaceStack.Push(child);
}
continue;
}
@@ -366,13 +372,14 @@ internal static class CppHeaderParser
freeBraceDepth--;
continue;
}
if (namespaceStack.Count > 1)
{
// Debug: Track where namespace was popped
var poppedNode = namespaceStack.Pop();
poppedNode.DebugPopLine = i + 1;
}
continue;
}
@@ -390,6 +397,7 @@ internal static class CppHeaderParser
}
}
}
continue;
}
@@ -436,6 +444,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -448,6 +457,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -455,17 +465,18 @@ internal static class CppHeaderParser
if (OutOfLineMethodRegex.IsMatch(trimmed))
{
int braces = CountChar(trimmed, '{') - CountChar(trimmed, '}');
DebugLines.Add($"[OUT-OF-LINE] line {i+1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
DebugLines.Add($"[OUT-OF-LINE] line {i + 1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
if (braces > 0)
{
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
else if (trimmed.Contains("::") && trimmed.Contains("(") && trimmed.Contains("{"))
{
DebugLines.Add($"[UNMATCHED-METHOD] line {i+1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
DebugLines.Add($"[UNMATCHED-METHOD] line {i + 1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
}
// ── Track function declarations with body on separate line ─
@@ -492,6 +503,7 @@ internal static class CppHeaderParser
inMultiLineConstexpr = true;
multiLineBraceCount = braces;
}
continue;
}
@@ -501,7 +513,7 @@ internal static class CppHeaderParser
{
var structName = structMatch.Groups[1].Value;
var baseType = structMatch.Groups[2].Success ? structMatch.Groups[2].Value : null;
DebugLines.Add($"[STRUCT-START] line {i+1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
DebugLines.Add($"[STRUCT-START] line {i + 1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
// Skip some special cases
if (structName.StartsWith("__") || structName == "Packet")
@@ -512,6 +524,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -530,7 +543,7 @@ internal static class CppHeaderParser
if (structNobraceMatch.Success)
{
var structName = structNobraceMatch.Groups[1].Value;
// Skip forward declarations (just struct Name;)
if (trimmed.EndsWith(";"))
continue;
@@ -559,8 +572,10 @@ internal static class CppHeaderParser
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -593,9 +608,11 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
continue;
}
@@ -637,14 +654,18 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -794,7 +815,7 @@ internal static class CppHeaderParser
int slashSlash = line.IndexOf("//");
if (slashSlash >= 0)
line = line.Substring(0, slashSlash);
// Remove /* */ style comments (simple - doesn't handle nested)
int blockStart = line.IndexOf("/*");
while (blockStart >= 0)
@@ -806,7 +827,7 @@ internal static class CppHeaderParser
line = line.Substring(0, blockStart);
blockStart = line.IndexOf("/*");
}
return line;
}
+75 -60
View File
@@ -238,7 +238,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Emit
var sb = new StringBuilder(65536);
EmitHeader(sb, totalExports, skippedExports);
// Add diagnostic comment about parsed structs
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine(" // Parsed structs diagnostic:");
@@ -246,6 +246,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
sb.AppendLine($" // {diag}");
}
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine();
@@ -270,31 +271,33 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitConstantsNode(sb, grandchild, typeMap, namespaceClassNames, 4);
}
continue;
}
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, 4);
}
sb.AppendLine("}"); // Close GWCA class
sb.AppendLine("}"); // Close Daybreak.API.Interop namespace
// Emit structs and enums into GuildWars namespace for consumer code compatibility
// Consumer code uses `using Daybreak.API.Interop.GuildWars;` to access types
sb.AppendLine();
sb.AppendLine("namespace Daybreak.API.Interop.GuildWars");
sb.AppendLine("{");
var emittedNames = new HashSet<string>(StringComparer.Ordinal);
// Collect inline array types needed by struct fields (for non-blittable array fields)
var inlineArrayTypes = new HashSet<(string csType, int size)>();
CollectInlineArrayTypes(headerRoot, typeMap, inlineArrayTypes);
// Emit manually-defined helper structs first (TLink, etc.)
EmitManualHelperStructs(sb, 4, emittedNames);
// Emit inline array types for non-blittable arrays (e.g., enum arrays)
EmitInlineArrayTypes(sb, 4, inlineArrayTypes, emittedNames);
// Emit enums first - they're often referenced by structs (e.g. Attribute enum vs Attribute struct)
EmitEnumsToGuildWarsNamespace(sb, headerRoot, 4, emittedNames);
EmitStructsToGuildWarsNamespace(sb, headerRoot, typeMap, 4, emittedNames, inlineArrayTypes);
@@ -551,6 +554,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
}
sb.AppendLine($"{innerPad}}}");
}
else
@@ -666,10 +670,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Only add structs that can actually be emitted
if (!CanEmitStruct(structDef))
continue;
// Determine the C# name, handling collisions with namespace classes or enums
var csName = SanitizeIdentifier(structDef.Name);
// Check if name collides with a namespace class, enum, or ANOTHER struct already using this name
if (namespaceClassNames.Contains(structDef.Name))
{
@@ -693,21 +697,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
csName += "_";
}
usedGuildWarsNames.Add(csName);
// Use a qualified key that includes the C++ path to distinguish structs with the same name
// from different namespaces (e.g., GW::Attribute vs GW::SkillbarMgr::Attribute)
var mapKey = structDef.Name;
var fqCsType = "global::Daybreak.API.Interop.GuildWars." + csName;
var simpleStructKey = "struct " + structDef.Name;
if (typeMap.ContainsKey(mapKey))
{
// Use qualified key to distinguish this struct from enum or another struct
// Include the csPath to make it unique (e.g., "struct GW.Attribute" vs "struct GW.SkillbarMgr.Attribute")
mapKey = "struct " + csPath.Replace("GWCA.", "") + "." + structDef.Name;
// Also add simple "struct X" key if not already taken (for field type resolution)
// This allows MapCppFieldTypeToCs to find the struct without knowing the full path
if (!typeMap.ContainsKey(simpleStructKey))
@@ -715,7 +719,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
typeMap[simpleStructKey] = fqCsType;
}
}
// Structs go into GuildWars namespace (flat), not nested GWCA classes
typeMap[mapKey] = fqCsType;
}
@@ -745,19 +749,20 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void CollectStructDiagnostics(ConstantsNode node, string path, List<string> diagnostics)
{
var currentPath = string.IsNullOrEmpty(path) ? node.Name : path + "." + node.Name;
// Show namespace pop line info
if (node.DebugPopLine > 0)
{
diagnostics.Add($"[NAMESPACE] {currentPath} popped at line {node.DebugPopLine}");
}
foreach (var structDef in node.Structs)
{
var (canEmit, reason) = CanEmitStructWithReason(structDef);
var status = canEmit ? "OK" : $"SKIP: {reason}";
diagnostics.Add($"{currentPath}.{structDef.Name}: {structDef.Fields.Count} fields [{status}]");
}
foreach (var child in node.Children.Values)
{
CollectStructDiagnostics(child, currentPath, diagnostics);
@@ -772,7 +777,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Skip structs with no fields (usually forward declarations that got parsed)
if (structDef.Fields.Count == 0)
return (false, "no fields");
// Check for mixed offset/no-offset fields (can't use Explicit layout if not all have offsets)
bool hasAnyOffset = structDef.Fields.Any(f => f.Offset.HasValue);
bool allHaveOffsets = structDef.Fields.All(f => f.Offset.HasValue);
@@ -788,7 +793,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
return (false, $"template param T in field {field.Name}");
// Skip structs with complex template containers we can't represent
// Note: TLink<T> is handled separately in MapCppFieldTypeToCs (8-byte linked list node)
if (cppType.Contains("TList<") ||
if (cppType.Contains("TList<") ||
cppType.Contains("PrioQ<") || cppType.Contains("PrioQLink<") ||
cppType.Contains("BaseArray<"))
return (false, $"complex template in field {field.Name}: {cppType}");
@@ -815,9 +820,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
if (cppType.Contains("EquipmentVTable") || cppType.Contains("VTable"))
return (false, $"vtable in field {field.Name}: {cppType}");
}
return (true, null);
}
private static bool CanEmitStruct(CppStructDef structDef) => CanEmitStructWithReason(structDef).canEmit;
/// <summary>
@@ -907,7 +913,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Start with the node's own name as the path base (GWCA for headerRoot)
EmitStructsRecursive(sb, node, node.Name, typeMap, indent, emittedNames, inlineArrayTypes);
}
private static void EmitStructsRecursive(StringBuilder sb, ConstantsNode node, string currentPath, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames, HashSet<(string csType, int size)> inlineArrayTypes)
{
foreach (var structDef in node.Structs.OrderBy(s => s.Name, StringComparer.Ordinal))
@@ -915,13 +921,13 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Skip structs that can't be emitted
if (!CanEmitStruct(structDef))
continue;
// Get the C# name from typeMap (which includes collision-resolution suffixes like "Struct")
// Try direct name first, then qualified struct key (for collision cases)
string? fqCsName;
var directLookup = typeMap.TryGetValue(structDef.Name, out fqCsName);
var containsGuildWars = fqCsName?.Contains("GuildWars.") ?? false;
if (!directLookup || !containsGuildWars)
{
// If direct lookup failed or returned an enum (not in GuildWars namespace),
@@ -929,22 +935,22 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var qualifiedKey = "struct " + currentPath.Replace("GWCA.", "") + "." + structDef.Name;
typeMap.TryGetValue(qualifiedKey, out fqCsName);
}
if (fqCsName is null)
continue;
// Extract just the struct name from the fully-qualified name
// e.g. "global::Daybreak.API.Interop.GuildWars.ItemStruct" -> "ItemStruct"
var csName = fqCsName.Substring(fqCsName.LastIndexOf('.') + 1);
// Skip duplicates (including if an enum with the same name was emitted)
if (emittedNames.Contains(csName))
continue;
emittedNames.Add(csName);
EmitStruct(sb, structDef, typeMap, indent, inlineArrayTypes, csName);
}
// Recurse into children, appending the child's name to the current path
foreach (var child in node.Children.Values)
{
@@ -959,7 +965,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void EmitManualHelperStructs(StringBuilder sb, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
// TLink<T> - doubly-linked list node used in Agent and other structs
// C++ definition: struct TLink { TLink* prev_link; T* next_node; }
// Size: 8 bytes (2 pointers on x86)
@@ -1003,16 +1009,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
if (!CanEmitStruct(structDef))
continue;
foreach (var field in structDef.Fields)
{
if (field.ArraySize is null)
continue;
var size = ParseArraySize(field.ArraySize);
if (size <= 0)
continue;
var csType = MapCppFieldTypeToCs(field.CppType, typeMap);
if (!IsBlittableForFixed(csType))
{
@@ -1020,7 +1026,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
}
}
}
foreach (var child in node.Children.Values)
{
CollectInlineArrayTypes(child, typeMap, inlineArrayTypes);
@@ -1034,16 +1040,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void EmitInlineArrayTypes(StringBuilder sb, int indent, HashSet<(string csType, int size)> inlineArrayTypes, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var (csType, size) in inlineArrayTypes.OrderBy(x => x.csType).ThenBy(x => x.size))
{
// Generate a name like "AttributeArray12" or "SkillIDArray8"
var simpleName = GetSimpleTypeName(csType);
var arrayTypeName = $"{simpleName}Array{size}";
if (!emittedNames.Add(arrayTypeName))
continue;
sb.AppendLine($"{pad}/// <summary>");
sb.AppendLine($"{pad}/// Inline array of {size} {simpleName} elements.");
sb.AppendLine($"{pad}/// </summary>");
@@ -1067,10 +1073,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var isPointer = csType.EndsWith("*");
if (isPointer)
csType = csType.TrimEnd('*');
var lastDot = csType.LastIndexOf('.');
var name = lastDot >= 0 ? csType.Substring(lastDot + 1) : csType;
// Append Ptr for pointer types to make valid identifier
return isPointer ? name + "Ptr" : name;
}
@@ -1092,30 +1098,30 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitEnumsRecursive(sb, node, indent, emittedNames);
}
private static void EmitEnumsRecursive(StringBuilder sb, ConstantsNode node, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var enumDef in node.Enums.OrderBy(e => e.Name, StringComparer.Ordinal))
{
// Skip anonymous enums
if (enumDef.Name is null)
continue;
// Skip duplicates (including if a struct with the same name was emitted)
if (emittedNames.Contains(enumDef.Name))
continue;
emittedNames.Add(enumDef.Name);
// Determine base type and map C++ types to C#
var baseType = MapCppEnumBaseType(enumDef.UnderlyingType ?? "int");
sb.AppendLine();
sb.AppendLine($"{pad}public enum {SanitizeIdentifier(enumDef.Name)} : {baseType}");
sb.AppendLine($"{pad}{{");
var innerPad = new string(' ', indent + 4);
foreach (var member in enumDef.Members)
{
@@ -1124,9 +1130,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
sb.AppendLine($"{innerPad}{SanitizeIdentifier(member.Name)},");
}
sb.AppendLine($"{pad}}}");
}
// Recurse into children
foreach (var child in node.Children.Values)
{
@@ -1141,19 +1148,19 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitTypeAliasesRecursive(sb, node, typeMap, indent, emittedNames);
}
private static void EmitTypeAliasesRecursive(StringBuilder sb, ConstantsNode node, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var alias in node.TypeAliases.OrderBy(a => a.AliasName, StringComparer.Ordinal))
{
// Skip duplicates
if (emittedNames.Contains(alias.AliasName))
continue;
emittedNames.Add(alias.AliasName);
var innerType = alias.TemplateArg.Replace("::", ".").Trim();
// Handle pointer types (e.g., "Item *" -> "nint")
if (innerType.EndsWith("*"))
@@ -1170,10 +1177,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Unmapped struct type - use nint as fallback
innerType = "nint";
}
sb.AppendLine($"{pad}public unsafe struct {alias.AliasName} {{ public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<{innerType}> Value; }}");
}
// Recurse into children
foreach (var child in node.Children.Values)
{
@@ -1259,6 +1266,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fallback for unmapped types
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
}
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
}
@@ -1286,7 +1294,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var parts = inner.Split(new[] { "::" }, StringSplitOptions.RemoveEmptyEntries);
innerStripped = parts[parts.Length - 1];
}
var csInner = MapCppFieldTypeToCs(inner, typeMap);
// Special case: void* and char* and wchar_t* map to nint
if (csInner is "void" or "char" or "byte")
@@ -1301,7 +1309,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Check if original type is from Constants namespace (typically enums)
// In this case, prefer enum mapping over struct mapping
var isFromConstantsNamespace = cppType.Contains("Constants::");
// Strip namespace qualifiers (GW::, GW::Constants::, etc.)
if (cppType.Contains("::"))
{
@@ -1357,6 +1365,11 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
"HMODULE" => "nint",
"HANDLE" => "nint",
"HWND" => "nint",
// EGL handles: `using EGLSurface = void*;` aliases declared inside
// GWCA/Managers/RenderMgr.h, which the header parser doesn't resolve.
"EGLSurface" => "nint",
"EGLContext" => "nint",
"EGLDisplay" => "nint",
"uintptr_t" => "nuint",
"intptr_t" => "nint",
"Vec2f" => "global::System.Numerics.Vector2",
@@ -1420,6 +1433,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
result.Append(part.Substring(1));
}
}
return result.ToString();
}
@@ -1649,7 +1663,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint",
};
}
/// <summary>
/// Resolves a struct name to its C# type, handling name collisions with enums.
/// </summary>
@@ -1664,7 +1678,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Unmapped struct -> nint
return "nint";
}
/// <summary>
/// Resolves a pointer inner type (the pointee) for struct or enum types,
/// handling name collisions between structs and enums.
@@ -1680,7 +1694,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fall back to direct lookup result (could be an enum) or just the name
return direct ?? name;
}
/// <summary>
/// Resolves a pointer type used as a template argument (e.g., Agent* in Array&lt;Agent*&gt;).
/// In unsafe C#, pointer types can be used as generic type arguments for unmanaged structs.
@@ -1689,17 +1703,17 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
// arg.Name is the inner type (e.g., "Agent" for Agent*)
var innerName = arg.Name;
// Check if the inner type is mapped (prefer struct mapping for GuildWars types)
var resolved = ResolveStructOrEnumPointerInner(innerName, typeMap);
if (resolved != innerName)
return resolved + "*";
// Check for primitives
var primitive = MapPrimitiveType(innerName);
if (primitive != innerName)
return primitive + "*";
// Special cases
return innerName switch
{
@@ -1707,7 +1721,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint", // unmapped pointer -> nint
};
}
/// <summary>
/// Maps C/C++ primitive type names to C# primitive types.
/// </summary>
@@ -1890,6 +1904,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
child = new ConstantsNode(childName);
this.Children[childName] = child;
}
return child;
}
}
@@ -1943,4 +1958,4 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
public string SourceType { get; set; } = ""; // e.g., "Array"
public string? TemplateArg { get; set; } // e.g., "Buff"
}
}
}
+1
View File
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
{
pos++; // skip Z - this terminates the function pointer, not the outer param list
}
break;
}
@@ -348,6 +348,7 @@ public sealed class KeyboardHookService : IHostedService, IKeyboardHookService,
result = window;
break;
}
current = NativeMethods.G_list_next(current);
}
@@ -221,7 +221,7 @@ internal sealed class ScreenManager(
/// global value, and under the forced X11 backend GDK cannot report reliable
/// per-monitor scales anyway.
/// </summary>
private int GetDpiForPosition(int x, int y)
private int GetDpiForPosition(int _, int __)
{
return (int)Math.Round(this.GetEffectiveScale() * DefaultDpi);
}
@@ -137,6 +137,7 @@ internal sealed class WindowManipulationService(ILogger<WindowManipulationServic
result = window;
break;
}
current = NativeMethods.G_list_next(current);
}
@@ -30,6 +30,13 @@ public interface IWinePrefixManager : IModService
/// </summary>
string GetWinePrefixPath();
/// <summary>
/// Configures the Windows computer name from the Linux host name.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successful, false otherwise.</returns>
Task<bool> ConfigureComputerName(CancellationToken cancellationToken);
/// <summary>
/// Launches a Windows executable through Wine with the managed prefix.
/// Uses event-based stdout/stderr reading to avoid pipe deadlocks.
@@ -0,0 +1,103 @@
using System.Diagnostics;
using Daybreak.Shared.Utils;
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Debug-build support for capturing Wine diagnostics, most importantly the
/// <c>winedbg --auto</c> backtrace produced when Guild Wars crashes.
///
/// Guild Wars is launched by Daybreak.Injector.exe, so it is a *grandchild* of the
/// Wine process Daybreak starts and inherits that process's stderr. Daybreak stops
/// reading that pipe as soon as the injector prints its result, which means a later
/// crash backtrace is written into a pipe nobody drains: the output is lost, and if
/// the pipe buffer fills, the game blocks inside a stderr write.
///
/// Redirecting stderr to a file inside the shell makes the descriptor outlive
/// Daybreak's interest in the process, so every descendant (injector, Gw.exe,
/// winedbg) appends to a durable log instead.
/// </summary>
internal static class WineDebugLog
{
private const string LogFileName = "wine-debug.log";
private const string EnableVariable = "DAYBREAK_WINE_DEBUG";
private const string ChannelsVariable = "DAYBREAK_WINE_DEBUG_CHANNELS";
/// <summary>
/// Enabled by default for Debug builds. Either build configuration can opt in or
/// out explicitly with <c>DAYBREAK_WINE_DEBUG=1</c> / <c>=0</c>.
/// </summary>
public static bool IsEnabled =>
Environment.GetEnvironmentVariable(EnableVariable) switch
{
"1" or "true" or "TRUE" => true,
"0" or "false" or "FALSE" => false,
#if DEBUG
_ => true,
#else
_ => false,
#endif
};
public static string LogPath => PathUtils.GetAbsolutePathFromRoot(LogFileName);
/// <summary>
/// Extra WINEDEBUG channels. Left unset by default: Wine's default <c>err</c> class
/// already reports unhandled exceptions and drives winedbg, while trace channels such
/// as <c>+seh</c> or <c>+relay</c> slow the game to a crawl. Set
/// <c>DAYBREAK_WINE_DEBUG_CHANNELS=+seh</c> when a specific investigation needs them.
/// </summary>
public static string? Channels =>
Environment.GetEnvironmentVariable(ChannelsVariable) is { Length: > 0 } channels
? channels
: null;
/// <summary>
/// Rewrites <paramref name="startInfo"/> to run the original command under
/// <c>/bin/sh</c> with stderr appended to <see cref="LogPath"/>.
/// </summary>
/// <remarks>
/// The command is split here and handed to the shell as separate positional parameters,
/// then executed with <c>exec "$@"</c>. The shell must never re-parse the command as source:
/// doing so applies a second round of expansion to values Daybreak has already quoted, so a
/// launch argument containing <c>$</c>, a backtick or a glob would be rewritten before the
/// game ever saw it. Splitting with <see cref="CommandLineUtils.SplitCommandLine"/> keeps the
/// argument vector identical to the one .NET builds when the log is disabled.
/// </remarks>
public static void Apply(ProcessStartInfo startInfo)
{
var logPath = LogPath;
var arguments = CommandLineUtils.SplitCommandLine(startInfo.Arguments);
var fileName = startInfo.FileName;
startInfo.FileName = "/bin/sh";
startInfo.Arguments = string.Empty;
startInfo.ArgumentList.Add("-c");
startInfo.ArgumentList.Add("exec \"$@\" 2>>\"$0\"");
startInfo.ArgumentList.Add(logPath);
startInfo.ArgumentList.Add(fileName);
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
if (Channels is { } channels)
{
startInfo.Environment["WINEDEBUG"] = channels;
}
}
public static void WriteSessionHeader(string description)
{
try
{
File.AppendAllText(
LogPath,
$"{Environment.NewLine}===== {DateTime.Now:yyyy-MM-dd HH:mm:ss} {description} ====={Environment.NewLine}");
}
catch
{
// Diagnostics only - never fail a launch because the log is unwritable.
}
}
}
@@ -19,6 +19,11 @@ public sealed class WinePrefixManager(
{
private const string WinePrefixFolder = "WinePrefix";
private const string WineExecutable = "wine";
private const string WineNetworkRegistryKey = @"HKCU\Software\Wine\Network";
private const string ComputerNameRegistryKey =
@"HKLM\System\CurrentControlSet\Control\ComputerName\ComputerName";
private const string ActiveComputerNameRegistryKey =
@"HKLM\System\CurrentControlSet\Control\ComputerName\ActiveComputerName";
private static readonly ProgressUpdate ProgressStarting = new(0, "Starting Wine prefix setup");
private static readonly ProgressUpdate ProgressCheckingWine = new(
@@ -117,7 +122,10 @@ public sealed class WinePrefixManager(
public IEnumerable<string> GetCustomArguments() => [];
/// <inheritdoc />
public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken)
public async Task OnGuildWarsStarting(
GuildWarsStartingContext guildWarsStartingContext,
CancellationToken cancellationToken
)
{
if (!this.IsAvailable())
{
@@ -127,7 +135,7 @@ public sealed class WinePrefixManager(
title: "Wine not installed",
description: "Wine is required to launch Guild Wars on Linux. Please install Wine and restart Daybreak.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
return;
}
if (!this.IsInitialized())
@@ -138,10 +146,23 @@ public sealed class WinePrefixManager(
title: "Wine prefix not initialized",
description: "The Wine prefix needs to be set up before launching Guild Wars. Click here to initialize it.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
return;
}
return Task.CompletedTask;
if (await this.ConfigureComputerName(cancellationToken))
{
return;
}
this.logger.LogError(
"Failed to configure the Linux host name as the Wine computer name. Blocking Guild Wars startup"
);
guildWarsStartingContext.CancelStartup = true;
this.notificationService.NotifyError(
title: "Wine prefix configuration failed",
description: "Daybreak could not configure the Linux host name as the Wine computer name. Check the logs for details.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15)
);
}
/// <inheritdoc />
@@ -243,6 +264,59 @@ public sealed class WinePrefixManager(
return this.winePrefixPath;
}
public async Task<bool> ConfigureComputerName(CancellationToken cancellationToken)
{
if (!this.IsInitialized())
{
this.logger.LogWarning(
"Cannot configure computer name before the Wine prefix is initialized"
);
return false;
}
var computerName = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
var useDnsComputerNameConfigured = await this.AddRegistryValue(
WineNetworkRegistryKey,
"UseDnsComputerName",
"N",
"REG_SZ",
cancellationToken
);
var computerNameConfigured = await this.AddRegistryValue(
ComputerNameRegistryKey,
"ComputerName",
computerName,
"REG_SZ",
cancellationToken
);
var activeComputerNameConfigured = await this.AddRegistryValue(
ActiveComputerNameRegistryKey,
"ComputerName",
computerName,
"REG_SZ",
cancellationToken
);
if (
!useDnsComputerNameConfigured
|| !computerNameConfigured
|| !activeComputerNameConfigured
)
{
this.logger.LogError(
"Failed to configure Wine computer name {ComputerName} from the Linux host name",
computerName
);
return false;
}
this.logger.LogInformation(
"Configured Wine computer name {ComputerName} from the Linux host name",
computerName
);
return true;
}
public IProgressAsyncOperation<bool> Install(CancellationToken cancellationToken)
{
return ProgressAsyncOperation.Create(
@@ -280,6 +354,13 @@ public sealed class WinePrefixManager(
"Wine prefix already initialized at {PrefixPath}",
this.winePrefixPath
);
if (!await this.ConfigureComputerName(cancellationToken))
{
progress.Report(ProgressFailed);
return false;
}
progress.Report(ProgressAlreadyInitialized);
return true;
}
@@ -333,6 +414,13 @@ public sealed class WinePrefixManager(
this.logger.LogWarning("Failed to set d3d9 DLL override, but continuing...");
}
progress.Report(new ProgressUpdate(0.9, "Configuring computer name"));
if (!await this.ConfigureComputerName(cancellationToken))
{
progress.Report(ProgressFailed);
return false;
}
this.logger.LogInformation("Wine prefix initialized successfully");
progress.Report(ProgressFinished);
return true;
@@ -390,6 +478,15 @@ public sealed class WinePrefixManager(
startInfo.Environment["WINEPREFIX"] = this.winePrefixPath;
// Guild Wars outlives this call, so its crash backtrace must go somewhere
// durable rather than into the stderr pipe we stop reading below.
if (WineDebugLog.IsEnabled)
{
WineDebugLog.WriteSessionHeader($"{wineExePath} {wineArgs}");
WineDebugLog.Apply(startInfo);
this.logger.LogDebug("Wine diagnostics are being appended to {WineDebugLog}", WineDebugLog.LogPath);
}
try
{
using var process = new Process { StartInfo = startInfo };
+2 -2
View File
@@ -7,8 +7,8 @@ namespace Daybreak.Shared.Models.Guildwars;
/// Definition of a Guild Wars skill. The static skill collections (per-campaign
/// lists, <see cref="AllSkills"/>, individual <c>public static readonly Skill X</c>
/// fields) are generated by <c>Tools/SkillUpdater</c> into <c>Skill.g.cs</c> from
/// the official wiki. Re-run the tool with
/// <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
/// the GWToolbox API, with names and icons from the official wiki. Re-run the
/// tool with <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
/// </summary>
public sealed partial class Skill : IIconUrlEntity
{
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
namespace Daybreak.Shared.Utils;
/// <summary>
/// Helpers for working with Windows style command line strings.
/// </summary>
public static class CommandLineUtils
{
/// <summary>
/// Splits a Windows style command line into the argument vector it represents.
/// </summary>
/// <remarks>
/// This mirrors the parser .NET applies to <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>
/// when starting a process on Unix. Anything that needs to hand those arguments to another launcher
/// must produce the same vector, otherwise the process observes a different command line depending on
/// how it was started.
/// </remarks>
/// <param name="commandLine">The command line to split. May be null or empty.</param>
/// <returns>The parsed arguments, with quoting and backslash escapes resolved.</returns>
public static List<string> SplitCommandLine(string? commandLine)
{
var results = new List<string>();
if (string.IsNullOrEmpty(commandLine))
{
return results;
}
var i = 0;
while (i < commandLine.Length)
{
while (i < commandLine.Length && (commandLine[i] is ' ' or '\t'))
{
i++;
}
if (i == commandLine.Length)
{
break;
}
results.Add(ReadArgument(commandLine, ref i));
}
return results;
}
private static string ReadArgument(string commandLine, ref int i)
{
var argument = new System.Text.StringBuilder();
var inQuotes = false;
while (i < commandLine.Length)
{
var backslashCount = 0;
while (i < commandLine.Length && commandLine[i] is '\\')
{
i++;
backslashCount++;
}
if (backslashCount > 0)
{
if (i >= commandLine.Length || commandLine[i] is not '"')
{
argument.Append('\\', backslashCount);
}
else
{
// Every pair of backslashes produces one literal backslash. A remaining
// backslash escapes the quote that follows it.
argument.Append('\\', backslashCount / 2);
if (backslashCount % 2 != 0)
{
argument.Append('"');
i++;
}
}
continue;
}
var c = commandLine[i];
if (c is '"')
{
if (inQuotes && i + 1 < commandLine.Length && commandLine[i + 1] is '"')
{
argument.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
i++;
continue;
}
if (!inQuotes && c is ' ' or '\t')
{
break;
}
argument.Append(c);
i++;
}
return argument.ToString();
}
}
@@ -0,0 +1,66 @@
using System.Text;
namespace Daybreak.Shared.Utils;
/// <summary>
/// Helpers for producing Windows compatible computer names.
/// </summary>
public static class ComputerNameUtils
{
/// <summary>
/// Maximum length of a Windows NetBIOS computer name, matching the Win32 MAX_COMPUTERNAME_LENGTH constant.
/// </summary>
public const int MaxComputerNameLength = 15;
/// <summary>
/// Name used when no valid computer name can be derived from the host.
/// </summary>
public const string FallbackComputerName = "DAYBREAK";
/// <summary>
/// Converts an arbitrary host name into a name that Win32 GetComputerName can return.
/// </summary>
/// <remarks>
/// GetComputerNameW is documented to fill a buffer of MAX_COMPUTERNAME_LENGTH + 1 characters, so callers
/// size their buffers accordingly. When the configured name is longer, the call fails with
/// ERROR_BUFFER_OVERFLOW and leaves the caller's buffer untouched, which applications such as GWToolbox
/// observe as an empty computer name. Wine's own wineboot upper-cases and truncates the Linux host name
/// for the same reason, so this method reproduces that behaviour.
/// </remarks>
/// <param name="name">The host name to sanitize.</param>
/// <returns>An upper-case, at most <see cref="MaxComputerNameLength"/> characters long, non-empty computer name.</returns>
public static string SanitizeComputerName(string? name)
{
if (name is null)
{
return FallbackComputerName;
}
var builder = new StringBuilder(MaxComputerNameLength);
foreach (var c in name)
{
if (builder.Length >= MaxComputerNameLength)
{
break;
}
if (char.IsAsciiLetterOrDigit(c))
{
builder.Append(char.ToUpperInvariant(c));
}
else if (c is '-' or '_')
{
builder.Append(c);
}
}
// A computer name may not start or end with a separator.
while (builder.Length > 0 && builder[^1] is '-' or '_')
{
builder.Length--;
}
var sanitized = builder.ToString().TrimStart('-', '_');
return sanitized.Length is 0 ? FallbackComputerName : sanitized;
}
}
@@ -0,0 +1,81 @@
using Daybreak.Shared.Utils;
using FluentAssertions;
namespace Daybreak.Tests.Utils;
[TestClass]
public sealed class CommandLineUtilsTests
{
[TestMethod]
public void SplitCommandLine_LaunchArguments_PreservesShellMetacharacters()
{
var commandLine =
"\"Z:\\Injector.exe\" launch True \"Z:\\Games\\Guild Wars\\Gw.exe\" " +
"-email \"user@example.com\" -password \"-MyP$SS\" -character \"Daybreak\"";
CommandLineUtils.SplitCommandLine(commandLine).Should().Equal(
"Z:\\Injector.exe",
"launch",
"True",
"Z:\\Games\\Guild Wars\\Gw.exe",
"-email",
"user@example.com",
"-password",
"-MyP$SS",
"-character",
"Daybreak");
}
[TestMethod]
[DataRow("-MyP$SS")]
[DataRow("pass`id`word")]
[DataRow("pass$(id -u)word")]
[DataRow("pass*word")]
[DataRow("pass word")]
[DataRow("~pass;word&more|x")]
[DataRow("$HOME")]
public void SplitCommandLine_QuotedValue_IsReturnedVerbatim(string value)
{
CommandLineUtils.SplitCommandLine($"-password \"{value}\"").Should().Equal("-password", value);
}
[TestMethod]
public void SplitCommandLine_QuotedPathWithSpaces_StaysOneArgument()
{
CommandLineUtils.SplitCommandLine("\"Z:\\Games\\Guild Wars\\Gw.exe\"")
.Should().Equal("Z:\\Games\\Guild Wars\\Gw.exe");
}
[TestMethod]
public void SplitCommandLine_WindowsPathTrailingBackslashes_AreLiteral()
{
CommandLineUtils.SplitCommandLine(@"C:\dir\ next").Should().Equal(@"C:\dir\", "next");
}
[TestMethod]
public void SplitCommandLine_EscapedQuote_IsLiteral()
{
CommandLineUtils.SplitCommandLine(@"a\""b").Should().Equal("a\"b");
}
[TestMethod]
public void SplitCommandLine_DoubledQuoteInsideQuotes_IsLiteral()
{
CommandLineUtils.SplitCommandLine("\"a\"\"b\"").Should().Equal("a\"b");
}
[TestMethod]
public void SplitCommandLine_RepeatedAndTabWhitespace_IsCollapsed()
{
CommandLineUtils.SplitCommandLine("a \t b").Should().Equal("a", "b");
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void SplitCommandLine_NoArguments_ReturnsEmpty(string? commandLine)
{
CommandLineUtils.SplitCommandLine(commandLine).Should().BeEmpty();
}
}
@@ -0,0 +1,82 @@
using Daybreak.Shared.Utils;
using FluentAssertions;
namespace Daybreak.Tests.Utils;
[TestClass]
public sealed class ComputerNameUtilsTests
{
[TestMethod]
[DataRow("zephyrs-cachyos-x8664", "ZEPHYRS-CACHYOS")]
[DataRow("bazzite-gaming-rig-01", "BAZZITE-GAMING")]
[DataRow("alex-desktop-machine", "ALEX-DESKTOP-MA")]
public void SanitizeComputerName_TooLong_TruncatesToMaxLength(string input, string expected)
{
var result = ComputerNameUtils.SanitizeComputerName(input);
result.Should().Be(expected);
result.Length.Should().BeLessThanOrEqualTo(ComputerNameUtils.MaxComputerNameLength);
}
[TestMethod]
[DataRow("cachyos-x8664", "CACHYOS-X8664")]
[DataRow("steamdeck", "STEAMDECK")]
[DataRow("ZEPHYRS-CACHYOS", "ZEPHYRS-CACHYOS")]
public void SanitizeComputerName_ValidName_UpperCasesAndPreserves(string input, string expected)
{
ComputerNameUtils.SanitizeComputerName(input).Should().Be(expected);
}
[TestMethod]
[DataRow("my.host.local", "MYHOSTLOCAL")]
[DataRow("hôtel de ville", "HTELDEVILLE")]
[DataRow("machine!@#$name", "MACHINENAME")]
public void SanitizeComputerName_InvalidCharacters_AreRemoved(string input, string expected)
{
ComputerNameUtils.SanitizeComputerName(input).Should().Be(expected);
}
[TestMethod]
[DataRow("--host--", "HOST")]
[DataRow("_host_", "HOST")]
public void SanitizeComputerName_LeadingOrTrailingSeparators_AreTrimmed(string input, string expected)
{
ComputerNameUtils.SanitizeComputerName(input).Should().Be(expected);
}
[TestMethod]
public void SanitizeComputerName_TruncationEndingOnSeparator_TrimsSeparator()
{
// 'THIS-IS-A-LONG' would be followed by '-' at index 15.
ComputerNameUtils.SanitizeComputerName("this-is-a-long--name").Should().Be("THIS-IS-A-LONG");
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("...")]
[DataRow("---")]
public void SanitizeComputerName_NoUsableCharacters_ReturnsFallback(string? input)
{
ComputerNameUtils.SanitizeComputerName(input).Should().Be(ComputerNameUtils.FallbackComputerName);
}
[TestMethod]
public void SanitizeComputerName_FallbackIsItselfValid()
{
ComputerNameUtils.FallbackComputerName.Length
.Should().BeLessThanOrEqualTo(ComputerNameUtils.MaxComputerNameLength);
ComputerNameUtils.SanitizeComputerName(ComputerNameUtils.FallbackComputerName)
.Should().Be(ComputerNameUtils.FallbackComputerName);
}
[TestMethod]
public void SanitizeComputerName_CurrentMachineName_IsAlwaysValid()
{
var result = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
result.Should().NotBeNullOrEmpty();
result.Length.Should().BeLessThanOrEqualTo(ComputerNameUtils.MaxComputerNameLength);
}
}
+20 -20
View File
@@ -470,26 +470,26 @@ namespace GW {
} // namespace Minipet
namespace SummoningStone {
constexpr int ImperialCripplingSlash = 9043;
constexpr int ImperialTripleChop = 9044;
constexpr int ImperialBarrage = 9045;
constexpr int ImperialQuiveringBlade = 9046;
constexpr int TenguHundredBlades = 9047;
constexpr int TenguBroadHeadArrow = 9048;
constexpr int TenguPalmStrike = 9049;
constexpr int TenguLifeSheath = 9050;
constexpr int TenguAngchuElementalist = 9051;
constexpr int TenguFeveredDreams = 9052;
constexpr int TenguSpitefulSpirit = 9053;
constexpr int TenguPreservation = 9054;
constexpr int TenguPrimalRage = 9055;
constexpr int TenguGlassArrows = 9056;
constexpr int TenguWayOftheAssassin = 9057;
constexpr int TenguPeaceandHarmony = 9058;
constexpr int TenguSandstorm = 9059;
constexpr int TenguPanic = 9060;
constexpr int TenguAuraOftheLich = 9061;
constexpr int TenguDefiantWasXinrae = 9062;
constexpr int ImperialCripplingSlash = 9268;
constexpr int ImperialTripleChop = 9269;
constexpr int ImperialBarrage = 9270;
constexpr int ImperialQuiveringBlade = 9271;
constexpr int TenguHundredBlades = 9272;
constexpr int TenguBroadHeadArrow = 9273;
constexpr int TenguPalmStrike = 9274;
constexpr int TenguLifeSheath = 9275;
constexpr int TenguAngchuElementalist = 9276;
constexpr int TenguFeveredDreams = 9277;
constexpr int TenguSpitefulSpirit = 9278;
constexpr int TenguPreservation = 9279;
constexpr int TenguPrimalRage = 9280;
constexpr int TenguGlassArrows = 9281;
constexpr int TenguWayOftheAssassin = 9282;
constexpr int TenguPeaceandHarmony = 9283;
constexpr int TenguSandstorm = 9284;
constexpr int TenguPanic = 9285;
constexpr int TenguAuraOftheLich = 9286;
constexpr int TenguDefiantWasXinrae = 9287;
} // namespace SummoningStone
} // namespace ModelID
} // namespace Constants
+3 -3
View File
@@ -22,7 +22,7 @@ namespace GW {
None, Warrior, Ranger, Monk, Necromancer, Mesmer,
Elementalist, Assassin, Ritualist, Paragon, Dervish
};
static const char* GetProfessionAcronym(Profession prof) {
inline const char* GetProfessionAcronym(Profession prof) {
switch (prof) {
case GW::Constants::Profession::None: return "X";
case GW::Constants::Profession::Warrior: return "W";
@@ -38,7 +38,7 @@ namespace GW {
default: return "";
}
}
static const wchar_t* GetWProfessionAcronym(Profession prof) {
inline const wchar_t* GetWProfessionAcronym(Profession prof) {
switch (prof) {
case GW::Constants::Profession::None: return L"X";
case GW::Constants::Profession::Warrior: return L"W";
@@ -234,7 +234,7 @@ namespace GW {
constexpr float Earshot = 1012.0f;
constexpr float Spellcast = 1248.0f;
constexpr float Spirit = 2512.0f;
constexpr float SpiritExtended = 3500.0f;
constexpr float SpiritExtended = 3000.0f;
constexpr float Compass = 5000.0f;
};
+2 -3
View File
@@ -3033,9 +3033,8 @@ namespace GW {
Vow_of_Revolution,
Heroic_Refrain,
Reforged_Mode=0xD6A,
Dhuums_Covenant_Broken,
Count = 0xD6c
};
Dhuums_Covenant_Broken
};
enum class SkillType {
Bounty = 1,
+72 -60
View File
@@ -166,11 +166,11 @@ namespace GW {
kChangeTarget, // 0x10000020, wparam = UIPacket::kChangeTarget*
kMessage_0x10000021, // 0x10000021
kMessage_0x10000022, // 0x10000022
kMessage_0x10000023, // 0x10000023
kAgentSkillActivated, // 0x10000024, kAgentSkillPacket
kAgentSkillActivatedInstantly, // 0x10000025, kAgentSkillPacket
kAgentSkillCancelled, // 0x10000026, kAgentSkillPacket
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentStartCasting*
kAgentSkillCancelled, // 0x10000023, wparam = kAgentSkillPacket; "<agent> canceled <skill>"
kAgentSkillActivated, // 0x10000024, wparam = kAgentSkillPacket; "<agent> executed <skill>"
kAgentSkillActivatedInstantly, // 0x10000025, wparam = kAgentSkillPacket; "<agent> used <skill>"
kAgentSkillInterrupted, // 0x10000026, wparam = kAgentSkillPacket; "<agent> was interrupted while using <skill>"
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentSkillStartedCast*; "<agent> is warming up <skill>"
kMessage_0x10000028, // 0x10000028
kShowMapEntryMessage, // 0x10000029, wparam = { wchar_t* title, wchar_t* subtitle }
kSetCurrentPlayerData, // 0x1000002a, fired after setting the worldcontext player name
@@ -560,41 +560,42 @@ namespace GW {
kInventoryRelated2, // 0x100001aa, added to GW 2026-02-26
kInventoryRelated3, // 0x100001ab, added to GW 2026-02-26
kInventoryRelated4, // 0x100001ac, added to GW 2026-04-28
kEquipItem, // 0x100001ad, wparam = { item_id, agent_id }
kMoveItem, // 0x100001ae, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemRelated_1, // 0x100001af
kItemTooltip, // 0x100001b0
kItemRelated_3, // 0x100001b1, added to GW 2026-02-26
kItemRelated_4, // 0x100001b2, added to GW 2026-02-26
kItemRelated_5, // 0x100001b3, added to GW 2026-04-28
kInitiateTrade, // 0x100001b4
kMessage_0x100001a7, // 0x100001b5
kMessage_0x100001a8, // 0x100001b6
kMessage_0x100001a9, // 0x100001b7
kMessage_0x100001aa, // 0x100001b8
kPartySearchWindowDestroyed, // 0x100001b9
kMessage_0x100001ac, // 0x100001ba
kPartySearchWindowCreated, // 0x100001bb
kMessage_0x100001ae, // 0x100001bc
kMessage_0x100001af, // 0x100001bd
kMessage_0x100001b0, // 0x100001be
kMessage_0x100001b1, // 0x100001bf
kMessage_0x100001b2, // 0x100001c0
kMessage_0x100001b3, // 0x100001c1
kMessage_0x100001b4, // 0x100001c2
kMessage_0x100001b5, // 0x100001c3
kInventoryAgentChanged, // 0x100001c4, Triggered when inventory needs updating due to agent change; no args
kInventoryRelated_1, // 0x100001c5
kInventoryRelated_2, // 0x100001c6
kMissionStatusRelated, // 0x100001c7
kUnused_1c2, // 0x100001c8
kCollapseExpandSkillListSection, // 0x100001c9
kPromptLoadTemplate, // 0x100001ca
kOpenTemplateManager, // 0x100001cb
kPromptSaveTemplate, // 0x100001cc
kOpenTemplate, // 0x100001cd, wparam = GW::UI::ChatTemplate*
kTemplateRelated_3, // 0x100001ce
kTemplateRelated_4, // 0x100001cf
kInventoryRelated4_1, // 0x100001ad, added to GW 2026-08-07
kEquipItem, // 0x100001ae, wparam = { item_id, agent_id }
kMoveItem, // 0x100001af, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemRelated_1, // 0x100001b0
kItemTooltip, // 0x100001b1
kItemRelated_3, // 0x100001b2, added to GW 2026-02-26
kItemRelated_4, // 0x100001b3, added to GW 2026-02-26
kItemRelated_5, // 0x100001b4, added to GW 2026-04-28
kInitiateTrade, // 0x100001b5
kMessage_0x100001a7, // 0x100001b6
kMessage_0x100001a8, // 0x100001b7
kMessage_0x100001a9, // 0x100001b8
kMessage_0x100001aa, // 0x100001b9
kPartySearchWindowDestroyed, // 0x100001ba
kMessage_0x100001ac, // 0x100001bb
kPartySearchWindowCreated, // 0x100001bc
kMessage_0x100001ae, // 0x100001bd
kMessage_0x100001af, // 0x100001be
kMessage_0x100001b0, // 0x100001bf
kMessage_0x100001b1, // 0x100001c0
kMessage_0x100001b2, // 0x100001c1
kMessage_0x100001b3, // 0x100001c2
kMessage_0x100001b4, // 0x100001c3
kMessage_0x100001b5, // 0x100001c4
kInventoryAgentChanged, // 0x100001c5, Triggered when inventory needs updating due to agent change; no args
kInventoryRelated_1, // 0x100001c6
kInventoryRelated_2, // 0x100001c7
kMissionStatusRelated, // 0x100001c8
kUnused_1c2, // 0x100001c9
kCollapseExpandSkillListSection, // 0x100001ca
kPromptLoadTemplate, // 0x100001cb
kOpenTemplateManager, // 0x100001cc
kPromptSaveTemplate, // 0x100001cd
kOpenTemplate, // 0x100001ce, wparam = GW::UI::ChatTemplate*
kTemplateRelated_3, // 0x100001cf
kTemplateRelated_4, // 0x100001d0
// GWCA Client to Server commands. Only added the ones that are used for hooks, everything else goes straight into GW
@@ -603,7 +604,7 @@ namespace GW {
kSendMoveItem = 0x30000000 | 0x5, // 0x30000005, wparam = UIPacket::kSendMoveItem*
kSendMerchantRequestQuote = 0x30000000 | 0x6, // 0x30000006, wparam = UIPacket::kSendMerchantRequestQuote*
kSendMerchantTransactItem = 0x30000000 | 0x7, // 0x30000007, wparam = UIPacket::kSendMerchantTransactItem*
kSendUseItem = 0x30000000 | 0x8, // 0x30000008, wparam = UIPacket::kSendUseItem*
kSendUseItem = 0x30000000 | 0x8, // 0x30000008, wparam = uint32_t item_id
kSendSetActiveQuest = 0x30000000 | 0x9, // 0x30000009, wparam = uint32_t quest_id
kSendAbandonQuest = 0x30000000 | 0xA, // 0x3000000a, wparam = uint32_t quest_id
kSendChangeTarget = 0x30000000 | 0xB, // 0x3000000b, wparam = UIPacket::kSendChangeTarget* // e.g. tell the gw client to focus on a different target
@@ -626,7 +627,7 @@ namespace GW {
kChatLinkClicked = 0x30000000 | 0x25 // 0x30000025, wparam = UIPacket::kChatLinkClicked. Triggered when the player clicks an <a> link in chat, e.g. build code
};
//static_assert(GW::UI::UIMessage::kOpenTemplate == (GW::UI::UIMessage)0x100001c4);
static_assert(GW::UI::UIMessage::kOpenTemplate == (GW::UI::UIMessage)0x100001ce);
namespace UIPacket {
struct kUIFeatureChanged {
@@ -673,6 +674,12 @@ namespace GW {
uint32_t agent_id;
GW::Constants::SkillID skill_id;
};
struct kAgentSkillStartedCast {
uint32_t agent_id;
GW::Constants::SkillID skill_id;
float duration;
float h000c;
};
struct kLoadMapContext {
const wchar_t* file_name;
Constants::MapID map_id;
@@ -723,19 +730,34 @@ namespace GW {
uint32_t h0004;
uint32_t h0008;
};
// A handler that leaves both entries at 0 falls through to the engine's
// own default sizing (GetMinSize()-based) rather than being treated as "wants zero size".
//
// flags (offset 0xc) is passed through uninitialized in that call site and never visibly
// set before the dispatch - not confirmed as meaningful; treat as reserved/unused for now.
struct kMeasureContent {
float max_width; // Maximum width constraint
float max_height; // Maximum height constraint
float* size_output; // Pointer to output buffer for calculated size
uint32_t flags; // Layout flags (similar to the 0x100 flag we saw)
float max_width; // Available width, after the frame's own margin/padding is already subtracted
float max_height; // Available height, after the frame's own margin/padding is already subtracted
float* size_output; // Points at a 2-float [width, height] buffer the handler must fill
uint32_t flags; // Not confirmed meaningful - see comment above
};
// Every field here is relative to the frame's own content-origin rect (its content_left/
// content_bottom/content_right/content_top) - not absolute screen coordinates:
// available_width = content_right - content_left
// available_height = content_top - content_bottom
// local_left = resolved_left - content_left
// local_bottom = resolved_bottom - content_bottom
// local_right = resolved_right - content_left
// local_top = resolved_top - content_bottom
// i.e. "here is how much room you have, and here is where your own just-resolved rect sits
// within it"
struct kSetLayout {
float field_0x0;
float field_0x4;
float field_0x8;
float field_0xc;
float available_width;
float available_height;
float local_left;
float local_bottom;
float local_right;
float local_top;
};
struct kSetAgentProfession {
AgentID agent_id;
@@ -795,12 +817,6 @@ namespace GW {
uint32_t h0008;
uint32_t h000c;
};
struct kAgentSkillStartedCast {
uint32_t agent_id;
Constants::SkillID skill_id;
float duration;
uint32_t h000c;
};
struct kPreStartSalvage {
uint32_t item_id;
uint32_t kit_id;
@@ -939,10 +955,6 @@ namespace GW {
uint32_t gold_recv;
Merchant::QuoteInfo recv;
};
struct kSendUseItem {
uint32_t item_id;
uint16_t quantity; // Unused, but would be cool
};
struct kSendChatMessage {
wchar_t* message;
uint32_t agent_id;
File diff suppressed because it is too large Load Diff
+109 -21
View File
@@ -1,5 +1,6 @@
#pragma once
#include <GWCA/GameContainers/GamePos.h>
#include <GWCA/GameContainers/List.h>
#include <GWCA/GameContainers/Array.h>
#include <GWCA/GameContainers/ObjectPool.h>
@@ -68,8 +69,7 @@ namespace GW {
};
static_assert(sizeof(MapStaticData) == 0xA0, "struct MapStaticData has incorrect size");
// Those are planes that are blocked and can be unblocked at runtime. e.g., the gates in foundry
// Those aren't in the dat file, but sent from the server
// Planes blocked but unblockable at runtime (e.g. the Foundry gates); sent by the server, not in the dat file.
typedef BaseArray<uint32_t> BlockedPlaneArray;
static_assert(sizeof(BlockedPlaneArray) == 0xC, "struct BlockedPlaneArray has incorrect size");
@@ -123,9 +123,7 @@ namespace GW {
};
static_assert(sizeof(PathContext) == 0x94, "struct PathContext has incorrect size");
// The game can optionally load a DLL to do the path finding.
// The DLL is named "PathEngine.dll", but not clear if it's a 3rd party or just their name
// for development.
// The game can optionally load "PathEngine.dll" for path finding; unclear if third party or their own dev name.
struct PathEngineContext {
/* +h0000 */ void **vtable;
/* +h0004 */ uint32_t h0004;
@@ -136,6 +134,102 @@ namespace GW {
};
static_assert(sizeof(PathEngineContext) == 0x18, "struct PathEngineContext has incorrect size");
// Point lights baked into the map, parsed from its "LITE" chunk by Engine\Map\MapLight.cpp.
// Torches, braziers and campfires are these, not props with a glow texture.
struct MapLightDescriptor {
/* +h0000 */ Vec3f position;
/* +h000C */ uint8_t red;
/* +h000D */ uint8_t green;
/* +h000E */ uint8_t blue;
/* +h000F */ uint8_t h000F;
/* +h0010 */ float intensity; // colour bytes are premultiplied by intensity / 255 when the light is built
/* +h0014 */ float inner_range;
/* +h0018 */ float outer_range;
};
static_assert(sizeof(MapLightDescriptor) == 0x1C, "struct MapLightDescriptor has incorrect size");
// Field layout taken from MapLightApplyDescriptor, which hands each field straight to a GrLight setter:
// GrLightSetColour(light, &desc->red, desc->intensity), GrLightSetPosition(light, &desc->position, 0),
// GrLightSetRange(light, desc->inner_range, desc->outer_range)
struct MapLightContext {
/* +h0000 */ Array<MapLightDescriptor> descriptors;
/* +h0010 */ Array<uint32_t> lights; // HGrLight handles, parallel to descriptors (same count)
};
static_assert(sizeof(MapLightContext) == 0x20, "struct MapLightContext has incorrect size");
// Only the fields below are confirmed; the real object is at least 0x1A0 bytes, so no size assert.
// Only present when MapContext::flags bit 1 is set.
struct MapWaterContext {
/* +h0000 */ uint32_t h0000[0x2b];
/* +h00AC */ uint32_t scene_program; // the single handle water contributes to the scene program list
/* +h00B0 */ uint32_t h00B0[0xc];
/* +h00E0 */ uint32_t shader_programs[2]; // two of the four per-map shader programs, chosen by quality
/* +h00E8 */ uint32_t h00E8;
/* +h00EC */ float plane_z; // world height of the water plane
/* +h00F0 */ uint32_t h00F0[0x2a];
/* +h0198 */ uint32_t shader_programs2[2]; // the other two quality variants
};
// === terrain textures / baked terrain shadows ===
//
// GW bakes the map's static terrain shadows into the terrain textures, and streams them in
// per tile. The data lives in two layers, both reachable from the terrain texture context:
// 1. a per-texel bitmask, entropy coded per tile and decoded on demand into a scratch
// buffer (soft edges, detail) - TrnTexShadowDecompressTile;
// 2. a per-block "this whole block is in shadow" bitmask, held in the map's own data and
// tested first - a set bit fills the block flat with the shadow colour without ever
// consulting layer 1 - TrnTexComposeTileLighting.
// Clearing only layer 1 leaves the solid interiors of large shadows behind, which is what
// makes layer 2 worth documenting.
// 32x32 blocks, one bit each, MSB first, 4 bytes per row; 0x80 bytes in total.
// Quality 0 walks it as two 16-row halves (the second at +0x40), every other quality walks
// all 32 rows in one pass - both reach exactly 0x80 bytes, so that is the whole mask.
typedef uint8_t TerrainBlockShadowMask[0x80];
// One per terrain tile, indexed by `shadow_grid_width * tile_y + tile_x`.
struct TerrainShadowRecord {
/* +h0000 */ void* compressed_stream; // layer 1, entropy coded, decoded per tile
/* +h0004 */ void* compressed_stream_end;
/* +h0008 */ TerrainBlockShadowMask* block_mask; // layer 2, the map's own data - not scratch
};
static_assert(sizeof(TerrainShadowRecord) == 0xC, "struct TerrainShadowRecord has incorrect size");
struct TerrainTexTile {
/* +h0000 */ uint32_t h0000[3];
/* +h000C */ uint32_t tile_x;
/* +h0010 */ uint32_t tile_y;
/* +h0014 */ uint32_t state; // set to 2 once the tile's shadow layer has been decoded
/* +h0018 */ uint32_t h0018; // zeroed alongside the state above
};
// A single streamed terrain texture. Only the two fields below are confirmed, so no size assert.
struct TerrainTexture {
/* +h0000 */ uint32_t h0000[0x27];
/* +h009C */ uint32_t stream_flags; // bit 0x800: decompress + upload still pending
/* +h00A0 */ uint32_t h00A0[0x37];
/* +h017C */ TerrainTexTile* tile; // tagged pointer: null, or bit 0 set, means no tile
};
// The map-wide terrain texture context (`TrnTex`). Partially mapped, so no size assert.
struct TerrainTexContext {
/* +h0000 */ uint32_t h0000[0x2a];
// The bound tested by the game itself before indexing, so a tile outside the grid is
// simply "no baked shadow" rather than a read off the end.
/* +h00A8 */ BaseArray<TerrainShadowRecord> shadow_records;
// Row stride for the index above. The three fields at +0xA8/+0xB0/+0xB4 read equally well
// as an Array<TerrainShadowRecord> whose m_param happens to be the width; naming it is
// the more useful of two identical layouts.
/* +h00B4 */ uint32_t shadow_grid_width;
/* +h00B8 */ uint32_t h00B8[0x10b];
// Scratch, shared by every tile this context streams: layer 1 is decoded here and
// uploaded, then the buffer is reused for the next tile. 0x110 rows of 0x22 bytes =
// 272x272 bits, one per terrain texel. A SET bit is shadow, a clear bit is lit.
/* +h04E4 */ uint8_t decoded_shadow_tile[0x110][0x22];
};
// Self-check on the padding above; the real object is at least this big, not exactly.
static_assert(sizeof(TerrainTexContext) == 0x2904, "struct TerrainTexContext has incorrect layout");
struct MapContext {
/* +h0000 */ uint32_t map_type; // less than 4
/* +h0004 */ Vec2f start_pos;
@@ -149,8 +243,8 @@ namespace GW {
/* +h0078 */ PathEngineContext* path_engine;
/* +h007C */ PropsContext* props;
/* +h0080 */ uint32_t h0080;
/* +h0084 */ void* terrain;
/* +h0088 */ uint32_t h0088;
/* +h0084 */ void* terrain; // relationship to TerrainTexContext is unconfirmed
/* +h0088 */ void* collision; // "Collision" map chunk (Engine\Map\Collision\CollApi.cpp)
/* +h008C */ GW::Constants::MapID map_id;
/* +h0090 */ uint32_t h0090;
/* +h0094 */ uint32_t h0094;
@@ -161,37 +255,31 @@ namespace GW {
/* +h00A8 */ uint32_t h00A8;
/* +h00AC */ uint32_t h00AC;
/* +h00B0 */ uint32_t h00B0;
/* +h00B4 */ uint32_t h00B4;
/* +h00B8 */ uint32_t h00B8;
/* +h00BC */ uint32_t h00BC;
/* +h00C0 */ uint32_t h00C0;
/* +h00C4 */ uint32_t h00C4;
/* +h00C8 */ uint32_t h00C8;
/* +h00CC */ uint32_t h00CC;
/* +h00D0 */ uint32_t h00D0;
/* +h00D4 */ uint32_t h00D4;
/* +h00B4 */ Vec3f view_eye; // cached by GmWorldUpdateView
/* +h00C0 */ Vec3f view_target;
/* +h00CC */ Vec3f view_up;
/* +h00D8 */ uint32_t h00D8;
/* +h00DC */ uint32_t h00DC;
/* +h00DC */ uint32_t view_flags; // bit 0: view has been updated this frame
/* +h00E0 */ uint32_t h00E0;
/* +h00E4 */ uint32_t h00E4;
/* +h00E8 */ uint32_t h00E8;
/* +h00EC */ uint32_t h00EC;
/* +h00EC */ MapLightContext* lights; // "Light" map chunk
/* +h00F0 */ uint32_t h00F0;
/* +h00F4 */ uint32_t h00F4;
/* +h00F8 */ uint32_t h00F8;
/* +h00FC */ uint32_t h00FC;
/* +h0100 */ uint32_t h0100;
/* +h0104 */ uint32_t h0104;
/* +h0108 */ uint32_t h0108;
/* +h0108 */ uint32_t flags; // bit 1: map has water
/* +h010C */ uint32_t h010C;
/* +h0110 */ uint32_t h0110;
/* +h0114 */ uint32_t h0114;
/* +h0118 */ uint32_t h0118;
/* +h011C */ uint32_t h011C;
/* +h0120 */ uint32_t h0120;
/* +h0120 */ void* shore; // "Shore" map chunk, only loaded when flags bit 1 is set
/* +h0124 */ uint32_t h0124;
/* +h0128 */ uint32_t h0128;
/* +h012C */ uint32_t h012C;
/* +h012C */ MapWaterContext* water;
/* +h0130 */ void* zones;
/* +h0134 */ uint32_t h0134;
};
@@ -38,9 +38,7 @@ namespace GW {
/* +h0148 */ GW::Array<LoginCharacter> chars;
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetAvailableChars();
}
+1 -2
View File
@@ -29,8 +29,7 @@ namespace GW {
/* +h0010 */ TradePlayer player;
/* +h0024 */ TradePlayer partner;
// bool GetPartnerAccepted();
// bool GetPartnerOfferSent();
// bool GetPartnerAccepted(); bool GetPartnerOfferSent();
bool GetIsTradeOffered() const { return (flags & TRADE_OFFER_SEND) != 0; }
bool GetIsTradeInitiated() const { return (flags & TRADE_INITIATED) != 0; }
+4 -4
View File
@@ -1,10 +1,10 @@
#pragma once
#define GWCA_VERSION_MAJOR 4
#define GWCA_VERSION_MINOR 7
#define GWCA_VERSION_PATCH 2
#define GWCA_VERSION_BUILD 3
#define GWCA_VERSION "4.7.2.3"
#define GWCA_VERSION_MINOR 8
#define GWCA_VERSION_PATCH 7
#define GWCA_VERSION_BUILD 0
#define GWCA_VERSION "4.8.7.0"
namespace GWCA {
constexpr int VersionMajor = GWCA_VERSION_MAJOR;
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#define GWCA_VERSION_MAJOR @GWCA_VERSION_MAJOR@
#define GWCA_VERSION_MINOR @GWCA_VERSION_MINOR@
#define GWCA_VERSION_PATCH @GWCA_VERSION_PATCH@
#define GWCA_VERSION_BUILD @GWCA_VERSION_BUILD@
#define GWCA_VERSION "@GWCA_VERSION@"
namespace GWCA {
constexpr int VersionMajor = GWCA_VERSION_MAJOR;
constexpr int VersionMinor = GWCA_VERSION_MINOR;
constexpr int VersionPatch = GWCA_VERSION_PATCH;
constexpr int VersionBuild = GWCA_VERSION_BUILD;
constexpr const char* Version = GWCA_VERSION;
}
+1 -5
View File
@@ -15,8 +15,4 @@ namespace GW {
static_assert(sizeof(ObjectPool) == 0xC, "struct ObjectPool has incorrect size");
}
// The functions used to allocate looks like:
// void *__thiscall ObjectPool::Alloc(ObjectPool *pool, int32_t typesize, const char *typename);
// So, the typename & typesize are passed at every allocs, similar to `MemAlloc`.
// It's worth nothing that the minimum typesize is 4, though Guild Wars doesn't assert it, it just
// ends up crashing. This is due to the freeList pointer lists that are 4 bytes each.
// ObjectPool::Alloc(pool, typesize, typename) passes type info per alloc; minimum typesize is 4 (freeList pointers) or it crashes.
+32 -14
View File
@@ -6,6 +6,7 @@
#include <GWCA/Constants/Constants.h>
#include <GWCA/GameEntities/Item.h>
#include <GWCA/Utilities/Export.h>
namespace GW {
typedef uint32_t AgentID;
@@ -90,19 +91,12 @@ namespace GW {
/* +h0109 */ uint8_t offhand_item_type; // Offhand item type for stance/animation
/* +h010A */ uint16_t offhand_item_id; // Offhand item id for stance/animation
inline uint32_t GetType() {
return vtable->GetType(this);
}
inline bool RedrawEquipmentSlot(uint32_t slot) {
if (!(slot < _countof(items) && items[slot].model_file_id))
return false;
return vtable->EquipItem(this, 0, slot), true;
}
inline bool UndrawEquipmentSlot(uint32_t slot) {
if (!(slot < _countof(items) && items[slot].model_file_id))
return false;
return vtable->RemoveItem(this, 0, slot), true;
}
// These three call through the game's vtable (see Source/Agent.cpp) -- kept
// out of line so the wasm table-adoption dance stays an implementation
// detail rather than something every caller's translation unit compiles.
GWCA_API uint32_t GetType();
GWCA_API bool RedrawEquipmentSlot(uint32_t slot);
GWCA_API bool UndrawEquipmentSlot(uint32_t slot);
};
static_assert(sizeof(NPCEquipment) == 0x10C);
@@ -145,6 +139,30 @@ namespace GW {
struct AgentGadget;
struct AgentLiving;
// Bits in GW::Agent::name_properties, which AvAgent.cpp recomputes name tag visibility from.
enum NameTagFlags : uint32_t {
// In the mouse pick list; cleared wholesale whenever that list is rebuilt.
NameTagFlags_Picked = 0x8,
// Moused-over agent: underlines the tag, glows the model, draws the selection decal.
NameTagFlags_Highlighted = 0x10,
// Within name tag draw distance (1500 gwinches from the camera).
NameTagFlags_InRange = 0x20,
// The evaluated target - manual target, else auto target.
NameTagFlags_EvaluatedTarget = 0x80,
// The manual target, while a different auto target exists.
NameTagFlags_ManualTarget = 0x100,
// Name tags globally suppressed (cutscenes, /hideui); refcounted by the client.
NameTagFlags_Suppressed = 0x200,
// Agent::type passes the persistent filter from the Guild Wars name tag options.
NameTagFlags_PassesFilter = 0x400,
// Dropped item reserved for another player, so it never gets a distance-based tag.
NameTagFlags_NotOwnedByPlayer = 0x800,
// Agent::type passes the transient filter, bound to the "show item names" key.
NameTagFlags_PassesTransientFilter = 0x1000,
// Name tag disabled for this agent regardless of any filter.
NameTagFlags_Disabled = 0x20000
};
struct Agent {
/* +h0000 */ uint32_t* vtable;
/* +h0004 */ uint32_t h0004;
@@ -165,7 +183,7 @@ namespace GW {
/* +h004C */ float rotation_angle; // Rotation in radians from East (-pi to pi)
/* +h0050 */ float rotation_cos; // cosine of rotation
/* +h0054 */ float rotation_sin; // sine of rotation
/* +h0058 */ uint32_t name_properties; // Bitmap basically telling what the agent is
/* +h0058 */ NameTagFlags name_properties; // Bitmap basically telling what the agent is
/* +h005C */ uint32_t ground;
/* +h0060 */ uint32_t h0060;
/* +h0064 */ Vec3f terrain_normal;
+1 -2
View File
@@ -65,8 +65,7 @@ namespace GW {
float GetYaw() const { return yaw; }
float GetPitch() const { return pitch; }
/// \brief This is not the FoV that GW uses to render
/// see GW::Render::GetFieldOfView()
// rief Not the FoV GW renders with -- see GW::Render::GetFieldOfView()
float GetFieldOfView() const { return field_of_view; }
bool IsCameraUnlocked() const { return camera_mode == 3; }
+3 -8
View File
@@ -65,7 +65,8 @@ namespace GW {
GWCA_API SortHandler_pt GetSortHandler();
GWCA_API bool ClearItems();
GWCA_API bool RemoveItem(uint32_t child_offset_id);
GWCA_API bool AddItem(uint32_t flags, uint32_t child_offset_id, GW::UI::UIInteractionCallback callback);
// Returns the frame_id of the added item's frame, or 0 on failure.
GWCA_API uint32_t AddItem(uint32_t flags, uint32_t child_offset_id, GW::UI::UIInteractionCallback callback);
GWCA_API uint32_t GetItemFrameId(uint32_t child_offset_id);
GWCA_API bool GetSelectedValue(uint32_t* selected_value);
@@ -89,13 +90,7 @@ namespace GW {
FrameWithValue& operator=(const FrameWithValue&) = default;
FrameWithValue& operator=(FrameWithValue&&) = default;
// Pure virtual: every concrete usage of this type is through one
// of its overriding subclasses below (ProgressBar, CheckboxFrame,
// DropdownFrame, SliderFrame all override both) — there was
// previously no definition anywhere in this codebase for a bare
// FrameWithValue's own GetValue/SetValue, which GCC's Itanium
// ABI (unlike MSVC's) requires in order to know where to emit a
// vtable for the class ("key function" rule).
// Pure virtual: GCC's Itanium ABI needs a key function to emit the vtable, and every concrete use is via an overriding subclass.
virtual uint32_t GetValue() = 0;
virtual bool SetValue(uint32_t value) = 0;
};
+1 -9
View File
@@ -50,15 +50,7 @@ namespace GW {
static_assert(sizeof(DyeInfo) == 3, "struct DyeInfo has incorrect size");
struct ItemData {
// No default member initializers: this type is used as a member
// of a nested anonymous struct/union in Agent.h
// (NPCEquipment) — MSVC permits default member initializers
// there (making the implicit default constructor non-trivial),
// but GCC rejects any member with a non-trivial constructor in
// an anonymous struct/union. ItemData is a raw overlay onto
// existing game memory throughout this codebase, never
// meaningfully default-constructed for its own sake, so this
// doesn't change any real behavior.
// No default member initializers: GCC rejects non-trivial members in the anonymous struct/union in Agent.h that uses this.
uint32_t model_file_id;
GW::Constants::ItemType type;
GW::DyeInfo dye;
+1 -2
View File
@@ -24,8 +24,7 @@ namespace GW {
/* +h0014 */ GW::Constants::Profession primary;
/* +h0018 */ GW::Constants::Profession secondary;
/* +h001C */ uint8_t default_level;
// +h001D uint8_t padding;
// +h001E uint16_t padding;
// +h001D uint8_t padding; +h001E uint16_t padding;
/* +h0020 */ wchar_t *name_enc;
/* +h0024 */ uint32_t *model_files;
/* +h0028 */ uint32_t files_count; // length of ModelFile
+125 -120
View File
@@ -3,136 +3,141 @@
#include <GWCA/GameContainers/Array.h>
namespace GW {
struct PathingTrapezoid { // total: 0x30/48
/* +h0000 */ uint32_t id;
union {
/* +h0004 */ PathingTrapezoid* adjacent[4];
struct {
/* +h0004 */ PathingTrapezoid* top_left;
/* +h0008 */ PathingTrapezoid* top_right;
/* +h000C */ PathingTrapezoid* bottom_left;
/* +h0010 */ PathingTrapezoid* bottom_right;
};
};
/* +h0014 */ uint16_t portal_left;
/* +h0016 */ uint16_t portal_right;
/* +h0018 */ float XTL;
/* +h001C */ float XTR;
/* +h0020 */ float YT;
/* +h0024 */ float XBL;
/* +h0028 */ float XBR;
/* +h002C */ float YB;
};
static_assert(sizeof(PathingTrapezoid) == 48, "struct PathingTrapezoid has incorrect size");
struct PathingTrapezoid { // total: 0x30/48
/* +h0000 */ uint32_t id;
union {
/* +h0004 */ PathingTrapezoid* adjacent[4];
struct {
/* +h0004 */ PathingTrapezoid* top_left;
/* +h0008 */ PathingTrapezoid* top_right;
/* +h000C */ PathingTrapezoid* bottom_left;
/* +h0010 */ PathingTrapezoid* bottom_right;
};
};
/* +h0014 */ uint16_t portal_left;
/* +h0016 */ uint16_t portal_right;
/* +h0018 */ float XTL;
/* +h001C */ float XTR;
/* +h0020 */ float YT;
/* +h0024 */ float XBL;
/* +h0028 */ float XBR;
/* +h002C */ float YB;
};
static_assert(sizeof(PathingTrapezoid) == 48, "struct PathingTrapezoid has incorrect size");
struct Node {
/* +h0000 */ uint32_t type; //XNode = 0, YNode = 1, SinkNode = 2
/* +h0004 */ uint32_t id;
};
struct Node {
/* +h0000 */ uint32_t type; //XNode = 0, YNode = 1, SinkNode = 2
/* +h0004 */ uint32_t id;
};
struct XNode : Node { // type = 0
/* +h0008 */ Vec2f pos;
/* +h0010 */ Vec2f dir;
/* +h0018 */ Node *left;
/* +h001C */ Node *right;
};
static_assert(sizeof(XNode) == 32, "struct XNode has incorrect size");
struct XNode : Node { // type = 0
/* +h0008 */ Vec2f pos;
/* +h0010 */ Vec2f dir;
/* +h0018 */ Node* left;
/* +h001C */ Node* right;
};
static_assert(sizeof(XNode) == 32, "struct XNode has incorrect size");
struct YNode : Node { // type = 1
/* +h0008 */ Vec2f pos;
/* +h0010 */ Node *above;
/* +h0014 */ Node *below;
};
static_assert(sizeof(YNode) == 24, "struct YNode has incorrect size");
struct YNode : Node { // type = 1
/* +h0008 */ Vec2f pos;
/* +h0010 */ Node* above;
/* +h0014 */ Node* below;
};
static_assert(sizeof(YNode) == 24, "struct YNode has incorrect size");
struct SinkNode : Node { // type = 2
/* +h0008 */ PathingTrapezoid *trapezoid;
};
static_assert(sizeof(SinkNode) == 12, "struct SinkNode has incorrect size");
struct SinkNode : Node { // type = 2
/* +h0008 */ PathingTrapezoid* trapezoid;
};
static_assert(sizeof(SinkNode) == 12, "struct SinkNode has incorrect size");
struct Portal { // total: 0x14/20
/* +h0000 */ uint16_t portal_plane;
/* +h0002 */ uint16_t neighbor_plane;
/* +h0004 */ uint32_t flags; // 0x4 => "Not used for path finding"
/* +h0008 */ Portal *pair;
/* +h000C */ uint32_t count;
/* +h0010 */ PathingTrapezoid **trapezoids;
};
static_assert(sizeof(Portal) == 20, "struct Portal has incorrect size");
struct Portal { // total: 0x14/20
/* +h0000 */ uint16_t portal_plane;
/* +h0002 */ uint16_t neighbor_plane;
/* +h0004 */ uint32_t flags; // 0x4 => "Not used for path finding"
/* +h0008 */ Portal* pair;
/* +h000C */ uint32_t count;
/* +h0010 */ PathingTrapezoid** trapezoids;
};
static_assert(sizeof(Portal) == 20, "struct Portal has incorrect size");
struct PathingMap { // total: 0x54/84
/* +h0000 */ uint32_t zplane; // ground plane = UINT_MAX, rest 0 based index
/* +h0004 */ uint32_t h0004;
/* +h0008 */ void *allocatedBuffer; // All following data are stored in a buffer allocated with a single MemAlloc. This is the pointer.
/* +h000C */ uint32_t h0010_count; // count of number h0010
/* +h0010 */ uint32_t *h0010; // elements of this array are 8 bytes
/* +h0014 */ uint32_t trapezoid_count;
/* +h0018 */ PathingTrapezoid* trapezoids;
/* +h001C */ uint32_t sink_node_count;
/* +h0020 */ SinkNode *sink_nodes;
/* +h0024 */ uint32_t x_node_count;
/* +h0028 */ XNode *x_nodes;
/* +h002C */ uint32_t y_node_count;
/* +h0030 */ YNode *y_nodes;
/* +h0034 */ uint32_t portal_trapezoids_count;
/* +h0038 */ PathingTrapezoid **portal_trapezoids;
/* +h003C */ uint32_t portal_count;
/* +h0040 */ Portal *portals;
/* +h0044 */ Node *root_node;
/* +h0048 */ BaseArray<Vec2f> dat_vectors; // this is an array of vectors read from the dat file. When reading the xnodes or ynodes from the dat file, an index will be used to get the pos.
};
static_assert(sizeof(PathingMap) == 84, "struct PathingMap has incorrect size");
struct PathingMap { // total: 0x54/84
/* +h0000 */ uint32_t zplane; // ground plane = UINT_MAX, rest 0 based index
/* +h0004 */ uint32_t h0004;
/* +h0008 */ void* allocatedBuffer; // All following data are stored in a buffer allocated with a single MemAlloc. This is the pointer.
/* +h000C */ uint32_t h0010_count; // count of number h0010
/* +h0010 */ uint32_t* h0010; // elements of this array are 8 bytes
/* +h0014 */ uint32_t trapezoid_count;
/* +h0018 */ PathingTrapezoid* trapezoids;
/* +h001C */ uint32_t sink_node_count;
/* +h0020 */ SinkNode* sink_nodes;
/* +h0024 */ uint32_t x_node_count;
/* +h0028 */ XNode* x_nodes;
/* +h002C */ uint32_t y_node_count;
/* +h0030 */ YNode* y_nodes;
/* +h0034 */ uint32_t portal_trapezoids_count;
/* +h0038 */ PathingTrapezoid** portal_trapezoids;
/* +h003C */ uint32_t portal_count;
/* +h0040 */ Portal* portals;
/* +h0044 */ Node* root_node;
/* +h0048 */ BaseArray<Vec2f> dat_vectors; // this is an array of vectors read from the dat file. When reading the xnodes or ynodes from the dat file, an index will be used to get the pos.
};
static_assert(sizeof(PathingMap) == 84, "struct PathingMap has incorrect size");
struct PropByType {
uint32_t object_id;
uint32_t prop_index;
};
struct PropByType {
uint32_t object_id;
uint32_t prop_index;
};
struct PropModelInfo {
/* +h0000 */ uint32_t h0000;
/* +h0004 */ uint32_t h0004;
/* +h0008 */ uint32_t h0008;
/* +h000C */ uint32_t h000C;
/* +h0010 */ uint32_t h0010;
/* +h0014 */ uint32_t h0014;
};
static_assert(sizeof(PropModelInfo) == 0x18, "struct PropModelInfo has incorrect size");
// Per-model collision bounds, shared by every prop using that model. Offsets confirmed against
// the client's prop ray-pick, which builds a cylinder as
// radius = MapProp::scale * bounding_radius
// z = MapProp::position.z - bounds_z_offsets[n] * MapProp::scale
struct PropModelInfo {
/* +h0000 */ uint32_t h0000;
/* +h0004 */ const wchar_t* model_file_name; // hash-encoded; FileHashToFileId() turns it into a file id
/* +h0008 */ float bounding_radius; // model space; scale by MapProp::scale for world units
/* +h000C */ float bounds_z_offsets[2];
/* +h0014 */ uint32_t h0014;
};
static_assert(sizeof(PropModelInfo) == 0x18, "struct PropModelInfo has incorrect size");
struct RecObject {
/* +h0000 */ void* vtable;
/* +h0004 */ uint32_t ref_count;
/* +h0008 */ uint32_t accessKey; // This is used by the game to make sure the data from the DAT matches the data in game
/* +h000c */ uint32_t standalone;
/* +h0010 */ uint32_t file_id;
/* +h0014 */ uint32_t stream_id;
/* +h0018 */ uint32_t flags;
/* +h001c */ uint32_t opened;
/* +h0020 */ uint32_t ref_count2;
};
static_assert(sizeof(RecObject) == 0x24, "struct RecObject has incorrect size");
struct RecObject {
/* +h0000 */ void* vtable;
/* +h0004 */ uint32_t ref_count;
/* +h0008 */ uint32_t accessKey; // This is used by the game to make sure the data from the DAT matches the data in game
/* +h000c */ uint32_t standalone;
/* +h0010 */ uint32_t file_id;
/* +h0014 */ uint32_t stream_id;
/* +h0018 */ uint32_t flags;
/* +h001c */ uint32_t opened;
/* +h0020 */ uint32_t ref_count2;
};
static_assert(sizeof(RecObject) == 0x24, "struct RecObject has incorrect size");
struct MapProp { // total: 0x54/84
/* +h0000 */ uint32_t h0000[5];
/* +h0014 */ uint32_t uptime_seconds; // time since spawned
/* +h0018 */ uint32_t h0018;
/* +h001C */ uint32_t prop_index;
/* +h0020 */ Vec3f position;
/* +h002C */ uint32_t model_file_id;
/* +h0030 */ uint32_t h0030[2];
/* +h0038 */ float rotation_angle;
/* +h003C */ float rotation_cos;
/* +h003C */ float rotation_sin;
/* +h0040 */ uint32_t h0034[5];
/* +h0058 */ RecObject* interactive_model;
/* +h005C */ uint32_t h005C[4];
/* +h006C */ uint32_t appearance_bitmap; // Modified when animation changes
/* +h0070 */ uint32_t animation_bits;
/* +h0064 */ uint32_t h0064[5];
/* +h0088 */ PropByType* prop_object_info;
/* +h008C */ uint32_t h008C;
};
struct MapProp { // total: 0x90/144
/* +h0000 */ uint32_t h0000[5];
/* +h0014 */ uint32_t uptime_seconds; // time since spawned
/* +h0018 */ uint32_t h0018;
/* +h001C */ uint32_t prop_index;
/* +h0020 */ Vec3f position;
/* +h002C */ uint32_t model_file_id;
/* +h0030 */ uint32_t h0030[2];
/* +h0038 */ float rotation_angle;
/* +h003C */ float rotation_cos;
/* +h0040 */ float rotation_sin;
/* +h0044 */ uint32_t h0044[3];
/* +h0050 */ float scale;
/* +h0054 */ PropModelInfo* model_info;
/* +h0058 */ RecObject* interactive_model;
/* +h005C */ uint32_t h005C[4];
/* +h006C */ uint32_t appearance_bitmap; // Modified when animation changes
/* +h0070 */ uint32_t animation_bits;
/* +h0074 */ uint32_t h0074[5];
/* +h0088 */ PropByType* prop_object_info;
/* +h008C */ uint32_t h008C;
};
static_assert(sizeof(MapProp) == 0x90, "struct MapProp has incorrect size");
static_assert(sizeof(MapProp) == 0x90, "struct MapProp has incorrect size");
typedef Array<PathingMap> PathingMapArray;
typedef Array<PathingMap> PathingMapArray;
}
+89 -95
View File
@@ -25,14 +25,18 @@ namespace GW {
struct Module;
extern Module AgentModule;
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4200) // nonstandard extension: zero-sized array in struct
#endif
struct AgentEffect {
uint32_t effect_type;
uint32_t data_size; // usually 0x34, detemines length of data in bytes
uint32_t data[];
};
#ifdef _MSC_VER
#pragma warning(pop)
#endif
enum class WorldActionId : uint32_t {
InteractEnemy,
@@ -58,106 +62,107 @@ namespace GW {
Include_Enemy = 0x000008,
Include_SpiritPet = 0x000010,
Accept_ActiveState = 0x000020,
Include_Minion = 0x000040,
Include_NPCMinipet = 0x000080,
Accept_Player = 0x000100,
Type_Gadget = 0x000200,
Type_Item = 0x000400,
Exclude_DeadAlly = 0x000800,
Exclude_DeadNeutral = 0x001000,
Exclude_DeadEnemy = 0x002000,
Exclude_DeadSpiritPet = 0x004000,
Exclude_DeadMinion = 0x008000,
Exclude_DeadNPCMinipet = 0x010000,
Exclude_UsedCorpse = 0x020000,
Exclude_AliveAlly = 0x040000,
Exclude_AliveNeutral = 0x080000,
Exclude_AliveEnemy = 0x100000,
Exclude_AliveSpiritPet = 0x200000,
Exclude_AliveMinion = 0x400000,
Exclude_AliveNPCMinipet = 0x800000,
Exclude_BeingObserved = 0x2000000,
ZeroPriority = 0x4000000,
NPCMinipet_ZeroPriority = 0x8000000,
};
Include_Minion = 0x000040,
Include_NPCMinipet = 0x000080,
Accept_Player = 0x000100,
Type_Gadget = 0x000200,
Type_Item = 0x000400,
Exclude_DeadAlly = 0x000800,
Exclude_DeadNeutral = 0x001000,
Exclude_DeadEnemy = 0x002000,
Exclude_DeadSpiritPet = 0x004000,
Exclude_DeadMinion = 0x008000,
Exclude_DeadNPCMinipet = 0x010000,
Exclude_UsedCorpse = 0x020000,
Exclude_AliveAlly = 0x040000,
Exclude_AliveNeutral = 0x080000,
Exclude_AliveEnemy = 0x100000,
Exclude_AliveSpiritPet = 0x200000,
Exclude_AliveMinion = 0x400000,
Exclude_AliveNPCMinipet = 0x800000,
Exclude_BeingObserved = 0x2000000,
ZeroPriority = 0x4000000,
NPCMinipet_ZeroPriority = 0x8000000,
};
inline AgentTargetFlags operator|(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator&(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator~(AgentTargetFlags a) {
return static_cast<AgentTargetFlags>(~static_cast<uint32_t>(a));
}
inline AgentTargetFlags operator|(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator&(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator~(AgentTargetFlags a) {
return static_cast<AgentTargetFlags>(~static_cast<uint32_t>(a));
}
namespace TargetFilter {
const AgentTargetFlags Enemies = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_DeadEnemy);
namespace TargetFilter {
const AgentTargetFlags Enemies = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_DeadEnemy);
const AgentTargetFlags Allies = static_cast<AgentTargetFlags>(
Include_Ally | Accept_Player | Exclude_DeadAlly);
const AgentTargetFlags Allies = static_cast<AgentTargetFlags>(
Include_Ally | Accept_Player | Exclude_DeadAlly);
const AgentTargetFlags Corpses = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_AliveEnemy | Exclude_UsedCorpse);
const AgentTargetFlags Corpses = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_AliveEnemy | Exclude_UsedCorpse);
const AgentTargetFlags Items = static_cast<AgentTargetFlags>(
Type_Item);
const AgentTargetFlags Items = static_cast<AgentTargetFlags>(
Type_Item);
const AgentTargetFlags Gadgets = static_cast<AgentTargetFlags>(
Type_Gadget);
const AgentTargetFlags Gadgets = static_cast<AgentTargetFlags>(
Type_Gadget);
const AgentTargetFlags AnyLiving = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player |
Exclude_DeadAlly | Exclude_DeadNeutral | Exclude_DeadEnemy |
Exclude_DeadSpiritPet | Exclude_DeadMinion | Exclude_DeadNPCMinipet);
const AgentTargetFlags AnyLiving = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player |
Exclude_DeadAlly | Exclude_DeadNeutral | Exclude_DeadEnemy |
Exclude_DeadSpiritPet | Exclude_DeadMinion | Exclude_DeadNPCMinipet);
const AgentTargetFlags Any = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player | Accept_HasQuest |
Type_Gadget | Type_Item);
}
const AgentTargetFlags Any = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player | Accept_HasQuest |
Type_Gadget | Type_Item);
}
namespace Agents {
typedef HookCallback<const GW::AgentLiving*, const AgentEffect*> AgentEffectCallback;
namespace Agents {
typedef HookCallback<const GW::AgentLiving*, const AgentEffect*> AgentEffectCallback;
// === Dialogs ===
// Same as pressing button (id) while talking to an NPC.
GWCA_API bool SendDialog(uint32_t dialog_id);
// Dialogs -- same as pressing button (id) while talking to an NPC.
GWCA_API bool SendDialog(uint32_t dialog_id);
// === Agent Array ===
// === Agent Array ===
// Get Agent ID of currently observed agent
GWCA_API uint32_t GetObservingId();
// Get Agent ID of client's logged in player
GWCA_API uint32_t GetControlledCharacterId();
// Get Agent ID of current target
GWCA_API uint32_t GetTargetId();
// Get Agent ID of current evaluated target - either auto target or actual target
GWCA_API uint32_t GetEvaluatedTargetId();
// Get Agent ID of currently observed agent
GWCA_API uint32_t GetObservingId();
// Get Agent ID of client's logged in player
GWCA_API uint32_t GetControlledCharacterId();
// Get Agent ID of current target
GWCA_API uint32_t GetTargetId();
// Get Agent ID of current evaluated target - either auto target or actual target
GWCA_API uint32_t GetEvaluatedTargetId();
// Returns Agentstruct Array of agents in compass range, full structs.
GWCA_API AgentArray* GetAgentArray();
// Returns Agentstruct Array of agents in compass range, full structs.
GWCA_API AgentArray* GetAgentArray();
// Get AgentArray Structures of player or target.
GWCA_API Agent *GetAgentByID(uint32_t id);
// Get agent that we're currently observing
inline Agent *GetObservingAgent() { return GetAgentByID(GetObservingId()); }
// Get Agent of current target
inline Agent *GetTarget() { return GetAgentByID(GetTargetId()); }
// Get Agent of current evaluated target - either auto target or actual target
inline Agent *GetEvaluatedTarget() { return GetAgentByID(GetEvaluatedTargetId()); }
// Get AgentArray Structures of player or target.
GWCA_API Agent* GetAgentByID(uint32_t id);
// Get agent that we're currently observing
inline Agent* GetObservingAgent() { return GetAgentByID(GetObservingId()); }
// Get Agent of current target
inline Agent* GetTarget() { return GetAgentByID(GetTargetId()); }
// Get Agent of current evaluated target - either auto target or actual target
inline Agent* GetEvaluatedTarget() { return GetAgentByID(GetEvaluatedTargetId()); }
GWCA_API Agent *GetPlayerByID(uint32_t player_id);
GWCA_API Agent* GetPlayerByID(uint32_t player_id);
// Get Agent of current logged in character
GWCA_API AgentLiving* GetControlledCharacter();
// Get Agent of current logged in character
GWCA_API AgentLiving* GetControlledCharacter();
GWCA_API bool GetAgentMatchesFlags(const Agent*, AgentTargetFlags flags = TargetFilter::Any);
GWCA_API bool GetAgentMatchesFlags(const Agent*, AgentTargetFlags flags = TargetFilter::Any);
GWCA_API bool RefreshAgentNameTag(const Agent*);
// Whether we're observing someone else
GWCA_API bool IsObserving();
@@ -167,8 +172,7 @@ namespace GW {
GWCA_API uint32_t GetAmountOfPlayersInInstance();
// Returns array of alternate agent array that can be read beyond compass range.
// Holds limited info and needs to be explored more.
// Alternate agent array readable beyond compass range; holds limited info and needs more exploration.
GWCA_API MapAgentArray* GetMapAgentArray();
GWCA_API uint32_t CountAllegianceInRange(GW::Constants::Allegiance allegiance, float sqr_range);
@@ -187,8 +191,7 @@ namespace GW {
GWCA_API bool ChangeTarget(const Agent *agent);
GWCA_API bool ChangeTarget(AgentID agent_id);
// Move to specified coordinates.
// Note: will do nothing if coordinate is outside the map!
// Move to specified coordinates. Does nothing if the coordinate is outside the map.
GWCA_API bool Move(float x, float y, uint32_t zplane = 0);
GWCA_API bool Move(GamePos pos);
@@ -205,18 +208,9 @@ namespace GW {
// Might be bugged, avoid to use.
GWCA_API wchar_t* GetAgentEncName(const Agent* agent);
GWCA_API wchar_t* GetAgentEncName(uint32_t agent_id);
GWCA_API void RegisterAgentEffectCallback(
HookEntry* entry,
const AgentEffectCallback& callback,
int altitude = -0x8000);
GWCA_API void RemoveFrameUIMessageCallback(
HookEntry* entry);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API bool SendDialog(uint32_t dialog_id);
+1 -4
View File
@@ -25,8 +25,7 @@ namespace GW {
GWCA_API float GetFieldOfView();
GWCA_API float GetYaw();
// ==== Camera patches ====
// Unlock camera & return the new state of it
// Camera patches -- unlock the camera and return its new state.
GWCA_API bool UnlockCam(bool flag);
GWCA_API bool GetCameraUnlock();
@@ -34,9 +33,7 @@ namespace GW {
GWCA_API bool SetFog(bool flag);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetCamera();
GWCA_API bool SetMaxDist(float dist);
+4 -2
View File
@@ -12,15 +12,19 @@ namespace GW {
namespace Chat {
typedef uint32_t Color;
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4200)
#endif
struct ChatMessage {
uint32_t channel;
uint32_t unk1;
FILETIME timestamp;
wchar_t message[0];
};
#ifdef _MSC_VER
#pragma warning(pop)
#endif
const size_t CHAT_LOG_LENGTH = 0x200;
struct ChatBuffer {
@@ -105,9 +109,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetChatLog();
GWCA_API bool AddToChatLog(wchar_t* message, uint32_t channel);
+1 -5
View File
@@ -20,9 +20,7 @@ namespace GW {
extern Module EffectModule;
namespace Effects {
// Returns current level of intoxication, 0-5 scale.
// If > 0 then skills that benefit from drunk will work.
// Important: requires SetupPostProcessingEffectHook() above.
// Current intoxication level, 0-5; above 0 makes drunk-benefit skills work. Requires SetupPostProcessingEffectHook().
GWCA_API uint32_t GetAlcoholLevel();
// Have fun with this ;))))))))))
@@ -59,9 +57,7 @@ namespace GW {
GWCA_API Buff *GetPlayerBuffBySkillId(Constants::SkillID skill_id);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API uint32_t GetAlcoholLevel();
GWCA_API void GetDrunkAf(float intensity, uint32_t tint);
+55 -4
View File
@@ -9,15 +9,65 @@ namespace GW {
extern Module EventMgrModule;
namespace EventMgr {
// Index into the game's m_handler array (EvtDispatch.cpp asserts id < EVENTS, which is 0x2f).
// Names below are read off Gw.exe: EvtOs.cpp translates each window message into one of these,
// EvtQueue.cpp drains them into the dispatcher, and every id the game itself registers a handler
// for was matched to that handler. Ids absent here are dispatched by the game but not yet identified.
enum class EventID {
kRecvPing = 0x8,
kSendFriendState = 0x26,
kRecvFriendState = 0x2c,
kAppActivated = 0x0, // WM_ACTIVATEAPP; packet is one dword, non-zero when activated
kFrameTick = 0x1, // once per frame from the pump, packet is the elapsed milliseconds
kRenderFinished = 0x2, // once per frame after the render, packet is one dword
kCharTyped = 0x3, // WM_CHAR: {character, repeat, modifier keys}
kCursorVisibility = 0x5, // packet is one dword, non-zero to show the cursor
kShutdown = 0x6, // posting this latches the event queue closed, so nothing dispatches after it
kScreenSizeChanged = 0x7, // {new width, new height, old width, old height}, raised inside the queue drain
kDriverError = 0x8, // the game's own handler logs "Driver internal error encountered." and quits
kRecvPing = kDriverError, // legacy GWCA name for 0x8; the game does not use this id for pings
kDpiChanged = 0xa, // WM_DPICHANGED: {x dpi, y dpi}
kGamepadAttached = 0xb, // the four below come from the same controller poll in EvtOs.cpp
kGamepadState = 0xc, // one dword
kGamepadAxis = 0xd, // four dwords
kGamepadButtonDown = 0xe, // {button, state}
kGamepadButtonUp = 0xf, // {button, state}
kCharTypedInjected = 0x13, // same shape as kCharTyped, from the game's own WM_APP+3 injection
kKeyDown = 0x1a, // {key, repeat, modifier keys}; the engine records the key as held here
kKeyUp = 0x1b, // same shape; the engine clears the held bit here
kFocusLost = 0x1c, // WM_KILLFOCUS, no packet
kMouseMoveRelative = 0x1d, // {button, 0, x, y, button mask, modifier keys} while the mouse is captured
kMouseButtonDown = 0x1e, // same shape as kMouseMoveRelative
kMouseLeave = 0x1f, // WM_MOUSELEAVE, no packet
kMouseMove = 0x20, // same shape as kMouseMoveRelative, only sent when the position changed
kMouseButtonUp = 0x21, // same shape; also synthesised for every held button when capture is lost
kMouseWheel = 0x22, // same shape, with the wheel delta in place of the button
kWindowMoved = 0x23, // WM_MOVE: {x, y}
// A shared channel: eight of the game's own subsystems register here, so filter on FriendEventType.
kFriend = 0x24,
kService = 0x25, // same packet shape as kFriend; FrApi forwards it as UI message 0x50
kServiceAlt = 0x26, // same again, forwarded as UI message 0x51
kFocusGained = 0x27, // WM_SETFOCUS, no packet
kWindowResized = 0x28, // WM_SIZE: {width, height}
kTouchDown = 0x2c, // the three touch ids carry {index, 0, x, y, touch mask}
kTouchMove = 0x2d,
kTouchUp = 0x2e, // also synthesised for every held touch when the sequence is cancelled
// AddHandler rejects 0x2a outright, and the dispatcher drops anything >= 0x2f, so 0x2f and
// 0x30 are engine-internal: 0x30 is what makes the queue raise kScreenSizeChanged.
kReserved = 0x2a,
kNone = 0xffff
};
// First dword of a kFriend packet, which is what the game's own handler switches over.
// These are the message ids FriendApi.cpp sends to the login frame, so they share that numbering.
enum class FriendEventType {
kStatusChanged = 0x26,
kLocationChanged = 0x28,
kFriendAddedOrRemoved = 0x2c
};
// (status, event_id, packet_bytes, packet_bytes_len). Altitude <= 0 runs before the game handles it, > 0 after.
typedef HookCallback<EventID, void*,uint32_t> EventCallback;
GWCA_API void RegisterEventCallback(
HookEntry *entry,
EventID event_id,
@@ -28,6 +78,7 @@ namespace GW {
HookEntry *entry,
EventID event_id = EventID::kNone);
GWCA_API bool SendEventMessage(EventID, void*);
// Took a length all along; the two-parameter declaration this replaces never had a definition to link against.
GWCA_API bool SendEventMessage(EventID event_id, void* packet_bytes, uint32_t packet_bytes_len);
};
}
+3 -2
View File
@@ -27,14 +27,15 @@ namespace GW {
HookEntry* entry);
GWCA_API bool AddFriend(const wchar_t* name, const wchar_t* alias = nullptr);
GWCA_API bool AddIgnore(const wchar_t* name, const wchar_t* alias = nullptr);
// Fire-and-forget, like AddFriend: true means the request was queued (it
// may need the Friends window to finish opening across a few game ticks
// first), not that the row is gone by the time this returns.
GWCA_API bool RemoveFriend(Friend* _friend);
GWCA_API bool ChangeFriendType(Friend* _friend, FriendType type);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetFriendList();
GWCA_API void* GetFriendByIndex(uint32_t index);
+1 -4
View File
@@ -16,8 +16,7 @@ namespace GW {
GWCA_API void ClearCalls();
// force_enqueue = false; will check if we're already in the game thread, and run immediately if we are.
// force_enqueue = true; enqueue the function for the next loop regardless
// force_enqueue = false runs immediately if already on the game thread; true always enqueues for the next loop.
GWCA_API void Enqueue(std::function<void ()> f, bool force_enqueue = false);
typedef HookCallback<> GameThreadCallback;
@@ -31,9 +30,7 @@ namespace GW {
GWCA_API bool IsInGameThread();
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
typedef void(__cdecl* GW_GameThreadCallback)();
typedef void(__cdecl* GW_GameThreadHookCallback)(GW::HookStatus* status);
-2
View File
@@ -21,9 +21,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetPlayerGuild();
GWCA_API void* GetCurrentGH();
-2
View File
@@ -97,9 +97,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
// Lookups
GWCA_API void* GetSalvageSessionInfo();
+3 -4
View File
@@ -125,6 +125,8 @@ namespace GW {
// Get the district number you are in.
GWCA_API int GetDistrict();
GWCA_API size_t GetDistrictCount();
// Get time, in ms, since the instance you are residing in has been created.
GWCA_API uint32_t GetInstanceTime();
@@ -140,8 +142,7 @@ namespace GW {
GWCA_API Constants::Language LanguageFromDistrict(const GW::Constants::District _district);
// Returns array of icons (res shrines, quarries, traders, etc) on mission map.
// Look at MissionMapIcon struct for more info.
// Array of icons (res shrines, quarries, traders...) on the mission map; see MissionMapIcon.
GWCA_API MissionMapIconArray* GetMissionMapIconArray();
// Returns pointer of collision trapezoid array.
@@ -164,9 +165,7 @@ namespace GW {
GWCA_API bool CancelEnterChallenge();
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetMissionMapContext();
GWCA_API void* GetWorldMapContext();
+1 -3
View File
@@ -16,9 +16,7 @@ namespace GW {
GWCA_API HWND GetGWWindowHandle();
// You probably don't want to use these functions. These are for allocating
// memory on the Guild Wars game heap, rather than your own heap. Memory allocated with
// these functions cannot be used with RAII and must be manually freed. USE AT YOUR OWN RISK.
// Allocates on the Guild Wars game heap, not yours: no RAII, must be freed manually. USE AT YOUR OWN RISK.
GWCA_API void* MemAlloc(size_t size);
GWCA_API void* MemRealloc(void* buf, size_t newSize);
GWCA_API void MemFree(void* buf);
+1 -2
View File
@@ -8,8 +8,7 @@ namespace GW {
void (*init_module)();
void (*exit_module)();
// Call those from game thread to be safe
// Do not free trampoline
// Call these from the game thread to be safe, and do not free the trampoline.
void (*enable_hooks)();
void (*disable_hooks)();
};
+1 -2
View File
@@ -23,8 +23,7 @@ namespace GW {
namespace PartyMgr {
// set or unset the fact that ticking will work as a toggle instead
// of showing a drop-down menu
// Set or unset whether ticking works as a toggle instead of showing a drop-down menu.
GWCA_API void SetTickToggle(bool enable);
-2
View File
@@ -55,9 +55,7 @@ namespace GW {
GWCA_API TitleClientData* GetTitleData(Constants::TitleID title_id);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API bool SetActiveTitle(uint32_t title_id);
GWCA_API bool RemoveActiveTitle();
-2
View File
@@ -42,9 +42,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API uint32_t GetActiveQuestId();
GWCA_API bool SetActiveQuestId(uint32_t quest_id);
+88 -48
View File
@@ -2,6 +2,9 @@
#include <GWCA/Utilities/Export.h>
#include <cstddef>
#include <cstdint>
// forward declaration, we don't need to include the full directx header here
struct IDirect3DDevice9;
@@ -12,37 +15,69 @@ namespace GW {
namespace Render {
typedef void(__cdecl* RenderCallback) (IDirect3DDevice9*);
typedef struct Mat4x3f {
float _11;
float _12;
float _13;
float _14;
float _21;
float _22;
float _23;
float _24;
float _31;
float _32;
float _33;
float _34;
enum Flags {
Shear = 1 << 3
};
uint32_t flags;
} Mat4x3f;
enum Transform : int {
TRANSFORM_PROJECTION_MATRIX = 0,
TRANSFORM_MODEL_MATRIX = 1,
// TODO:
TRANSFORM_COUNT = 5
// Runtime, not build-time: Gw.exe ships both backends and picks one via Param::GetFlag(10).
enum class Backend : uint32_t {
Unknown = 0,
D3D9,
GLES3,
};
GWCA_API Backend GetBackend();
// VirtualDeviceRenderer::renderer_mode -- all windowed variants, ordered as the settings dropdown lists them.
enum class RendererMode : uint32_t {
Windowed = 0,
WindowedBorderless = 1,
WindowedFullscreen = 2,
};
// Unknown values pass through rather than being coerced.
GWCA_API RendererMode GetRendererMode();
// GetDevice() is the Dx9 device, GetGlesDevice() the GLES3 one; pick with GetBackend(). The rest works on all three.
// GLES3 device, captured from a hook since ddi_device is an id, not a pointer. Offsets confirmed against a running client.
using EGLSurface = void*;
using EGLContext = void*;
using EGLDisplay = void*;
// The std140 block the fragment shader reads; the layout is exact, taken from the shader source embedded in the client.
struct GlFragmentRenderState {
/* +h0000 */ float texture_factor[4];
/* +h0010 */ float fog_color[4];
/* +h0020 */ float sampler_biases[2][4];
/* +h0040 */ float bump_env_mat[8][4];
/* +h00C0 */ float discard_settings[4];
};
static_assert(sizeof(GlFragmentRenderState) == 0xD0);
// ArenaNet's own struct wrapping EGL handles, not a COM object -- you cannot call methods on it, only read and write cached state.
struct GlesDevice {
/* +h0000 */ uint8_t h0000[0x7D4];
/* +h07D4 */ uint32_t dev_mode; // GR_MODE_*
/* +h07D8 */ uint8_t h07D8[0x28];
/* +h0800 */ void* dev_window; // native window
/* +h0804 */ uint8_t h0804[0x8];
/* +h080C */ EGLSurface dev_surface;
/* +h0810 */ uint32_t width; // live surface size
/* +h0814 */ uint32_t height;
/* +h0818 */ EGLContext dev_context;
/* +h081C */ uint8_t h081C[0x7D4];
/* +h0FF0 */ GlFragmentRenderState fragment_state;
/* +h10C0 */ uint32_t uniform_buffer; // the UBO name
};
static_assert(offsetof(GlesDevice, dev_mode) == 0x07D4);
static_assert(offsetof(GlesDevice, dev_window) == 0x0800);
static_assert(offsetof(GlesDevice, dev_surface) == 0x080C);
static_assert(offsetof(GlesDevice, dev_context) == 0x0818);
static_assert(offsetof(GlesDevice, fragment_state) == 0x0FF0);
static_assert(offsetof(GlesDevice, uniform_buffer) == 0x10C0);
// Captured from a hook -- not reachable from the generic device. Null unless GetBackend() is GLES3.
GWCA_API GlesDevice* GetGlesDevice();
typedef void(__cdecl* RenderCallback) (IDirect3DDevice9*);
enum Metric : uint32_t {
Metric0,
Metric1,
@@ -73,27 +108,17 @@ namespace GW {
Count
};
// Careful, this doesn't return correct values if you have the U mission map, or other things open
// prefer to calculate the transformation matrix yourself using Render::GetFieldOfView()
[[deprecated]] GWCA_API Mat4x3f* GetTransform(Transform transform);
GWCA_API void EnableHooks();
// this returns the FoV used for rendering
GWCA_API float GetFieldOfView();
// Set up a callback for drawing on screen.
// Will be called after GW render.
//
// Important: if you use this, you should call GW::Terminate()
// or at least GW::Render::RestoreHooks() from within the callback
// Called after GW render each frame on D3D9 (End Scene); call GW::Terminate() or RestoreHooks() from within it.
GWCA_API void SetRenderCallback(RenderCallback callback);
GWCA_API RenderCallback GetRenderCallback();
// Flush GW's deferred GR command queue so previously-submitted draws (e.g. the 3D
// world) are materialised into the back/depth buffer. No-op unless the queue is in
// a flushable state. Lets a render hook draw between GW's world and UI passes.
// Flush GW's deferred GR queue so submitted draws materialise, letting a render hook draw between the world and UI passes.
GWCA_API void FlushCommandQueue();
// Can be used to get information like vsync status or monitor refresh rate of the renderer
@@ -105,23 +130,38 @@ namespace GW {
// Set a hard upper limit for frame rate. Actual limit may be lower (but not higher) depending on vsync/in-game preference
GWCA_API bool SetFrameLimit(uint32_t value);
// Set up a callback for directx device reset
// D3D9 device reset. GLES has no lost-device concept; SetGlesResetCallback covers the corresponding event.
GWCA_API void SetResetCallback(RenderCallback callback);
GWCA_API RenderCallback GetResetCallback();
// Check if gw is in fullscreen
// Note: requires one or both callbacks to be set and called before
// Note: does not update while minimized
// Note: returns -1 if it doesn't know yet
// The same two events typed on the GLES device -- set whichever pair matches GetBackend().
// Fires each frame on GLES3 at the queue flush, so anything drawn goes out with that frame's commands.
typedef void(__cdecl* GlesRenderCallback)(GlesDevice* gles_device);
GWCA_API void SetGlesRenderCallback(GlesRenderCallback callback);
GWCA_API GlesRenderCallback GetGlesRenderCallback();
// Fires when the GLES device is created or updated -- a state-change event, not per-frame, and where the pointer is captured.
GWCA_API void SetGlesResetCallback(GlesRenderCallback callback);
GWCA_API GlesRenderCallback GetGlesResetCallback();
// The same frame boundary as the render callbacks, without a device, for callers that only need the tick.
typedef void(__cdecl* FrameCallback)();
GWCA_API void SetFrameCallback(FrameCallback callback);
GWCA_API FrameCallback GetFrameCallback();
// Needs a callback set and called first, does not update while minimized, and returns -1 until known.
GWCA_API int GetIsFullscreen();
GWCA_API bool SetFog(bool enabled);
GWCA_API HWND GetWindowHandle();
// Null unless GetBackend() == Backend::D3D9.
GWCA_API IDirect3DDevice9* GetDevice();
GWCA_API bool GetIsInRenderLoop();
GWCA_API uint32_t GetViewportWidth();
GWCA_API uint32_t GetViewportHeight();
}
+4 -10
View File
@@ -36,9 +36,7 @@ namespace GW {
};
static_assert(sizeof(SkillTemplate) == 140);
// Handlers run in altitude order around the hooked game function: altitude <= 0 before it
// (may set status->blocked to skip it), altitude > 0 after it. Handlers that produce a
// template write it into out_template and set *result to the game's success value.
// Handlers run in altitude order: <= 0 before the hooked function (may set status->blocked), > 0 after it.
typedef HookCallback<SkillTemplate* /*out_template*/, void* /*reader*/, uint8_t* /*result*/> DecodeTemplateHeaderCallback;
GWCA_API void RegisterDecodeTemplateHeaderCallback(HookEntry* entry, const DecodeTemplateHeaderCallback& callback, int altitude = -0x8000);
@@ -56,22 +54,20 @@ namespace GW {
GWCA_API void RemoveTemplateCallback(HookEntry* entry);
// Get the skill slot in the player bar of the player.
// Returns -1 if the skill is not there
// Skill slot in the player's bar, or -1 if the skill is not there.
GWCA_API int GetSkillSlot(Constants::SkillID skill_id);
// Use Skill in slot (Slot) on (Agent), optionally call that you are using said skill.
GWCA_API bool UseSkill(uint32_t slot, uint32_t target = 0);
// Send raw packet to use skill with ID (SkillID).
// Same as above except the skillbar client struct will not be registered as casting.
// Raw packet to use skill ID; unlike above, the skillbar client struct is not registered as casting.
GWCA_API bool UseSkillByID(uint32_t skill_id, uint32_t target = 0);
// Get skill structure of said id, houses pretty much everything you would want to know about the skill.
GWCA_API Skill* GetSkillConstantData(Constants::SkillID skill_id);
// Number of entries in the client's skill constant data array; can exceed Constants::SkillID::Count when GW adds skills.
GWCA_API uint32_t GetSkillConstantDataCount();
GWCA_API uint32_t GetSkillCount();
// Name/Description/Profession etc for an attribute by id
GWCA_API AttributeInfo* GetAttributeConstantData(Constants::Attribute attribute_id);
@@ -101,9 +97,7 @@ namespace GW {
GWCA_API bool GetSkillTemplate(SkillTemplate& skill_template);
}
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API int GetSkillSlot(uint32_t skill_id);
GWCA_API bool UseSkill(uint32_t slot, uint32_t target);
+2 -7
View File
@@ -5,10 +5,7 @@
namespace GW {
/*
StoC Manager
See https://github.com/GameRevision/GWLP-R/wiki/GStoC for some already explored packets.
*/
// StoC Manager. See https://github.com/GameRevision/GWLP-R/wiki/GStoC for some already explored packets.
namespace Packet {
namespace StoC {
@@ -24,9 +21,7 @@ namespace GW {
namespace StoC {
typedef HookCallback<Packet::StoC::PacketBase *> PacketCallback;
// Register a function to be called when a packet is received.
// An altitude of 0 or less will be triggered before the packet is processed.
// An altitude greater than 0 will be triggered after the packet has been processed.
// Altitude <= 0 fires before the packet is processed, > 0 after it.
GWCA_API bool RegisterPacketCallback(
HookEntry *entry,
uint32_t header,
-2
View File
@@ -21,9 +21,7 @@ namespace GW {
GWCA_API bool OfferItem(uint32_t item_id, uint32_t quantity = 0);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API bool OpenTradeWindow(uint32_t agent_id);
GWCA_API bool AcceptTrade();
+36 -8
View File
@@ -148,7 +148,7 @@ namespace GW {
uint32_t field15_0x3c;
uint32_t field16_0x40;
uint32_t field17_0x44;
uint32_t field18_0x48;
uint32_t m_ctlSpec;
uint32_t field19_0x4c;
uint32_t field20_0x50;
uint32_t field21_0x54;
@@ -236,10 +236,10 @@ namespace GW {
struct AgentNameTagInfo {
/* +h0000 */ uint32_t agent_id;
/* +h0004 */ uint32_t h0002;
/* +h0008 */ uint32_t h0003;
/* +h0004 */ uint32_t h0004;
/* +h0008 */ uint32_t h0008;
/* +h000C */ wchar_t* name_enc;
/* +h0010 */ uint8_t h0010;
/* +h0010 */ uint8_t highlight;
/* +h0011 */ uint8_t h0012;
/* +h0012 */ uint8_t h0013;
/* +h0013 */ uint8_t background_alpha; // ARGB, NB: Actual color is ignored, only alpha is used
@@ -250,6 +250,8 @@ namespace GW {
/* +h001E */ uint8_t h001E;
/* +h001F */ uint8_t h001F;
/* +h0020 */ wchar_t* extra_info_enc; // Title etc
/* +h0024 */ uint32_t extra_info_color; // ARGB
/* +h0028 */ uint32_t extra_info_attributes; // bold/size etc
};
// Note: some windows are affected by UI scale (e.g. party members), others are not (e.g. compass)
@@ -783,10 +785,35 @@ namespace GW {
GWCA_API bool DestroyUIComponent(Frame* frame);
// Frame layout primitives (GW's Frame::SetBounds/SetPosition), for measuring and
// stacking child frames from a custom container's kMeasureContent/kSetLayout handler.
GWCA_API void SetFrameBounds(Frame* frame, uint32_t mode, float* rect, float* size_out);
GWCA_API void SetFramePosition(Frame* frame, uint32_t mode, float* rect);
// Bitfield accepted by Frame::SetBounds/SetPositionInternal
enum FrameLayoutMode : uint32_t {
FrameLayoutMode_None = 0,
FrameLayoutMode_AnchorBottom = 0x1, // vertical: anchor to the rect's bottom edge
FrameLayoutMode_Center = 0x2, // required alongside centered placement on either axis (asserts otherwise)
FrameLayoutMode_AnchorLeft = 0x4, // horizontal: anchor to the rect's left edge
FrameLayoutMode_AnchorRight = 0x8, // horizontal: anchor to the rect's right edge
FrameLayoutMode_AnchorTop = 0x10, // vertical: anchor to the rect's top edge
FrameLayoutMode_StretchWidth = 0x20, // horizontal: fill the rect's full width (also behaves as a left anchor)
FrameLayoutMode_StretchHeight = 0x40, // vertical: fill the rect's full height (also behaves as a bottom anchor)
FrameLayoutMode_AnchorHorizontalMargin = 0x80, // gate: trim the resolved rect inward on the AnchorLeft/AnchorRight edge(s)
FrameLayoutMode_AnchorVerticalMargin = 0x100, // gate: trim the resolved rect inward on the AnchorTop/AnchorBottom edge(s)
};
inline FrameLayoutMode operator|(FrameLayoutMode a, FrameLayoutMode b) {
return static_cast<FrameLayoutMode>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
}
inline FrameLayoutMode operator&(FrameLayoutMode a, FrameLayoutMode b) {
return static_cast<FrameLayoutMode>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
}
inline FrameLayoutMode operator~(FrameLayoutMode a) {
return static_cast<FrameLayoutMode>(~static_cast<uint32_t>(a));
}
// Frame layout primitives (GW's Frame::SetBounds/SetPosition) for a container's kMeasureContent/kSetLayout handler.
GWCA_API void SetFrameBounds(Frame* frame, FrameLayoutMode mode, float* rect, float* size_out);
GWCA_API void SetFramePosition(Frame* frame, FrameLayoutMode mode, float* rect);
// Appends an additional handler onto frame->frame_callbacks
GWCA_API bool AddFrameCallback(Frame* frame, UIInteractionCallback callback, void* uictl_context = nullptr);
GWCA_API bool SelectDropdownOption(Frame* frame, uint32_t value);
@@ -867,6 +894,7 @@ namespace GW {
GWCA_API bool SetFrameVisible(UI::Frame* frame, bool flag);
GWCA_API bool SetFrameDisabled(UI::Frame* frame, bool flag);
// Stubbed: always returns false. The underlying game function is not scanned for.
GWCA_API bool AddFrameUIInteractionCallback(GW::UI::Frame*, UI::UIInteractionCallback callback, void* wparam);
GWCA_API bool TriggerFrameRedraw(UI::Frame* frame);
+10 -49
View File
@@ -3,15 +3,7 @@
#include <GWCA/Packets/Opcodes.h>
#include <GWCA/GameContainers/GamePos.h>
/*
Server to client packets, sorted by header
most packets are not filled in, however the list and types of
fields is given as comments
feel free to fill packets, and you can also add a suffix to
the packet name, e.g. P391 -> P391_InstanceLoadMap
*/
// Server to client packets, sorted by header. Unfilled ones list their fields as comments -- fill them in, optionally suffixing the name (P391 -> P391_InstanceLoadMap).
namespace GW {
typedef uint32_t AgentID;
@@ -28,11 +20,7 @@ namespace GW {
struct PacketBase {
uint32_t header;
};
// Used for:
// GenericValue
// GenericValueTarget
// GenericModifier (i.e. GenericFloatTarget)
// GenericFloat
// Used for GenericValue, GenericValueTarget, GenericModifier (GenericFloatTarget) and GenericFloat.
namespace GenericValueID {
const uint32_t melee_attack_finished = 1; // GenericValue. Last melee attack finished successfully.
const uint32_t attack_stopped = 3; // GenericValue. Last melee/ranged attack stopped unsuccessfully. May be followed by interrupted (35).
@@ -82,18 +70,9 @@ namespace GW {
}
namespace JumboMessageValue {
// The following values represent the first and second parties in an explorable areas
// (inc. observer mode) with 2 parties.
// if there are 3 parties in the explorable area (like some HA maps) then:
// - party 1 = 6579558
// - party 2 = 1635021873
// - party 3 = 1635021874
// First and second parties in an explorable area; with 3 parties (some HA maps) they are 6579558, 1635021873, 1635021874.
// TODO:
// These numbers appear big and random, their origin or relation to other values
// is not understood.
// In addition, there may be a danger these variables could change with GW updates...
// Consider these values as experimental and use with caution
// TODO: these numbers are big, random and not understood, and may change with GW updates -- use with caution.
const uint32_t PARTY_ONE = 1635021873;
const uint32_t PARTY_TWO = 1635021874;
}
@@ -456,12 +435,7 @@ namespace GW {
};
template<> constexpr uint32_t Packet<GenericFloat>::STATIC_HEADER = GAME_SMSG_AGENT_ATTR_UPDATE_FLOAT;
// damage or healing done packet, but also has other purposes.
// to be investigated further.
// all types have their value in the float field 'value'.
// in all types the value is in percentage, unless otherwise specified.
// the value can be negative (e.g. damage, sacrifice)
// or positive (e.g. heal, lifesteal).
// Damage or healing done, plus other uses; 'value' is a percentage unless stated, negative or positive by type.
struct GenericModifier : Packet<GenericModifier> {
uint32_t type; // type as specified above in P156_Type
uint32_t target_id; // agent id of who is affected by the change
@@ -470,9 +444,7 @@ namespace GW {
};
template<> constexpr uint32_t Packet<GenericModifier>::STATIC_HEADER = GAME_SMSG_AGENT_ATTR_UPDATE_FLOAT_TARGET;
// Projectile launched from an agent
// Can be from a martial weapon (spear, bow), or projectile-launching skill
// also acts as an attack_finished packet for ranged weapons, if `is_attack == 1`
// Projectile launched from an agent, martial or skill; also an attack_finished for ranged weapons when is_attack == 1.
struct AgentProjectileLaunched : Packet<AgentProjectileLaunched> {
uint32_t agent_id;
Vec2f destination;
@@ -657,8 +629,7 @@ namespace GW {
};
template<> constexpr uint32_t Packet<TownAllianceObject>::STATIC_HEADER = GAME_SMSG_TOWN_ALLIANCE_OBJECT;
// Info about any guilds applicable to current outpost.
// NOTE: When entering a guild hall, that guild will always be the first added (local_id = 1).
// Guilds applicable to the current outpost; entering a guild hall always adds that guild first (local_id = 1).
struct GuildGeneral : Packet<GuildGeneral> {
uint32_t local_id;
uint32_t ghkey[4]; // blob[16]
@@ -770,17 +741,12 @@ namespace GW {
};
template<> constexpr uint32_t Packet<SalvageSessionSuccess>::STATIC_HEADER = GAME_SMSG_ITEM_SALVAGE_SESSION_SUCCESS;
// JumboMessage represents a message strewn across the center of the screen in big red or green characters.
// Things like moral boosts, flag captures, victory, defeat...
// A message strewn across the centre of the screen in big red or green characters -- moral boosts, captures, victory.
struct JumboMessage : GW::Packet::StoC::Packet<JumboMessage> {
uint8_t type; // JumboMessageType
uint32_t value; // JumboMessageValue
};
// Diagnostic GCC/MinGW build: unlike the other specializations
// here, this one isn't constexpr (so isn't implicitly inline),
// causing an ODR multiple-definition link error across TUs that
// MSVC's linker tolerates but GCC's doesn't. Match the pattern
// used everywhere else in this file.
// Not constexpr unlike the others here, so GCC raises an ODR multiple-definition error MSVC tolerates -- match the pattern.
template<> constexpr uint32_t GW::Packet::StoC::Packet<JumboMessage>::STATIC_HEADER = GAME_SMSG_JUMBO_MESSAGE;
struct InstanceLoadFile : Packet<InstanceLoadFile> {
@@ -934,12 +900,7 @@ namespace GW {
};
template<> constexpr uint32_t Packet<PartyPlayerReady>::STATIC_HEADER = GAME_SMSG_PARTY_PLAYER_READY;
// When a new party is created:
// 1. PartyPlayerStreamStart packet is sent
// 2. PartyPlayerAdd packet per member
// PartyHeroAdd packet per hero
// PartyHenchmanAdd packer per henchman
// 3. PartyPlayerStreamEnd packet is sent
// New party: PartyPlayerStreamStart, then Add packets per player/hero/henchman, then PartyPlayerStreamEnd.
struct PartyPlayerStreamStart : Packet<PartyPlayerStreamStart> {
uint32_t party_id; // word
};
+1 -4
View File
@@ -1,9 +1,6 @@
#pragma once
#ifdef _DEBUG
// Diagnostic GCC/MinGW build: plain __VA_ARGS__ leaves a trailing comma
// when a call site passes no extra args (e.g. GWCA_INFO("message")),
// which MSVC's preprocessor tolerates but GCC's doesn't. ##__VA_ARGS__
// is a GNU extension that swallows the comma in that case.
// ##__VA_ARGS__ is a GNU extension swallowing the trailing comma when no extra args are passed; MSVC tolerates plain __VA_ARGS__.
#define GWCA_TRACE(fmt, ...) GW::LogMessage(GW::LEVEL_TRACE, __FILE__, (unsigned)__LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
#define GWCA_DEBUG(fmt, ...) GW::LogMessage(GW::LEVEL_DEBUG, __FILE__, (unsigned)__LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
#define GWCA_INFO(fmt, ...) GW::LogMessage(GW::LEVEL_INFO, __FILE__, (unsigned)__LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
+12 -1
View File
@@ -1,6 +1,17 @@
#pragma once
#if defined(__clang__) || defined(__GNUC__)
#include <cstdint>
// GWCA is 32-bit only: every struct offset here comes from a 32-bit client, so a 64-bit build would read the wrong fields.
static_assert(sizeof(void*) == 4,
"GWCA is 32-bit only (x86 or wasm32). Configure with -A Win32, "
"or emcmake for wasm.");
#if !defined(_WIN32)
// Non-PE targets: dllexport is meaningless and only warns; wasm exports come from EXPORTED_FUNCTIONS (tools/gen_wasm_exports.py).
# define DllExport
# define DllImport
#elif defined(__clang__) || defined(__GNUC__)
# define DllExport __attribute__((dllexport))
# define DllImport __attribute__((dllimport))
#elif defined(_MSC_VER)
+111 -6
View File
@@ -2,19 +2,124 @@
#include <GWCA/Utilities/Export.h>
#include <cstdint>
namespace GW {
namespace Hook {
// A code address on x86, a function index on wasm; not void*, since function-pointer-to-void* is ill-formed and clang rejects it.
struct HookTarget {
uintptr_t value = 0;
constexpr HookTarget() = default;
constexpr HookTarget(decltype(nullptr)) {}
template <class T> HookTarget(T* p)
: value((uintptr_t)p) {}
constexpr explicit operator bool() const { return value != 0; }
};
#if GWCA_WASM
// The loader sizes the dispatch table with TableBytes and passes it in -- the no-arg form below has no wasm definition.
GWCA_API size_t TableBytes(uint32_t first_func, uint32_t func_count);
GWCA_API bool Initialize(void* memory, uint32_t first_func, uint32_t func_count);
// Move one funcref between GWCA's table and the game's, for a callback written into a game data structure (see StoCMgr).
GWCA_API uint32_t PublishFuncref(HookTarget local);
GWCA_API uint32_t AdoptFuncref(uint32_t game_slot);
// A scanned function made callable. A scan yields a tagged code offset,
// which is not a table index -- calling one directly traps with "table
// index is out of bounds". A hooked function gets a callable form for
// free as its trampoline; anything called directly and never hooked has
// to be converted here first. 0 if it does not resolve.
GWCA_API uintptr_t CallableFromScan(uintptr_t scan_result);
#else
GWCA_API void Initialize();
// x86 scan results are already code addresses, so call sites can be unconditional.
inline uintptr_t CallableFromScan(uintptr_t scan_result) { return scan_result; }
#endif
GWCA_API void Deinitialize();
// static void EnqueueHook(HookBase* base);
// static void RemoveHook(HookBase* base);
// static void EnqueueHook(HookBase*); static void RemoveHook(HookBase*);
GWCA_API void EnableHooks(void* target = NULL);
GWCA_API void DisableHooks(void* target = NULL);
GWCA_API void EnableHooks(HookTarget target = nullptr);
GWCA_API void DisableHooks(HookTarget target = nullptr);
GWCA_API int CreateHook(void** target, void* detour, void** trampoline);
GWCA_API void RemoveHook(void* target);
#if GWCA_WASM
// A trampoline is only valid for call_indirect FROM WHICHEVER MODULE'S
// OWN "table_adopt" import populated it -- every wasm module gets its
// own table (see gw_in_browser's inject.js TableManager, one instance
// per module), and CreateHook is code compiled into gwca.wasm. A mod
// calling it cross-module got its trampoline written into GWCA's own
// table, not the mod's; the mod's own call_indirect through it then
// traps "table index is out of bounds" -- silently, since CreateHook
// itself reports success (the adopt genuinely succeeded, just into
// the wrong table). Found hooking a wasm test mod's own model-loading
// experiment; every existing hook avoided this only because GWCA
// hooks itself, so the code that creates the trampoline and the code
// that later calls it are always the same module by construction.
//
// This parameter closes that hole: it is mandatory, not defaulted, so
// a mod cannot compile a call to CreateHook without deciding what to
// pass. Supply the SAME kind of import GWCA's own uses internally --
// an `extern "C" __attribute__((import_module("env"),
// import_name("table_adopt"))) uint32_t table_adopt(uint32_t);` declared
// in the mod's own translation unit, so the loader resolves it against
// the mod's own TableManager. GWCA's own 44 internal call sites use
// GW::Hook::GwcaTableAdopt via the GWCA_CREATE_HOOK macro below rather
// than repeat this by hand.
typedef uint32_t (*TableAdoptFn)(uint32_t game_slot);
// gwca's OWN table_adopt import, valid ONLY for a trampoline that will
// be called from code compiled into gwca.wasm. Passing this from a mod
// reproduces exactly the bug this parameter exists to prevent -- a mod
// must supply its own import instead (see above).
GWCA_API uint32_t GwcaTableAdopt(uint32_t game_slot);
GWCA_API int CreateHook(void** target, HookTarget detour, void** trampoline,
TableAdoptFn caller_table_adopt);
// Prefer this form. The void** overload erases both signatures, so a
// detour whose type differs from its target's compiles on either
// platform: x86 ignores the difference, and wasm's call_indirect traps
// at the first call -- in a map load, far from the declaration. Here the
// two must deduce the same T, so a mismatch is a compile error instead.
template <class T>
int CreateHook(T* target, T detour, T* trampoline, TableAdoptFn caller_table_adopt)
{
return CreateHook((void**)target, HookTarget(detour), (void**)trampoline, caller_table_adopt);
}
// GWCA's own call sites: `GWCA_CREATE_HOOK(&Func, Detour, &Ret)`, no
// different from the pre-parameter shape -- this is what supplies
// GwcaTableAdopt on their behalf so 44 internal call sites did not
// each need editing by hand. Not for mod code: a mod including this
// header gets the real, mandatory-parameter CreateHook above, and
// reaching for this macro to sidestep that is exactly the mistake the
// parameter exists to prevent -- pass your own import, not this one.
#define GWCA_CREATE_HOOK(target, detour, trampoline) \
GW::Hook::CreateHook(target, detour, trampoline, GW::Hook::GwcaTableAdopt)
#else
GWCA_API int CreateHook(void** target, HookTarget detour, void** trampoline);
// Prefer this form. The void** overload erases both signatures, so a
// detour whose type differs from its target's compiles on either
// platform: x86 ignores the difference, and wasm's call_indirect traps
// at the first call -- in a map load, far from the declaration. Here the
// two must deduce the same T, so a mismatch is a compile error instead.
template <class T>
int CreateHook(T* target, T detour, T* trampoline)
{
return CreateHook((void**)target, HookTarget(detour), (void**)trampoline);
}
// x86 has no wasm-style cross-module trampoline trap, so this is just
// CreateHook -- same macro name on both platforms so GWCA's own 44
// internal call sites do not need an #if.
#define GWCA_CREATE_HOOK(target, detour, trampoline) \
GW::Hook::CreateHook(target, detour, trampoline)
#endif
GWCA_API void RemoveHook(HookTarget target);
GWCA_API void EnterHook();
GWCA_API void LeaveHook();
+84 -3
View File
@@ -2,6 +2,10 @@
#include "Export.h"
#include <cstdint>
#include <string>
#include <vector>
namespace GW {
enum ScannerSection : uint8_t {
Section_TEXT = 0,
@@ -13,9 +17,7 @@ namespace GW {
uintptr_t start = 0;
uintptr_t end = 0;
};
// class PatternScanner
// 32 bit pattern scanner for x86 programs.
// Credits to Zat & Midi12 @ unknowncheats.me for the functionality of this class.
// 32-bit pattern scanner for x86 programs. Credits to Zat & Midi12 @ unknowncheats.me.
namespace Scanner {
// Initializer to determine scan range.
GWCA_API void Initialize(const char* moduleName = NULL);
@@ -40,6 +42,10 @@ namespace GW {
// Actual pattern finder.
GWCA_API uintptr_t Find(const char* pattern, const char* mask = 0, int offset = 0, ScannerSection section = ScannerSection::Section_TEXT);
// The nth match of a pattern, 0-based: FindNth(p, m, 0, off) == Find(p, m, off).
// Use when a pattern is deliberately ambiguous and it is a later hit you want.
GWCA_API uintptr_t FindNth(const char* pattern, const char* mask, size_t nth, int offset = 0, ScannerSection section = ScannerSection::Section_TEXT);
GWCA_API void GetSectionAddressRange(ScannerSection section, uintptr_t* start = nullptr, uintptr_t* end = nullptr);
// Check if current address is a valid pointer (usually to a data variable in DATA)
@@ -49,5 +55,80 @@ namespace GW {
GWCA_API uintptr_t FunctionFromNearCall(uintptr_t call_instruction_address, bool check_valid_ptr = true);
GWCA_API uintptr_t ToFunctionStart(uintptr_t call_instruction_address, uint32_t scan_range = 0xff);
#if GWCA_WASM
// Every scan returns an address, as on x86 -- here a tagged CODE OFFSET
// (0x80000000 | offset) rather than a linear one. FunctionAtCodeOffset is the one
// way back to a bare function index; ToFunctionStart and the arity checks take
// either form. See Source/Platform/Wasm/Scanner.cpp.
// Initialise from the module bytes -- a running module cannot read its own code section.
GWCA_API bool Initialize(const void* wasm_bytes, size_t length);
GWCA_API bool IsInitialized();
// Scans with no wasm equivalent record what they were asked for rather than failing silently.
GWCA_API const std::vector<std::string>& GetUnportedScans();
// Replaces *(uintptr_t*)address: LLVM puts the global in the load's memarg offset, not in a constant.
GWCA_API uintptr_t GlobalFromFunction(uintptr_t func_index, size_t nth = 0);
GWCA_API std::vector<uint32_t> GlobalsInFunction(uintptr_t func_index);
// The global read straight out of a load/store you already located. 0 if that offset is not a load/store.
GWCA_API uintptr_t GlobalAtCodeOffset(uintptr_t code_offset);
// The i32.const immediate at an offset you already located, decoded from LEB128 --
// what a deref does on x86, which cannot work here: the code section is not in
// linear memory, and the immediate is a varint rather than a raw word.
GWCA_API uintptr_t ConstAtCodeOffset(uintptr_t code_offset);
// A static passed as an i32.const rather than loaded from; restricted to .bss to exclude .data string constants.
GWCA_API uintptr_t BssConstFromFunction(uintptr_t func_index, size_t nth = 0);
// The one function referencing `str` with this exact type; 0 if none or ambiguous.
// For an overload pair a shared string cannot separate and an ordinal must not: nth
// orders by .text address on x86 but by function index here, and they disagree.
GWCA_API uintptr_t FindUseOfStringWithSignature(const char* str, uint32_t params,
uint32_t results);
// Replaces the Find+FunctionFromNearCall idiom: find where a constant is passed, take the callee.
GWCA_API uintptr_t FindCallAfterConst(int32_t value, int window = 6);
GWCA_API uintptr_t FindCallAfterConstWithArity(int32_t value, uint32_t nparams,
int window = 6);
// Does the resolved index take `expected` wasm params? True when it matches or cannot be determined; drive it via the macro.
GWCA_API bool CheckArity(uintptr_t func_index, uint32_t expected, const char* what);
// Same, plus the RESULT count -- the only guard catching the return-type mismatch that makes call_indirect trap.
GWCA_API bool CheckSignature(uintptr_t func_index, uint32_t expected_params,
uint32_t expected_results, const char* what);
// Resolve a code offset or bare index to a function index (0 if it does not resolve); GW::Hook uses it to find what it hooks.
GWCA_API uintptr_t FunctionAtCodeOffset(uintptr_t address);
// The function's start as a CODE OFFSET, which is what ToFunctionStart
// used to give. Only for scanning on from it -- FindInRange bounds and
// address arithmetic. ToFunctionStart now returns something callable,
// and an offset is not callable any more than a callable is scannable.
GWCA_API uintptr_t FunctionStartAddress(uintptr_t address, uint32_t scan_range = 0);
// Records that `callable` is `func_index`, so a scan result that has been
// made callable still resolves for CheckArity, GlobalFromFunction and
// CreateHook. GW::Hook fills this in as it adopts.
GWCA_API void NoteCallable(uintptr_t callable, uintptr_t func_index);
#endif
}
}
// Discard a scan result whose resolved function takes the wrong wasm parameter count. No-op on x86.
#if GWCA_WASM
// Discards with `= 0` not `= nullptr`, so it takes a raw uintptr_t scan result as well as a typed function pointer.
#define GWCA_SCAN_CHECK_ARITY(func, expected) \
do { if (!GW::Scanner::CheckArity((uintptr_t)(func), (uint32_t)(expected), #func)) (func) = 0; } while (0)
// Prefer over GWCA_SCAN_CHECK_ARITY when the result count is known: 0 for void, 1 otherwise.
#define GWCA_SCAN_CHECK_SIG(func, params, results) \
do { if (!GW::Scanner::CheckSignature((uintptr_t)(func), (uint32_t)(params), \
(uint32_t)(results), #func)) (func) = 0; } while (0)
#else
#define GWCA_SCAN_CHECK_ARITY(func, expected) ((void)0)
#define GWCA_SCAN_CHECK_SIG(func, params, results) ((void)0)
#endif
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
#include <GWCA/Utilities/Export.h>
// Guards against headers and binary being from different releases -- struct offsets shift and nothing else catches it.
#define GWCA_ABI_VERSION 0x04080700u
extern "C" {
// The version the binary was built at, against GWCA_ABI_VERSION which is what the caller compiled against.
GWCA_API uint32_t gwca_abi_version(void);
}
namespace GW {
// Call before anything else: a mismatch means every struct offset is suspect.
inline bool AbiVersionMatches()
{
return gwca_abi_version() == GWCA_ABI_VERSION;
}
}
+13
View File
@@ -1,5 +1,7 @@
#pragma once
// MSVC-only: every other compiler reports the whole block as unknown-pragma noise.
#ifdef _MSC_VER
#pragma warning(disable: 4619) // there is no warning number 'number'
#pragma warning(push)
@@ -20,6 +22,7 @@
#pragma warning(disable: 5027) // 'type': move assignment operator was implicitly defined as deleted
#pragma warning(disable: 5039) // 'function': pointer or reference to potentially throwing function passed to extern C function under -EHc. Undefined behavior may occur if this function throws an exception.
#pragma warning(disable: 5045) // Compiler will insert Spectre mitigation for memory load if /Qspectre switch specified
#endif
#ifndef _CRT_SECURE_NO_WARNINGS
# define _CRT_SECURE_NO_WARNINGS 1
@@ -49,10 +52,18 @@
# define WIN32_LEAN_AND_MEAN
#endif
// GWCA_WASM comes from the build, not a header, so an undefined macro takes the Win32 branch -- what a consumer wants.
#if GWCA_WASM
// No Win32 here: the shim keeps the few Windows typedefs in GWCA's public headers parsing, and does not ship.
#include <Platform/Wasm/Win32Shim.h>
#else
#include <Windows.h>
#include <ShellApi.h>
#endif
#ifdef _MSC_VER
#pragma warning(pop)
#endif
#ifndef _MSC_VER
// GCC/MinGW diagnostic-build compatibility shim (not needed on MSVC).
@@ -60,6 +71,7 @@
#endif
#ifdef _MSC_VER
#pragma warning(disable: 4061) // enumerator 'identifier' in switch of enum 'enumeration' is not explicitly handled by a case label
#pragma warning(disable: 4201) // nonstandard extension used : nameless struct/union
#pragma warning(disable: 4514) // 'function' : unreferenced inline function has been removed
@@ -77,3 +89,4 @@
#pragma warning(disable: 5027) // 'type': move assignment operator was implicitly defined as deleted
#pragma warning(disable: 5045) // Compiler will insert Spectre mitigation for memory load if /Qspectre switch specified
#pragma warning(disable: 28159) // Consider using 'GetTickCount64' instead of 'GetTickCount'.
#endif
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<NoWarn>$(NoWarn);IL2104;IL3053;IL3000;IL3002;NU1701;CS0108</NoWarn>
<Version>0.9.10.17</Version>
<Version>0.9.10.21</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
+28 -28
View File
@@ -7,40 +7,40 @@
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="DiffPlex" Version="1.9.0" />
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="HtmlAgilityPack" Version="1.13.0" />
<PackageVersion Include="ini-parser-netstandard" Version="2.5.3" />
<PackageVersion Include="MegaApiClient" Version="1.10.5" />
<PackageVersion Include="MemoryPack" Version="1.21.4" />
<PackageVersion Include="MemoryPack.Generator" Version="1.21.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageVersion Include="Microsoft.Authentication.WebAssembly.Msal" Version="9.0.8" />
<PackageVersion Include="Microsoft.CorrelationVector" Version="1.0.42" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.14.3" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.14.3" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.86.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.14.4" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.14.4" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.88.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<PackageVersion Include="MinHook.NET" Version="1.1.2" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.3.2" />
<PackageVersion Include="MSTest.TestFramework" Version="4.3.2" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.3.3" />
<PackageVersion Include="MSTest.TestFramework" Version="4.3.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
<PackageVersion Include="Net.Sdk.Web.Extensions" Version="0.8.13" />
<PackageVersion Include="Net.Sdk.Web.Extensions.SourceGenerators" Version="0.9.6" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Process" Version="1.16.0-rc.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Process" Version="1.18.0-rc.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
<PackageVersion Include="PeNet" Version="6.1.2" />
<PackageVersion Include="Photino.Blazor" Version="4.0.13" />
<PackageVersion Include="Plumsy" Version="1.2.0" />
@@ -54,14 +54,14 @@
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="SevenZipExtractor" Version="1.0.19" />
<PackageVersion Include="SharpCompress" Version="0.50.0" />
<PackageVersion Include="StbImageSharp" Version="2.30.15" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.15" />
<PackageVersion Include="SharpCompress" Version="0.50.4" />
<PackageVersion Include="StbImageSharp" Version="2.30.16" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.2" />
<PackageVersion Include="Sybil" Version="0.8.4" />
<PackageVersion Include="System.IO.Compression" Version="4.3.0" />
<PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="10.0.10" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.10" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.10" />
<PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="10.0.11" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.11" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.11" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Reflection.Metadata" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
+12 -13
View File
@@ -1,21 +1,20 @@
MIT License
Copyright (c) 2025 Macocian Alexandru Victor
Copyright (c) 2026 Macocian Alexandru Victor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+72 -22
View File
@@ -1,6 +1,6 @@
# Daybreak
<img width="754" height="716" alt="image" src="https://github.com/user-attachments/assets/3eb74d22-a3ac-4463-8d26-8142cea3d237" />
![Daybreak launcher][screenshot]
Custom launcher for Guild Wars.
@@ -14,11 +14,18 @@ Custom launcher for Guild Wars.
Please check the [wiki](https://github.com/AlexMacocian/Daybreak/wiki) for
project description and features.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for the branching model and release
process.
______________________________________________________________________
## Architecture Overview
Daybreak is a cross-platform application built with .NET 10 and Photino.Blazor. The launcher provides a native UI on each platform while sharing the majority of business logic through a common core library.
Daybreak is a cross-platform application built with .NET 10 and Photino.Blazor.
The launcher provides a native UI on each platform while sharing the majority of
business logic through a common core library.
### Project Structure
@@ -56,17 +63,17 @@ graph TB
### Component Descriptions
| Project | Description |
| ------- | ----------- |
| **Daybreak.Windows** | Windows executable with WebView2, MSAL authentication, shortcuts, and native screen management |
| **Daybreak.Linux** | Linux executable using GTK/WebKit via Photino, with Wine-based game injection |
| **Daybreak.Core** | Shared Blazor UI, services, and configuration (multi-targeted for Windows-specific features) |
| **Daybreak.Shared** | Common models, utilities, and interfaces used across all projects |
| **Daybreak.Injector** | NativeAOT x86 executable that injects DLLs into the Guild Wars process |
| **Daybreak.API** | NativeAOT x86 library injected into Guild Wars, exposes game data via WebSocket/REST |
| **Daybreak.Installer** | Standalone installer/updater executable |
| Project | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------- |
| **Daybreak.Windows** | Windows executable with WebView2, MSAL authentication, shortcuts, and native screen management |
| **Daybreak.Linux** | Linux executable using GTK/WebKit via Photino, with Wine-based game injection |
| **Daybreak.Core** | Shared Blazor UI, services, and configuration (multi-targeted for Windows-specific features) |
| **Daybreak.Shared** | Common models, utilities, and interfaces used across all projects |
| **Daybreak.Injector** | NativeAOT x86 executable that injects DLLs into the Guild Wars process |
| **Daybreak.API** | NativeAOT x86 library injected into Guild Wars, exposes game data via WebSocket/REST |
| **Daybreak.Installer** | Standalone installer/updater executable |
---
______________________________________________________________________
## Build Requirements
@@ -118,11 +125,13 @@ In addition to .NET, the following are required to run Daybreak on Linux:
- **GTK 3** and **WebKitGTK** (for Photino)
- **Wine** (for running the Windows-based injector and Guild Wars)
---
______________________________________________________________________
## Wine Integration (Linux)
On Linux, Daybreak uses Wine to run the Windows-based `Daybreak.Injector.exe` and Guild Wars itself. This section documents the architecture and implementation plan.
On Linux, Daybreak uses Wine to run the Windows-based `Daybreak.Injector.exe`
and Guild Wars itself. This section documents the architecture and
implementation plan.
### Architecture
@@ -151,7 +160,8 @@ graph LR
### Wine Prefix Management
Daybreak manages a single dedicated Wine prefix for all Wine operations. This is handled by `IWinePrefixManager`:
Daybreak manages a single dedicated Wine prefix for all Wine operations. This is
handled by `IWinePrefixManager`:
```csharp
public interface IWinePrefixManager
@@ -184,11 +194,12 @@ public interface IWinePrefixManager
### Path Translation
Wine exposes the Linux filesystem through the `Z:` drive. The `PathUtils` class provides a method to convert native Linux paths to Wine-compatible paths:
Wine exposes the Linux filesystem through the `Z:` drive. The `PathUtils` class
provides a method to convert native Linux paths to Wine-compatible paths:
| Linux Path | Wine Path |
|------------|-----------|
| `/mnt/games/Guild Wars/Gw.exe` | `Z:/mnt/games/Guild Wars/Gw.exe` |
| Linux Path | Wine Path |
| ----------------------------------------------------- | ------------------------------------------------------- |
| `/mnt/games/Guild Wars/Gw.exe` | `Z:/mnt/games/Guild Wars/Gw.exe` |
| `/home/user/.daybreak/Injector/Daybreak.Injector.exe` | `Z:/home/user/.daybreak/Injector/Daybreak.Injector.exe` |
```csharp
@@ -200,10 +211,49 @@ public static string ToWinePath(string linuxPath)
}
```
---
### Wine Debug Log
Debug builds append all Wine output to `wine-debug.log` in the Daybreak output
folder:
```text
Daybreak.Linux/bin/Debug/net10.0/linux-x64/wine-debug.log
```
This is the place to look when Guild Wars dies without an error. Guild Wars is a
grandchild of the Wine process Daybreak starts, so its stderr would otherwise go
to a pipe nobody reads and the crash report would be lost. Wine runs
`winedbg --auto` on a crash, so the log contains the faulting stack — including
managed frames when `Daybreak.API` is at fault:
```text
err:eventlog:ReportEventW L"Message: Access Violation: Attempted to read or write protected memory..."
err:eventlog:ReportEventW L" at Daybreak.API.Interop.GuildWars.GuildWarsArray`1.Enumerator.get_Current()"
err:eventlog:ReportEventW L" at Daybreak.API.Services.CharacterSelectService...
err:seh:NtRaiseException Unhandled exception code c0000409
```
Note that `Daybreak.API` is NativeAOT, which cannot throw
`AccessViolationException`. A bad pointer read therefore calls `FailFast` and
terminates Guild Wars instantly, with nothing written to `Daybreak.API.log`.
Each launch is delimited by a `===== <timestamp> <command> =====` header. The
log is append-only, so delete it when it gets large.
Environment variables:
| Variable | Effect |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `DAYBREAK_WINE_DEBUG` | `1` forces logging on, `0` off. Defaults to on for Debug builds, off for Release. |
| `DAYBREAK_WINE_DEBUG_CHANNELS` | Sets `WINEDEBUG`, e.g. `+seh`. Unset by default — Wine's default `err` class already reports crashes, and trace channels slow the game badly. |
______________________________________________________________________
## Credits
- Daybreak project is distributed under [MIT license](https://mit-license.org/)
- Tango icons - [LordBiro](https://wiki.guildwars.com/wiki/User:LordBiro)
- Icons `Daybreak/wwwroot/img/tango` are distributed under [GFDL license](https://en.wikipedia.org/wiki/GNU_Free_Documentation_License)
- Icons `Daybreak/wwwroot/img/tango` are distributed under
[GFDL license](https://en.wikipedia.org/wiki/GNU_Free_Documentation_License)
[screenshot]: https://github.com/user-attachments/assets/3eb74d22-a3ac-4463-8d26-8142cea3d237
+32 -14
View File
@@ -19,6 +19,15 @@ set -euo pipefail
# ─── configuration ────────────────────────────────────────────────────────────
DOTNET_SDK_CHANNEL="${DOTNET_SDK_CHANNEL:-10.0}"
# Pinned, not "latest": the SDK's bundled Roslyn must be >= the
# Microsoft.CodeAnalysis.CSharp version referenced by Daybreak.Generators
# (see Directory.Packages.props). A generator built against a newer Roslyn is
# rejected with CS9057 and silently dropped, which makes Daybreak.API fail to
# compile with a wall of "GWCAEquivalentAttribute could not be found" errors.
# SDK 10.0.300 -> Roslyn 5.6.0.0
# SDK 10.0.400 -> Roslyn 5.9.0.0
# Set DOTNET_SDK_VERSION=latest to track the newest SDK in the channel.
DOTNET_SDK_VERSION="${DOTNET_SDK_VERSION:-10.0.400}"
LLVM_VERSION="${LLVM_VERSION:-22.1.5}"
XWIN_VERSION="${XWIN_VERSION:-0.9.0}"
WINEPREFIX_DEFAULT="$HOME/.wine-daybreak"
@@ -68,21 +77,28 @@ fi
# ─── 2. .NET SDK ──────────────────────────────────────────────────────────────
DOTNET_DIR="$WINEPREFIX/drive_c/dotnet"
if [[ ! -f "$DOTNET_DIR/dotnet.exe" ]]; then
log "Resolving latest .NET $DOTNET_SDK_CHANNEL SDK"
SDK_INFO=$(curl -fsSL "https://builds.dotnet.microsoft.com/dotnet/release-metadata/${DOTNET_SDK_CHANNEL}/releases.json" | python3 -c "
log "Resolving .NET SDK ($DOTNET_SDK_VERSION) from channel $DOTNET_SDK_CHANNEL"
SDK_INFO=$(curl -fsSL "https://builds.dotnet.microsoft.com/dotnet/release-metadata/${DOTNET_SDK_CHANNEL}/releases.json" | python3 -c "
import json,sys
want=sys.argv[1]
d=json.load(sys.stdin)
v=d['latest-sdk']
if want=='latest':
want=d['latest-sdk']
for r in d['releases']:
if r['sdk']['version']==v:
for f in r['sdk']['files']:
for s in ([r['sdk']] + r.get('sdks',[])):
if s['version']!=want:
continue
for f in s['files']:
if f['rid']=='win-x64' and f['name'].endswith('.zip'):
print(v); print(f['url']); sys.exit(0)
sys.exit('No win-x64 SDK zip found')
")
SDK_VER=$(echo "$SDK_INFO" | sed -n '1p')
SDK_URL=$(echo "$SDK_INFO" | sed -n '2p')
print(want); print(f['url']); sys.exit(0)
sys.exit(f'No win-x64 SDK zip found for {want}')
" "$DOTNET_SDK_VERSION")
SDK_VER=$(echo "$SDK_INFO" | sed -n '1p')
SDK_URL=$(echo "$SDK_INFO" | sed -n '2p')
# Keyed on the versioned SDK directory, not dotnet.exe: bumping
# DOTNET_SDK_VERSION must actually install the new SDK into an existing prefix.
if [[ ! -d "$DOTNET_DIR/sdk/$SDK_VER" ]]; then
SDK_ZIP="$CACHE_DIR/dotnet-sdk-${SDK_VER}-win-x64.zip"
if [[ ! -f "$SDK_ZIP" ]]; then
@@ -94,10 +110,12 @@ sys.exit('No win-x64 SDK zip found')
fi
mkdir -p "$DOTNET_DIR"
log "Extracting SDK into $DOTNET_DIR"
(cd "$DOTNET_DIR" && unzip -q "$SDK_ZIP")
# -o so a newer SDK lands side-by-side and refreshes the shared host/runtime.
# With no global.json in the repo, dotnet then selects the highest SDK.
log "Extracting SDK $SDK_VER into $DOTNET_DIR"
(cd "$DOTNET_DIR" && unzip -qo "$SDK_ZIP")
else
log ".NET SDK already present in $DOTNET_DIR"
log ".NET SDK $SDK_VER already present in $DOTNET_DIR"
fi
# ─── 3. LLVM Windows binaries (lld-link.exe, llvm-lib.exe) ───────────────────
+106
View File
@@ -0,0 +1,106 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// Downloads the GWToolbox API's skill catalogue — the single source of truth
/// for every skill *value* the generator emits. One request; no throttling
/// needed, unlike <see cref="WikiHttpClient"/>.
/// </summary>
internal sealed class GwToolboxClient(string userAgent) : IDisposable
{
private const string SkillsUrl = "https://api.gwtoolbox.com/v1/en/skills.json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient httpClient = CreateHttpClient(userAgent);
private static HttpClient CreateHttpClient(string userAgent)
{
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
/// <summary>Fetches the catalogue indexed by skill id.</summary>
public async Task<GwToolboxCatalog> FetchSkillsAsync(CancellationToken cancellationToken)
{
await using var stream = await this.httpClient.GetStreamAsync(SkillsUrl, cancellationToken);
var skills = await JsonSerializer.DeserializeAsync<List<GwToolboxSkill>>(stream, SerializerOptions, cancellationToken)
?? throw new InvalidOperationException($"GET {SkillsUrl} returned no skills.");
return new GwToolboxCatalog(skills);
}
public void Dispose() => this.httpClient.Dispose();
}
/// <summary>
/// The API's skills indexed for lookup by id, and — for the wiki pages that
/// omit the id — by name.
/// </summary>
internal sealed partial class GwToolboxCatalog
{ private readonly Dictionary<int, GwToolboxSkill> byId;
private readonly Dictionary<string, List<GwToolboxSkill>> byName;
public GwToolboxCatalog(IReadOnlyList<GwToolboxSkill> skills)
{
this.byId = new Dictionary<int, GwToolboxSkill>(skills.Count);
this.byName = new Dictionary<string, List<GwToolboxSkill>>(StringComparer.Ordinal);
foreach (var skill in skills)
{
this.byId[skill.Id] = skill;
var name = NormalizeName(skill.Name);
if (name.Length == 0 || name == "(none)")
{
continue;
}
if (!this.byName.TryGetValue(name, out var bucket))
{
this.byName[name] = bucket = [];
}
bucket.Add(skill);
}
}
public int Count => this.byId.Count;
public bool TryGetById(int id, out GwToolboxSkill skill) => this.byId.TryGetValue(id, out skill!);
/// <summary>
/// Resolves a skill by name, but only when the name identifies exactly one
/// — the client reuses names freely, so anything else is ambiguous.
/// </summary>
public bool TryGetUniqueByName(string name, out GwToolboxSkill skill)
{
skill = null!;
if (!this.byName.TryGetValue(NormalizeName(name), out var bucket) || bucket.Count != 1)
{
return false;
}
skill = bucket[0];
return true;
}
/// <summary>
/// Matches the wiki's convention: collapsed whitespace (some API names
/// carry a double space before a "(PvP)" suffix) and no surrounding quotes
/// (which the API keeps on shouts and the wiki page titles do not).
/// </summary>
private static string NormalizeName(string? name)
{
var trimmed = WhitespaceRegex().Replace(name ?? string.Empty, " ").Trim();
return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
? trimmed[1..^1]
: trimmed;
}
[GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex();
}
+78
View File
@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// One record of <c>https://api.gwtoolbox.com/v1/en/skills.json</c>, scanned
/// straight out of the Guild Wars client. Only the fields the generator needs
/// are modelled.
/// </summary>
/// <remarks>
/// The API omits any key equal to its default (<c>0</c>, <c>""</c>, <c>null</c>)
/// to keep the payload small, so every field here is nullable and a missing key
/// must be read as its zero value — see the "Omitted default-valued keys"
/// section of the API's README.
/// </remarks>
internal sealed class GwToolboxSkill
{
[JsonPropertyName("id")] public int Id { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("concise")] public string? Concise { get; init; }
[JsonPropertyName("campaign")] public int Campaign { get; init; }
[JsonPropertyName("profession")] public int Profession { get; init; }
/// <summary>
/// <c>GW::Constants::AttributeByte</c>, absent when the skill has no
/// governing attribute. Id 0 (Fast Casting) is emitted explicitly, so a
/// missing key is "none" rather than "Fast Casting".
/// </summary>
[JsonPropertyName("attribute")] public int? Attribute { get; init; }
/// <summary><c>GW::Constants::SkillType</c> — a single value, not a bitmask.</summary>
[JsonPropertyName("type")] public int Type { get; init; }
/// <summary>
/// Encoded cost rather than the literal one: values up to 10 are the cost
/// itself, 11 means 15 and 12 means 25.
/// </summary>
[JsonPropertyName("energy_cost")] public int EnergyCost { get; init; }
/// <summary>Adrenaline in the client's internal units; 25 per strike.</summary>
[JsonPropertyName("adrenaline")] public int Adrenaline { get; init; }
[JsonPropertyName("overcast")] public int Overcast { get; init; }
/// <summary>Sacrificed health as a whole percentage (e.g. 17 for 17%).</summary>
[JsonPropertyName("health_cost")] public int HealthCost { get; init; }
[JsonPropertyName("activation")] public double Activation { get; init; }
[JsonPropertyName("recharge")] public double Recharge { get; init; }
/// <summary>
/// Effect duration at 0 ranks, in game time units.
/// <see cref="SkillMapper.MaintainedDuration"/> is the client's sentinel for
/// a maintained enchantment, which is what Daybreak models as upkeep.
/// </summary>
[JsonPropertyName("duration0")] public int Duration0 { get; init; }
/// <summary>Raw weapon-type requirement bitmask; see <see cref="SkillMapper"/>.</summary>
[JsonPropertyName("weapon_req")] public int WeaponRequirement { get; init; }
/// <summary>Dagger attack-chain position: 1 lead, 2 off-hand, 3 dual.</summary>
[JsonPropertyName("combo")] public int Combo { get; init; }
[JsonPropertyName("elite")] public int Elite { get; init; }
[JsonPropertyName("touch_range")] public int TouchRange { get; init; }
[JsonPropertyName("pve_only")] public int PvEOnly { get; init; }
[JsonPropertyName("pvp_only")] public int PvPOnly { get; init; }
}
+1 -1
View File
@@ -29,7 +29,7 @@ internal sealed class IconResolver(WikiHttpClient client)
private const int BatchSize = 25;
public async Task<IReadOnlyDictionary<string, string>> ResolveAsync(
IReadOnlyList<ParsedSkill> skills,
IReadOnlyList<WikiSkillEntry> skills,
CancellationToken cancellationToken)
{
// Track candidate (skillName, filename) pairs across passes; a skill
+27
View File
@@ -0,0 +1,27 @@
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// A skill in the exact shape the writer will emit it: every value is already
/// the literal C# token it should appear as in <c>Skill.g.cs</c>. This is
/// deliberately tool-specific — the runtime <c>WikiService</c> has its own
/// parser in <c>Daybreak.Shared</c> built around typed model objects.
/// </summary>
public sealed record ParsedSkill(
int Id,
string Name,
string CampaignIdentifier,
string ProfessionIdentifier,
string AttributeIdentifier,
bool PvEOnly,
bool PvP,
bool Elite,
string TypeExpression,
double? Energy,
double? Activation,
double? Recharge,
double? Overcast,
double? Adrenaline,
double? Sacrifice,
double? Upkeep,
string Description,
string ConciseDescription);
+51 -9
View File
@@ -24,23 +24,65 @@ internal static class Program
try
{
using var client = new WikiHttpClient(UserAgent);
var enumerator = new SkillEnumerator(client);
using var wikiClient = new WikiHttpClient(UserAgent);
using var apiClient = new GwToolboxClient(UserAgent);
Console.WriteLine("Enumerating skills from wiki…");
var skills = await enumerator.EnumerateAsync(cancellationSource.Token);
Console.WriteLine($"Collected {skills.Count} skills.");
Console.WriteLine("Fetching skill data from api.gwtoolbox.com…");
var apiSkills = await apiClient.FetchSkillsAsync(cancellationSource.Token);
Console.WriteLine($"Fetched {apiSkills.Count} skill records.");
Console.WriteLine();
var iconResolver = new IconResolver(client);
var iconUrls = await iconResolver.ResolveAsync(skills, cancellationSource.Token);
var enumerator = new SkillEnumerator(wikiClient);
Console.WriteLine("Enumerating the skill roster from the wiki…");
var roster = await enumerator.EnumerateAsync(cancellationSource.Token);
Console.WriteLine($"Collected {roster.Count} skill pages.");
Console.WriteLine();
var skills = new List<ParsedSkill>();
var warnings = new List<string>();
foreach (var entry in roster)
{
// Monster and environment skill pages routinely omit the id
// from their infobox. The API still knows them, so fall back to
// its name index rather than emitting an unusable id-0 entry.
if (entry.Ids.Count == 0)
{
if (apiSkills.TryGetUniqueByName(entry.Name, out var byName))
{
skills.Add(SkillMapper.Map(byName.Id, entry.Name, byName));
}
else
{
warnings.Add($"skipped '{entry.Name}': the wiki lists no id and the name is not unique in the API");
}
continue;
}
foreach (var id in entry.Ids)
{
if (!apiSkills.TryGetById(id, out var apiSkill))
{
warnings.Add($"skipped '{entry.Name}' (id {id}): no record in the API");
continue;
}
skills.Add(SkillMapper.Map(id, entry.Name, apiSkill));
}
}
Console.WriteLine($"Mapped {skills.Count} skills from API data.");
Console.WriteLine();
var iconResolver = new IconResolver(wikiClient);
var iconUrls = await iconResolver.ResolveAsync(roster, cancellationSource.Token);
Console.WriteLine();
Console.WriteLine("Rendering Skill.g.cs…");
var (content, warnings) = SkillFileWriter.Render(skills, iconUrls);
var (content, renderWarnings) = SkillFileWriter.Render(skills, iconUrls);
await File.WriteAllTextAsync(skillFile, content, cancellationSource.Token);
Console.WriteLine($"Wrote {content.Length:N0} chars to {skillFile}");
foreach (var warn in warnings)
foreach (var warn in renderWarnings.Concat(warnings))
{
Console.Error.WriteLine($" ! {warn}");
}
+45 -7
View File
@@ -1,8 +1,10 @@
# SkillUpdater
One-shot console tool that regenerates `Daybreak.Shared/Models/Guildwars/Skill.g.cs`
from <https://wiki.guildwars.com>. The wiki is the single source of truth for skill
data — this tool does not read the existing `Skill.g.cs`.
One-shot console tool that regenerates `Daybreak.Shared/Models/Guildwars/Skill.g.cs`.
Skill *values* come from the GWToolbox API
(<https://api.gwtoolbox.com/v1/en/skills.json>), which is generated straight from
the Guild Wars client; <https://wiki.guildwars.com> supplies only the roster and
the icon URLs. This tool does not read the existing `Skill.g.cs`.
## Run
@@ -10,16 +12,52 @@ data — this tool does not read the existing `Skill.g.cs`.
dotnet run --project Tools/SkillUpdater
```
## Why two sources
The API is the source of truth for every value because it is the client's own
data: descriptions arrive already rendered ("20...44...50% faster") instead of
as wiki markup the generator has to interpret (`{{gr|20|50||%}}`), and the
numbers behind them are exact.
The wiki is kept for two things the API cannot provide:
- **The roster.** The client's skill names are not unique — four different
skills are called "Charm Animal", and ~1,600 unreleased/internal skills sit
alongside the live ones. The wiki disambiguates them ("Charm Animal (White
Mantle)"), and those unique names are what the generated C# identifiers and
the icon lookup are keyed on.
- **The icons.** The API serves DDS textures; Daybreak's builder and UI load the
wiki's JPEGs dynamically.
## What it does
1. Enumerates every skill page via the MediaWiki API
1. Downloads the API's skill catalogue and indexes it by skill id.
2. Enumerates every skill page via the MediaWiki API
(`generator=categorymembers` over the five campaign categories
`Core_skills`, `Prophecies_skills`, `Factions_skills`, `Nightfall_skills`,
`Eye_of_the_North_skills`) with `prop=revisions&rvslots=main&rvprop=content`.
2. Filters to pages containing a `{{Skill infobox}}` template.
3. Parses each infobox via the tool-local `WikiSkillParser`.
`Eye_of_the_North_skills`) with `prop=revisions&rvslots=main&rvprop=content`,
filters to pages containing a `{{Skill infobox}}` template, and reads the
page title plus the infobox's `id` field.
3. Maps each id's API record onto Daybreak's model (`SkillMapper`).
4. Resolves the canonical CDN URL for each skill icon by batched
`prop=imageinfo` queries — preferring the high-resolution
`<Name> (large).jpg`, falling back to `<Name>.jpg`, leaving the URL
empty when neither file exists.
5. Writes a sorted, grouped `Skill.g.cs`.
## Mapping notes
`SkillMapper` translates the client's encodings, which are not Daybreak's:
| Daybreak | API | Translation |
| --- | --- | --- |
| `Energy` | `energy_cost` | An encoded cost: `11` means 15, `12` means 25. |
| `Adrenaline` | `adrenaline` | Stored in 25ths of a strike; rounded up. |
| `Sacrifice` | `health_cost` | A whole percentage; Daybreak stores a fraction. |
| `Upkeep` | `duration0` | Not a field — `131072` is the "maintained" sentinel, and those are exactly the `Upkeep = -1` skills. |
| `Type` | `type` (+ `weapon_req`, `combo`, `profession`, `touch_range`, `activation`) | `GW::Constants::SkillType` is one value where Daybreak's `SkillType` is a flags enum, so sub-type flags (`Touch`, `Flash`, weapon, `Lead`/`OffHand`/`Dual`, `Binding`/`Nature`/`EbonVanguard`) are recovered from the accompanying fields. |
`campaign`, `profession` and `attribute` ids already match Daybreak's own, so
they are looked up directly. Note that the API omits any key equal to its
default, so a missing `energy_cost` means 0 — and a missing `attribute` means
the skill has no governing attribute.
+28 -30
View File
@@ -10,6 +10,11 @@ namespace Daybreak.Tools.SkillUpdater;
/// <c>gcmcontinue</c>. Pages without a <c>{{Skill infobox}}</c> block (category
/// indexes, "List of …" pages) are skipped. De-duplicates by page id.
/// </summary>
/// <remarks>
/// This yields the roster only — the page's name and the ids it covers. The
/// values behind each id come from the GWToolbox API; see
/// <see cref="WikiSkillEntry"/>.
/// </remarks>
internal sealed class SkillEnumerator(WikiHttpClient client)
{
private const string ApiBase = "https://wiki.guildwars.com/api.php";
@@ -23,15 +28,15 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
"Eye_of_the_North_skills",
];
public async Task<IReadOnlyList<ParsedSkill>> EnumerateAsync(CancellationToken cancellationToken)
public async Task<IReadOnlyList<WikiSkillEntry>> EnumerateAsync(CancellationToken cancellationToken)
{
var seen = new Dictionary<int, ParsedSkill>();
var seen = new Dictionary<int, WikiSkillEntry>();
foreach (var category in CampaignCategories)
{
Console.WriteLine($"-> Category:{category}");
var newCount = 0;
var skipped = 0;
await foreach (var (pageId, skill, isSkill) in this.EnumerateCategoryAsync(category, cancellationToken))
await foreach (var (pageId, entry, isSkill) in this.EnumerateCategoryAsync(category, cancellationToken))
{
if (!isSkill)
{
@@ -39,7 +44,7 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
continue;
}
if (seen.TryAdd(pageId, skill!))
if (seen.TryAdd(pageId, entry!))
{
newCount++;
}
@@ -51,7 +56,7 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
return [.. seen.Values];
}
private async IAsyncEnumerable<(int PageId, ParsedSkill? Skill, bool IsSkill)> EnumerateCategoryAsync(
private async IAsyncEnumerable<(int PageId, WikiSkillEntry? Entry, bool IsSkill)> EnumerateCategoryAsync(
string category,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -78,10 +83,10 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
{
foreach (var page in pages.EnumerateArray())
{
if (TryProjectSkill(page, out var pageId, out var skill))
if (TryProjectEntry(page, out var pageId, out var entry))
{
batchSkills++;
yield return (pageId, skill, true);
yield return (pageId, entry, true);
}
else
{
@@ -96,10 +101,10 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
while (gcmcontinue is not null);
}
private static bool TryProjectSkill(JsonElement page, out int pageId, out ParsedSkill skill)
private static bool TryProjectEntry(JsonElement page, out int pageId, out WikiSkillEntry entry)
{
pageId = 0;
skill = null!;
entry = null!;
if (!page.TryGetProperty("title", out var titleEl) ||
!page.TryGetProperty("pageid", out var pageIdEl))
{
@@ -131,33 +136,26 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
return false;
}
if (!WikiSkillParser.TryParse(content, out var parsed))
{
Console.Error.WriteLine($" ! parse failed for '{title}' (pageid {pageIdEl.GetInt32()})");
return false;
}
// Monster and environment skill pages routinely omit the id from their
// infobox; keep them in the roster with no ids so the mapper can fall
// back to resolving them by name against the API.
WikiSkillRosterParser.TryParseIds(content, out var ids);
// The infobox `name` field can omit the `(PvP)` suffix even though the
// page title carries it. Prefer the page title as the canonical name,
// stripping the surrounding quotes some shout titles carry — and
// remember the quoted form so the icon resolver can try it as well
// (image files keep the quotes in their filenames).
// The page title is the canonical name: the infobox `name` field can
// omit the `(PvP)` suffix the title carries. Strip the surrounding
// quotes some shout titles carry — and remember the quoted form so the
// icon resolver can try it as well (image files keep the quotes in
// their filenames).
var rawTitle = title.Trim();
var isQuoted = rawTitle.Length >= 2 && rawTitle[0] == '"' && rawTitle[^1] == '"';
var canonicalTitle = isQuoted ? rawTitle[1..^1] : rawTitle;
if (!string.Equals(parsed.Name, canonicalTitle, StringComparison.Ordinal))
{
parsed = parsed with { Name = canonicalTitle };
}
var iconBaseNames = isQuoted
? (IReadOnlyList<string>)[canonicalTitle, rawTitle]
: [canonicalTitle];
parsed = parsed with { IconBaseNames = iconBaseNames };
pageId = pageIdEl.GetInt32();
skill = parsed;
entry = new WikiSkillEntry(canonicalTitle, ids)
{
IconBaseNames = isQuoted ? [canonicalTitle, rawTitle] : [canonicalTitle],
};
return true;
}
+3 -16
View File
@@ -14,7 +14,8 @@ internal static class SkillFileWriter
private const string Header = """
// <auto-generated>
// Generated by Tools/SkillUpdater. Do not edit manually — re-run
// `dotnet run --project Tools/SkillUpdater` to refresh from the wiki.
// `dotnet run --project Tools/SkillUpdater` to refresh from the
// GWToolbox API.
// </auto-generated>
#nullable enable
using Daybreak.Shared.Models.Guildwars;
@@ -35,23 +36,9 @@ internal static class SkillFileWriter
{
// Expand skills carrying multiple ids (e.g. "Save Yourselves!" with
// 1954 Luxon + 2097 Kurzick) into one emitted entry per id.
var expanded = new List<ParsedSkill>();
foreach (var skill in skills)
{
expanded.Add(skill);
for (var i = 0; i < skill.AdditionalIds.Count; i++)
{
expanded.Add(skill with
{
Id = skill.AdditionalIds[i],
AdditionalIds = [],
});
}
}
// Stable ordering: by id ascending so identifier collisions get resolved
// deterministically (lower id keeps the bare identifier).
var ordered = expanded
var ordered = skills
.OrderBy(s => s.Id)
.ThenBy(s => s.Name, StringComparer.Ordinal)
.ToList();
+370
View File
@@ -0,0 +1,370 @@
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// Turns one <see cref="GwToolboxSkill"/> into the literal C# tokens the writer
/// emits. Every value Daybreak stores comes from here; the wiki only supplies
/// the roster (which ids exist, under which display name) and the icon URL.
/// </summary>
/// <remarks>
/// Where the client's data model differs from Daybreak's, the translation is
/// derived from the client's own encoding rather than guessed:
/// <list type="bullet">
/// <item>energy is an encoded cost, not a literal one (11 → 15, 12 → 25);</item>
/// <item>adrenaline is stored in 25ths of a strike;</item>
/// <item>health cost is a whole percentage where Daybreak stores a fraction;</item>
/// <item>upkeep is not a field at all — it is what the client's "maintained"
/// duration sentinel means;</item>
/// <item><c>GW::Constants::SkillType</c> is a single value, where Daybreak's
/// <c>SkillType</c> is a flags enum, so the sub-type flags are recovered
/// from the accompanying weapon/combo/profession fields.</item>
/// </list>
/// </remarks>
internal static class SkillMapper
{
/// <summary>
/// <c>duration0</c> of a maintained enchantment. The client has no upkeep
/// field; a maintained enchantment is exactly the set of skills Daybreak
/// records as <c>Upkeep = -1</c>.
/// </summary>
public const int MaintainedDuration = 131072;
/// <summary>Adrenaline units the client stores per strike.</summary>
private const int AdrenalinePerStrike = 25;
/// <summary>
/// The client's "this skill has no attribute" placeholder, which the API
/// used to publish verbatim before it started omitting the key instead.
/// </summary>
private const int NoAttribute = 51;
public static ParsedSkill Map(int id, string name, GwToolboxSkill skill) =>
new(
Id: id,
Name: name,
CampaignIdentifier: ResolveCampaign(skill.Campaign),
ProfessionIdentifier: ResolveProfession(skill.Profession),
AttributeIdentifier: ResolveAttribute(skill.Attribute),
PvEOnly: skill.PvEOnly != 0,
PvP: skill.PvPOnly != 0,
Elite: skill.Elite != 0,
TypeExpression: ResolveSkillType(skill),
Energy: NullIfZero(DecodeEnergyCost(skill.EnergyCost)),
Activation: NullIfZero(skill.Activation),
Recharge: NullIfZero(skill.Recharge),
Overcast: NullIfZero(skill.Overcast),
Adrenaline: NullIfZero(DecodeAdrenaline(skill.Adrenaline)),
Sacrifice: NullIfZero(skill.HealthCost / 100.0),
Upkeep: skill.Duration0 == MaintainedDuration ? -1 : null,
Description: skill.Description ?? string.Empty,
ConciseDescription: skill.Concise ?? string.Empty);
private static double? NullIfZero(double value) => value == 0 ? null : value;
/// <summary>
/// The client stores energy as a code, not a cost: everything up to 10 is
/// the cost itself, and the two costs above it get their own codes.
/// </summary>
private static double DecodeEnergyCost(int raw) => raw switch
{
11 => 15,
12 => 25,
_ => raw,
};
/// <summary>
/// Adrenaline is stored in 25ths of a strike, but a skill costing a whole
/// number of strikes can still hold a value that is not a clean multiple
/// (an 80 that means four strikes), so the count rounds up.
/// </summary>
private static double DecodeAdrenaline(int raw) =>
raw == 0 ? 0 : Math.Ceiling(raw / (double)AdrenalinePerStrike);
private static string ResolveCampaign(int campaign) =>
campaign >= 0 && campaign < Campaigns.Length ? Campaigns[campaign] : "None";
private static string ResolveProfession(int profession) =>
profession >= 0 && profession < Professions.Length ? Professions[profession] : "None";
private static string ResolveAttribute(int? attribute) =>
attribute is not int id || id == NoAttribute
? "None"
: Attributes.GetValueOrDefault(id, "None");
/// <summary>
/// Expands the client's single skill type into Daybreak's flag set, adding
/// the sub-type flags the type alone does not carry.
/// </summary>
private static string ResolveSkillType(GwToolboxSkill skill)
{
var flags = new List<string>();
void Add(string flag)
{
if (!flags.Contains(flag, StringComparer.Ordinal))
{
flags.Add(flag);
}
}
switch (skill.Type)
{
case GwSkillType.Stance: Add("Stance"); break;
case GwSkillType.Hex: Add("Hex"); Add("Spell"); break;
case GwSkillType.Spell: Add("Spell"); break;
case GwSkillType.Enchantment:
// A flash enchantment is one that takes no time to cast.
if (skill.Activation == 0)
{
Add("Flash");
}
Add("Enchantment");
Add("Spell");
break;
case GwSkillType.Signet: Add("Signet"); break;
case GwSkillType.Well: Add("Well"); Add("Spell"); break;
case GwSkillType.Skill or GwSkillType.Skill2 or GwSkillType.Passive or GwSkillType.Environmental:
Add("Skill");
break;
case GwSkillType.Ward: Add("Ward"); Add("Spell"); break;
case GwSkillType.Glyph: Add("Glyph"); break;
case GwSkillType.Attack:
AddWeaponFlags(skill, Add);
Add("Attack");
break;
case GwSkillType.Shout: Add("Shout"); break;
case GwSkillType.Preparation: Add("Preparation"); break;
case GwSkillType.PetAttack: Add("Pet"); Add("Attack"); break;
case GwSkillType.Trap or GwSkillType.EnvironmentalTrap: Add("Trap"); break;
case GwSkillType.Ritual:
Add(ResolveRitualFlag(skill.Profession));
Add("Ritual");
break;
case GwSkillType.ItemSpell: Add("Item"); Add("Spell"); break;
case GwSkillType.WeaponSpell: Add("Weapon"); Add("Spell"); break;
case GwSkillType.Form: Add("Form"); break;
case GwSkillType.Chant: Add("Chant"); break;
case GwSkillType.EchoRefrain: Add("Echo"); break;
default:
// Bounty, Scroll, Condition, Title and Disguise have no Daybreak
// counterpart; they are effects rather than usable skills.
break;
}
if (skill.TouchRange != 0)
{
flags.Insert(0, "Touch");
}
return flags.Count == 0
? "SkillType.None"
: string.Join(" | ", flags.Select(f => $"SkillType.{f}"));
}
/// <summary>
/// A ritual's kind is not stored; the profession that owns it is what tells
/// binding, nature and Ebon Vanguard rituals apart.
/// </summary>
private static string ResolveRitualFlag(int profession) => profession switch
{
GwProfession.Ritualist => "Binding",
GwProfession.Ranger => "Nature",
_ => "EbonVanguard",
};
/// <summary>
/// Recovers the attack's weapon flags from the requirement bitmask. A mask
/// naming exactly one weapon is that weapon; one naming several is a
/// generic melee or ranged attack, which is how Daybreak models it.
/// </summary>
private static void AddWeaponFlags(GwToolboxSkill skill, Action<string> add)
{
var requirement = skill.WeaponRequirement;
if (requirement == 0)
{
return;
}
// Daggers have no flag of their own: a dagger attack is identified by
// its place in the attack chain instead.
if (requirement == GwWeapon.Daggers)
{
AddComboFlag(skill.Combo, add);
return;
}
if (SingleWeaponFlags.TryGetValue(requirement, out var weapon))
{
add(weapon);
return;
}
var meleeWeapons = System.Numerics.BitOperations.PopCount((uint)(requirement & GwWeapon.AnyMelee));
var rangedWeapons = System.Numerics.BitOperations.PopCount((uint)(requirement & GwWeapon.AnyRanged));
// A mask spanning both melee and ranged weapons is "any weapon at all",
// which is no restriction to record.
if (meleeWeapons > 1 && rangedWeapons > 1)
{
return;
}
if (meleeWeapons > 1)
{
add("Melee");
}
else if (rangedWeapons > 1)
{
add("Ranged");
}
}
/// <summary>Dagger attacks additionally carry their place in the attack chain.</summary>
private static void AddComboFlag(int combo, Action<string> add)
{
switch (combo)
{
case 1: add("Lead"); break;
case 2: add("OffHand"); break;
case 3: add("Dual"); break;
}
}
/// <summary><c>GW::Constants::SkillType</c>.</summary>
private static class GwSkillType
{
public const int Stance = 3;
public const int Hex = 4;
public const int Spell = 5;
public const int Enchantment = 6;
public const int Signet = 7;
public const int Well = 9;
public const int Skill = 10;
public const int Ward = 11;
public const int Glyph = 12;
public const int Attack = 14;
public const int Shout = 15;
public const int Skill2 = 16;
public const int Passive = 17;
public const int Environmental = 18;
public const int Preparation = 19;
public const int PetAttack = 20;
public const int Trap = 21;
public const int Ritual = 22;
public const int EnvironmentalTrap = 23;
public const int ItemSpell = 24;
public const int WeaponSpell = 25;
public const int Form = 26;
public const int Chant = 27;
public const int EchoRefrain = 28;
}
private static class GwProfession
{
public const int Ranger = 2;
public const int Ritualist = 8;
}
private static class GwWeapon
{
public const int Axe = 0x01;
public const int Bow = 0x02;
public const int Daggers = 0x08;
public const int Hammer = 0x10;
public const int Scythe = 0x20;
public const int Spear = 0x40;
public const int Sword = 0x80;
/// <summary>Bit 0x04 has no Daybreak counterpart but is a ranged weapon.</summary>
public const int AnyRanged = Bow | 0x04 | Spear;
public const int AnyMelee = Axe | Daggers | Hammer | Scythe | Sword;
}
private static readonly Dictionary<int, string> SingleWeaponFlags = new()
{
[GwWeapon.Axe] = "Axe",
[GwWeapon.Bow] = "Bow",
[GwWeapon.Hammer] = "Hammer",
[GwWeapon.Scythe] = "Scythe",
[GwWeapon.Spear] = "Spear",
[GwWeapon.Sword] = "Sword",
};
/// <summary>Indexed by the API's campaign id.</summary>
private static readonly string[] Campaigns =
[
"Core",
"Prophecies",
"Factions",
"Nightfall",
"EyeOfTheNorth",
"BonusMissionPack",
];
/// <summary>Indexed by the API's profession id.</summary>
private static readonly string[] Professions =
[
"None",
"Warrior",
"Ranger",
"Monk",
"Necromancer",
"Mesmer",
"Elementalist",
"Assassin",
"Ritualist",
"Paragon",
"Dervish",
];
/// <summary>
/// Keyed by the API's attribute id, which is
/// <c>GW::Constants::AttributeByte</c> — the same id Daybreak's
/// <c>Attribute</c> already carries. Ids with no attribute are absent and
/// resolve to <c>None</c>.
/// </summary>
private static readonly Dictionary<int, string> Attributes = new()
{
[0] = "FastCasting",
[1] = "IllusionMagic",
[2] = "DominationMagic",
[3] = "InspirationMagic",
[4] = "BloodMagic",
[5] = "DeathMagic",
[6] = "SoulReaping",
[7] = "Curses",
[8] = "AirMagic",
[9] = "EarthMagic",
[10] = "FireMagic",
[11] = "WaterMagic",
[12] = "EnergyStorage",
[13] = "HealingPrayers",
[14] = "SmitingPrayers",
[15] = "ProtectionPrayers",
[16] = "DivineFavor",
[17] = "Strength",
[18] = "AxeMastery",
[19] = "HammerMastery",
[20] = "Swordsmanship",
[21] = "Tactics",
[22] = "BeastMastery",
[23] = "Expertise",
[24] = "WildernessSurvival",
[25] = "Marksmanship",
[29] = "DaggerMastery",
[30] = "DeadlyArts",
[31] = "ShadowArts",
[32] = "Communing",
[33] = "RestorationMagic",
[34] = "ChannelingMagic",
[35] = "CriticalStrikes",
[36] = "SpawningPower",
[37] = "SpearMastery",
[38] = "Command",
[39] = "Motivation",
[40] = "Leadership",
[41] = "ScytheMastery",
[42] = "WindPrayers",
[43] = "EarthPrayers",
[44] = "Mysticism",
};
}
-482
View File
@@ -1,482 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// A skill in the exact shape the writer will emit it: every value is already
/// the literal C# token it should appear as in <c>Skill.g.cs</c>. This is
/// deliberately tool-specific — the runtime <c>WikiService</c> has its own
/// parser in <c>Daybreak.Shared</c> built around typed model objects.
/// </summary>
public sealed record ParsedSkill(
int Id,
string Name,
string CampaignIdentifier,
string ProfessionIdentifier,
string AttributeIdentifier,
bool PvEOnly,
bool PvP,
bool Elite,
string TypeExpression,
double? Energy,
double? Activation,
double? Recharge,
double? Overcast,
double? Adrenaline,
double? Sacrifice,
double? Upkeep,
string Description,
string ConciseDescription)
{
/// <summary>
/// Extra ids the wiki listed alongside <see cref="Id"/> in the same
/// <c>id =</c> field (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick
/// "Save Yourselves!"). Each one becomes its own emitted skill.
/// </summary>
public IReadOnlyList<int> AdditionalIds { get; init; } = [];
/// <summary>
/// Filenames (without the <c>File:</c> prefix) the icon resolver should
/// try, in priority order. Defaults to just <see cref="Name"/>; shouts
/// also include the quoted form because their image files preserve the
/// surrounding quotes (e.g. <c>"Save Yourselves!".jpg</c>).
/// </summary>
public IReadOnlyList<string> IconBaseNames { get; init; } = [];
}
/// <summary>
/// Parses a wiki page's <c>{{Skill infobox …}}</c> into a <see cref="ParsedSkill"/>
/// directly addressed at the codegen output. Every helper here exists because
/// the codegen step needs it; nothing is exposed beyond <see cref="TryParse"/>.
/// </summary>
internal static partial class WikiSkillParser
{
public static bool TryParse(string? wikiText, out ParsedSkill skill)
{
skill = null!;
if (string.IsNullOrWhiteSpace(wikiText))
{
return false;
}
var cleaned = CleanWikiText(wikiText);
var body = ExtractInfoboxBody(cleaned);
if (body is null)
{
return false;
}
var fields = ExtractFields(body);
string Get(string key) => fields.TryGetValue(key, out var v) ? v : string.Empty;
bool YesFlag(string key) => Get(key) is var v && v.Length > 0 && v[0] is 'y' or 'Y';
var allIds = ParseAllIds(Get("id"));
skill = new ParsedSkill(
Id: allIds.Count > 0 ? allIds[0] : 0,
Name: NormalizeName(Get("name")),
CampaignIdentifier: ResolveCampaign(Get("campaign")),
ProfessionIdentifier: ResolveProfession(Get("profession")),
AttributeIdentifier: ResolveAttribute(Get("attribute")),
PvEOnly: YesFlag("pve-only"),
PvP: YesFlag("is-pvp"),
Elite: YesFlag("elite"),
TypeExpression: ResolveSkillType(Get("type")),
Energy: ParseNumber(Get("energy")),
Activation: ParseNumber(Get("activation")),
Recharge: ParseNumber(Get("recharge")),
Overcast: ParseNumber(Get("overcast")),
Adrenaline: ParseNumber(Get("adrenaline")),
Sacrifice: ParseNumber(Get("sacrifice")),
Upkeep: ParseNumber(Get("upkeep")),
Description: Get("description"),
ConciseDescription: Get("concise description"));
if (allIds.Count > 1)
{
skill = skill with { AdditionalIds = [.. allIds.Skip(1)] };
}
return true;
}
/// <summary>
/// Strips wiki markup the codegen output should not carry over (links,
/// fraction templates, ranges, italics, HTML).
/// </summary>
private static string CleanWikiText(string wikiText)
{
var c = wikiText;
c = HalfFractionRegex().Replace(c, "¹⁄₂");
c = QuarterFractionRegex().Replace(c, "¹⁄₄");
c = ThreeQuarterFractionRegex().Replace(c, "³⁄₄");
c = ThreeOverTwoFractionRegex().Replace(c, "³⁄₂");
c = DecimalRegex().Replace(c, "$1");
c = GreenRangeRegex().Replace(c, "$1…$2");
c = GreenRange2Regex().Replace(c, "$1…$2");
c = GrayRangeRegex().Replace(c, "$1…$2");
c = LinkWithDisplayTextRegex().Replace(c, "$2");
c = SimpleLinkRegex().Replace(c, "$1");
c = GrayTextRegex().Replace(c, "");
c = GreyTextRegex().Replace(c, "");
c = SicTextRegex().Replace(c, "");
c = BoldTextRegex().Replace(c, "$1");
c = ItalicTextRegex().Replace(c, "$1");
c = HtmlTagRegex().Replace(c, "");
return c;
}
private static string? ExtractInfoboxBody(string wikiText)
{
const string startPattern = "{{Skill infobox";
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
if (startIndex == -1)
{
return null;
}
var pipeIndex = wikiText.IndexOf('|', startIndex);
if (pipeIndex == -1)
{
return null;
}
var braceCount = 2;
var index = startIndex + startPattern.Length;
while (index < wikiText.Length && braceCount > 0)
{
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{') { braceCount += 2; index += 2; }
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}') { braceCount -= 2; index += 2; }
else { index++; }
}
if (braceCount != 0)
{
return null;
}
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
}
/// <summary>
/// Walks the body manually so that values containing pipes inside nested
/// templates (<c>{{gr|5|11}}</c>) or links don't get truncated.
/// </summary>
private static Dictionary<string, string> ExtractFields(string body)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var i = 0;
while (i < body.Length)
{
while (i < body.Length && (body[i] == '|' || char.IsWhiteSpace(body[i])))
{
i++;
}
var nameStart = i;
while (i < body.Length && body[i] != '=' && body[i] != '|')
{
i++;
}
if (i >= body.Length || body[i] != '=')
{
continue;
}
var name = body[nameStart..i].Trim();
i++;
var valueStart = i;
int braceDepth = 0, bracketDepth = 0;
while (i < body.Length)
{
var c = body[i];
if (c == '{' && i + 1 < body.Length && body[i + 1] == '{') { braceDepth++; i += 2; continue; }
if (c == '}' && i + 1 < body.Length && body[i + 1] == '}') { braceDepth--; i += 2; continue; }
if (c == '[' && i + 1 < body.Length && body[i + 1] == '[') { bracketDepth++; i += 2; continue; }
if (c == ']' && i + 1 < body.Length && body[i + 1] == ']') { bracketDepth--; i += 2; continue; }
if (c == '|' && braceDepth == 0 && bracketDepth == 0)
{
break;
}
i++;
}
var value = WhitespaceRegex().Replace(body[valueStart..i].Trim(), " ").Trim();
fields[name] = value;
}
return fields;
}
/// <summary>
/// Numeric infobox value → <c>double?</c>. Empty / non-numeric text
/// (percentages, "morale boost", etc.) becomes <c>null</c>.
/// </summary>
private static double? ParseNumber(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
var s = raw.Trim();
// Plain mixed/unit fractions glyph-substituted by CleanWikiText.
var fraction = s.Contains("¹⁄₂") ? 0.5
: s.Contains("¹⁄₄") ? 0.25
: s.Contains("³⁄₄") ? 0.75
: s.Contains("³⁄₂") ? 1.5
: (double?)null;
if (fraction is double frac)
{
var fractionStart = s.IndexOfAny(['¹', '³']);
if (fractionStart > 0 &&
double.TryParse(s[..fractionStart].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var whole))
{
return whole + frac;
}
return frac;
}
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
{
return d;
}
if (s.EndsWith('%') &&
double.TryParse(s[..^1].TrimEnd('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out var pct))
{
return pct / 100.0;
}
return null;
}
/// <summary>
/// Some skill infoboxes carry multiple ids in one field
/// (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick variants of "Save Yourselves!").
/// We return every integer found in source order; the writer turns each
/// one into its own emitted <c>Skill</c> entry.
/// </summary>
private static List<int> ParseAllIds(string raw)
{
var ids = new List<int>();
if (string.IsNullOrWhiteSpace(raw))
{
return ids;
}
foreach (Match match in FirstIntegerRegex().Matches(raw))
{
if (int.TryParse(match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
{
ids.Add(id);
}
}
return ids;
}
/// <summary>
/// Strips the surrounding double quotes some wiki page titles carry
/// (shouts, e.g. <c>"Save Yourselves!"</c>) so the C# identifier and
/// display name match the historical Daybreak convention.
/// </summary>
private static string NormalizeName(string raw)
{
var trimmed = raw.Trim();
if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"')
{
return trimmed[1..^1];
}
return trimmed;
}
private static string ResolveSkillType(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return "SkillType.None";
}
var seen = new HashSet<string>(StringComparer.Ordinal);
var parts = new List<string>();
foreach (var token in TypeTokenSplitRegex().Split(raw))
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
if (TypeTokens.TryGetValue(token, out var canonical) && seen.Add(canonical))
{
parts.Add($"SkillType.{canonical}");
}
}
return parts.Count == 0 ? "SkillType.None" : string.Join(" | ", parts);
}
private static string ResolveCampaign(string raw) =>
Campaigns.GetValueOrDefault(raw.Trim(), "None");
private static string ResolveProfession(string raw) =>
Professions.GetValueOrDefault(raw.Trim(), "None");
private static string ResolveAttribute(string raw) =>
Attributes.GetValueOrDefault(raw.Trim(), "None");
// -------------------------------------------------------------------------
// The lookup tables intentionally live in this file, since they're a
// codegen detail of the parser — the runtime types in Daybreak.Shared
// already have their own canonical name lists.
// -------------------------------------------------------------------------
private static readonly Dictionary<string, string> Campaigns = new(StringComparer.OrdinalIgnoreCase)
{
[""] = "None",
["None"] = "None",
["Core"] = "Core",
["Prophecies"] = "Prophecies",
["Factions"] = "Factions",
["Nightfall"] = "Nightfall",
["Eye of the North"] = "EyeOfTheNorth",
["EotN"] = "EyeOfTheNorth",
["Bonus Mission Pack"] = "BonusMissionPack",
["BMP"] = "BonusMissionPack",
};
private static readonly Dictionary<string, string> Professions = new(StringComparer.OrdinalIgnoreCase)
{
[""] = "None",
["None"] = "None",
["Common"] = "None",
["Warrior"] = "Warrior",
["Ranger"] = "Ranger",
["Monk"] = "Monk",
["Necromancer"] = "Necromancer",
["Mesmer"] = "Mesmer",
["Elementalist"] = "Elementalist",
["Assassin"] = "Assassin",
["Ritualist"] = "Ritualist",
["Paragon"] = "Paragon",
["Dervish"] = "Dervish",
};
private static readonly Dictionary<string, string> Attributes = new(StringComparer.OrdinalIgnoreCase)
{
[""] = "None",
["None"] = "None",
["Fast Casting"] = "FastCasting",
["Illusion Magic"] = "IllusionMagic",
["Domination Magic"] = "DominationMagic",
["Inspiration Magic"] = "InspirationMagic",
["Blood Magic"] = "BloodMagic",
["Death Magic"] = "DeathMagic",
["Soul Reaping"] = "SoulReaping",
["Curses"] = "Curses",
["Air Magic"] = "AirMagic",
["Earth Magic"] = "EarthMagic",
["Fire Magic"] = "FireMagic",
["Water Magic"] = "WaterMagic",
["Energy Storage"] = "EnergyStorage",
["Healing Prayers"] = "HealingPrayers",
["Smiting Prayers"] = "SmitingPrayers",
["Protection Prayers"] = "ProtectionPrayers",
["Divine Favor"] = "DivineFavor",
["Strength"] = "Strength",
["Axe Mastery"] = "AxeMastery",
["Hammer Mastery"] = "HammerMastery",
["Swordsmanship"] = "Swordsmanship",
["Tactics"] = "Tactics",
["Beast Mastery"] = "BeastMastery",
["Expertise"] = "Expertise",
["Wilderness Survival"] = "WildernessSurvival",
["Marksmanship"] = "Marksmanship",
["Critical Strikes"] = "CriticalStrikes",
["Dagger Mastery"] = "DaggerMastery",
["Deadly Arts"] = "DeadlyArts",
["Shadow Arts"] = "ShadowArts",
["Spawning Power"] = "SpawningPower",
["Channeling Magic"] = "ChannelingMagic",
["Communing"] = "Communing",
["Restoration Magic"] = "RestorationMagic",
["Spear Mastery"] = "SpearMastery",
["Command"] = "Command",
["Motivation"] = "Motivation",
["Leadership"] = "Leadership",
["Scythe Mastery"] = "ScytheMastery",
["Wind Prayers"] = "WindPrayers",
["Earth Prayers"] = "EarthPrayers",
["Mysticism"] = "Mysticism",
};
private static readonly Dictionary<string, string> TypeTokens = new(StringComparer.OrdinalIgnoreCase)
{
["skill"] = "Skill",
["touch"] = "Touch",
["spell"] = "Spell",
["hex"] = "Hex",
["enchantment"] = "Enchantment",
["flash"] = "Flash",
["echo"] = "Echo",
["ward"] = "Ward",
["glyph"] = "Glyph",
["well"] = "Well",
["ritual"] = "Ritual",
["binding"] = "Binding",
["nature"] = "Nature",
["ebon"] = "EbonVanguard",
["vanguard"] = "EbonVanguard",
["item"] = "Item",
["weapon"] = "Weapon",
["attack"] = "Attack",
["axe"] = "Axe",
["bow"] = "Bow",
["melee"] = "Melee",
["hammer"] = "Hammer",
["sword"] = "Sword",
["spear"] = "Spear",
["pet"] = "Pet",
["lead"] = "Lead",
["off-hand"] = "OffHand",
["offhand"] = "OffHand",
["dual"] = "Dual",
["scythe"] = "Scythe",
["ranged"] = "Ranged",
["chant"] = "Chant",
["shout"] = "Shout",
["signet"] = "Signet",
["preparation"] = "Preparation",
["stance"] = "Stance",
["form"] = "Form",
["trap"] = "Trap",
};
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex HalfFractionRegex();
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex QuarterFractionRegex();
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeQuarterFractionRegex();
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeOverTwoFractionRegex();
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}")] private static partial Regex DecimalRegex();
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRangeRegex();
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRange2Regex();
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayRangeRegex();
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)] private static partial Regex SicTextRegex();
[GeneratedRegex(@"\[\[([^\]|]+)\]\]")] private static partial Regex SimpleLinkRegex();
[GeneratedRegex(@"\[\[([^|\]]+)\|([^\]]+)\]\]")] private static partial Regex LinkWithDisplayTextRegex();
[GeneratedRegex(@"'''([^']+)'''")] private static partial Regex BoldTextRegex();
[GeneratedRegex(@"''([^']+)''")] private static partial Regex ItalicTextRegex();
[GeneratedRegex(@"<[^>]*>")] private static partial Regex HtmlTagRegex();
[GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex();
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayTextRegex();
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreyTextRegex();
[GeneratedRegex(@"[\s\-]+")] private static partial Regex TypeTokenSplitRegex();
[GeneratedRegex(@"\d+")] private static partial Regex FirstIntegerRegex();
}
+113
View File
@@ -0,0 +1,113 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// One skill page on the wiki: the display name Daybreak knows the skill by,
/// and the skill ids that name covers.
/// </summary>
/// <remarks>
/// The wiki is the roster only — it says which skills exist and what to call
/// them. Every emitted value comes from the GWToolbox API instead
/// (<see cref="SkillMapper"/>). The wiki is kept for the roster because the
/// client's own names are not unique: four different skills are called "Charm
/// Animal", where the wiki disambiguates them ("Charm Animal (White Mantle)"),
/// and those unique names are what the generated C# identifiers and the icon
/// lookup are keyed on.
/// </remarks>
public sealed record WikiSkillEntry(string Name, IReadOnlyList<int> Ids)
{
/// <summary>
/// Filenames (without the <c>File:</c> prefix) the icon resolver should
/// try, in priority order. Shouts include the quoted form because their
/// image files preserve the surrounding quotes
/// (e.g. <c>"Save Yourselves!".jpg</c>).
/// </summary>
public IReadOnlyList<string> IconBaseNames { get; init; } = [];
}
/// <summary>
/// Pulls the roster out of a wiki page's <c>{{Skill infobox …}}</c>: the
/// <c>id</c> field, and nothing else.
/// </summary>
internal static partial class WikiSkillRosterParser
{
/// <summary>
/// Reads the skill ids out of a page's infobox. Some infoboxes carry
/// several ids in the one field (e.g. <c>id = 1954, 2097</c> for the
/// Luxon and Kurzick "Save Yourselves!"); each becomes its own emitted
/// skill, with its own values from the API.
/// </summary>
public static bool TryParseIds(string? wikiText, out IReadOnlyList<int> ids)
{
ids = [];
if (string.IsNullOrWhiteSpace(wikiText))
{
return false;
}
var body = ExtractInfoboxBody(wikiText);
if (body is null)
{
return false;
}
var match = IdFieldRegex().Match(body);
if (!match.Success)
{
return false;
}
var parsed = new List<int>();
foreach (Match integer in IntegerRegex().Matches(match.Groups[1].Value))
{
if (int.TryParse(integer.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
{
parsed.Add(id);
}
}
ids = parsed;
return parsed.Count > 0;
}
/// <summary>
/// Returns the infobox's body by matching braces from <c>{{Skill infobox</c>
/// so that nested templates don't end it early.
/// </summary>
private static string? ExtractInfoboxBody(string wikiText)
{
const string startPattern = "{{Skill infobox";
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
if (startIndex == -1)
{
return null;
}
var pipeIndex = wikiText.IndexOf('|', startIndex);
if (pipeIndex == -1)
{
return null;
}
var braceCount = 2;
var index = startIndex + startPattern.Length;
while (index < wikiText.Length && braceCount > 0)
{
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{') { braceCount += 2; index += 2; }
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}') { braceCount -= 2; index += 2; }
else { index++; }
}
if (braceCount != 0)
{
return null;
}
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
}
[GeneratedRegex(@"(?:^|\|)\s*id\s*=\s*([^|\r\n]*)", RegexOptions.IgnoreCase)] private static partial Regex IdFieldRegex();
[GeneratedRegex(@"\d+")] private static partial Regex IntegerRegex();
}