Compare commits

...
31 Commits
Author SHA1 Message Date
amacocian fe4720c5e1 Update GWCA - fixes Character Select widget (#1626) 2026-09-09 22:58:09 +02:00
github-actions[bot]andamacocian b57d2ad5d3 Bump version to 0.9.10.21 2026-09-09 22:58:09 +02:00
amacocian 5faeae64d3 Download service robustness (Closes #1074) (#1624) 2026-09-04 19:34:25 +02:00
amacocian fdcf238d68 Fix wine password param expansion (#1623) 2026-09-04 19:34:25 +02:00
amacocian d9f7d05672 Add widgets for empty views (Closes #1093) (#1622) 2026-09-04 19:34:25 +02:00
amacocian c61ee2ecd7 Fix toolbox assertion error in wine due to long computer name (Closes #1619) (#1621) 2026-09-04 19:34:25 +02:00
amacocian 82512f9461 Improve documentation (#1620) 2026-09-04 19:34:25 +02:00
github-actions[bot]andamacocian 2dc13b7184 Bump version to 0.9.10.20 2026-09-04 19:34:25 +02:00
amacocian da691e3310 Improvements to skill updater (#1617) 2026-08-31 23:07:24 +02:00
amacocian 5e020f51d9 Update skills (#1616) 2026-08-31 23:07:24 +02:00
github-actions[bot]andamacocian 1f63e59b8a Bump version to 0.9.10.19 2026-08-31 23:07:24 +02:00
amacocian dcb9c29505 Update deps (#1612)
* Update dependencies

* Fix GWCA issues
2026-08-30 23:27:31 +02:00
amacocian db83e5a954 Setup stable computer name in Wine prefix (Closes #1609) (#1611) 2026-08-30 23:27:31 +02:00
amacocian c7c5506ee0 Update GWCA (#1610) 2026-08-30 23:27:31 +02:00
github-actions[bot]andamacocian 2988b6b38d Bump version to 0.9.10.18 2026-08-30 23:27:31 +02:00
amacocian debef9a36f Update documentation with Steam support (#1603) 2026-08-02 15:01:35 +02:00
amacocian 839bc57b48 Improve interaction with system shell (#1601) 2026-08-02 15:01:35 +02:00
amacocian 175c16bd00 Notifications show an animation for their expiration (#1600) 2026-08-02 15:01:35 +02:00
amacocian ad356f431e Steam login support (Windows) (Closes #1597) (#1599) 2026-08-02 15:01:35 +02:00
github-actions[bot]andamacocian ebe368e61e Bump version to 0.9.10.17 2026-08-02 15:01:35 +02:00
amacocian d800a630b5 Sanitize secrets (#1595) 2026-07-28 14:55:57 +02:00
github-actions[bot]andamacocian 239f91a65f Bump version to 0.9.10.16 2026-07-28 14:55:57 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aff7c24b6a Bump actions/setup-dotnet from 5 to 6 (#1588)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 13:54:14 +02:00
amacocian 5bf0af61ba Fix commandline arguments with space (#1593) 2026-07-28 13:54:14 +02:00
amacocian 03ed31e85b Fix telemetry (Closes #1591) (#1592) 2026-07-28 13:54:14 +02:00
amacocian b21a4482bd Redact sensitive logs (#1590) 2026-07-28 13:54:14 +02:00
github-actions[bot]andamacocian 2ad8ae79ce Bump version to 0.9.10.15 2026-07-28 13:54:14 +02:00
amacocianandAlexandru Macocian a4be747dd5 Fix more notifications expiration times (#1587)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian a6cd5d6f37 Update dependencies (#1585)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian 0e80fb7d86 Update GWCA (#1584)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
github-actions[bot]andamacocian 0793f181bb Bump version to 0.9.10.14 2026-07-18 07:00:00 +02:00
137 changed files with 8190 additions and 5221 deletions
+71
View File
@@ -0,0 +1,71 @@
# Copilot instructions for Daybreak
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
Photino.Blazor. Read the
[Architecture Overview](../README.md#architecture-overview) for the project
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
process.
## Workflow
- Branch off the current `release/[version]` branch, never off `master`.
- One feature or fix per branch and per PR.
- Never push to `master` and never edit the version in `Directory.Build.props`.
Both are owned by the release workflows.
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
## Build and test
CI does not run on PRs targeting a release branch, so validate locally. Run the
smallest command that covers the change:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
```
Keep the build warning-free.
## Where code goes
| Change | Project |
| -------------------------------------------------- | ------------------------------------ |
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
| Blazor views and services | `Daybreak.Core` |
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
thin caller.
Add NuGet packages through `Directory.Packages.props`.
## C# conventions
`.editorconfig` is the source of truth. The projects are set up to treat
warnings as errors.
## Verify before claiming
Prefer evidence over reasoning about behaviour, especially for the Wine and
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
the fix against it rather than inferring what a Win32 call does.
## Writing style
Applies to docs, comments, PR descriptions and answers.
- Be short. Cut filler, recap and process narration.
- No AI-isms and no inflated language.
- Do not paste code into documentation. Link to the file so there is only one
copy of it.
- Explain the reason for a change, not a summary of the diff.
## Markdown
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
`img` tag.
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
frontmatter inside the fence is rejected by some renderers.
- Long URLs belong in reference-style link definitions at the bottom of the
file.
+3 -3
View File
@@ -48,7 +48,7 @@ jobs:
echo "version=$version" >> $env:GITHUB_OUTPUT
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -111,7 +111,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -177,7 +177,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -55,7 +55,7 @@ jobs:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
# If this step fails, then you should remove it and run the build manually (see below)
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+113
View File
@@ -0,0 +1,113 @@
# Contributing to Daybreak
## Getting started
Install the prerequisites listed under
[Build Requirements](README.md#build-requirements), then clone with submodules:
```bash
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
```
Read the [Architecture Overview](README.md#architecture-overview) before making
changes so you put code in the right project.
## Branching model
There is always exactly one active release branch, named `release/[version]`
(for example `release/0.9.10.20`). It is the integration branch: all work
targets it, and `master` only receives finished releases.
```mermaid
flowchart LR
subgraph cycle["Release cycle"]
direction LR
R["release/0.9.10.20<br/><i>active release branch</i>"]
F["feature branch<br/><i>one per change</i>"]
R -->|fork| F
F -->|"PR, squash merge"| R
end
R -->|"PR, rebase merge"| M["master"]
M --> CD["CD pipeline<br/>builds and publishes the release"]
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
```
### Feature work
1. Branch off the current release branch.
2. Implement one feature or fix. Keep the branch scoped to a single change.
3. Open a PR against the release branch.
4. Merge with **squash**, so each feature lands as one commit.
### Releasing
1. Open a PR from the release branch onto `master`.
2. Merge with **rebase**, keeping full history. Each feature stays a single
commit on `master`.
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
builds the Windows and Linux bundles and publishes a GitHub release.
4. On success,
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
bumps the revision and creates the next `release/[version]` branch
automatically. That becomes the new active release branch.
Do not push directly to `master` or create release branches by hand.
## Versions
The version lives in a single place,
[`Directory.Build.props`](Directory.Build.props), and follows
`major.minor.build.revision`. It is bumped by the release branch creation job,
not manually.
Revision bumps happen automatically after every release. For the other
components, run the matching workflow manually before starting the next cycle:
| Workflow | Bumps |
| ------------------------------------------------------------------------------------------ | --------------------- |
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
All four delegate to
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
## Pull requests
Title format follows the existing history: a short description, plus the issue
reference when it closes one.
```text
Setup stable computer name in Wine prefix (Closes #1609)
```
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
`close/fix/resolve #N` from the title and body of PRs merged into a release
branch and labels the linked issues, so use those keywords.
## Checks
[CI](.github/workflows/ci.yaml) and
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
targeting `master`, so a feature PR onto a release branch is not covered by
them. Run the equivalent locally before opening one:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
```
The version check fails a PR to `master` if the version in
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
must come from the release branch job.
## Code style
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
compiler and analyzers; keep the build warning-free.
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
want platform code covered by tests, put the testable part in
[`Daybreak.Shared`](Daybreak.Shared).
File diff suppressed because it is too large Load Diff
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
public readonly uint Size;
public readonly uint Param;
/// <summary>
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
/// rather than a real array.
/// </summary>
private const uint MaxPlausibleCapacity = 0x10000;
/// <summary>
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
/// these fields transiently inconsistent while it (re)allocates an array, and a
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
/// <c>size &lt;= capacity</c>.
/// </summary>
public bool IsValid =>
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
this.Size <= this.Capacity &&
this.Capacity <= MaxPlausibleCapacity;
/// <summary>
/// Number of elements that can safely be read. Zero whenever the array is not backed by
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
/// </summary>
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
public T this[int index]
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
return this.Buffer[index];
}
}
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
@@ -7,6 +7,18 @@ public readonly unsafe struct WrappedPointer<T>(T* pointer)
public bool IsNull => this.Pointer is null;
/// <summary>
/// True when the pointer is non-null and plausibly dereferenceable.
/// </summary>
/// <remarks>
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
/// </remarks>
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
@@ -35,21 +35,28 @@ public sealed class CharacterSelectService(
{
var gameContext = this.gameContextService.GetGameContext();
if (gameContext.IsNull ||
gameContext.Pointer->World is null)
gameContext.Pointer->World is null ||
gameContext.Pointer->Character is null)
{
scopedLogger.LogError("Game context is not initialized");
return default;
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (availableCharsContext.IsNull)
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
{
scopedLogger.LogError("Available characters context is not initialized");
scopedLogger.LogError(
"Available characters context is not initialized (array@0x{Array:X8} buffer=0x{Buffer:X8} capacity={Capacity} size={Size})",
(nuint)availableCharsContext.Pointer,
availableCharsContext.IsValid ? (nuint)availableCharsContext.Pointer->Buffer : 0,
availableCharsContext.IsValid ? availableCharsContext.Pointer->Capacity : 0,
availableCharsContext.IsValid ? availableCharsContext.Pointer->Size : 0);
return default;
}
var currentUuid = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString();
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Size);
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Count);
foreach (var charContext in *availableCharsContext.Pointer)
{
var name = new string(charContext.Name);
@@ -196,7 +203,9 @@ public sealed class CharacterSelectService(
// Find target character index
uint targetIdx = 0xFFFF;
var charCount = ctx.Pointer->Chars.Size;
// Count (not Size) so a not-yet-allocated roster yields zero iterations
// instead of indexing through a null buffer.
var charCount = ctx.Pointer->Chars.Count;
for (uint i = 0; i < charCount; i++)
{
@@ -406,7 +415,8 @@ public sealed class CharacterSelectService(
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (availableCharsContext.IsNull)
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
{
scopedLogger.LogError("Available characters context is not initialized");
return default;
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
using Daybreak.Shared.Attributes;
namespace Daybreak.Configuration.Options;
[OptionsName(Name = "Downloads")]
internal sealed class DownloadOptions
{
[JsonPropertyName(nameof(HeaderTimeout))]
[OptionName(
Name = "Header Timeout",
Description = "Amount of seconds Daybreak will wait for a download to respond before giving up on the attempt"
)]
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
public double HeaderTimeout { get; set; } = 30;
[JsonPropertyName(nameof(ChunkTimeout))]
[OptionName(
Name = "Chunk Timeout",
Description = "Amount of seconds Daybreak will wait for the next chunk of a download before giving up on the attempt"
)]
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
public double ChunkTimeout { get; set; } = 30;
[JsonPropertyName(nameof(Retries))]
[OptionName(
Name = "Retries",
Description = "Amount of times Daybreak will retry a failed download before reporting it as failed"
)]
[OptionRange<int>(MinValue = 0, MaxValue = 10)]
public int Retries { get; set; } = 3;
[JsonPropertyName(nameof(RetryDelay))]
[OptionName(
Name = "Retry Delay",
Description = "Amount of seconds Daybreak will wait before retrying a failed download"
)]
[OptionRange<double>(MinValue = 0, MaxValue = 60)]
public double RetryDelay { get; set; } = 2;
}
@@ -128,7 +128,13 @@ public class ProjectConfiguration : PluginConfigurationBase
var resourceBuilder = ResourceBuilder
.CreateDefault()
.AddService("Daybreak", serviceVersion: CurrentVersion.ToString());
var attributes = new List<KeyValuePair<string, object>>();
var attributes = new List<KeyValuePair<string, object>>
{
// Mirror the fleet-wide `service` stream field (promtail sets it for
// container logs) so Daybreak shows up in Grafana Logs Drilldown's
// `service` picker, alongside the `service_name` set by AddService.
new("service", "Daybreak"),
};
#if DEBUG
attributes.Add(new KeyValuePair<string, object>("deployment.environment", "debug"));
#else
@@ -343,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>();
@@ -4,5 +4,14 @@ namespace Daybreak.Models;
public sealed class NotificationWrapper
{
public required Notification Notification { get; init; }
public DateTime StartTime { get; set; }
public DateTime ExpirationTime { get; set; }
/// <summary>
/// Incremented every time the notification lifetime is prolonged so the UI can
/// restart the expiration progress animation.
/// </summary>
public int LifetimeGeneration { get; set; }
public double LifetimeMilliseconds => Math.Max(0, (this.ExpirationTime - this.StartTime).TotalMilliseconds);
}
@@ -30,6 +30,7 @@ internal sealed class ApplicationLauncher(
IGuildWarsProcessFinder guildWarsProcessFinder,
IDaybreakRestartingService daybreakRestartingService,
IPrivilegeManager privilegeManager,
ISteamService steamService,
ILogger<ApplicationLauncher> logger
) : IApplicationLauncher
{
@@ -55,6 +56,7 @@ internal sealed class ApplicationLauncher(
private readonly IPrivilegeManager privilegeManager =
privilegeManager.ThrowIfNull();
private readonly ISteamService steamService = steamService.ThrowIfNull();
public async Task<GuildWarsApplicationLaunchContext?> LaunchGuildwars(
LaunchConfigurationWithCredentials launchConfigurationWithCredentials,
@@ -63,6 +65,27 @@ internal sealed class ApplicationLauncher(
{
launchConfigurationWithCredentials.ThrowIfNull();
launchConfigurationWithCredentials.Credentials.ThrowIfNull();
if (launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true)
{
if (!this.steamService.IsSteamLoginSupported)
{
this.notificationService.NotifyError(
title: "Steam login is not supported",
description: "Steam login is not supported on this platform yet. Please select a different set of credentials"
);
return default;
}
if (!this.steamService.IsSteamRunning())
{
this.notificationService.NotifyError(
title: "Steam is not running",
description: "Steam login is selected but the Steam client is not running. Please start Steam and log in, then try again"
);
return default;
}
}
using var timeout = new CancellationTokenSource(LaunchTimeout);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
@@ -107,8 +130,9 @@ internal sealed class ApplicationLauncher(
)
{
var scopedLogger = this.logger.CreateScopedLogger();
var email = launchConfigurationWithCredentials.Credentials?.Username;
var password = launchConfigurationWithCredentials.Credentials?.Password;
var isSteamLogin = launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true;
var email = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Username;
var password = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Password;
var executable = launchConfigurationWithCredentials.ExecutablePath;
if (executable is not null && File.Exists(executable) is false)
{
@@ -229,7 +253,7 @@ internal sealed class ApplicationLauncher(
};
var steamAppIdFilePath = Path.Combine(workingDirectory, SteamAppIdFile);
if (launchConfigurationWithCredentials.SteamSupport)
if (launchConfigurationWithCredentials.SteamSupport || isSteamLogin)
{
await File.WriteAllTextAsync(steamAppIdFilePath, SteamAppId, cancellationToken);
scopedLogger.LogDebug(
@@ -26,6 +26,12 @@ internal sealed class CredentialManager(
public bool TryGetCredentialsByIdentifier(string identifier, out LoginCredentials? loginCredentials)
{
loginCredentials = default;
if (string.Equals(identifier, LoginCredentials.SteamLoginIdentifier, StringComparison.Ordinal))
{
loginCredentials = LoginCredentials.SteamLogin;
return true;
}
if (this.GetCredentialList().FirstOrDefault(l => l.Identifier == identifier) is LoginCredentials foundCredentials)
{
loginCredentials = foundCredentials;
@@ -56,6 +62,7 @@ internal sealed class CredentialManager(
this.logger.LogDebug("Storing credentials");
var options = this.liveOptions.CurrentValue;
options.ProtectedLoginCredentials = [.. loginCredentials
.Where(c => !c.IsSteamLogin)
.Select(this.ProtectCredentials)
.OfType<ProtectedLoginCredentials>()];
this.optionsProvider.SaveOption(options);
@@ -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);
}
}
}
@@ -1,6 +1,7 @@
using Daybreak.Configuration.Options;
using Daybreak.Services.Notifications.Handlers;
using Daybreak.Shared.Converters;
using Daybreak.Shared;
using Daybreak.Shared.Models.Guildwars;
using Daybreak.Shared.Services.Events;
using Daybreak.Shared.Services.Notifications;
@@ -32,7 +33,7 @@ internal sealed class EventNotifierService(
await Task.Delay(5000, cancellationToken);
foreach (var e in this.eventService.GetCurrentActiveEvents())
{
this.notificationService.NotifyInformation<NavigateToCalendarViewHandler>(e.Title!, $"{GetRemainingTime(e)}\n{e.Description!}", expirationTime: DateTime.Now + TimeSpan.FromSeconds(15), metaData: e.Title);
this.notificationService.NotifyInformation<NavigateToCalendarViewHandler>(e.Title!, $"{GetRemainingTime(e)}\n{e.Description!}", expirationTime: Global.NotificationShortExpiration, metaData: e.Title);
}
}
@@ -125,7 +125,7 @@ internal sealed class GuildWarsVersionChecker(
this.notificationService.NotifyError<GuildWarsBatchUpdateNotificationHandler>(
title: "Guild Wars needs an update",
description: $"Click here to update the executable located at {guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath}",
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
expirationTime: Global.NotificationShortExpiration);
}
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,13 +1,12 @@
using Daybreak.Launch;
using Daybreak.Models;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;
using System.Collections.Immutable;
namespace Daybreak.Services.Logging;
public sealed class InMemorySink : ILogEventSink, IDisposable
public sealed class InMemorySink : RedactingLogEventSink, IDisposable
{
private const int MaxLogEvents = 50000;
@@ -30,7 +29,7 @@ public sealed class InMemorySink : ILogEventSink, IDisposable
this.logEvents.Clear();
}
public void Emit(LogEvent logEvent)
protected override void EmitRedacted(LogEvent logEvent)
{
lock (this.snapShotLock)
{
@@ -0,0 +1,155 @@
using Serilog.Events;
using Serilog.Parsing;
using System.Buffers;
using System.Text.RegularExpressions;
namespace Daybreak.Services.Logging;
/// <summary>
/// Rewrites <see cref="LogEvent"/> instances so that sensitive user information (usernames,
/// e-mail addresses and passwords) is masked before it reaches a sink.
/// <para>
/// It relies on two complementary strategies:
/// <list type="bullet">
/// <item>Regex matching against the command-line style tokens (<c>-email &lt;value&gt;</c>,
/// <c>-password &lt;value&gt;</c>) that appear in rendered messages and property values.</item>
/// <item>Property-name matching, which masks any structured property named after a credential
/// field (Username, Password, Email) regardless of its value.</item>
/// </list>
/// </para>
/// </summary>
public static partial class LogEventRedactor
{
public const string RedactedValue = "[REDACTED]";
// Structured property names whose value must always be masked.
private static readonly HashSet<string> SensitivePropertyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Username",
"Password",
"Email",
};
// Cheap, vectorized pre-filter: the (comparatively expensive) look-behind regex patterns are
// skipped unless one of these credential flags is present in the text. SearchValues performs a
// single multi-pattern scan (Teddy/Aho-Corasick internally) that outperforms both individual
// Contains calls and a hand-rolled automaton, and scales as the marker set grows. Every secret
// pattern below must be gated by one of these markers.
private static readonly SearchValues<string> SecretMarkers = SearchValues.Create(
["-email", "-password"],
StringComparison.OrdinalIgnoreCase);
// Patterns whose single match is the secret value to mask. The credential flag itself is kept
// in a look-behind so only the value is replaced.
private static readonly Regex[] SecretPatterns =
[
EmailArgumentRegex(),
PasswordArgumentRegex(),
];
/// <summary>
/// Returns a copy of <paramref name="logEvent"/> with sensitive information masked. The original
/// event is returned unchanged when nothing needs to be redacted.
/// </summary>
public static LogEvent Redact(LogEvent logEvent)
{
ArgumentNullException.ThrowIfNull(logEvent);
var redactedTemplate = RedactTemplate(logEvent.MessageTemplate);
var redactedProperties = logEvent.Properties
.Select(p => new LogEventProperty(p.Key, RedactPropertyValue(p.Key, p.Value)))
.ToList();
return new LogEvent(
logEvent.Timestamp,
logEvent.Level,
logEvent.Exception,
redactedTemplate,
redactedProperties);
}
/// <summary>
/// Masks the credential values embedded in an arbitrary string using the configured regex patterns.
/// </summary>
public static string RedactText(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text ?? string.Empty;
}
// Fast path: avoid running the look-behind regexes over the overwhelming majority of log
// text that cannot contain a credential flag. This keeps redaction global (fail-safe) while
// costing only a vectorized substring scan for events that carry no secrets.
if (!MightContainSecret(text))
{
return text;
}
foreach (var pattern in SecretPatterns)
{
text = pattern.Replace(text, RedactedValue);
}
return text;
}
private static bool MightContainSecret(string text)
{
return text.AsSpan().ContainsAny(SecretMarkers);
}
private static MessageTemplate RedactTemplate(MessageTemplate template)
{
var tokens = template.Tokens
.Select(token => token is TextToken textToken
? new TextToken(RedactText(textToken.Text))
: token)
.ToList();
return new MessageTemplate(tokens);
}
private static LogEventPropertyValue RedactPropertyValue(string propertyName, LogEventPropertyValue value)
{
if (SensitivePropertyNames.Contains(propertyName))
{
return new ScalarValue(RedactedValue);
}
return RedactPropertyValue(value);
}
private static LogEventPropertyValue RedactPropertyValue(LogEventPropertyValue value)
{
switch (value)
{
case ScalarValue { Value: string stringValue }:
return new ScalarValue(RedactText(stringValue));
case SequenceValue sequence:
return new SequenceValue(sequence.Elements.Select(RedactPropertyValue));
case StructureValue structure:
var structureProperties = structure.Properties
.Select(p => new LogEventProperty(p.Name, RedactPropertyValue(p.Name, p.Value)));
return new StructureValue(structureProperties, structure.TypeTag);
case DictionaryValue dictionary:
var elements = dictionary.Elements
.Select(kvp => new KeyValuePair<ScalarValue, LogEventPropertyValue>(
kvp.Key,
RedactPropertyValue(kvp.Value)));
return new DictionaryValue(elements);
default:
return value;
}
}
[GeneratedRegex(@"(?<=-email[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex EmailArgumentRegex();
[GeneratedRegex(@"(?<=-password[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex PasswordArgumentRegex();
}
@@ -0,0 +1,27 @@
using Serilog.Core;
using Serilog.Events;
namespace Daybreak.Services.Logging;
/// <summary>
/// Base class for sinks that must never receive sensitive user information. Every emitted
/// <see cref="LogEvent"/> is passed through <see cref="LogEventRedactor"/> before being handed to
/// the concrete sink via <see cref="EmitRedacted"/>.
/// <para>
/// This lets us keep the Console sink (only visible to the local user) unredacted while masking
/// sensitive user information for sinks that persist or forward logs off the machine, such as the
/// in-memory sink used for exporting logs and the telemetry sink used to forward logs to the server.
/// </para>
/// </summary>
public abstract class RedactingLogEventSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
this.EmitRedacted(LogEventRedactor.Redact(logEvent));
}
/// <summary>
/// Handles the log event after sensitive information has been masked.
/// </summary>
protected abstract void EmitRedacted(LogEvent logEvent);
}
@@ -10,6 +10,7 @@ using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.ReShade;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Utils;
using Daybreak.Views.Mods;
using HtmlAgilityPack;
@@ -18,7 +19,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Data;
using System.Diagnostics;
using System.Extensions;
using System.Extensions.Core;
using System.IO.Compression;
@@ -34,6 +34,7 @@ internal sealed class ReShadeService(
IHttpClient<ReShadeService> httpClient,
IDownloadService downloadService,
IViewManager viewManager,
IShellExecutor shellExecutor,
ILogger<ReShadeService> logger) : IReShadeService
{
private const string PackagesIniUrl = "https://raw.githubusercontent.com/crosire/reshade-shaders/list/EffectPackages.ini";
@@ -68,6 +69,7 @@ internal sealed class ReShadeService(
private readonly IHttpClient<ReShadeService> httpClient = httpClient.ThrowIfNull();
private readonly IDownloadService downloadService = downloadService.ThrowIfNull();
private readonly IViewManager viewManager = viewManager.ThrowIfNull();
private readonly IShellExecutor shellExecutor = shellExecutor.ThrowIfNull();
private readonly ILogger<ReShadeService> logger = logger.ThrowIfNull();
public string Name => "ReShade";
@@ -220,7 +222,7 @@ internal sealed class ReShadeService(
public void OpenReShadeFolder()
{
Process.Start("explorer.exe", ReShadePath);
this.shellExecutor.OpenPath(ReShadePath);
}
public async Task<IEnumerable<ShaderPackage>> GetStockPackages(CancellationToken cancellationToken)
@@ -24,7 +24,7 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
private const string UnknownHost = "Unknown";
private const string MaskedValue = "[REDACTED]";
private static readonly string ApmEndpoint = SecretManager.GetSecret(SecretKeys.ApmUri);
private static readonly string ApmEndpoint = SanitizeUri(SecretManager.GetSecret(SecretKeys.ApmUri));
private static readonly string ApmServiceAccount = SecretManager.GetSecret(SecretKeys.ApmServiceAccount);
private static readonly string ApmServiceKey = SecretManager.GetSecret(SecretKeys.ApmServiceKey);
@@ -159,7 +159,10 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
return;
}
var apmUri = new Uri(ApmEndpoint);
// Endpoints are resolved relative to the vmauth root, so the base must end with '/'
// otherwise Uri resolution would drop the trailing path segment.
var normalizedEndpoint = ApmEndpoint.EndsWith('/') ? ApmEndpoint : ApmEndpoint + "/";
var apmUri = new Uri(normalizedEndpoint);
var creds = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{ApmServiceAccount}:{ApmServiceKey}"));
this.meter = Sdk.CreateMeterProviderBuilder()
@@ -172,11 +175,13 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
.AddMeter("OpenTelemetry.Instrumentation.SqlClient")
.AddOtlpExporter((exporterOptions, readerOptions) =>
{
exporterOptions.Endpoint = new Uri(apmUri, "v1/metrics");
exporterOptions.Endpoint = new Uri(apmUri, "opentelemetry/v1/metrics");
exporterOptions.Headers = $"Authorization=Basic {creds}";
exporterOptions.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
readerOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10_000;
readerOptions.TemporalityPreference = MetricReaderTemporalityPreference.Delta;
// VictoriaMetrics works best with cumulative temporality; delta breaks
// standard rate()/increase() queries used by the Grafana dashboards.
readerOptions.TemporalityPreference = MetricReaderTemporalityPreference.Cumulative;
})
.Build();
@@ -189,8 +194,8 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
logOpts.SetResourceBuilder(this.resourceBuilder);
logOpts.AddOtlpExporter(exp =>
{
exp.Endpoint = new Uri(apmUri, "v1/logs");
exp.Headers = $"Authorization=Basic {creds}";
exp.Endpoint = new Uri(apmUri, "insert/opentelemetry/v1/logs");
exp.Headers = $"Authorization=Basic {creds}";
exp.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
});
@@ -201,6 +206,20 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
TelemetryLogSink.Instance.LoggingHandler = this.ForwardLogEvent;
}
// Configured URIs must never contain whitespace. Stray characters such as a non-breaking space
// (U+00A0) from copy-pasted secrets survive SecretManager sanitization (which keeps whitespace to
// preserve JSON formatting) and would otherwise be percent-encoded into a bogus path segment
// (e.g. ".../%C2%A0/opentelemetry/v1/metrics"), producing 400 responses from the collector.
private static string SanitizeUri(string uri)
{
if (string.IsNullOrEmpty(uri))
{
return uri;
}
return new string([.. uri.Where(c => !char.IsWhiteSpace(c) && !char.IsControl(c))]);
}
private void ForwardLogEvent(LogEvent logEvent)
{
if (this.otlpLogger is null)
@@ -1,9 +1,9 @@
using Serilog.Core;
using Daybreak.Services.Logging;
using Serilog.Events;
namespace Daybreak.Services.Telemetry;
public sealed class TelemetryLogSink : ILogEventSink
public sealed class TelemetryLogSink : RedactingLogEventSink
{
public readonly static TelemetryLogSink Instance = new();
@@ -13,7 +13,7 @@ public sealed class TelemetryLogSink : ILogEventSink
{
}
public void Emit(LogEvent logEvent)
protected override void EmitRedacted(LogEvent logEvent)
{
this.LoggingHandler?.Invoke(logEvent);
}
@@ -1,6 +1,7 @@
using Daybreak.Configuration.Options;
using Daybreak.Services.TradeChat.Models;
using Daybreak.Services.TradeChat.Notifications;
using Daybreak.Shared;
using Daybreak.Shared.Configuration.Options;
using Daybreak.Shared.Converters;
using Daybreak.Shared.Models.Trade;
@@ -197,7 +198,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
this.notificationService.NotifyInformation(
title: "Quote alert!",
description: description,
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
expirationTime: Global.NotificationShortExpiration);
}
await Task.Delay(TimeSpan.FromSeconds(this.options.CurrentValue.QuoteAlertsInterval), cancellationToken);
@@ -258,7 +259,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
title: $"{source} Trader Alert",
description: $"{alert.Name} has matched on a trader message. Sender: {traderMessageDTO.Sender}. Message: {traderMessageDTO.Message}",
metaData: JsonSerializer.Serialize(traderMessage),
expirationTime: DateTime.Now + TimeSpan.FromDays(1));
expirationTime: DateTime.UtcNow + TimeSpan.FromDays(1));
}
private static bool CheckMatch(string toCheck, string toMatch, bool isRegex)
+80 -70
View File
@@ -8,80 +8,90 @@
<div class="option-view-title">
<h3>Accounts</h3>
</div>
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
@if (this.ViewModel.LoginCredentials.Count is 0)
{
<EmptyStateWidget Message="No accounts configured"
ActionText="Add account"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
OnAction="@this.ViewModel.CreateCredential" />
}
else
{
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
+5 -9
View File
@@ -8,6 +8,7 @@ using Daybreak.Shared.Services.Menu;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Window;
@@ -17,7 +18,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Photino.Blazor;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
@@ -39,6 +39,7 @@ public sealed class AppViewModel
private readonly JSConsoleInterop jsConsoleInterop;
private readonly INotificationService notificationService;
private readonly IWindowManipulationService windowManipulationService;
private readonly IShellExecutor shellExecutor;
private readonly ILogger<App> logger;
private SemaphoreSlim themeChangeSemaphore = new(1, 1);
@@ -89,6 +90,7 @@ public sealed class AppViewModel
INotificationProducer notificationProducer,
INotificationService notificationService,
IWindowManipulationService windowManipulationService,
IShellExecutor shellExecutor,
ILogger<App> logger)
{
this.keyboardHookService = keyboardHookService.ThrowIfNull();
@@ -104,6 +106,7 @@ public sealed class AppViewModel
this.NotificationProducer = notificationProducer.ThrowIfNull();
this.notificationService = notificationService.ThrowIfNull();
this.windowManipulationService = windowManipulationService.ThrowIfNull();
this.shellExecutor = shellExecutor.ThrowIfNull();
this.logger = logger.ThrowIfNull();
@@ -215,14 +218,7 @@ public sealed class AppViewModel
public void OpenIssues()
{
try
{
Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Encountered exception while opening issues page");
}
this.shellExecutor.OpenUrl(IssueUrl);
}
public void OpenSynchronizationView()
@@ -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;
}
@@ -25,6 +25,9 @@
{
<p class="notification-description">@notificationTuple.Notification.Description</p>
}
<div class="notification-progress"
@key="@($"{notificationTuple.Notification.Id}-{notificationTuple.LifetimeGeneration}")"
style="animation-duration: @(notificationTuple.LifetimeMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture))ms;"></div>
</div>
}
</div>
@@ -72,7 +75,7 @@
try
{
await this.notificationLock.WaitAsync(cancellationToken);
this.notifications.Add(new NotificationWrapper { Notification = notification, ExpirationTime = notification.ExpirationTime });
this.notifications.Add(new NotificationWrapper { Notification = notification, StartTime = DateTime.UtcNow, ExpirationTime = notification.ExpirationTime });
}
catch (OperationCanceledException)
{
@@ -167,7 +170,9 @@
private void ExtendNotification(NotificationWrapper notification)
{
notification.StartTime = DateTime.UtcNow;
notification.ExpirationTime = DateTime.UtcNow + NotificationExtension;
notification.LifetimeGeneration++;
}
private async Task OpenNofitication(NotificationWrapper notification)
@@ -99,21 +99,25 @@
/* Notification level styling */
.notification-item.level-information {
border-left: 4px solid var(--accent-fill-rest);
--notification-accent: var(--accent-fill-rest);
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-warning {
border-left: 4px solid #ff9500;
--notification-accent: #ff9500;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-error,
.notification-item.level-critical {
border-left: 4px solid #d13438;
--notification-accent: #d13438;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-debug,
.notification-item.level-trace {
border-left: 4px solid var(--neutral-stroke-accessible);
--notification-accent: var(--neutral-stroke-accessible);
border-left: 4px solid var(--notification-accent);
}
.notification-item[class*="level-"]:hover {
@@ -121,6 +125,32 @@
cursor: pointer;
}
/* Expiration progress line animating from start time to expiration */
.notification-progress {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background: var(--notification-accent, var(--accent-fill-rest));
transform: scaleX(0);
transform-origin: left center;
animation-name: notificationProgress;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-iteration-count: 1;
pointer-events: none;
}
@keyframes notificationProgress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
@@ -179,4 +209,9 @@
animation: none;
opacity: 0;
}
.notification-progress {
animation: none;
transform: scaleX(1);
}
}
@@ -6,6 +6,7 @@ using System.Core.Extensions;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class EventCalendarViewModel(IEventService eventService)
: ViewModelBase<EventCalendarViewModel, EventCalendarView>
{
@@ -92,7 +93,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
{
dates.Add(new DateTime(today.Year, today.Month, day));
}
return dates;
}
@@ -107,6 +108,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
events.Add(eventItem);
}
}
return events;
}
}
+68 -57
View File
@@ -8,65 +8,76 @@
<div class="option-view-title">
<h3>Executables</h3>
</div>
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
@if (this.ViewModel.Executables.Count is 0)
{
<EmptyStateWidget Message="No executables configured"
ActionText="Add executable"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
Disabled="@(!this.ViewModel.AddButtonEnabled)"
OnAction="@this.ViewModel.CreateExecutable" />
}
else
{
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
{
this.Executables.Remove(executable);
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
this.RefreshView();
}
public async Task CreateExecutable()
+151 -141
View File
@@ -9,151 +9,161 @@
<div class="option-view-title">
<h3>Launch Configurations</h3>
</div>
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
@if (this.ViewModel.LaunchConfigurations.Count is 0)
{
<EmptyStateWidget Message="No launch configurations yet"
ActionText="Add launch configuration"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
}
else
{
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
</div>
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -30,6 +30,7 @@ public sealed class LaunchConfigurationsViewModel(
public override ValueTask ParametersSet(LaunchConfigurationsView view, CancellationToken cancellationToken)
{
this.Credentials.ClearAnd().AddRange(this.credentialManager.GetCredentialList());
this.Credentials.Add(LoginCredentials.SteamLogin);
this.LaunchConfigurations.ClearAnd().AddRange(this.launchConfigurationService.GetLaunchConfigurations());
this.Executables.ClearAnd().AddRange(this.guildWarsExecutableManager.GetExecutableList()).Add(string.Empty);
return base.ParametersSet(view, cancellationToken);
+4 -1
View File
@@ -507,9 +507,12 @@ public sealed class LaunchViewModel(
cancellationToken
);
// If the API is available but it does not belong to the desired user, return null
// If the API is available but it does not belong to the desired user, return null.
// Steam login configurations use a virtual credential whose username is not the account
// email, so the ownership check is skipped for them.
if (
maybeApiContext is not null
&& launcherViewContext.Configuration.Credentials.IsSteamLogin is false
&& await maybeApiContext.GetLoginInfo(cancellationToken) is LoginInfo loginInfo
&& loginInfo.Email != launcherViewContext.Configuration.Credentials.Username
)
+5 -3
View File
@@ -1,8 +1,8 @@
using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.Plugins;
using Daybreak.Shared.Services.Shell;
using Photino.NET;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -12,13 +12,15 @@ public sealed class PluginsViewModel(
PhotinoWindow window,
IApplicationLauncher applicationLauncher,
IViewManager viewManager,
IPluginsService pluginsService)
IPluginsService pluginsService,
IShellExecutor shellExecutor)
: ViewModelBase<PluginsViewModel, PluginsView>
{
private readonly PhotinoWindow window = window;
private readonly IApplicationLauncher applicationLauncher = applicationLauncher;
private readonly IViewManager viewManager = viewManager;
private readonly IPluginsService pluginsService = pluginsService;
private readonly IShellExecutor shellExecutor = shellExecutor;
public List<AvailablePlugin> AvailablePlugins { get; init; } = [];
public bool CanSave { get; private set; } = false;
@@ -71,7 +73,7 @@ public sealed class PluginsViewModel(
public void NavigateToPlugin(AvailablePlugin plugin)
{
Process.Start("explorer.exe", plugin.Path);
this.shellExecutor.OpenPath(plugin.Path);
}
private void UpdatePlugins()
@@ -1,15 +1,23 @@
<div class="stretch-container backdrop-panel">
<div class="content">
<Virtualize Items="@this.ViewModel.Versions"
Context="version">
<div class="version-row">
<div class="version-row-center" @onclick="@(() => this.ViewModel.OpenVersionPage(version))">
<span>@version.ToString()</span>
Context="version"
ItemSize="56">
<div class="version-item" tabindex="0"
@onclick="@(() => this.ViewModel.OpenVersionPage(version))">
<div class="version-info">
<div class="version-icon">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.Tag())"
Color="Color.Neutral" />
</div>
<span class="version-name">@version.ToString()</span>
</div>
<div class="version-row-right">
<div class="version-actions"
@onclick="@(() => this.ViewModel.DownloadVersion(version))"
@onclick:stopPropagation="true">
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
OnClick="@(() => this.ViewModel.DownloadVersion(version))">
Title="Download version">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())" />
</FluentButton>
</div>
@@ -1,5 +1,5 @@
using Daybreak.Shared.Services.Updater;
using System.Diagnostics;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Updater;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -7,13 +7,15 @@ using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class VersionManagementViewModel(
IViewManager viewManager,
IApplicationUpdater applicationUpdater)
IApplicationUpdater applicationUpdater,
IShellExecutor shellExecutor)
: ViewModelBase<VersionManagementViewModel, VersionManagementView>
{
private const string VersionPlaceholder = "{VERSION}";
private const string ReleaseURL = $"https://github.com/AlexMacocian/Daybreak/releases/tag/v{VersionPlaceholder}";
private readonly IViewManager viewManager = viewManager;
private readonly IApplicationUpdater applicationUpdater = applicationUpdater;
private readonly IShellExecutor shellExecutor = shellExecutor;
public List<Version> Versions { get; init; } = [];
public Version? CurrentVersion { get; set; }
@@ -31,6 +33,6 @@ public sealed class VersionManagementViewModel(
public void OpenVersionPage(Version version)
{
Process.Start("explorer.exe", ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
this.shellExecutor.OpenUrl(ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
}
}
@@ -13,15 +13,55 @@
margin: 50px 0px 0px 0px;
}
.version-row {
.version-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border-bottom: 1px solid var(--neutral-stroke-divider-rest);
cursor: pointer;
transition: background-color 0.2s ease;
box-sizing: border-box;
border-radius: 6px;
height: 56px;
}
.version-row-center {
cursor: pointer;
text-align: center;
align-content: center;
.version-item:hover {
background-color: var(--accent-fill-hover);
}
.version-item:focus {
outline: 2px solid var(--accent-fill-focus);
outline-offset: -2px;
}
.version-info {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
}
.version-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.version-name {
font-weight: 600;
font-size: var(--font-size-medium);
color: var(--neutral-foreground-rest);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.version-actions {
flex-shrink: 0;
}
+33 -12
View File
@@ -178,6 +178,7 @@ internal static class CppHeaderParser
{
inMultiLineConstexpr = false;
}
continue;
}
@@ -189,6 +190,7 @@ internal static class CppHeaderParser
{
inSkipBlock = false;
}
continue;
}
@@ -211,6 +213,7 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(trimmed.Substring(0, closeBraceIdx), currentEnum);
}
inEnum = false;
var node = namespaceStack.Peek();
node.Enums.Add(currentEnum);
@@ -231,11 +234,11 @@ internal static class CppHeaderParser
int closeBraces = CountChar(lineWithoutComments, '}');
int prevDepth = structBraceDepth;
structBraceDepth += openBraces - closeBraces;
// Debug: Track brace changes for AgentLiving
if (currentStruct.Name == "AgentLiving" && (openBraces > 0 || closeBraces > 0))
{
DebugLines.Add($"[BRACE] line {i+1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
DebugLines.Add($"[BRACE] line {i + 1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
}
// If we hit the closing brace of the struct
@@ -252,8 +255,8 @@ internal static class CppHeaderParser
pathParts.Add(n.Name);
pathParts.Reverse();
currentStruct.DebugNamespacePath = string.Join(".", pathParts);
DebugLines.Add($"[STRUCT-END] line {i+1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
DebugLines.Add($"[STRUCT-END] line {i + 1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
node.Structs.Add(currentStruct);
currentStruct = null;
inStruct = false;
@@ -278,6 +281,7 @@ internal static class CppHeaderParser
if (field is not null)
currentStruct.Fields.Add(field);
}
continue;
}
@@ -295,7 +299,7 @@ internal static class CppHeaderParser
var trailingCommentIdx = trimmed.IndexOf("//");
if (trailingCommentIdx > 0)
trimmedWithoutTrailingComment = trimmed.Substring(0, trailingCommentIdx).TrimEnd();
if (trimmedWithoutTrailingComment.Contains("(") && (trimmedWithoutTrailingComment.Contains(")") || trimmedWithoutTrailingComment.Contains("{")))
continue;
@@ -339,6 +343,7 @@ internal static class CppHeaderParser
};
currentStruct.Fields.Add(field);
}
continue;
}
@@ -354,6 +359,7 @@ internal static class CppHeaderParser
var child = current.GetOrCreateChild(part);
namespaceStack.Push(child);
}
continue;
}
@@ -366,13 +372,14 @@ internal static class CppHeaderParser
freeBraceDepth--;
continue;
}
if (namespaceStack.Count > 1)
{
// Debug: Track where namespace was popped
var poppedNode = namespaceStack.Pop();
poppedNode.DebugPopLine = i + 1;
}
continue;
}
@@ -390,6 +397,7 @@ internal static class CppHeaderParser
}
}
}
continue;
}
@@ -436,6 +444,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -448,6 +457,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -455,17 +465,18 @@ internal static class CppHeaderParser
if (OutOfLineMethodRegex.IsMatch(trimmed))
{
int braces = CountChar(trimmed, '{') - CountChar(trimmed, '}');
DebugLines.Add($"[OUT-OF-LINE] line {i+1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
DebugLines.Add($"[OUT-OF-LINE] line {i + 1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
if (braces > 0)
{
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
else if (trimmed.Contains("::") && trimmed.Contains("(") && trimmed.Contains("{"))
{
DebugLines.Add($"[UNMATCHED-METHOD] line {i+1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
DebugLines.Add($"[UNMATCHED-METHOD] line {i + 1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
}
// ── Track function declarations with body on separate line ─
@@ -492,6 +503,7 @@ internal static class CppHeaderParser
inMultiLineConstexpr = true;
multiLineBraceCount = braces;
}
continue;
}
@@ -501,7 +513,7 @@ internal static class CppHeaderParser
{
var structName = structMatch.Groups[1].Value;
var baseType = structMatch.Groups[2].Success ? structMatch.Groups[2].Value : null;
DebugLines.Add($"[STRUCT-START] line {i+1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
DebugLines.Add($"[STRUCT-START] line {i + 1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
// Skip some special cases
if (structName.StartsWith("__") || structName == "Packet")
@@ -512,6 +524,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -530,7 +543,7 @@ internal static class CppHeaderParser
if (structNobraceMatch.Success)
{
var structName = structNobraceMatch.Groups[1].Value;
// Skip forward declarations (just struct Name;)
if (trimmed.EndsWith(";"))
continue;
@@ -559,8 +572,10 @@ internal static class CppHeaderParser
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -593,9 +608,11 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
continue;
}
@@ -637,14 +654,18 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -794,7 +815,7 @@ internal static class CppHeaderParser
int slashSlash = line.IndexOf("//");
if (slashSlash >= 0)
line = line.Substring(0, slashSlash);
// Remove /* */ style comments (simple - doesn't handle nested)
int blockStart = line.IndexOf("/*");
while (blockStart >= 0)
@@ -806,7 +827,7 @@ internal static class CppHeaderParser
line = line.Substring(0, blockStart);
blockStart = line.IndexOf("/*");
}
return line;
}
+75 -60
View File
@@ -238,7 +238,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Emit
var sb = new StringBuilder(65536);
EmitHeader(sb, totalExports, skippedExports);
// Add diagnostic comment about parsed structs
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine(" // Parsed structs diagnostic:");
@@ -246,6 +246,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
sb.AppendLine($" // {diag}");
}
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine();
@@ -270,31 +271,33 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitConstantsNode(sb, grandchild, typeMap, namespaceClassNames, 4);
}
continue;
}
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, 4);
}
sb.AppendLine("}"); // Close GWCA class
sb.AppendLine("}"); // Close Daybreak.API.Interop namespace
// Emit structs and enums into GuildWars namespace for consumer code compatibility
// Consumer code uses `using Daybreak.API.Interop.GuildWars;` to access types
sb.AppendLine();
sb.AppendLine("namespace Daybreak.API.Interop.GuildWars");
sb.AppendLine("{");
var emittedNames = new HashSet<string>(StringComparer.Ordinal);
// Collect inline array types needed by struct fields (for non-blittable array fields)
var inlineArrayTypes = new HashSet<(string csType, int size)>();
CollectInlineArrayTypes(headerRoot, typeMap, inlineArrayTypes);
// Emit manually-defined helper structs first (TLink, etc.)
EmitManualHelperStructs(sb, 4, emittedNames);
// Emit inline array types for non-blittable arrays (e.g., enum arrays)
EmitInlineArrayTypes(sb, 4, inlineArrayTypes, emittedNames);
// Emit enums first - they're often referenced by structs (e.g. Attribute enum vs Attribute struct)
EmitEnumsToGuildWarsNamespace(sb, headerRoot, 4, emittedNames);
EmitStructsToGuildWarsNamespace(sb, headerRoot, typeMap, 4, emittedNames, inlineArrayTypes);
@@ -551,6 +554,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
}
sb.AppendLine($"{innerPad}}}");
}
else
@@ -666,10 +670,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Only add structs that can actually be emitted
if (!CanEmitStruct(structDef))
continue;
// Determine the C# name, handling collisions with namespace classes or enums
var csName = SanitizeIdentifier(structDef.Name);
// Check if name collides with a namespace class, enum, or ANOTHER struct already using this name
if (namespaceClassNames.Contains(structDef.Name))
{
@@ -693,21 +697,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
csName += "_";
}
usedGuildWarsNames.Add(csName);
// Use a qualified key that includes the C++ path to distinguish structs with the same name
// from different namespaces (e.g., GW::Attribute vs GW::SkillbarMgr::Attribute)
var mapKey = structDef.Name;
var fqCsType = "global::Daybreak.API.Interop.GuildWars." + csName;
var simpleStructKey = "struct " + structDef.Name;
if (typeMap.ContainsKey(mapKey))
{
// Use qualified key to distinguish this struct from enum or another struct
// Include the csPath to make it unique (e.g., "struct GW.Attribute" vs "struct GW.SkillbarMgr.Attribute")
mapKey = "struct " + csPath.Replace("GWCA.", "") + "." + structDef.Name;
// Also add simple "struct X" key if not already taken (for field type resolution)
// This allows MapCppFieldTypeToCs to find the struct without knowing the full path
if (!typeMap.ContainsKey(simpleStructKey))
@@ -715,7 +719,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
typeMap[simpleStructKey] = fqCsType;
}
}
// Structs go into GuildWars namespace (flat), not nested GWCA classes
typeMap[mapKey] = fqCsType;
}
@@ -745,19 +749,20 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void CollectStructDiagnostics(ConstantsNode node, string path, List<string> diagnostics)
{
var currentPath = string.IsNullOrEmpty(path) ? node.Name : path + "." + node.Name;
// Show namespace pop line info
if (node.DebugPopLine > 0)
{
diagnostics.Add($"[NAMESPACE] {currentPath} popped at line {node.DebugPopLine}");
}
foreach (var structDef in node.Structs)
{
var (canEmit, reason) = CanEmitStructWithReason(structDef);
var status = canEmit ? "OK" : $"SKIP: {reason}";
diagnostics.Add($"{currentPath}.{structDef.Name}: {structDef.Fields.Count} fields [{status}]");
}
foreach (var child in node.Children.Values)
{
CollectStructDiagnostics(child, currentPath, diagnostics);
@@ -772,7 +777,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Skip structs with no fields (usually forward declarations that got parsed)
if (structDef.Fields.Count == 0)
return (false, "no fields");
// Check for mixed offset/no-offset fields (can't use Explicit layout if not all have offsets)
bool hasAnyOffset = structDef.Fields.Any(f => f.Offset.HasValue);
bool allHaveOffsets = structDef.Fields.All(f => f.Offset.HasValue);
@@ -788,7 +793,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
return (false, $"template param T in field {field.Name}");
// Skip structs with complex template containers we can't represent
// Note: TLink<T> is handled separately in MapCppFieldTypeToCs (8-byte linked list node)
if (cppType.Contains("TList<") ||
if (cppType.Contains("TList<") ||
cppType.Contains("PrioQ<") || cppType.Contains("PrioQLink<") ||
cppType.Contains("BaseArray<"))
return (false, $"complex template in field {field.Name}: {cppType}");
@@ -815,9 +820,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
if (cppType.Contains("EquipmentVTable") || cppType.Contains("VTable"))
return (false, $"vtable in field {field.Name}: {cppType}");
}
return (true, null);
}
private static bool CanEmitStruct(CppStructDef structDef) => CanEmitStructWithReason(structDef).canEmit;
/// <summary>
@@ -907,7 +913,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Start with the node's own name as the path base (GWCA for headerRoot)
EmitStructsRecursive(sb, node, node.Name, typeMap, indent, emittedNames, inlineArrayTypes);
}
private static void EmitStructsRecursive(StringBuilder sb, ConstantsNode node, string currentPath, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames, HashSet<(string csType, int size)> inlineArrayTypes)
{
foreach (var structDef in node.Structs.OrderBy(s => s.Name, StringComparer.Ordinal))
@@ -915,13 +921,13 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Skip structs that can't be emitted
if (!CanEmitStruct(structDef))
continue;
// Get the C# name from typeMap (which includes collision-resolution suffixes like "Struct")
// Try direct name first, then qualified struct key (for collision cases)
string? fqCsName;
var directLookup = typeMap.TryGetValue(structDef.Name, out fqCsName);
var containsGuildWars = fqCsName?.Contains("GuildWars.") ?? false;
if (!directLookup || !containsGuildWars)
{
// If direct lookup failed or returned an enum (not in GuildWars namespace),
@@ -929,22 +935,22 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var qualifiedKey = "struct " + currentPath.Replace("GWCA.", "") + "." + structDef.Name;
typeMap.TryGetValue(qualifiedKey, out fqCsName);
}
if (fqCsName is null)
continue;
// Extract just the struct name from the fully-qualified name
// e.g. "global::Daybreak.API.Interop.GuildWars.ItemStruct" -> "ItemStruct"
var csName = fqCsName.Substring(fqCsName.LastIndexOf('.') + 1);
// Skip duplicates (including if an enum with the same name was emitted)
if (emittedNames.Contains(csName))
continue;
emittedNames.Add(csName);
EmitStruct(sb, structDef, typeMap, indent, inlineArrayTypes, csName);
}
// Recurse into children, appending the child's name to the current path
foreach (var child in node.Children.Values)
{
@@ -959,7 +965,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void EmitManualHelperStructs(StringBuilder sb, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
// TLink<T> - doubly-linked list node used in Agent and other structs
// C++ definition: struct TLink { TLink* prev_link; T* next_node; }
// Size: 8 bytes (2 pointers on x86)
@@ -1003,16 +1009,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
if (!CanEmitStruct(structDef))
continue;
foreach (var field in structDef.Fields)
{
if (field.ArraySize is null)
continue;
var size = ParseArraySize(field.ArraySize);
if (size <= 0)
continue;
var csType = MapCppFieldTypeToCs(field.CppType, typeMap);
if (!IsBlittableForFixed(csType))
{
@@ -1020,7 +1026,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
}
}
}
foreach (var child in node.Children.Values)
{
CollectInlineArrayTypes(child, typeMap, inlineArrayTypes);
@@ -1034,16 +1040,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void EmitInlineArrayTypes(StringBuilder sb, int indent, HashSet<(string csType, int size)> inlineArrayTypes, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var (csType, size) in inlineArrayTypes.OrderBy(x => x.csType).ThenBy(x => x.size))
{
// Generate a name like "AttributeArray12" or "SkillIDArray8"
var simpleName = GetSimpleTypeName(csType);
var arrayTypeName = $"{simpleName}Array{size}";
if (!emittedNames.Add(arrayTypeName))
continue;
sb.AppendLine($"{pad}/// <summary>");
sb.AppendLine($"{pad}/// Inline array of {size} {simpleName} elements.");
sb.AppendLine($"{pad}/// </summary>");
@@ -1067,10 +1073,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var isPointer = csType.EndsWith("*");
if (isPointer)
csType = csType.TrimEnd('*');
var lastDot = csType.LastIndexOf('.');
var name = lastDot >= 0 ? csType.Substring(lastDot + 1) : csType;
// Append Ptr for pointer types to make valid identifier
return isPointer ? name + "Ptr" : name;
}
@@ -1092,30 +1098,30 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitEnumsRecursive(sb, node, indent, emittedNames);
}
private static void EmitEnumsRecursive(StringBuilder sb, ConstantsNode node, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var enumDef in node.Enums.OrderBy(e => e.Name, StringComparer.Ordinal))
{
// Skip anonymous enums
if (enumDef.Name is null)
continue;
// Skip duplicates (including if a struct with the same name was emitted)
if (emittedNames.Contains(enumDef.Name))
continue;
emittedNames.Add(enumDef.Name);
// Determine base type and map C++ types to C#
var baseType = MapCppEnumBaseType(enumDef.UnderlyingType ?? "int");
sb.AppendLine();
sb.AppendLine($"{pad}public enum {SanitizeIdentifier(enumDef.Name)} : {baseType}");
sb.AppendLine($"{pad}{{");
var innerPad = new string(' ', indent + 4);
foreach (var member in enumDef.Members)
{
@@ -1124,9 +1130,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
sb.AppendLine($"{innerPad}{SanitizeIdentifier(member.Name)},");
}
sb.AppendLine($"{pad}}}");
}
// Recurse into children
foreach (var child in node.Children.Values)
{
@@ -1141,19 +1148,19 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitTypeAliasesRecursive(sb, node, typeMap, indent, emittedNames);
}
private static void EmitTypeAliasesRecursive(StringBuilder sb, ConstantsNode node, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var alias in node.TypeAliases.OrderBy(a => a.AliasName, StringComparer.Ordinal))
{
// Skip duplicates
if (emittedNames.Contains(alias.AliasName))
continue;
emittedNames.Add(alias.AliasName);
var innerType = alias.TemplateArg.Replace("::", ".").Trim();
// Handle pointer types (e.g., "Item *" -> "nint")
if (innerType.EndsWith("*"))
@@ -1170,10 +1177,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Unmapped struct type - use nint as fallback
innerType = "nint";
}
sb.AppendLine($"{pad}public unsafe struct {alias.AliasName} {{ public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<{innerType}> Value; }}");
}
// Recurse into children
foreach (var child in node.Children.Values)
{
@@ -1259,6 +1266,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fallback for unmapped types
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
}
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
}
@@ -1286,7 +1294,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var parts = inner.Split(new[] { "::" }, StringSplitOptions.RemoveEmptyEntries);
innerStripped = parts[parts.Length - 1];
}
var csInner = MapCppFieldTypeToCs(inner, typeMap);
// Special case: void* and char* and wchar_t* map to nint
if (csInner is "void" or "char" or "byte")
@@ -1301,7 +1309,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Check if original type is from Constants namespace (typically enums)
// In this case, prefer enum mapping over struct mapping
var isFromConstantsNamespace = cppType.Contains("Constants::");
// Strip namespace qualifiers (GW::, GW::Constants::, etc.)
if (cppType.Contains("::"))
{
@@ -1357,6 +1365,11 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
"HMODULE" => "nint",
"HANDLE" => "nint",
"HWND" => "nint",
// EGL handles: `using EGLSurface = void*;` aliases declared inside
// GWCA/Managers/RenderMgr.h, which the header parser doesn't resolve.
"EGLSurface" => "nint",
"EGLContext" => "nint",
"EGLDisplay" => "nint",
"uintptr_t" => "nuint",
"intptr_t" => "nint",
"Vec2f" => "global::System.Numerics.Vector2",
@@ -1420,6 +1433,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
result.Append(part.Substring(1));
}
}
return result.ToString();
}
@@ -1649,7 +1663,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint",
};
}
/// <summary>
/// Resolves a struct name to its C# type, handling name collisions with enums.
/// </summary>
@@ -1664,7 +1678,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Unmapped struct -> nint
return "nint";
}
/// <summary>
/// Resolves a pointer inner type (the pointee) for struct or enum types,
/// handling name collisions between structs and enums.
@@ -1680,7 +1694,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fall back to direct lookup result (could be an enum) or just the name
return direct ?? name;
}
/// <summary>
/// Resolves a pointer type used as a template argument (e.g., Agent* in Array&lt;Agent*&gt;).
/// In unsafe C#, pointer types can be used as generic type arguments for unmanaged structs.
@@ -1689,17 +1703,17 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
// arg.Name is the inner type (e.g., "Agent" for Agent*)
var innerName = arg.Name;
// Check if the inner type is mapped (prefer struct mapping for GuildWars types)
var resolved = ResolveStructOrEnumPointerInner(innerName, typeMap);
if (resolved != innerName)
return resolved + "*";
// Check for primitives
var primitive = MapPrimitiveType(innerName);
if (primitive != innerName)
return primitive + "*";
// Special cases
return innerName switch
{
@@ -1707,7 +1721,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint", // unmapped pointer -> nint
};
}
/// <summary>
/// Maps C/C++ primitive type names to C# primitive types.
/// </summary>
@@ -1890,6 +1904,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
child = new ConstantsNode(childName);
this.Children[childName] = child;
}
return child;
}
}
@@ -1943,4 +1958,4 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
public string SourceType { get; set; } = ""; // e.g., "Array"
public string? TemplateArg { get; set; } // e.g., "Buff"
}
}
}
+1
View File
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
{
pos++; // skip Z - this terminates the function pointer, not the outer param list
}
break;
}
+1 -1
View File
@@ -17,7 +17,7 @@ public sealed class ProcessLauncher
{
var sw = Stopwatch.StartNew();
Process? process;
processId = LaunchClient(path, string.Join(" ", args), elevated, out threadId);
processId = LaunchClient(path, args, elevated, out threadId);
if (processId is 0)
{
Console.WriteLine("Failed to launch GuildWars process.");
+45 -1
View File
@@ -22,6 +22,50 @@ static void PrintUsage()
Console.WriteLine("======================================================");
}
// Re-quotes a single argument token using the same rules as CommandLineToArgvW so that
// values containing spaces, tabs or quotes survive being reconstructed into a command line.
// Without this, tokens are joined with plain spaces and any argument value with whitespace
// (e.g. a quoted custom argument or a path with spaces) is silently split apart.
static string EscapeArgument(string argument)
{
if (argument.Length > 0 &&
!argument.Any(c => c is ' ' or '\t' or '\n' or '\v' or '"'))
{
return argument;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
for (var i = 0; i < argument.Length; i++)
{
var backslashes = 0;
while (i < argument.Length && argument[i] == '\\')
{
i++;
backslashes++;
}
if (i == argument.Length)
{
builder.Append('\\', backslashes * 2);
break;
}
else if (argument[i] == '"')
{
builder.Append('\\', (backslashes * 2) + 1);
builder.Append('"');
}
else
{
builder.Append('\\', backslashes);
builder.Append(argument[i]);
}
}
builder.Append('"');
return builder.ToString();
}
static bool TryParseInjectWinApiArgs(
string[] args,
[NotNullWhen(true)] out Process? process,
@@ -137,7 +181,7 @@ static bool TryParseLaunchArgs(
}
elevated = parsedElevated;
gwArgs = args.Length > 3 ? string.Join(" ", args.Skip(3)) : string.Empty;
gwArgs = args.Length > 3 ? string.Join(" ", args.Skip(3).Select(EscapeArgument)) : string.Empty;
if (!File.Exists(gwPath))
{
@@ -13,6 +13,7 @@ using Daybreak.Linux.Services.DirectSong;
using Daybreak.Linux.Services.Registry;
using Daybreak.Linux.Services.Themes;
using Daybreak.Linux.Services.SevenZip;
using Daybreak.Linux.Services.Shell;
using Daybreak.Linux.Services.Window;
using Daybreak.Linux.Services.ExceptionHandling;
using Daybreak.Linux.Services.Wine;
@@ -32,6 +33,7 @@ using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Screens;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.SevenZip;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.ExceptionHandling;
using Daybreak.Shared.Services.Window;
@@ -55,11 +57,13 @@ public sealed class LinuxPlatformConfiguration : PluginConfigurationBase
services.AddSingleton<IWinePidMapper, WinePidMapper>();
services.AddScoped<IGuildWarsReadyChecker, GuildWarsReadyChecker>();
services.AddScoped<IGuildWarsProcessFinder, GuildWarsProcessFinder>();
services.AddSingleton<ISteamService, SteamService>();
services.AddSingleton<IModPathResolver, ModPathResolver>();
services.AddSingleton<IDirectSongRegistrar, DirectSongRegistrar>();
services.AddScoped<IRegistryService, RegistryService>();
services.AddSingleton<ISystemThemeDetector, SystemThemeDetector>();
services.AddSingleton<ISevenZipExtractor, SevenZipArchiveExtractor>();
services.AddSingleton<IShellExecutor, ShellExecutor>();
services.AddSingleton<IPidProvider, PidProvider>();
services.AddSingleton<IWindowManipulationService, WindowManipulationService>();
services.AddSingleton<ICrashDumpService, CrashDumpService>();
@@ -0,0 +1,76 @@
using System.Diagnostics;
using Daybreak.Shared.Services.ApplicationLauncher;
namespace Daybreak.Linux.Services.ApplicationLauncher;
/// <summary>
/// Linux-specific Steam service.
/// Detects the running Steam client primarily via the Steam client's pid file
/// (~/.steam/steam.pid), which holds the live Steam PID. Falls back to matching the Steam
/// client processes by name.
/// </summary>
public sealed class SteamService : ISteamService
{
private static readonly string[] SteamPidFileRelativePaths =
[
".steam/steam.pid",
".steam/steam/steam.pid"
];
private static readonly string[] SteamProcessNames = ["steam", "steamwebhelper"];
public bool IsSteamLoginSupported => false;
public bool IsSteamRunning()
{
return IsSteamRunningViaPidFile() ?? IsSteamRunningViaProcessName();
}
private static bool? IsSteamRunningViaPidFile()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
{
return default;
}
foreach (var relativePath in SteamPidFileRelativePaths)
{
var pidFilePath = Path.Combine(home, relativePath);
if (!File.Exists(pidFilePath))
{
continue;
}
try
{
if (!int.TryParse(File.ReadAllText(pidFilePath).Trim(), out var pid) || pid <= 0)
{
continue;
}
try
{
using var _ = Process.GetProcessById(pid);
return true;
}
catch (ArgumentException)
{
// No process with the recorded PID is running, the pid file is stale.
return false;
}
}
catch
{
// Unreadable pid file, fall through to the next candidate / process-name check.
}
}
return default;
}
private static bool IsSteamRunningViaProcessName()
{
return SteamProcessNames.Any(name => Process.GetProcessesByName(name).Length > 0);
}
}
@@ -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);
}
@@ -0,0 +1,45 @@
using Daybreak.Shared.Services.Shell;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace Daybreak.Linux.Services.Shell;
/// <summary>
/// Linux implementation of <see cref="IShellExecutor"/>.
/// Both URLs and file-system paths are delegated to <c>xdg-open</c>, which routes to the
/// user's default browser or file manager as appropriate.
/// </summary>
internal sealed class ShellExecutor(
ILogger<ShellExecutor> logger) : IShellExecutor
{
private const string XdgOpenExecutable = "xdg-open";
private readonly ILogger<ShellExecutor> logger = logger;
public void OpenUrl(string url) => this.Open(url);
public void OpenPath(string path) => this.Open(path);
private void Open(string target)
{
if (string.IsNullOrWhiteSpace(target))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = XdgOpenExecutable,
UseShellExecute = false,
};
startInfo.ArgumentList.Add(target);
Process.Start(startInfo);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to open target {target}", target);
}
}
}
@@ -137,6 +137,7 @@ internal sealed class WindowManipulationService(ILogger<WindowManipulationServic
result = window;
break;
}
current = NativeMethods.G_list_next(current);
}
@@ -30,6 +30,13 @@ public interface IWinePrefixManager : IModService
/// </summary>
string GetWinePrefixPath();
/// <summary>
/// Configures the Windows computer name from the Linux host name.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successful, false otherwise.</returns>
Task<bool> ConfigureComputerName(CancellationToken cancellationToken);
/// <summary>
/// Launches a Windows executable through Wine with the managed prefix.
/// Uses event-based stdout/stderr reading to avoid pipe deadlocks.
@@ -0,0 +1,103 @@
using System.Diagnostics;
using Daybreak.Shared.Utils;
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Debug-build support for capturing Wine diagnostics, most importantly the
/// <c>winedbg --auto</c> backtrace produced when Guild Wars crashes.
///
/// Guild Wars is launched by Daybreak.Injector.exe, so it is a *grandchild* of the
/// Wine process Daybreak starts and inherits that process's stderr. Daybreak stops
/// reading that pipe as soon as the injector prints its result, which means a later
/// crash backtrace is written into a pipe nobody drains: the output is lost, and if
/// the pipe buffer fills, the game blocks inside a stderr write.
///
/// Redirecting stderr to a file inside the shell makes the descriptor outlive
/// Daybreak's interest in the process, so every descendant (injector, Gw.exe,
/// winedbg) appends to a durable log instead.
/// </summary>
internal static class WineDebugLog
{
private const string LogFileName = "wine-debug.log";
private const string EnableVariable = "DAYBREAK_WINE_DEBUG";
private const string ChannelsVariable = "DAYBREAK_WINE_DEBUG_CHANNELS";
/// <summary>
/// Enabled by default for Debug builds. Either build configuration can opt in or
/// out explicitly with <c>DAYBREAK_WINE_DEBUG=1</c> / <c>=0</c>.
/// </summary>
public static bool IsEnabled =>
Environment.GetEnvironmentVariable(EnableVariable) switch
{
"1" or "true" or "TRUE" => true,
"0" or "false" or "FALSE" => false,
#if DEBUG
_ => true,
#else
_ => false,
#endif
};
public static string LogPath => PathUtils.GetAbsolutePathFromRoot(LogFileName);
/// <summary>
/// Extra WINEDEBUG channels. Left unset by default: Wine's default <c>err</c> class
/// already reports unhandled exceptions and drives winedbg, while trace channels such
/// as <c>+seh</c> or <c>+relay</c> slow the game to a crawl. Set
/// <c>DAYBREAK_WINE_DEBUG_CHANNELS=+seh</c> when a specific investigation needs them.
/// </summary>
public static string? Channels =>
Environment.GetEnvironmentVariable(ChannelsVariable) is { Length: > 0 } channels
? channels
: null;
/// <summary>
/// Rewrites <paramref name="startInfo"/> to run the original command under
/// <c>/bin/sh</c> with stderr appended to <see cref="LogPath"/>.
/// </summary>
/// <remarks>
/// The command is split here and handed to the shell as separate positional parameters,
/// then executed with <c>exec "$@"</c>. The shell must never re-parse the command as source:
/// doing so applies a second round of expansion to values Daybreak has already quoted, so a
/// launch argument containing <c>$</c>, a backtick or a glob would be rewritten before the
/// game ever saw it. Splitting with <see cref="CommandLineUtils.SplitCommandLine"/> keeps the
/// argument vector identical to the one .NET builds when the log is disabled.
/// </remarks>
public static void Apply(ProcessStartInfo startInfo)
{
var logPath = LogPath;
var arguments = CommandLineUtils.SplitCommandLine(startInfo.Arguments);
var fileName = startInfo.FileName;
startInfo.FileName = "/bin/sh";
startInfo.Arguments = string.Empty;
startInfo.ArgumentList.Add("-c");
startInfo.ArgumentList.Add("exec \"$@\" 2>>\"$0\"");
startInfo.ArgumentList.Add(logPath);
startInfo.ArgumentList.Add(fileName);
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
if (Channels is { } channels)
{
startInfo.Environment["WINEDEBUG"] = channels;
}
}
public static void WriteSessionHeader(string description)
{
try
{
File.AppendAllText(
LogPath,
$"{Environment.NewLine}===== {DateTime.Now:yyyy-MM-dd HH:mm:ss} {description} ====={Environment.NewLine}");
}
catch
{
// Diagnostics only - never fail a launch because the log is unwritable.
}
}
}
@@ -19,6 +19,11 @@ public sealed class WinePrefixManager(
{
private const string WinePrefixFolder = "WinePrefix";
private const string WineExecutable = "wine";
private const string WineNetworkRegistryKey = @"HKCU\Software\Wine\Network";
private const string ComputerNameRegistryKey =
@"HKLM\System\CurrentControlSet\Control\ComputerName\ComputerName";
private const string ActiveComputerNameRegistryKey =
@"HKLM\System\CurrentControlSet\Control\ComputerName\ActiveComputerName";
private static readonly ProgressUpdate ProgressStarting = new(0, "Starting Wine prefix setup");
private static readonly ProgressUpdate ProgressCheckingWine = new(
@@ -117,7 +122,10 @@ public sealed class WinePrefixManager(
public IEnumerable<string> GetCustomArguments() => [];
/// <inheritdoc />
public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken)
public async Task OnGuildWarsStarting(
GuildWarsStartingContext guildWarsStartingContext,
CancellationToken cancellationToken
)
{
if (!this.IsAvailable())
{
@@ -127,7 +135,7 @@ public sealed class WinePrefixManager(
title: "Wine not installed",
description: "Wine is required to launch Guild Wars on Linux. Please install Wine and restart Daybreak.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
return;
}
if (!this.IsInitialized())
@@ -138,10 +146,23 @@ public sealed class WinePrefixManager(
title: "Wine prefix not initialized",
description: "The Wine prefix needs to be set up before launching Guild Wars. Click here to initialize it.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
return;
}
return Task.CompletedTask;
if (await this.ConfigureComputerName(cancellationToken))
{
return;
}
this.logger.LogError(
"Failed to configure the Linux host name as the Wine computer name. Blocking Guild Wars startup"
);
guildWarsStartingContext.CancelStartup = true;
this.notificationService.NotifyError(
title: "Wine prefix configuration failed",
description: "Daybreak could not configure the Linux host name as the Wine computer name. Check the logs for details.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15)
);
}
/// <inheritdoc />
@@ -243,6 +264,59 @@ public sealed class WinePrefixManager(
return this.winePrefixPath;
}
public async Task<bool> ConfigureComputerName(CancellationToken cancellationToken)
{
if (!this.IsInitialized())
{
this.logger.LogWarning(
"Cannot configure computer name before the Wine prefix is initialized"
);
return false;
}
var computerName = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
var useDnsComputerNameConfigured = await this.AddRegistryValue(
WineNetworkRegistryKey,
"UseDnsComputerName",
"N",
"REG_SZ",
cancellationToken
);
var computerNameConfigured = await this.AddRegistryValue(
ComputerNameRegistryKey,
"ComputerName",
computerName,
"REG_SZ",
cancellationToken
);
var activeComputerNameConfigured = await this.AddRegistryValue(
ActiveComputerNameRegistryKey,
"ComputerName",
computerName,
"REG_SZ",
cancellationToken
);
if (
!useDnsComputerNameConfigured
|| !computerNameConfigured
|| !activeComputerNameConfigured
)
{
this.logger.LogError(
"Failed to configure Wine computer name {ComputerName} from the Linux host name",
computerName
);
return false;
}
this.logger.LogInformation(
"Configured Wine computer name {ComputerName} from the Linux host name",
computerName
);
return true;
}
public IProgressAsyncOperation<bool> Install(CancellationToken cancellationToken)
{
return ProgressAsyncOperation.Create(
@@ -280,6 +354,13 @@ public sealed class WinePrefixManager(
"Wine prefix already initialized at {PrefixPath}",
this.winePrefixPath
);
if (!await this.ConfigureComputerName(cancellationToken))
{
progress.Report(ProgressFailed);
return false;
}
progress.Report(ProgressAlreadyInitialized);
return true;
}
@@ -333,6 +414,13 @@ public sealed class WinePrefixManager(
this.logger.LogWarning("Failed to set d3d9 DLL override, but continuing...");
}
progress.Report(new ProgressUpdate(0.9, "Configuring computer name"));
if (!await this.ConfigureComputerName(cancellationToken))
{
progress.Report(ProgressFailed);
return false;
}
this.logger.LogInformation("Wine prefix initialized successfully");
progress.Report(ProgressFinished);
return true;
@@ -390,6 +478,15 @@ public sealed class WinePrefixManager(
startInfo.Environment["WINEPREFIX"] = this.winePrefixPath;
// Guild Wars outlives this call, so its crash backtrace must go somewhere
// durable rather than into the stderr pipe we stop reading below.
if (WineDebugLog.IsEnabled)
{
WineDebugLog.WriteSessionHeader($"{wineExePath} {wineArgs}");
WineDebugLog.Apply(startInfo);
this.logger.LogDebug("Wine diagnostics are being appended to {WineDebugLog}", WineDebugLog.LogPath);
}
try
{
using var process = new Process { StartInfo = startInfo };
+2 -2
View File
@@ -7,8 +7,8 @@ namespace Daybreak.Shared.Models.Guildwars;
/// Definition of a Guild Wars skill. The static skill collections (per-campaign
/// lists, <see cref="AllSkills"/>, individual <c>public static readonly Skill X</c>
/// fields) are generated by <c>Tools/SkillUpdater</c> into <c>Skill.g.cs</c> from
/// the official wiki. Re-run the tool with
/// <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
/// the GWToolbox API, with names and icons from the official wiki. Re-run the
/// tool with <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
/// </summary>
public sealed partial class Skill : IIconUrlEntity
{
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,21 @@
public sealed class LoginCredentials : IEquatable<LoginCredentials>
{
public const string SteamLoginIdentifier = "__STEAM_LOGIN__";
public static LoginCredentials SteamLogin { get; } = new()
{
Identifier = SteamLoginIdentifier,
Username = "Steam Login",
Password = string.Empty
};
public string? Identifier { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public bool IsSteamLogin => string.Equals(this.Identifier, SteamLoginIdentifier, StringComparison.Ordinal);
public bool Equals(LoginCredentials? other)
{
return this?.Identifier?.Equals(other?.Identifier) is true &&
@@ -0,0 +1,19 @@
namespace Daybreak.Shared.Services.ApplicationLauncher;
/// <summary>
/// Platform-specific service for interacting with the Steam client.
/// Used to determine whether Steam is available before launching Guild Wars with Steam login.
/// </summary>
public interface ISteamService
{
/// <summary>
/// Returns true if Steam login is supported on the current platform.
/// Steam login is currently only supported on Windows.
/// </summary>
bool IsSteamLoginSupported { get; }
/// <summary>
/// Returns true if the Steam client is currently running.
/// </summary>
bool IsSteamRunning();
}
@@ -0,0 +1,21 @@
namespace Daybreak.Shared.Services.Shell;
/// <summary>
/// Opens URLs and file-system paths using the operating system's default handler.
/// Provides a cross-platform abstraction over platform-specific shell invocations
/// (e.g. <c>explorer.exe</c> on Windows, <c>xdg-open</c> on Linux).
/// </summary>
public interface IShellExecutor
{
/// <summary>
/// Opens the given URL using the default web browser.
/// </summary>
/// <param name="url">The absolute URL to open.</param>
void OpenUrl(string url);
/// <summary>
/// Opens the given file or folder using the default file manager or associated program.
/// </summary>
/// <param name="path">The file-system path to open.</param>
void OpenPath(string path);
}
+109
View File
@@ -0,0 +1,109 @@
namespace Daybreak.Shared.Utils;
/// <summary>
/// Helpers for working with Windows style command line strings.
/// </summary>
public static class CommandLineUtils
{
/// <summary>
/// Splits a Windows style command line into the argument vector it represents.
/// </summary>
/// <remarks>
/// This mirrors the parser .NET applies to <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>
/// when starting a process on Unix. Anything that needs to hand those arguments to another launcher
/// must produce the same vector, otherwise the process observes a different command line depending on
/// how it was started.
/// </remarks>
/// <param name="commandLine">The command line to split. May be null or empty.</param>
/// <returns>The parsed arguments, with quoting and backslash escapes resolved.</returns>
public static List<string> SplitCommandLine(string? commandLine)
{
var results = new List<string>();
if (string.IsNullOrEmpty(commandLine))
{
return results;
}
var i = 0;
while (i < commandLine.Length)
{
while (i < commandLine.Length && (commandLine[i] is ' ' or '\t'))
{
i++;
}
if (i == commandLine.Length)
{
break;
}
results.Add(ReadArgument(commandLine, ref i));
}
return results;
}
private static string ReadArgument(string commandLine, ref int i)
{
var argument = new System.Text.StringBuilder();
var inQuotes = false;
while (i < commandLine.Length)
{
var backslashCount = 0;
while (i < commandLine.Length && commandLine[i] is '\\')
{
i++;
backslashCount++;
}
if (backslashCount > 0)
{
if (i >= commandLine.Length || commandLine[i] is not '"')
{
argument.Append('\\', backslashCount);
}
else
{
// Every pair of backslashes produces one literal backslash. A remaining
// backslash escapes the quote that follows it.
argument.Append('\\', backslashCount / 2);
if (backslashCount % 2 != 0)
{
argument.Append('"');
i++;
}
}
continue;
}
var c = commandLine[i];
if (c is '"')
{
if (inQuotes && i + 1 < commandLine.Length && commandLine[i + 1] is '"')
{
argument.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
i++;
continue;
}
if (!inQuotes && c is ' ' or '\t')
{
break;
}
argument.Append(c);
i++;
}
return argument.ToString();
}
}
@@ -0,0 +1,66 @@
using System.Text;
namespace Daybreak.Shared.Utils;
/// <summary>
/// Helpers for producing Windows compatible computer names.
/// </summary>
public static class ComputerNameUtils
{
/// <summary>
/// Maximum length of a Windows NetBIOS computer name, matching the Win32 MAX_COMPUTERNAME_LENGTH constant.
/// </summary>
public const int MaxComputerNameLength = 15;
/// <summary>
/// Name used when no valid computer name can be derived from the host.
/// </summary>
public const string FallbackComputerName = "DAYBREAK";
/// <summary>
/// Converts an arbitrary host name into a name that Win32 GetComputerName can return.
/// </summary>
/// <remarks>
/// GetComputerNameW is documented to fill a buffer of MAX_COMPUTERNAME_LENGTH + 1 characters, so callers
/// size their buffers accordingly. When the configured name is longer, the call fails with
/// ERROR_BUFFER_OVERFLOW and leaves the caller's buffer untouched, which applications such as GWToolbox
/// observe as an empty computer name. Wine's own wineboot upper-cases and truncates the Linux host name
/// for the same reason, so this method reproduces that behaviour.
/// </remarks>
/// <param name="name">The host name to sanitize.</param>
/// <returns>An upper-case, at most <see cref="MaxComputerNameLength"/> characters long, non-empty computer name.</returns>
public static string SanitizeComputerName(string? name)
{
if (name is null)
{
return FallbackComputerName;
}
var builder = new StringBuilder(MaxComputerNameLength);
foreach (var c in name)
{
if (builder.Length >= MaxComputerNameLength)
{
break;
}
if (char.IsAsciiLetterOrDigit(c))
{
builder.Append(char.ToUpperInvariant(c));
}
else if (c is '-' or '_')
{
builder.Append(c);
}
}
// A computer name may not start or end with a separator.
while (builder.Length > 0 && builder[^1] is '-' or '_')
{
builder.Length--;
}
var sanitized = builder.ToString().TrimStart('-', '_');
return sanitized.Length is 0 ? FallbackComputerName : sanitized;
}
}
@@ -128,4 +128,25 @@ public sealed class CredentialManagerTests
a.Username.Should().BeEmpty();
a.Password.Should().BeEmpty();
}
[TestMethod]
public void TryGetCredentialsByIdentifier_SteamLoginSentinel_ReturnsVirtualCredential()
{
var ok = this.manager.TryGetCredentialsByIdentifier(LoginCredentials.SteamLoginIdentifier, out var found);
ok.Should().BeTrue();
found.Should().BeSameAs(LoginCredentials.SteamLogin);
found!.IsSteamLogin.Should().BeTrue();
}
[TestMethod]
public void StoreCredentials_DoesNotPersistSteamLoginSentinel()
{
var real = new LoginCredentials { Identifier = "id-1", Username = "alice", Password = "pw" };
this.manager.StoreCredentials([LoginCredentials.SteamLogin, real]);
this.options.ProtectedLoginCredentials.Should().ContainSingle()
.Which.Identifier.Should().Be("id-1");
}
}
@@ -0,0 +1,135 @@
using Daybreak.Services.Logging;
using FluentAssertions;
using Serilog.Events;
using Serilog.Parsing;
namespace Daybreak.Tests.Services.Logging;
[TestClass]
public sealed class LogEventRedactorTests
{
private static readonly MessageTemplateParser Parser = new();
private static LogEvent BuildEvent(
string template = "hello",
IEnumerable<LogEventProperty>? properties = null,
Exception? exception = null)
{
return new LogEvent(
new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero),
LogEventLevel.Information,
exception,
Parser.Parse(template),
properties ?? []);
}
[TestMethod]
public void RedactText_MasksQuotedEmailArgument()
{
LogEventRedactor.RedactText("Launching -email \"user@example.com\" -character \"Daybreak\"")
.Should().Be("Launching -email [REDACTED] -character \"Daybreak\"");
}
[TestMethod]
public void RedactText_MasksQuotedPasswordArgument()
{
LogEventRedactor.RedactText("-password \"s3cr3t p@ss\"")
.Should().Be("-password [REDACTED]");
}
[TestMethod]
public void RedactText_MasksUnquotedArguments()
{
LogEventRedactor.RedactText("-email user -password hunter2")
.Should().Be("-email [REDACTED] -password [REDACTED]");
}
[TestMethod]
[DataRow("-EMAIL \"user\"", "-EMAIL [REDACTED]")]
[DataRow("-Password \"pw\"", "-Password [REDACTED]")]
public void RedactText_IsCaseInsensitive(string input, string expected)
{
LogEventRedactor.RedactText(input).Should().Be(expected);
}
[TestMethod]
public void RedactText_DoesNotMatchLongerFlagNames()
{
LogEventRedactor.RedactText("-emailaddress kept").Should().Be("-emailaddress kept");
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
public void RedactText_ReturnsEmptyForNullOrEmpty(string? input)
{
LogEventRedactor.RedactText(input).Should().BeEmpty();
}
[TestMethod]
public void Redact_MasksSecretsInsideRenderedMessage()
{
var properties = new[] { new LogEventProperty("Output", new ScalarValue("started with -email \"a@b.c\" -password \"pw\"")) };
var redacted = LogEventRedactor.Redact(BuildEvent("Injector output: {Output}", properties));
redacted.RenderMessage().Should().Be("Injector output: \"started with -email [REDACTED] -password [REDACTED]\"");
}
[TestMethod]
public void Redact_MasksSecretsEmbeddedInTemplateText()
{
var redacted = LogEventRedactor.Redact(BuildEvent("Running -password \"literal\" now"));
redacted.RenderMessage().Should().Be("Running -password [REDACTED] now");
}
[TestMethod]
[DataRow("Username")]
[DataRow("Password")]
[DataRow("Email")]
[DataRow("username")]
public void Redact_MasksSensitivePropertyValuesByName(string propertyName)
{
var properties = new[] { new LogEventProperty(propertyName, new ScalarValue("john.doe")) };
var redacted = LogEventRedactor.Redact(BuildEvent($"user {{{propertyName}}}", properties));
redacted.Properties[propertyName].Should().BeEquivalentTo(new ScalarValue("[REDACTED]"));
redacted.RenderMessage().Should().Be("user \"[REDACTED]\"");
}
[TestMethod]
public void Redact_MasksSensitivePropertiesNestedInStructures()
{
var structure = new StructureValue(
[
new LogEventProperty("Identifier", new ScalarValue("acc-1")),
new LogEventProperty("Username", new ScalarValue("john")),
new LogEventProperty("Password", new ScalarValue("pw")),
]);
var properties = new[] { new LogEventProperty("Credentials", structure) };
var redacted = LogEventRedactor.Redact(BuildEvent("{@Credentials}", properties));
var redactedStructure = (StructureValue)redacted.Properties["Credentials"];
redactedStructure.Properties.Single(p => p.Name == "Identifier").Value.Should().BeEquivalentTo(new ScalarValue("acc-1"));
redactedStructure.Properties.Single(p => p.Name == "Username").Value.Should().BeEquivalentTo(new ScalarValue("[REDACTED]"));
redactedStructure.Properties.Single(p => p.Name == "Password").Value.Should().BeEquivalentTo(new ScalarValue("[REDACTED]"));
}
[TestMethod]
public void Redact_PreservesNonSensitiveDataAndMetadata()
{
var exception = new InvalidOperationException("boom");
var properties = new[] { new LogEventProperty("Count", new ScalarValue(42)) };
var original = BuildEvent("processed {Count}", properties, exception);
var redacted = LogEventRedactor.Redact(original);
redacted.Timestamp.Should().Be(original.Timestamp);
redacted.Level.Should().Be(original.Level);
redacted.Exception.Should().BeSameAs(exception);
redacted.RenderMessage().Should().Be("processed 42");
}
}
@@ -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);
}
}
@@ -13,6 +13,7 @@ using Daybreak.Shared.Services.Shortcuts;
using Daybreak.Shared.Services.DirectSong;
using Daybreak.Shared.Services.Registry;
using Daybreak.Shared.Services.SevenZip;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.ReShade;
@@ -28,6 +29,7 @@ using Daybreak.Windows.Services.Privilege;
using Daybreak.Windows.Services.Screens;
using Daybreak.Windows.Services.Shortcuts;
using Daybreak.Windows.Services.SevenZip;
using Daybreak.Windows.Services.Shell;
using Daybreak.Windows.Services.UMod;
using Daybreak.Windows.Services.Window;
using Daybreak.Windows.Services.ExceptionHandling;
@@ -72,11 +74,13 @@ public sealed class WindowsPlatformConfiguration : PluginConfigurationBase
services.AddScoped<IDaybreakInjector, DaybreakInjector>();
services.AddScoped<IGuildWarsReadyChecker, GuildWarsReadyChecker>();
services.AddScoped<IGuildWarsProcessFinder, GuildWarsProcessFinder>();
services.AddSingleton<ISteamService, SteamService>();
services.AddSingleton<IModPathResolver, ModPathResolver>();
services.AddSingleton<IDirectSongRegistrar, DirectSongRegistrar>();
services.AddScoped<IRegistryService, RegistryService>();
services.AddSingleton<ISystemThemeDetector, SystemThemeDetector>();
services.AddSingleton<ISevenZipExtractor, SevenZipArchiveExtractor>();
services.AddSingleton<IShellExecutor, ShellExecutor>();
services.AddSingleton<IPidProvider, PidProvider>();
services.AddSingleton<IWindowManipulationService, WindowManipulationService>();
services.AddSingleton<ICrashDumpService, CrashDumpService>();
@@ -0,0 +1,19 @@
using System.Diagnostics;
using Daybreak.Shared.Services.ApplicationLauncher;
namespace Daybreak.Windows.Services.ApplicationLauncher;
/// <summary>
/// Windows-specific Steam service. Detects the running Steam client by process name.
/// </summary>
public sealed class SteamService : ISteamService
{
private const string SteamProcessName = "steam";
public bool IsSteamLoginSupported => true;
public bool IsSteamRunning()
{
return Process.GetProcessesByName(SteamProcessName).Length > 0;
}
}
@@ -0,0 +1,51 @@
using Daybreak.Shared.Services.Shell;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace Daybreak.Windows.Services.Shell;
/// <summary>
/// Windows implementation of <see cref="IShellExecutor"/>.
/// URLs are opened via shell execution (default browser); paths are opened via <c>explorer.exe</c>.
/// </summary>
internal sealed class ShellExecutor(
ILogger<ShellExecutor> logger) : IShellExecutor
{
private const string ExplorerExecutable = "explorer.exe";
private readonly ILogger<ShellExecutor> logger = logger;
public void OpenUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to open url {url}", url);
}
}
public void OpenPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
try
{
Process.Start(ExplorerExecutable, path);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to open path {path}", path);
}
}
}
+23 -20
View File
@@ -279,6 +279,7 @@ namespace GW {
constexpr int Winnowing = 2926;
constexpr int EoE = 2927;
constexpr int Symbiosis = 2930;
constexpr int FrozenSoil = 2933;
constexpr int QZ = 2937;
@@ -454,6 +455,8 @@ namespace GW {
constexpr int LockedChest = 8192; // this is actually ->ExtraType
constexpr int VarnyTheZealot = 8258;
namespace Minipet {
constexpr int MiniatureConfessorDorian = 8344;
constexpr int MiniaturePrincessSalma = 8349;
@@ -467,26 +470,26 @@ namespace GW {
} // namespace Minipet
namespace SummoningStone {
constexpr int ImperialCripplingSlash = 9043;
constexpr int ImperialTripleChop = 9044;
constexpr int ImperialBarrage = 9045;
constexpr int ImperialQuiveringBlade = 9046;
constexpr int TenguHundredBlades = 9047;
constexpr int TenguBroadHeadArrow = 9048;
constexpr int TenguPalmStrike = 9049;
constexpr int TenguLifeSheath = 9050;
constexpr int TenguAngchuElementalist = 9051;
constexpr int TenguFeveredDreams = 9052;
constexpr int TenguSpitefulSpirit = 9053;
constexpr int TenguPreservation = 9054;
constexpr int TenguPrimalRage = 9055;
constexpr int TenguGlassArrows = 9056;
constexpr int TenguWayOftheAssassin = 9057;
constexpr int TenguPeaceandHarmony = 9058;
constexpr int TenguSandstorm = 9059;
constexpr int TenguPanic = 9060;
constexpr int TenguAuraOftheLich = 9061;
constexpr int TenguDefiantWasXinrae = 9062;
constexpr int ImperialCripplingSlash = 9268;
constexpr int ImperialTripleChop = 9269;
constexpr int ImperialBarrage = 9270;
constexpr int ImperialQuiveringBlade = 9271;
constexpr int TenguHundredBlades = 9272;
constexpr int TenguBroadHeadArrow = 9273;
constexpr int TenguPalmStrike = 9274;
constexpr int TenguLifeSheath = 9275;
constexpr int TenguAngchuElementalist = 9276;
constexpr int TenguFeveredDreams = 9277;
constexpr int TenguSpitefulSpirit = 9278;
constexpr int TenguPreservation = 9279;
constexpr int TenguPrimalRage = 9280;
constexpr int TenguGlassArrows = 9281;
constexpr int TenguWayOftheAssassin = 9282;
constexpr int TenguPeaceandHarmony = 9283;
constexpr int TenguSandstorm = 9284;
constexpr int TenguPanic = 9285;
constexpr int TenguAuraOftheLich = 9286;
constexpr int TenguDefiantWasXinrae = 9287;
} // namespace SummoningStone
} // namespace ModelID
} // namespace Constants
+3 -3
View File
@@ -22,7 +22,7 @@ namespace GW {
None, Warrior, Ranger, Monk, Necromancer, Mesmer,
Elementalist, Assassin, Ritualist, Paragon, Dervish
};
static const char* GetProfessionAcronym(Profession prof) {
inline const char* GetProfessionAcronym(Profession prof) {
switch (prof) {
case GW::Constants::Profession::None: return "X";
case GW::Constants::Profession::Warrior: return "W";
@@ -38,7 +38,7 @@ namespace GW {
default: return "";
}
}
static const wchar_t* GetWProfessionAcronym(Profession prof) {
inline const wchar_t* GetWProfessionAcronym(Profession prof) {
switch (prof) {
case GW::Constants::Profession::None: return L"X";
case GW::Constants::Profession::Warrior: return L"W";
@@ -234,7 +234,7 @@ namespace GW {
constexpr float Earshot = 1012.0f;
constexpr float Spellcast = 1248.0f;
constexpr float Spirit = 2512.0f;
constexpr float SpiritExtended = 3500.0f;
constexpr float SpiritExtended = 3000.0f;
constexpr float Compass = 5000.0f;
};
+2 -2
View File
@@ -895,10 +895,10 @@ namespace GW {
Forsaken_Tunnels_Presearing_Level2,
Forsaken_Tunnels_Presearing_Level3,
Unknown_879,
Forsaken_Tunnels_Level1,
Forsaken_Tunnels_Level2,
Forsaken_Tunnels_Level3,
Forsaken_Tunnels_Level4,
Forsaken_Tunnels_Level3,
Count = 0x373,
};
+2 -3
View File
@@ -3033,9 +3033,8 @@ namespace GW {
Vow_of_Revolution,
Heroic_Refrain,
Reforged_Mode=0xD6A,
Dhuums_Covenant_Broken,
Count = 0xD6c
};
Dhuums_Covenant_Broken
};
enum class SkillType {
Bounty = 1,
+98 -85
View File
@@ -166,11 +166,11 @@ namespace GW {
kChangeTarget, // 0x10000020, wparam = UIPacket::kChangeTarget*
kMessage_0x10000021, // 0x10000021
kMessage_0x10000022, // 0x10000022
kMessage_0x10000023, // 0x10000023
kAgentSkillActivated, // 0x10000024, kAgentSkillPacket
kAgentSkillActivatedInstantly, // 0x10000025, kAgentSkillPacket
kAgentSkillCancelled, // 0x10000026, kAgentSkillPacket
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentStartCasting*
kAgentSkillCancelled, // 0x10000023, wparam = kAgentSkillPacket; "<agent> canceled <skill>"
kAgentSkillActivated, // 0x10000024, wparam = kAgentSkillPacket; "<agent> executed <skill>"
kAgentSkillActivatedInstantly, // 0x10000025, wparam = kAgentSkillPacket; "<agent> used <skill>"
kAgentSkillInterrupted, // 0x10000026, wparam = kAgentSkillPacket; "<agent> was interrupted while using <skill>"
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentSkillStartedCast*; "<agent> is warming up <skill>"
kMessage_0x10000028, // 0x10000028
kShowMapEntryMessage, // 0x10000029, wparam = { wchar_t* title, wchar_t* subtitle }
kSetCurrentPlayerData, // 0x1000002a, fired after setting the worldcontext player name
@@ -313,7 +313,7 @@ namespace GW {
kTargetNPCPartyMember, // 0x100000b3, wparam = { uint32_t unk, uint32_t agent_id }
kTargetPlayerPartyMember, // 0x100000b4, wparam = { uint32_t unk, uint32_t player_number }
kVendorWindow, // 0x100000b5, wparam = UIPacket::kVendorWindow
kMessage_0x100000b6, // 0x100000b6
kVendorError, // 0x100000b6, wparam = { uint32_t flag, uint32_t error_id }
kMessage_0x100000b7, // 0x100000b7
kMessage_0x100000b8, // 0x100000b8
kVendorItems, // 0x100000b9, wparam = UIPacket::kVendorItems
@@ -535,65 +535,67 @@ namespace GW {
kMessage_0x1000018b, // 0x10000191
kMessage_0x1000018c, // 0x10000192
kMessage_0x1000018d, // 0x10000193
kAppendMessageToChat, // 0x10000194, wparam = wchar_t* message
kMessage_0x1000018f, // 0x10000195
kMessage_0x10000190, // 0x10000196
kMessage_0x10000191, // 0x10000197
kMessage_0x10000192, // 0x10000198
kMessage_0x10000193, // 0x10000199
kMessage_0x10000194, // 0x1000019a
kMessage_0x10000195, // 0x1000019b
kMessage_0x10000196, // 0x1000019c
kMessage_0x10000197, // 0x1000019d
kMessage_0x10000198, // 0x1000019e
kMessage_0x10000199, // 0x1000019f
kMessage_0x1000019a, // 0x100001a0
kMessage_0x1000019b, // 0x100001a1
kHideHeroPanel, // 0x100001a2, wparam = hero_id
kShowHeroPanel, // 0x100001a3, wparam = hero_id
kMessage_0x1000019e, // 0x100001a4
kMessage_0x1000019f, // 0x100001a5
kQuerySuppressedKeyAction, // 0x100001a6, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
kGetInventoryAgentId, // 0x100001a7, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemRelated_1, // 0x100001ae
kItemTooltip, // 0x100001af
kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
kInitiateTrade, // 0x100001b3
kMessage_0x100001a7, // 0x100001b4
kMessage_0x100001a8, // 0x100001b5
kMessage_0x100001a9, // 0x100001b6
kMessage_0x100001aa, // 0x100001b7
kPartySearchWindowDestroyed, // 0x100001b8
kMessage_0x100001ac, // 0x100001b9
kPartySearchWindowCreated, // 0x100001ba
kMessage_0x100001ae, // 0x100001bb
kMessage_0x100001af, // 0x100001bc
kMessage_0x100001b0, // 0x100001bd
kMessage_0x100001b1, // 0x100001be
kMessage_0x100001b2, // 0x100001bf
kMessage_0x100001b3, // 0x100001c0
kMessage_0x100001b4, // 0x100001c1
kMessage_0x100001b5, // 0x100001c2
kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
kInventoryRelated_1, // 0x100001c4
kInventoryRelated_2, // 0x100001c5
kMissionStatusRelated, // 0x100001c6
kUnused_1c2, // 0x100001c7
kCollapseExpandSkillListSection, // 0x100001c8
kPromptLoadTemplate, // 0x100001c9
kOpenTemplateManager, // 0x100001ca
kPromptSaveTemplate, // 0x100001cb
kOpenTemplate, // 0x100001cc, wparam = GW::UI::ChatTemplate*
kTemplateRelated_3, // 0x100001cd
kTemplateRelated_4, // 0x100001ce
kMessage_0x1000018d_2, // 0x10000194, added to GW 2026-06-17
kAppendMessageToChat, // 0x10000195, wparam = wchar_t* message
kMessage_0x1000018f, // 0x10000196
kMessage_0x10000190, // 0x10000197
kMessage_0x10000191, // 0x10000198
kMessage_0x10000192, // 0x10000199
kMessage_0x10000193, // 0x1000019a
kMessage_0x10000194, // 0x1000019b
kMessage_0x10000195, // 0x1000019c
kMessage_0x10000196, // 0x1000019d
kMessage_0x10000197, // 0x1000019e
kMessage_0x10000198, // 0x1000019f
kMessage_0x10000199, // 0x100001a0
kMessage_0x1000019a, // 0x100001a1
kMessage_0x1000019b, // 0x100001a2
kHideHeroPanel, // 0x100001a3, wparam = hero_id
kShowHeroPanel, // 0x100001a4, wparam = hero_id
kMessage_0x1000019e, // 0x100001a5
kMessage_0x1000019f, // 0x100001a6
kQuerySuppressedKeyAction, // 0x100001a7, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
kGetInventoryAgentId, // 0x100001a8, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
kInventoryRelated1, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated2, // 0x100001aa, added to GW 2026-02-26
kInventoryRelated3, // 0x100001ab, added to GW 2026-02-26
kInventoryRelated4, // 0x100001ac, added to GW 2026-04-28
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
@@ -602,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
@@ -625,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 {
@@ -672,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;
@@ -722,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;
@@ -794,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;
@@ -938,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;
+98 -85
View File
@@ -166,11 +166,11 @@ namespace GW {
kChangeTarget, // 0x10000020, wparam = UIPacket::kChangeTarget*
kMessage_0x10000021, // 0x10000021
kMessage_0x10000022, // 0x10000022
kMessage_0x10000023, // 0x10000023
kAgentSkillActivated, // 0x10000024, kAgentSkillPacket
kAgentSkillActivatedInstantly, // 0x10000025, kAgentSkillPacket
kAgentSkillCancelled, // 0x10000026, kAgentSkillPacket
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentStartCasting*
kAgentSkillCancelled, // 0x10000023, wparam = kAgentSkillPacket; "<agent> canceled <skill>"
kAgentSkillActivated, // 0x10000024, wparam = kAgentSkillPacket; "<agent> executed <skill>"
kAgentSkillActivatedInstantly, // 0x10000025, wparam = kAgentSkillPacket; "<agent> used <skill>"
kAgentSkillInterrupted, // 0x10000026, wparam = kAgentSkillPacket; "<agent> was interrupted while using <skill>"
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentSkillStartedCast*; "<agent> is warming up <skill>"
kMessage_0x10000028, // 0x10000028
kShowMapEntryMessage, // 0x10000029, wparam = { wchar_t* title, wchar_t* subtitle }
kSetCurrentPlayerData, // 0x1000002a, fired after setting the worldcontext player name
@@ -313,7 +313,7 @@ namespace GW {
kTargetNPCPartyMember, // 0x100000b3, wparam = { uint32_t unk, uint32_t agent_id }
kTargetPlayerPartyMember, // 0x100000b4, wparam = { uint32_t unk, uint32_t player_number }
kVendorWindow, // 0x100000b5, wparam = UIPacket::kVendorWindow
kMessage_0x100000b6, // 0x100000b6
kVendorError, // 0x100000b6, wparam = { uint32_t flag, uint32_t error_id }
kMessage_0x100000b7, // 0x100000b7
kMessage_0x100000b8, // 0x100000b8
kVendorItems, // 0x100000b9, wparam = UIPacket::kVendorItems
@@ -535,65 +535,67 @@ namespace GW {
kMessage_0x1000018b, // 0x10000191
kMessage_0x1000018c, // 0x10000192
kMessage_0x1000018d, // 0x10000193
kAppendMessageToChat, // 0x10000194, wparam = wchar_t* message
kMessage_0x1000018f, // 0x10000195
kMessage_0x10000190, // 0x10000196
kMessage_0x10000191, // 0x10000197
kMessage_0x10000192, // 0x10000198
kMessage_0x10000193, // 0x10000199
kMessage_0x10000194, // 0x1000019a
kMessage_0x10000195, // 0x1000019b
kMessage_0x10000196, // 0x1000019c
kMessage_0x10000197, // 0x1000019d
kMessage_0x10000198, // 0x1000019e
kMessage_0x10000199, // 0x1000019f
kMessage_0x1000019a, // 0x100001a0
kMessage_0x1000019b, // 0x100001a1
kHideHeroPanel, // 0x100001a2, wparam = hero_id
kShowHeroPanel, // 0x100001a3, wparam = hero_id
kMessage_0x1000019e, // 0x100001a4
kMessage_0x1000019f, // 0x100001a5
kQuerySuppressedKeyAction, // 0x100001a6, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
kGetInventoryAgentId, // 0x100001a7, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemRelated_1, // 0x100001ae
kItemTooltip, // 0x100001af
kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
kInitiateTrade, // 0x100001b3
kMessage_0x100001a7, // 0x100001b4
kMessage_0x100001a8, // 0x100001b5
kMessage_0x100001a9, // 0x100001b6
kMessage_0x100001aa, // 0x100001b7
kPartySearchWindowDestroyed, // 0x100001b8
kMessage_0x100001ac, // 0x100001b9
kPartySearchWindowCreated, // 0x100001ba
kMessage_0x100001ae, // 0x100001bb
kMessage_0x100001af, // 0x100001bc
kMessage_0x100001b0, // 0x100001bd
kMessage_0x100001b1, // 0x100001be
kMessage_0x100001b2, // 0x100001bf
kMessage_0x100001b3, // 0x100001c0
kMessage_0x100001b4, // 0x100001c1
kMessage_0x100001b5, // 0x100001c2
kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
kInventoryRelated_1, // 0x100001c4
kInventoryRelated_2, // 0x100001c5
kMissionStatusRelated, // 0x100001c6
kUnused_1c2, // 0x100001c7
kCollapseExpandSkillListSection, // 0x100001c8
kPromptLoadTemplate, // 0x100001c9
kOpenTemplateManager, // 0x100001ca
kPromptSaveTemplate, // 0x100001cb
kOpenTemplate, // 0x100001cc, wparam = GW::UI::ChatTemplate*
kTemplateRelated_3, // 0x100001cd
kTemplateRelated_4, // 0x100001ce
kMessage_0x1000018d_2, // 0x10000194, added to GW 2026-06-17
kAppendMessageToChat, // 0x10000195, wparam = wchar_t* message
kMessage_0x1000018f, // 0x10000196
kMessage_0x10000190, // 0x10000197
kMessage_0x10000191, // 0x10000198
kMessage_0x10000192, // 0x10000199
kMessage_0x10000193, // 0x1000019a
kMessage_0x10000194, // 0x1000019b
kMessage_0x10000195, // 0x1000019c
kMessage_0x10000196, // 0x1000019d
kMessage_0x10000197, // 0x1000019e
kMessage_0x10000198, // 0x1000019f
kMessage_0x10000199, // 0x100001a0
kMessage_0x1000019a, // 0x100001a1
kMessage_0x1000019b, // 0x100001a2
kHideHeroPanel, // 0x100001a3, wparam = hero_id
kShowHeroPanel, // 0x100001a4, wparam = hero_id
kMessage_0x1000019e, // 0x100001a5
kMessage_0x1000019f, // 0x100001a6
kQuerySuppressedKeyAction, // 0x100001a7, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
kGetInventoryAgentId, // 0x100001a8, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
kInventoryRelated1, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated2, // 0x100001aa, added to GW 2026-02-26
kInventoryRelated3, // 0x100001ab, added to GW 2026-02-26
kInventoryRelated4, // 0x100001ac, added to GW 2026-04-28
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
@@ -602,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
@@ -625,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 {
@@ -672,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;
@@ -722,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;
@@ -794,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;
@@ -938,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;
+109 -21
View File
@@ -1,5 +1,6 @@
#pragma once
#include <GWCA/GameContainers/GamePos.h>
#include <GWCA/GameContainers/List.h>
#include <GWCA/GameContainers/Array.h>
#include <GWCA/GameContainers/ObjectPool.h>
@@ -68,8 +69,7 @@ namespace GW {
};
static_assert(sizeof(MapStaticData) == 0xA0, "struct MapStaticData has incorrect size");
// Those are planes that are blocked and can be unblocked at runtime. e.g., the gates in foundry
// Those aren't in the dat file, but sent from the server
// Planes blocked but unblockable at runtime (e.g. the Foundry gates); sent by the server, not in the dat file.
typedef BaseArray<uint32_t> BlockedPlaneArray;
static_assert(sizeof(BlockedPlaneArray) == 0xC, "struct BlockedPlaneArray has incorrect size");
@@ -123,9 +123,7 @@ namespace GW {
};
static_assert(sizeof(PathContext) == 0x94, "struct PathContext has incorrect size");
// The game can optionally load a DLL to do the path finding.
// The DLL is named "PathEngine.dll", but not clear if it's a 3rd party or just their name
// for development.
// The game can optionally load "PathEngine.dll" for path finding; unclear if third party or their own dev name.
struct PathEngineContext {
/* +h0000 */ void **vtable;
/* +h0004 */ uint32_t h0004;
@@ -136,6 +134,102 @@ namespace GW {
};
static_assert(sizeof(PathEngineContext) == 0x18, "struct PathEngineContext has incorrect size");
// Point lights baked into the map, parsed from its "LITE" chunk by Engine\Map\MapLight.cpp.
// Torches, braziers and campfires are these, not props with a glow texture.
struct MapLightDescriptor {
/* +h0000 */ Vec3f position;
/* +h000C */ uint8_t red;
/* +h000D */ uint8_t green;
/* +h000E */ uint8_t blue;
/* +h000F */ uint8_t h000F;
/* +h0010 */ float intensity; // colour bytes are premultiplied by intensity / 255 when the light is built
/* +h0014 */ float inner_range;
/* +h0018 */ float outer_range;
};
static_assert(sizeof(MapLightDescriptor) == 0x1C, "struct MapLightDescriptor has incorrect size");
// Field layout taken from MapLightApplyDescriptor, which hands each field straight to a GrLight setter:
// GrLightSetColour(light, &desc->red, desc->intensity), GrLightSetPosition(light, &desc->position, 0),
// GrLightSetRange(light, desc->inner_range, desc->outer_range)
struct MapLightContext {
/* +h0000 */ Array<MapLightDescriptor> descriptors;
/* +h0010 */ Array<uint32_t> lights; // HGrLight handles, parallel to descriptors (same count)
};
static_assert(sizeof(MapLightContext) == 0x20, "struct MapLightContext has incorrect size");
// Only the fields below are confirmed; the real object is at least 0x1A0 bytes, so no size assert.
// Only present when MapContext::flags bit 1 is set.
struct MapWaterContext {
/* +h0000 */ uint32_t h0000[0x2b];
/* +h00AC */ uint32_t scene_program; // the single handle water contributes to the scene program list
/* +h00B0 */ uint32_t h00B0[0xc];
/* +h00E0 */ uint32_t shader_programs[2]; // two of the four per-map shader programs, chosen by quality
/* +h00E8 */ uint32_t h00E8;
/* +h00EC */ float plane_z; // world height of the water plane
/* +h00F0 */ uint32_t h00F0[0x2a];
/* +h0198 */ uint32_t shader_programs2[2]; // the other two quality variants
};
// === terrain textures / baked terrain shadows ===
//
// GW bakes the map's static terrain shadows into the terrain textures, and streams them in
// per tile. The data lives in two layers, both reachable from the terrain texture context:
// 1. a per-texel bitmask, entropy coded per tile and decoded on demand into a scratch
// buffer (soft edges, detail) - TrnTexShadowDecompressTile;
// 2. a per-block "this whole block is in shadow" bitmask, held in the map's own data and
// tested first - a set bit fills the block flat with the shadow colour without ever
// consulting layer 1 - TrnTexComposeTileLighting.
// Clearing only layer 1 leaves the solid interiors of large shadows behind, which is what
// makes layer 2 worth documenting.
// 32x32 blocks, one bit each, MSB first, 4 bytes per row; 0x80 bytes in total.
// Quality 0 walks it as two 16-row halves (the second at +0x40), every other quality walks
// all 32 rows in one pass - both reach exactly 0x80 bytes, so that is the whole mask.
typedef uint8_t TerrainBlockShadowMask[0x80];
// One per terrain tile, indexed by `shadow_grid_width * tile_y + tile_x`.
struct TerrainShadowRecord {
/* +h0000 */ void* compressed_stream; // layer 1, entropy coded, decoded per tile
/* +h0004 */ void* compressed_stream_end;
/* +h0008 */ TerrainBlockShadowMask* block_mask; // layer 2, the map's own data - not scratch
};
static_assert(sizeof(TerrainShadowRecord) == 0xC, "struct TerrainShadowRecord has incorrect size");
struct TerrainTexTile {
/* +h0000 */ uint32_t h0000[3];
/* +h000C */ uint32_t tile_x;
/* +h0010 */ uint32_t tile_y;
/* +h0014 */ uint32_t state; // set to 2 once the tile's shadow layer has been decoded
/* +h0018 */ uint32_t h0018; // zeroed alongside the state above
};
// A single streamed terrain texture. Only the two fields below are confirmed, so no size assert.
struct TerrainTexture {
/* +h0000 */ uint32_t h0000[0x27];
/* +h009C */ uint32_t stream_flags; // bit 0x800: decompress + upload still pending
/* +h00A0 */ uint32_t h00A0[0x37];
/* +h017C */ TerrainTexTile* tile; // tagged pointer: null, or bit 0 set, means no tile
};
// The map-wide terrain texture context (`TrnTex`). Partially mapped, so no size assert.
struct TerrainTexContext {
/* +h0000 */ uint32_t h0000[0x2a];
// The bound tested by the game itself before indexing, so a tile outside the grid is
// simply "no baked shadow" rather than a read off the end.
/* +h00A8 */ BaseArray<TerrainShadowRecord> shadow_records;
// Row stride for the index above. The three fields at +0xA8/+0xB0/+0xB4 read equally well
// as an Array<TerrainShadowRecord> whose m_param happens to be the width; naming it is
// the more useful of two identical layouts.
/* +h00B4 */ uint32_t shadow_grid_width;
/* +h00B8 */ uint32_t h00B8[0x10b];
// Scratch, shared by every tile this context streams: layer 1 is decoded here and
// uploaded, then the buffer is reused for the next tile. 0x110 rows of 0x22 bytes =
// 272x272 bits, one per terrain texel. A SET bit is shadow, a clear bit is lit.
/* +h04E4 */ uint8_t decoded_shadow_tile[0x110][0x22];
};
// Self-check on the padding above; the real object is at least this big, not exactly.
static_assert(sizeof(TerrainTexContext) == 0x2904, "struct TerrainTexContext has incorrect layout");
struct MapContext {
/* +h0000 */ uint32_t map_type; // less than 4
/* +h0004 */ Vec2f start_pos;
@@ -149,8 +243,8 @@ namespace GW {
/* +h0078 */ PathEngineContext* path_engine;
/* +h007C */ PropsContext* props;
/* +h0080 */ uint32_t h0080;
/* +h0084 */ void* terrain;
/* +h0088 */ uint32_t h0088;
/* +h0084 */ void* terrain; // relationship to TerrainTexContext is unconfirmed
/* +h0088 */ void* collision; // "Collision" map chunk (Engine\Map\Collision\CollApi.cpp)
/* +h008C */ GW::Constants::MapID map_id;
/* +h0090 */ uint32_t h0090;
/* +h0094 */ uint32_t h0094;
@@ -161,37 +255,31 @@ namespace GW {
/* +h00A8 */ uint32_t h00A8;
/* +h00AC */ uint32_t h00AC;
/* +h00B0 */ uint32_t h00B0;
/* +h00B4 */ uint32_t h00B4;
/* +h00B8 */ uint32_t h00B8;
/* +h00BC */ uint32_t h00BC;
/* +h00C0 */ uint32_t h00C0;
/* +h00C4 */ uint32_t h00C4;
/* +h00C8 */ uint32_t h00C8;
/* +h00CC */ uint32_t h00CC;
/* +h00D0 */ uint32_t h00D0;
/* +h00D4 */ uint32_t h00D4;
/* +h00B4 */ Vec3f view_eye; // cached by GmWorldUpdateView
/* +h00C0 */ Vec3f view_target;
/* +h00CC */ Vec3f view_up;
/* +h00D8 */ uint32_t h00D8;
/* +h00DC */ uint32_t h00DC;
/* +h00DC */ uint32_t view_flags; // bit 0: view has been updated this frame
/* +h00E0 */ uint32_t h00E0;
/* +h00E4 */ uint32_t h00E4;
/* +h00E8 */ uint32_t h00E8;
/* +h00EC */ uint32_t h00EC;
/* +h00EC */ MapLightContext* lights; // "Light" map chunk
/* +h00F0 */ uint32_t h00F0;
/* +h00F4 */ uint32_t h00F4;
/* +h00F8 */ uint32_t h00F8;
/* +h00FC */ uint32_t h00FC;
/* +h0100 */ uint32_t h0100;
/* +h0104 */ uint32_t h0104;
/* +h0108 */ uint32_t h0108;
/* +h0108 */ uint32_t flags; // bit 1: map has water
/* +h010C */ uint32_t h010C;
/* +h0110 */ uint32_t h0110;
/* +h0114 */ uint32_t h0114;
/* +h0118 */ uint32_t h0118;
/* +h011C */ uint32_t h011C;
/* +h0120 */ uint32_t h0120;
/* +h0120 */ void* shore; // "Shore" map chunk, only loaded when flags bit 1 is set
/* +h0124 */ uint32_t h0124;
/* +h0128 */ uint32_t h0128;
/* +h012C */ uint32_t h012C;
/* +h012C */ MapWaterContext* water;
/* +h0130 */ void* zones;
/* +h0134 */ uint32_t h0134;
};
@@ -38,9 +38,7 @@ namespace GW {
/* +h0148 */ GW::Array<LoginCharacter> chars;
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetAvailableChars();
}
+1 -2
View File
@@ -29,8 +29,7 @@ namespace GW {
/* +h0010 */ TradePlayer player;
/* +h0024 */ TradePlayer partner;
// bool GetPartnerAccepted();
// bool GetPartnerOfferSent();
// bool GetPartnerAccepted(); bool GetPartnerOfferSent();
bool GetIsTradeOffered() const { return (flags & TRADE_OFFER_SEND) != 0; }
bool GetIsTradeInitiated() const { return (flags & TRADE_INITIATED) != 0; }
+3 -3
View File
@@ -1,10 +1,10 @@
#pragma once
#define GWCA_VERSION_MAJOR 4
#define GWCA_VERSION_MINOR 5
#define GWCA_VERSION_PATCH 3
#define GWCA_VERSION_MINOR 8
#define GWCA_VERSION_PATCH 7
#define GWCA_VERSION_BUILD 0
#define GWCA_VERSION "4.5.3.0"
#define GWCA_VERSION "4.8.7.0"
namespace GWCA {
constexpr int VersionMajor = GWCA_VERSION_MAJOR;
+68 -69
View File
@@ -5,87 +5,86 @@
namespace GW {
// Base Array class without m_param
template <typename T>
class BaseArray {
public:
typedef T* iterator;
typedef const T* const_iterator;
// Base Array class without m_param
template <typename T>
class BaseArray {
public:
typedef T* iterator;
typedef const T* const_iterator;
iterator begin() { return m_buffer; }
const_iterator begin() const { return m_buffer; }
iterator end() { return m_buffer + m_size; }
const_iterator end() const { return m_buffer + m_size; }
iterator begin() { return m_buffer; }
const_iterator begin() const { return m_buffer; }
iterator end() { return m_buffer + m_size; }
const_iterator end() const { return m_buffer + m_size; }
BaseArray()
: m_buffer(nullptr)
, m_capacity(0)
, m_size(0)
{
}
BaseArray()
: m_buffer(nullptr)
, m_capacity(0)
, m_size(0)
{
}
BaseArray(const BaseArray&) = delete;
BaseArray& operator=(const BaseArray&) = delete;
BaseArray(const BaseArray&) = delete;
BaseArray& operator=(const BaseArray&) = delete;
T& at(size_t index) {
GWCA_ASSERT(m_buffer && index < m_size);
return m_buffer[index];
}
T& at(size_t index) {
GWCA_ASSERT(m_buffer && index < m_size);
return m_buffer[index];
}
T* get(size_t index) const {
return m_buffer && index < m_size ? &m_buffer[index] : nullptr;
}
T* get(size_t index) const {
return m_buffer && index < m_size ? &m_buffer[index] : nullptr;
}
const T& at(size_t index) const {
GWCA_ASSERT(m_buffer && index < m_size);
return m_buffer[index];
}
const T& at(size_t index) const {
GWCA_ASSERT(m_buffer && index < m_size);
return m_buffer[index];
}
T& operator[](uint32_t index) {
return at(index);
}
T& operator[](uint32_t index) {
return at(index);
}
const T& operator[](uint32_t index) const {
return at(index);
}
const T& operator[](uint32_t index) const {
return at(index);
}
bool valid() const { return (!m_buffer || !(reinterpret_cast<uintptr_t>(m_buffer) & 0x3)) && m_size <= m_capacity; }
void clear() { m_size = 0; }
bool valid() const { return m_buffer != nullptr; }
void clear() { m_size = 0; }
uint32_t size() const { return m_size; }
uint32_t capacity() const { return m_capacity; }
// Resize using GW's heap - safe to use with GW-managed arrays
bool resize(uint32_t new_size) {
if (new_size > m_capacity) {
auto* new_buf = static_cast<T*>(GW::MemoryMgr::MemRealloc(m_buffer, new_size * sizeof(T)));
if (!new_buf) return false;
m_buffer = new_buf;
m_capacity = new_size;
}
m_size = new_size;
return true;
}
uint32_t size() const { return m_size; }
uint32_t capacity() const { return m_capacity; }
// Resize using GW's heap - safe to use with GW-managed arrays
bool resize(uint32_t new_size) {
if (new_size > m_capacity) {
auto* new_buf = static_cast<T*>(GW::MemoryMgr::MemRealloc(m_buffer, new_size * sizeof(T)));
if (!new_buf) return false;
m_buffer = new_buf;
m_capacity = new_size;
}
m_size = new_size;
return true;
}
public:
T* m_buffer; // +h0000
uint32_t m_capacity; // +h0004
uint32_t m_size; // +h0008
}; // Size: 0x000C
public:
T* m_buffer; // +h0000
uint32_t m_capacity; // +h0004
uint32_t m_size; // +h0008
}; // Size: 0x000C
// Extended Array class with m_param
template <typename T>
class Array : public BaseArray<T> {
public:
Array()
: BaseArray<T>()
, m_param(0)
{
}
// Extended Array class with m_param
template <typename T>
class Array : public BaseArray<T> {
public:
Array()
: BaseArray<T>()
, m_param(0)
{
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
uint32_t m_param; // +h000C
}; // Size: 0x0010
uint32_t m_param; // +h000C
}; // Size: 0x0010
}
+1 -5
View File
@@ -15,8 +15,4 @@ namespace GW {
static_assert(sizeof(ObjectPool) == 0xC, "struct ObjectPool has incorrect size");
}
// The functions used to allocate looks like:
// void *__thiscall ObjectPool::Alloc(ObjectPool *pool, int32_t typesize, const char *typename);
// So, the typename & typesize are passed at every allocs, similar to `MemAlloc`.
// It's worth nothing that the minimum typesize is 4, though Guild Wars doesn't assert it, it just
// ends up crashing. This is due to the freeList pointer lists that are 4 bytes each.
// ObjectPool::Alloc(pool, typesize, typename) passes type info per alloc; minimum typesize is 4 (freeList pointers) or it crashes.
+32 -14
View File
@@ -6,6 +6,7 @@
#include <GWCA/Constants/Constants.h>
#include <GWCA/GameEntities/Item.h>
#include <GWCA/Utilities/Export.h>
namespace GW {
typedef uint32_t AgentID;
@@ -90,19 +91,12 @@ namespace GW {
/* +h0109 */ uint8_t offhand_item_type; // Offhand item type for stance/animation
/* +h010A */ uint16_t offhand_item_id; // Offhand item id for stance/animation
inline uint32_t GetType() {
return vtable->GetType(this);
}
inline bool RedrawEquipmentSlot(uint32_t slot) {
if (!(slot < _countof(items) && items[slot].model_file_id))
return false;
return vtable->EquipItem(this, 0, slot), true;
}
inline bool UndrawEquipmentSlot(uint32_t slot) {
if (!(slot < _countof(items) && items[slot].model_file_id))
return false;
return vtable->RemoveItem(this, 0, slot), true;
}
// These three call through the game's vtable (see Source/Agent.cpp) -- kept
// out of line so the wasm table-adoption dance stays an implementation
// detail rather than something every caller's translation unit compiles.
GWCA_API uint32_t GetType();
GWCA_API bool RedrawEquipmentSlot(uint32_t slot);
GWCA_API bool UndrawEquipmentSlot(uint32_t slot);
};
static_assert(sizeof(NPCEquipment) == 0x10C);
@@ -145,6 +139,30 @@ namespace GW {
struct AgentGadget;
struct AgentLiving;
// Bits in GW::Agent::name_properties, which AvAgent.cpp recomputes name tag visibility from.
enum NameTagFlags : uint32_t {
// In the mouse pick list; cleared wholesale whenever that list is rebuilt.
NameTagFlags_Picked = 0x8,
// Moused-over agent: underlines the tag, glows the model, draws the selection decal.
NameTagFlags_Highlighted = 0x10,
// Within name tag draw distance (1500 gwinches from the camera).
NameTagFlags_InRange = 0x20,
// The evaluated target - manual target, else auto target.
NameTagFlags_EvaluatedTarget = 0x80,
// The manual target, while a different auto target exists.
NameTagFlags_ManualTarget = 0x100,
// Name tags globally suppressed (cutscenes, /hideui); refcounted by the client.
NameTagFlags_Suppressed = 0x200,
// Agent::type passes the persistent filter from the Guild Wars name tag options.
NameTagFlags_PassesFilter = 0x400,
// Dropped item reserved for another player, so it never gets a distance-based tag.
NameTagFlags_NotOwnedByPlayer = 0x800,
// Agent::type passes the transient filter, bound to the "show item names" key.
NameTagFlags_PassesTransientFilter = 0x1000,
// Name tag disabled for this agent regardless of any filter.
NameTagFlags_Disabled = 0x20000
};
struct Agent {
/* +h0000 */ uint32_t* vtable;
/* +h0004 */ uint32_t h0004;
@@ -165,7 +183,7 @@ namespace GW {
/* +h004C */ float rotation_angle; // Rotation in radians from East (-pi to pi)
/* +h0050 */ float rotation_cos; // cosine of rotation
/* +h0054 */ float rotation_sin; // sine of rotation
/* +h0058 */ uint32_t name_properties; // Bitmap basically telling what the agent is
/* +h0058 */ NameTagFlags name_properties; // Bitmap basically telling what the agent is
/* +h005C */ uint32_t ground;
/* +h0060 */ uint32_t h0060;
/* +h0064 */ Vec3f terrain_normal;
+1 -2
View File
@@ -65,8 +65,7 @@ namespace GW {
float GetYaw() const { return yaw; }
float GetPitch() const { return pitch; }
/// \brief This is not the FoV that GW uses to render
/// see GW::Render::GetFieldOfView()
// rief Not the FoV GW renders with -- see GW::Render::GetFieldOfView()
float GetFieldOfView() const { return field_of_view; }
bool IsCameraUnlocked() const { return camera_mode == 3; }
+5 -3
View File
@@ -65,7 +65,8 @@ namespace GW {
GWCA_API SortHandler_pt GetSortHandler();
GWCA_API bool ClearItems();
GWCA_API bool RemoveItem(uint32_t child_offset_id);
GWCA_API bool AddItem(uint32_t flags, uint32_t child_offset_id, GW::UI::UIInteractionCallback callback);
// Returns the frame_id of the added item's frame, or 0 on failure.
GWCA_API uint32_t AddItem(uint32_t flags, uint32_t child_offset_id, GW::UI::UIInteractionCallback callback);
GWCA_API uint32_t GetItemFrameId(uint32_t child_offset_id);
GWCA_API bool GetSelectedValue(uint32_t* selected_value);
@@ -89,8 +90,9 @@ namespace GW {
FrameWithValue& operator=(const FrameWithValue&) = default;
FrameWithValue& operator=(FrameWithValue&&) = default;
virtual uint32_t GetValue();
virtual bool SetValue(uint32_t value);
// 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;
};
struct EditableTextFrame : UI::Frame {
+6 -5
View File
@@ -50,11 +50,12 @@ namespace GW {
static_assert(sizeof(DyeInfo) == 3, "struct DyeInfo has incorrect size");
struct ItemData {
uint32_t model_file_id = 0;
GW::Constants::ItemType type = (GW::Constants::ItemType)0xff;
GW::DyeInfo dye = {};
uint32_t value = 0;
uint32_t interaction = 0;
// 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;
uint32_t value;
uint32_t interaction;
};
static_assert(sizeof(ItemData) == 0x10, "struct ItemData has incorrect size");
+1 -2
View File
@@ -24,8 +24,7 @@ namespace GW {
/* +h0014 */ GW::Constants::Profession primary;
/* +h0018 */ GW::Constants::Profession secondary;
/* +h001C */ uint8_t default_level;
// +h001D uint8_t padding;
// +h001E uint16_t padding;
// +h001D uint8_t padding; +h001E uint16_t padding;
/* +h0020 */ wchar_t *name_enc;
/* +h0024 */ uint32_t *model_files;
/* +h0028 */ uint32_t files_count; // length of ModelFile
+125 -120
View File
@@ -3,136 +3,141 @@
#include <GWCA/GameContainers/Array.h>
namespace GW {
struct PathingTrapezoid { // total: 0x30/48
/* +h0000 */ uint32_t id;
union {
/* +h0004 */ PathingTrapezoid* adjacent[4];
struct {
/* +h0004 */ PathingTrapezoid* top_left;
/* +h0008 */ PathingTrapezoid* top_right;
/* +h000C */ PathingTrapezoid* bottom_left;
/* +h0010 */ PathingTrapezoid* bottom_right;
};
};
/* +h0014 */ uint16_t portal_left;
/* +h0016 */ uint16_t portal_right;
/* +h0018 */ float XTL;
/* +h001C */ float XTR;
/* +h0020 */ float YT;
/* +h0024 */ float XBL;
/* +h0028 */ float XBR;
/* +h002C */ float YB;
};
static_assert(sizeof(PathingTrapezoid) == 48, "struct PathingTrapezoid has incorrect size");
struct PathingTrapezoid { // total: 0x30/48
/* +h0000 */ uint32_t id;
union {
/* +h0004 */ PathingTrapezoid* adjacent[4];
struct {
/* +h0004 */ PathingTrapezoid* top_left;
/* +h0008 */ PathingTrapezoid* top_right;
/* +h000C */ PathingTrapezoid* bottom_left;
/* +h0010 */ PathingTrapezoid* bottom_right;
};
};
/* +h0014 */ uint16_t portal_left;
/* +h0016 */ uint16_t portal_right;
/* +h0018 */ float XTL;
/* +h001C */ float XTR;
/* +h0020 */ float YT;
/* +h0024 */ float XBL;
/* +h0028 */ float XBR;
/* +h002C */ float YB;
};
static_assert(sizeof(PathingTrapezoid) == 48, "struct PathingTrapezoid has incorrect size");
struct Node {
/* +h0000 */ uint32_t type; //XNode = 0, YNode = 1, SinkNode = 2
/* +h0004 */ uint32_t id;
};
struct Node {
/* +h0000 */ uint32_t type; //XNode = 0, YNode = 1, SinkNode = 2
/* +h0004 */ uint32_t id;
};
struct XNode : Node { // type = 0
/* +h0008 */ Vec2f pos;
/* +h0010 */ Vec2f dir;
/* +h0018 */ Node *left;
/* +h001C */ Node *right;
};
static_assert(sizeof(XNode) == 32, "struct XNode has incorrect size");
struct XNode : Node { // type = 0
/* +h0008 */ Vec2f pos;
/* +h0010 */ Vec2f dir;
/* +h0018 */ Node* left;
/* +h001C */ Node* right;
};
static_assert(sizeof(XNode) == 32, "struct XNode has incorrect size");
struct YNode : Node { // type = 1
/* +h0008 */ Vec2f pos;
/* +h0010 */ Node *above;
/* +h0014 */ Node *below;
};
static_assert(sizeof(YNode) == 24, "struct YNode has incorrect size");
struct YNode : Node { // type = 1
/* +h0008 */ Vec2f pos;
/* +h0010 */ Node* above;
/* +h0014 */ Node* below;
};
static_assert(sizeof(YNode) == 24, "struct YNode has incorrect size");
struct SinkNode : Node { // type = 2
/* +h0008 */ PathingTrapezoid *trapezoid;
};
static_assert(sizeof(SinkNode) == 12, "struct SinkNode has incorrect size");
struct SinkNode : Node { // type = 2
/* +h0008 */ PathingTrapezoid* trapezoid;
};
static_assert(sizeof(SinkNode) == 12, "struct SinkNode has incorrect size");
struct Portal { // total: 0x14/20
/* +h0000 */ uint16_t portal_plane;
/* +h0002 */ uint16_t neighbor_plane;
/* +h0004 */ uint32_t flags; // 0x4 => "Not used for path finding"
/* +h0008 */ Portal *pair;
/* +h000C */ uint32_t count;
/* +h0010 */ PathingTrapezoid **trapezoids;
};
static_assert(sizeof(Portal) == 20, "struct Portal has incorrect size");
struct Portal { // total: 0x14/20
/* +h0000 */ uint16_t portal_plane;
/* +h0002 */ uint16_t neighbor_plane;
/* +h0004 */ uint32_t flags; // 0x4 => "Not used for path finding"
/* +h0008 */ Portal* pair;
/* +h000C */ uint32_t count;
/* +h0010 */ PathingTrapezoid** trapezoids;
};
static_assert(sizeof(Portal) == 20, "struct Portal has incorrect size");
struct PathingMap { // total: 0x54/84
/* +h0000 */ uint32_t zplane; // ground plane = UINT_MAX, rest 0 based index
/* +h0004 */ uint32_t h0004;
/* +h0008 */ void *allocatedBuffer; // All following data are stored in a buffer allocated with a single MemAlloc. This is the pointer.
/* +h000C */ uint32_t h0010_count; // count of number h0010
/* +h0010 */ uint32_t *h0010; // elements of this array are 8 bytes
/* +h0014 */ uint32_t trapezoid_count;
/* +h0018 */ PathingTrapezoid* trapezoids;
/* +h001C */ uint32_t sink_node_count;
/* +h0020 */ SinkNode *sink_nodes;
/* +h0024 */ uint32_t x_node_count;
/* +h0028 */ XNode *x_nodes;
/* +h002C */ uint32_t y_node_count;
/* +h0030 */ YNode *y_nodes;
/* +h0034 */ uint32_t portal_trapezoids_count;
/* +h0038 */ PathingTrapezoid **portal_trapezoids;
/* +h003C */ uint32_t portal_count;
/* +h0040 */ Portal *portals;
/* +h0044 */ Node *root_node;
/* +h0048 */ BaseArray<Vec2f> dat_vectors; // this is an array of vectors read from the dat file. When reading the xnodes or ynodes from the dat file, an index will be used to get the pos.
};
static_assert(sizeof(PathingMap) == 84, "struct PathingMap has incorrect size");
struct PathingMap { // total: 0x54/84
/* +h0000 */ uint32_t zplane; // ground plane = UINT_MAX, rest 0 based index
/* +h0004 */ uint32_t h0004;
/* +h0008 */ void* allocatedBuffer; // All following data are stored in a buffer allocated with a single MemAlloc. This is the pointer.
/* +h000C */ uint32_t h0010_count; // count of number h0010
/* +h0010 */ uint32_t* h0010; // elements of this array are 8 bytes
/* +h0014 */ uint32_t trapezoid_count;
/* +h0018 */ PathingTrapezoid* trapezoids;
/* +h001C */ uint32_t sink_node_count;
/* +h0020 */ SinkNode* sink_nodes;
/* +h0024 */ uint32_t x_node_count;
/* +h0028 */ XNode* x_nodes;
/* +h002C */ uint32_t y_node_count;
/* +h0030 */ YNode* y_nodes;
/* +h0034 */ uint32_t portal_trapezoids_count;
/* +h0038 */ PathingTrapezoid** portal_trapezoids;
/* +h003C */ uint32_t portal_count;
/* +h0040 */ Portal* portals;
/* +h0044 */ Node* root_node;
/* +h0048 */ BaseArray<Vec2f> dat_vectors; // this is an array of vectors read from the dat file. When reading the xnodes or ynodes from the dat file, an index will be used to get the pos.
};
static_assert(sizeof(PathingMap) == 84, "struct PathingMap has incorrect size");
struct PropByType {
uint32_t object_id;
uint32_t prop_index;
};
struct PropByType {
uint32_t object_id;
uint32_t prop_index;
};
struct PropModelInfo {
/* +h0000 */ uint32_t h0000;
/* +h0004 */ uint32_t h0004;
/* +h0008 */ uint32_t h0008;
/* +h000C */ uint32_t h000C;
/* +h0010 */ uint32_t h0010;
/* +h0014 */ uint32_t h0014;
};
static_assert(sizeof(PropModelInfo) == 0x18, "struct PropModelInfo has incorrect size");
// Per-model collision bounds, shared by every prop using that model. Offsets confirmed against
// the client's prop ray-pick, which builds a cylinder as
// radius = MapProp::scale * bounding_radius
// z = MapProp::position.z - bounds_z_offsets[n] * MapProp::scale
struct PropModelInfo {
/* +h0000 */ uint32_t h0000;
/* +h0004 */ const wchar_t* model_file_name; // hash-encoded; FileHashToFileId() turns it into a file id
/* +h0008 */ float bounding_radius; // model space; scale by MapProp::scale for world units
/* +h000C */ float bounds_z_offsets[2];
/* +h0014 */ uint32_t h0014;
};
static_assert(sizeof(PropModelInfo) == 0x18, "struct PropModelInfo has incorrect size");
struct RecObject {
/* +h0000 */ void* vtable;
/* +h0004 */ uint32_t ref_count;
/* +h0008 */ uint32_t accessKey; // This is used by the game to make sure the data from the DAT matches the data in game
/* +h000c */ uint32_t standalone;
/* +h0010 */ uint32_t file_id;
/* +h0014 */ uint32_t stream_id;
/* +h0018 */ uint32_t flags;
/* +h001c */ uint32_t opened;
/* +h0020 */ uint32_t ref_count2;
};
static_assert(sizeof(RecObject) == 0x24, "struct RecObject has incorrect size");
struct RecObject {
/* +h0000 */ void* vtable;
/* +h0004 */ uint32_t ref_count;
/* +h0008 */ uint32_t accessKey; // This is used by the game to make sure the data from the DAT matches the data in game
/* +h000c */ uint32_t standalone;
/* +h0010 */ uint32_t file_id;
/* +h0014 */ uint32_t stream_id;
/* +h0018 */ uint32_t flags;
/* +h001c */ uint32_t opened;
/* +h0020 */ uint32_t ref_count2;
};
static_assert(sizeof(RecObject) == 0x24, "struct RecObject has incorrect size");
struct MapProp { // total: 0x54/84
/* +h0000 */ uint32_t h0000[5];
/* +h0014 */ uint32_t uptime_seconds; // time since spawned
/* +h0018 */ uint32_t h0018;
/* +h001C */ uint32_t prop_index;
/* +h0020 */ Vec3f position;
/* +h002C */ uint32_t model_file_id;
/* +h0030 */ uint32_t h0030[2];
/* +h0038 */ float rotation_angle;
/* +h003C */ float rotation_cos;
/* +h003C */ float rotation_sin;
/* +h0040 */ uint32_t h0034[5];
/* +h0058 */ RecObject* interactive_model;
/* +h005C */ uint32_t h005C[4];
/* +h006C */ uint32_t appearance_bitmap; // Modified when animation changes
/* +h0070 */ uint32_t animation_bits;
/* +h0064 */ uint32_t h0064[5];
/* +h0088 */ PropByType* prop_object_info;
/* +h008C */ uint32_t h008C;
};
struct MapProp { // total: 0x90/144
/* +h0000 */ uint32_t h0000[5];
/* +h0014 */ uint32_t uptime_seconds; // time since spawned
/* +h0018 */ uint32_t h0018;
/* +h001C */ uint32_t prop_index;
/* +h0020 */ Vec3f position;
/* +h002C */ uint32_t model_file_id;
/* +h0030 */ uint32_t h0030[2];
/* +h0038 */ float rotation_angle;
/* +h003C */ float rotation_cos;
/* +h0040 */ float rotation_sin;
/* +h0044 */ uint32_t h0044[3];
/* +h0050 */ float scale;
/* +h0054 */ PropModelInfo* model_info;
/* +h0058 */ RecObject* interactive_model;
/* +h005C */ uint32_t h005C[4];
/* +h006C */ uint32_t appearance_bitmap; // Modified when animation changes
/* +h0070 */ uint32_t animation_bits;
/* +h0074 */ uint32_t h0074[5];
/* +h0088 */ PropByType* prop_object_info;
/* +h008C */ uint32_t h008C;
};
static_assert(sizeof(MapProp) == 0x90, "struct MapProp has incorrect size");
static_assert(sizeof(MapProp) == 0x90, "struct MapProp has incorrect size");
typedef Array<PathingMap> PathingMapArray;
typedef Array<PathingMap> PathingMapArray;
}
+1 -1
View File
@@ -11,7 +11,7 @@ namespace GW {
enum class SkillType;
}
struct Skill { // total : 0xA0/160
struct Skill { // total : 0xA4/164
/* +h0000 */ GW::Constants::SkillID skill_id;
/* +h0004 */ uint32_t h0004;
/* +h0008 */ GW::Constants::Campaign campaign;
+89 -95
View File
@@ -25,14 +25,18 @@ namespace GW {
struct Module;
extern Module AgentModule;
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4200) // nonstandard extension: zero-sized array in struct
#endif
struct AgentEffect {
uint32_t effect_type;
uint32_t data_size; // usually 0x34, detemines length of data in bytes
uint32_t data[];
};
#ifdef _MSC_VER
#pragma warning(pop)
#endif
enum class WorldActionId : uint32_t {
InteractEnemy,
@@ -58,106 +62,107 @@ namespace GW {
Include_Enemy = 0x000008,
Include_SpiritPet = 0x000010,
Accept_ActiveState = 0x000020,
Include_Minion = 0x000040,
Include_NPCMinipet = 0x000080,
Accept_Player = 0x000100,
Type_Gadget = 0x000200,
Type_Item = 0x000400,
Exclude_DeadAlly = 0x000800,
Exclude_DeadNeutral = 0x001000,
Exclude_DeadEnemy = 0x002000,
Exclude_DeadSpiritPet = 0x004000,
Exclude_DeadMinion = 0x008000,
Exclude_DeadNPCMinipet = 0x010000,
Exclude_UsedCorpse = 0x020000,
Exclude_AliveAlly = 0x040000,
Exclude_AliveNeutral = 0x080000,
Exclude_AliveEnemy = 0x100000,
Exclude_AliveSpiritPet = 0x200000,
Exclude_AliveMinion = 0x400000,
Exclude_AliveNPCMinipet = 0x800000,
Exclude_BeingObserved = 0x2000000,
ZeroPriority = 0x4000000,
NPCMinipet_ZeroPriority = 0x8000000,
};
Include_Minion = 0x000040,
Include_NPCMinipet = 0x000080,
Accept_Player = 0x000100,
Type_Gadget = 0x000200,
Type_Item = 0x000400,
Exclude_DeadAlly = 0x000800,
Exclude_DeadNeutral = 0x001000,
Exclude_DeadEnemy = 0x002000,
Exclude_DeadSpiritPet = 0x004000,
Exclude_DeadMinion = 0x008000,
Exclude_DeadNPCMinipet = 0x010000,
Exclude_UsedCorpse = 0x020000,
Exclude_AliveAlly = 0x040000,
Exclude_AliveNeutral = 0x080000,
Exclude_AliveEnemy = 0x100000,
Exclude_AliveSpiritPet = 0x200000,
Exclude_AliveMinion = 0x400000,
Exclude_AliveNPCMinipet = 0x800000,
Exclude_BeingObserved = 0x2000000,
ZeroPriority = 0x4000000,
NPCMinipet_ZeroPriority = 0x8000000,
};
inline AgentTargetFlags operator|(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator&(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator~(AgentTargetFlags a) {
return static_cast<AgentTargetFlags>(~static_cast<uint32_t>(a));
}
inline AgentTargetFlags operator|(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator&(AgentTargetFlags a, AgentTargetFlags b) {
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
}
inline AgentTargetFlags operator~(AgentTargetFlags a) {
return static_cast<AgentTargetFlags>(~static_cast<uint32_t>(a));
}
namespace TargetFilter {
const AgentTargetFlags Enemies = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_DeadEnemy);
namespace TargetFilter {
const AgentTargetFlags Enemies = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_DeadEnemy);
const AgentTargetFlags Allies = static_cast<AgentTargetFlags>(
Include_Ally | Accept_Player | Exclude_DeadAlly);
const AgentTargetFlags Allies = static_cast<AgentTargetFlags>(
Include_Ally | Accept_Player | Exclude_DeadAlly);
const AgentTargetFlags Corpses = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_AliveEnemy | Exclude_UsedCorpse);
const AgentTargetFlags Corpses = static_cast<AgentTargetFlags>(
Include_Enemy | Exclude_AliveEnemy | Exclude_UsedCorpse);
const AgentTargetFlags Items = static_cast<AgentTargetFlags>(
Type_Item);
const AgentTargetFlags Items = static_cast<AgentTargetFlags>(
Type_Item);
const AgentTargetFlags Gadgets = static_cast<AgentTargetFlags>(
Type_Gadget);
const AgentTargetFlags Gadgets = static_cast<AgentTargetFlags>(
Type_Gadget);
const AgentTargetFlags AnyLiving = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player |
Exclude_DeadAlly | Exclude_DeadNeutral | Exclude_DeadEnemy |
Exclude_DeadSpiritPet | Exclude_DeadMinion | Exclude_DeadNPCMinipet);
const AgentTargetFlags AnyLiving = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player |
Exclude_DeadAlly | Exclude_DeadNeutral | Exclude_DeadEnemy |
Exclude_DeadSpiritPet | Exclude_DeadMinion | Exclude_DeadNPCMinipet);
const AgentTargetFlags Any = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player | Accept_HasQuest |
Type_Gadget | Type_Item);
}
const AgentTargetFlags Any = static_cast<AgentTargetFlags>(
Include_Ally | Include_Neutral | Include_Enemy |
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
Accept_Player | Accept_HasQuest |
Type_Gadget | Type_Item);
}
namespace Agents {
typedef HookCallback<const GW::AgentLiving*, const AgentEffect*> AgentEffectCallback;
namespace Agents {
typedef HookCallback<const GW::AgentLiving*, const AgentEffect*> AgentEffectCallback;
// === Dialogs ===
// Same as pressing button (id) while talking to an NPC.
GWCA_API bool SendDialog(uint32_t dialog_id);
// Dialogs -- same as pressing button (id) while talking to an NPC.
GWCA_API bool SendDialog(uint32_t dialog_id);
// === Agent Array ===
// === Agent Array ===
// Get Agent ID of currently observed agent
GWCA_API uint32_t GetObservingId();
// Get Agent ID of client's logged in player
GWCA_API uint32_t GetControlledCharacterId();
// Get Agent ID of current target
GWCA_API uint32_t GetTargetId();
// Get Agent ID of current evaluated target - either auto target or actual target
GWCA_API uint32_t GetEvaluatedTargetId();
// Get Agent ID of currently observed agent
GWCA_API uint32_t GetObservingId();
// Get Agent ID of client's logged in player
GWCA_API uint32_t GetControlledCharacterId();
// Get Agent ID of current target
GWCA_API uint32_t GetTargetId();
// Get Agent ID of current evaluated target - either auto target or actual target
GWCA_API uint32_t GetEvaluatedTargetId();
// Returns Agentstruct Array of agents in compass range, full structs.
GWCA_API AgentArray* GetAgentArray();
// Returns Agentstruct Array of agents in compass range, full structs.
GWCA_API AgentArray* GetAgentArray();
// Get AgentArray Structures of player or target.
GWCA_API Agent *GetAgentByID(uint32_t id);
// Get agent that we're currently observing
inline Agent *GetObservingAgent() { return GetAgentByID(GetObservingId()); }
// Get Agent of current target
inline Agent *GetTarget() { return GetAgentByID(GetTargetId()); }
// Get Agent of current evaluated target - either auto target or actual target
inline Agent *GetEvaluatedTarget() { return GetAgentByID(GetEvaluatedTargetId()); }
// Get AgentArray Structures of player or target.
GWCA_API Agent* GetAgentByID(uint32_t id);
// Get agent that we're currently observing
inline Agent* GetObservingAgent() { return GetAgentByID(GetObservingId()); }
// Get Agent of current target
inline Agent* GetTarget() { return GetAgentByID(GetTargetId()); }
// Get Agent of current evaluated target - either auto target or actual target
inline Agent* GetEvaluatedTarget() { return GetAgentByID(GetEvaluatedTargetId()); }
GWCA_API Agent *GetPlayerByID(uint32_t player_id);
GWCA_API Agent* GetPlayerByID(uint32_t player_id);
// Get Agent of current logged in character
GWCA_API AgentLiving* GetControlledCharacter();
// Get Agent of current logged in character
GWCA_API AgentLiving* GetControlledCharacter();
GWCA_API bool GetAgentMatchesFlags(const Agent*, AgentTargetFlags flags = TargetFilter::Any);
GWCA_API bool GetAgentMatchesFlags(const Agent*, AgentTargetFlags flags = TargetFilter::Any);
GWCA_API bool RefreshAgentNameTag(const Agent*);
// Whether we're observing someone else
GWCA_API bool IsObserving();
@@ -167,8 +172,7 @@ namespace GW {
GWCA_API uint32_t GetAmountOfPlayersInInstance();
// Returns array of alternate agent array that can be read beyond compass range.
// Holds limited info and needs to be explored more.
// Alternate agent array readable beyond compass range; holds limited info and needs more exploration.
GWCA_API MapAgentArray* GetMapAgentArray();
GWCA_API uint32_t CountAllegianceInRange(GW::Constants::Allegiance allegiance, float sqr_range);
@@ -187,8 +191,7 @@ namespace GW {
GWCA_API bool ChangeTarget(const Agent *agent);
GWCA_API bool ChangeTarget(AgentID agent_id);
// Move to specified coordinates.
// Note: will do nothing if coordinate is outside the map!
// Move to specified coordinates. Does nothing if the coordinate is outside the map.
GWCA_API bool Move(float x, float y, uint32_t zplane = 0);
GWCA_API bool Move(GamePos pos);
@@ -205,18 +208,9 @@ namespace GW {
// Might be bugged, avoid to use.
GWCA_API wchar_t* GetAgentEncName(const Agent* agent);
GWCA_API wchar_t* GetAgentEncName(uint32_t agent_id);
GWCA_API void RegisterAgentEffectCallback(
HookEntry* entry,
const AgentEffectCallback& callback,
int altitude = -0x8000);
GWCA_API void RemoveFrameUIMessageCallback(
HookEntry* entry);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API bool SendDialog(uint32_t dialog_id);
+1 -4
View File
@@ -25,8 +25,7 @@ namespace GW {
GWCA_API float GetFieldOfView();
GWCA_API float GetYaw();
// ==== Camera patches ====
// Unlock camera & return the new state of it
// Camera patches -- unlock the camera and return its new state.
GWCA_API bool UnlockCam(bool flag);
GWCA_API bool GetCameraUnlock();
@@ -34,9 +33,7 @@ namespace GW {
GWCA_API bool SetFog(bool flag);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetCamera();
GWCA_API bool SetMaxDist(float dist);
+4 -2
View File
@@ -12,15 +12,19 @@ namespace GW {
namespace Chat {
typedef uint32_t Color;
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4200)
#endif
struct ChatMessage {
uint32_t channel;
uint32_t unk1;
FILETIME timestamp;
wchar_t message[0];
};
#ifdef _MSC_VER
#pragma warning(pop)
#endif
const size_t CHAT_LOG_LENGTH = 0x200;
struct ChatBuffer {
@@ -105,9 +109,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetChatLog();
GWCA_API bool AddToChatLog(wchar_t* message, uint32_t channel);
+1 -5
View File
@@ -20,9 +20,7 @@ namespace GW {
extern Module EffectModule;
namespace Effects {
// Returns current level of intoxication, 0-5 scale.
// If > 0 then skills that benefit from drunk will work.
// Important: requires SetupPostProcessingEffectHook() above.
// Current intoxication level, 0-5; above 0 makes drunk-benefit skills work. Requires SetupPostProcessingEffectHook().
GWCA_API uint32_t GetAlcoholLevel();
// Have fun with this ;))))))))))
@@ -59,9 +57,7 @@ namespace GW {
GWCA_API Buff *GetPlayerBuffBySkillId(Constants::SkillID skill_id);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API uint32_t GetAlcoholLevel();
GWCA_API void GetDrunkAf(float intensity, uint32_t tint);
+55 -4
View File
@@ -9,15 +9,65 @@ namespace GW {
extern Module EventMgrModule;
namespace EventMgr {
// Index into the game's m_handler array (EvtDispatch.cpp asserts id < EVENTS, which is 0x2f).
// Names below are read off Gw.exe: EvtOs.cpp translates each window message into one of these,
// EvtQueue.cpp drains them into the dispatcher, and every id the game itself registers a handler
// for was matched to that handler. Ids absent here are dispatched by the game but not yet identified.
enum class EventID {
kRecvPing = 0x8,
kSendFriendState = 0x26,
kRecvFriendState = 0x2c,
kAppActivated = 0x0, // WM_ACTIVATEAPP; packet is one dword, non-zero when activated
kFrameTick = 0x1, // once per frame from the pump, packet is the elapsed milliseconds
kRenderFinished = 0x2, // once per frame after the render, packet is one dword
kCharTyped = 0x3, // WM_CHAR: {character, repeat, modifier keys}
kCursorVisibility = 0x5, // packet is one dword, non-zero to show the cursor
kShutdown = 0x6, // posting this latches the event queue closed, so nothing dispatches after it
kScreenSizeChanged = 0x7, // {new width, new height, old width, old height}, raised inside the queue drain
kDriverError = 0x8, // the game's own handler logs "Driver internal error encountered." and quits
kRecvPing = kDriverError, // legacy GWCA name for 0x8; the game does not use this id for pings
kDpiChanged = 0xa, // WM_DPICHANGED: {x dpi, y dpi}
kGamepadAttached = 0xb, // the four below come from the same controller poll in EvtOs.cpp
kGamepadState = 0xc, // one dword
kGamepadAxis = 0xd, // four dwords
kGamepadButtonDown = 0xe, // {button, state}
kGamepadButtonUp = 0xf, // {button, state}
kCharTypedInjected = 0x13, // same shape as kCharTyped, from the game's own WM_APP+3 injection
kKeyDown = 0x1a, // {key, repeat, modifier keys}; the engine records the key as held here
kKeyUp = 0x1b, // same shape; the engine clears the held bit here
kFocusLost = 0x1c, // WM_KILLFOCUS, no packet
kMouseMoveRelative = 0x1d, // {button, 0, x, y, button mask, modifier keys} while the mouse is captured
kMouseButtonDown = 0x1e, // same shape as kMouseMoveRelative
kMouseLeave = 0x1f, // WM_MOUSELEAVE, no packet
kMouseMove = 0x20, // same shape as kMouseMoveRelative, only sent when the position changed
kMouseButtonUp = 0x21, // same shape; also synthesised for every held button when capture is lost
kMouseWheel = 0x22, // same shape, with the wheel delta in place of the button
kWindowMoved = 0x23, // WM_MOVE: {x, y}
// A shared channel: eight of the game's own subsystems register here, so filter on FriendEventType.
kFriend = 0x24,
kService = 0x25, // same packet shape as kFriend; FrApi forwards it as UI message 0x50
kServiceAlt = 0x26, // same again, forwarded as UI message 0x51
kFocusGained = 0x27, // WM_SETFOCUS, no packet
kWindowResized = 0x28, // WM_SIZE: {width, height}
kTouchDown = 0x2c, // the three touch ids carry {index, 0, x, y, touch mask}
kTouchMove = 0x2d,
kTouchUp = 0x2e, // also synthesised for every held touch when the sequence is cancelled
// AddHandler rejects 0x2a outright, and the dispatcher drops anything >= 0x2f, so 0x2f and
// 0x30 are engine-internal: 0x30 is what makes the queue raise kScreenSizeChanged.
kReserved = 0x2a,
kNone = 0xffff
};
// First dword of a kFriend packet, which is what the game's own handler switches over.
// These are the message ids FriendApi.cpp sends to the login frame, so they share that numbering.
enum class FriendEventType {
kStatusChanged = 0x26,
kLocationChanged = 0x28,
kFriendAddedOrRemoved = 0x2c
};
// (status, event_id, packet_bytes, packet_bytes_len). Altitude <= 0 runs before the game handles it, > 0 after.
typedef HookCallback<EventID, void*,uint32_t> EventCallback;
GWCA_API void RegisterEventCallback(
HookEntry *entry,
EventID event_id,
@@ -28,6 +78,7 @@ namespace GW {
HookEntry *entry,
EventID event_id = EventID::kNone);
GWCA_API bool SendEventMessage(EventID, void*);
// Took a length all along; the two-parameter declaration this replaces never had a definition to link against.
GWCA_API bool SendEventMessage(EventID event_id, void* packet_bytes, uint32_t packet_bytes_len);
};
}
+3 -2
View File
@@ -27,14 +27,15 @@ namespace GW {
HookEntry* entry);
GWCA_API bool AddFriend(const wchar_t* name, const wchar_t* alias = nullptr);
GWCA_API bool AddIgnore(const wchar_t* name, const wchar_t* alias = nullptr);
// Fire-and-forget, like AddFriend: true means the request was queued (it
// may need the Friends window to finish opening across a few game ticks
// first), not that the row is gone by the time this returns.
GWCA_API bool RemoveFriend(Friend* _friend);
GWCA_API bool ChangeFriendType(Friend* _friend, FriendType type);
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetFriendList();
GWCA_API void* GetFriendByIndex(uint32_t index);
+1 -4
View File
@@ -16,8 +16,7 @@ namespace GW {
GWCA_API void ClearCalls();
// force_enqueue = false; will check if we're already in the game thread, and run immediately if we are.
// force_enqueue = true; enqueue the function for the next loop regardless
// force_enqueue = false runs immediately if already on the game thread; true always enqueues for the next loop.
GWCA_API void Enqueue(std::function<void ()> f, bool force_enqueue = false);
typedef HookCallback<> GameThreadCallback;
@@ -31,9 +30,7 @@ namespace GW {
GWCA_API bool IsInGameThread();
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
typedef void(__cdecl* GW_GameThreadCallback)();
typedef void(__cdecl* GW_GameThreadHookCallback)(GW::HookStatus* status);
-2
View File
@@ -21,9 +21,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetPlayerGuild();
GWCA_API void* GetCurrentGH();
-2
View File
@@ -97,9 +97,7 @@ namespace GW {
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
// Lookups
GWCA_API void* GetSalvageSessionInfo();
+3 -4
View File
@@ -125,6 +125,8 @@ namespace GW {
// Get the district number you are in.
GWCA_API int GetDistrict();
GWCA_API size_t GetDistrictCount();
// Get time, in ms, since the instance you are residing in has been created.
GWCA_API uint32_t GetInstanceTime();
@@ -140,8 +142,7 @@ namespace GW {
GWCA_API Constants::Language LanguageFromDistrict(const GW::Constants::District _district);
// Returns array of icons (res shrines, quarries, traders, etc) on mission map.
// Look at MissionMapIcon struct for more info.
// Array of icons (res shrines, quarries, traders...) on the mission map; see MissionMapIcon.
GWCA_API MissionMapIconArray* GetMissionMapIconArray();
// Returns pointer of collision trapezoid array.
@@ -164,9 +165,7 @@ namespace GW {
GWCA_API bool CancelEnterChallenge();
};
}
// ============================================================
// C Interop API
// ============================================================
extern "C" {
GWCA_API void* GetMissionMapContext();
GWCA_API void* GetWorldMapContext();

Some files were not shown because too many files have changed in this diff Show More