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