Compare commits

..
11 Commits
40 changed files with 4190 additions and 3230 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).
+9 -9
View File
@@ -8977,7 +8977,7 @@ public static unsafe partial class GWCA
internal const float Nearby = 252.0f;
internal const float Spellcast = 1248.0f;
internal const float Spirit = 2512.0f;
internal const float SpiritExtended = 3500.0f;
internal const float SpiritExtended = 3000.0f;
internal const float Touch = 144.0f;
}
}
@@ -9625,11 +9625,11 @@ public static unsafe partial class GWCA
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
@@ -15740,10 +15740,10 @@ namespace Daybreak.API.Interop.GuildWars
kChangeTarget,
kMessage_0x10000021,
kMessage_0x10000022,
kMessage_0x10000023,
kAgentSkillCancelled,
kAgentSkillActivated,
kAgentSkillActivatedInstantly,
kAgentSkillCancelled,
kAgentSkillInterrupted,
kAgentSkillStartedCast,
kMessage_0x10000028,
kShowMapEntryMessage,
@@ -19702,7 +19702,7 @@ namespace Daybreak.API.Interop.GuildWars
public uint AgentId;
public global::Daybreak.API.Interop.GWCA.GW.Constants.SkillID SkillId;
public float Duration;
public uint H000c;
public float H000c;
}
[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1)]
@@ -415,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>();
@@ -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;
}
+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>
+14 -7
View File
@@ -57,22 +57,29 @@ internal static class WineDebugLog
/// <c>/bin/sh</c> with stderr appended to <see cref="LogPath"/>.
/// </summary>
/// <remarks>
/// The command is passed as a single positional parameter and re-parsed with
/// <c>eval</c> so the caller's existing double-quoting (paths such as
/// <c>"Z:\...\Guild Wars\Gw.exe"</c> contain spaces) keeps working unchanged.
/// 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 command = $"\"{startInfo.FileName}\" {startInfo.Arguments}";
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("log=\"$1\"; shift; eval \"exec $* 2>>'$log'\"");
startInfo.ArgumentList.Add("daybreak-wine");
startInfo.ArgumentList.Add("exec \"$@\" 2>>\"$0\"");
startInfo.ArgumentList.Add(logPath);
startInfo.ArgumentList.Add(command);
startInfo.ArgumentList.Add(fileName);
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
if (Channels is { } channels)
{
@@ -274,7 +274,7 @@ public sealed class WinePrefixManager(
return false;
}
var computerName = Environment.MachineName;
var computerName = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
var useDnsComputerNameConfigured = await this.AddRegistryValue(
WineNetworkRegistryKey,
"UseDnsComputerName",
+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);
}
}
+1 -1
View File
@@ -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;
};
+11 -11
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
@@ -674,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;
@@ -811,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;
+11 -11
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
@@ -674,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;
@@ -811,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;
+2 -2
View File
@@ -2,9 +2,9 @@
#define GWCA_VERSION_MAJOR 4
#define GWCA_VERSION_MINOR 8
#define GWCA_VERSION_PATCH 5
#define GWCA_VERSION_PATCH 7
#define GWCA_VERSION_BUILD 0
#define GWCA_VERSION "4.8.5.0"
#define GWCA_VERSION "4.8.7.0"
namespace GWCA {
constexpr int VersionMajor = GWCA_VERSION_MAJOR;
+1 -1
View File
@@ -5,7 +5,7 @@
#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 0x04080500u
#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.
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.18</Version>
<Version>0.9.10.21</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
+53 -31
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
@@ -202,36 +213,47 @@ public static string ToWinePath(string linuxPath)
### Wine Debug Log
Debug builds append all Wine output to `wine-debug.log` in the Daybreak output folder:
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:
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`.
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.
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. |
| 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
+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",
};
}
-486
View File
@@ -1,486 +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();
}