mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-09-16 13:29:30 +00:00
Compare commits
31
Commits
v0.9.10.13
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4720c5e1 | ||
|
|
b57d2ad5d3 | ||
|
|
5faeae64d3 | ||
|
|
fdcf238d68 | ||
|
|
d9f7d05672 | ||
|
|
c61ee2ecd7 | ||
|
|
82512f9461 | ||
|
|
2dc13b7184 | ||
|
|
da691e3310 | ||
|
|
5e020f51d9 | ||
|
|
1f63e59b8a | ||
|
|
dcb9c29505 | ||
|
|
db83e5a954 | ||
|
|
c7c5506ee0 | ||
|
|
2988b6b38d | ||
|
|
debef9a36f | ||
|
|
839bc57b48 | ||
|
|
175c16bd00 | ||
|
|
ad356f431e | ||
|
|
ebe368e61e | ||
|
|
d800a630b5 | ||
|
|
239f91a65f | ||
|
|
aff7c24b6a | ||
|
|
5bf0af61ba | ||
|
|
03ed31e85b | ||
|
|
b21a4482bd | ||
|
|
2ad8ae79ce | ||
|
|
a4be747dd5 | ||
|
|
a6cd5d6f37 | ||
|
|
0e80fb7d86 | ||
|
|
0793f181bb |
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
+634
-219
File diff suppressed because it is too large
Load Diff
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
|
||||
public readonly uint Size;
|
||||
public readonly uint Param;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
|
||||
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
|
||||
/// rather than a real array.
|
||||
/// </summary>
|
||||
private const uint MaxPlausibleCapacity = 0x10000;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
|
||||
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
|
||||
/// these fields transiently inconsistent while it (re)allocates an array, and a
|
||||
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
|
||||
/// <c>size <= capacity</c>.
|
||||
/// </summary>
|
||||
public bool IsValid =>
|
||||
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
|
||||
this.Size <= this.Capacity &&
|
||||
this.Capacity <= MaxPlausibleCapacity;
|
||||
|
||||
/// <summary>
|
||||
/// Number of elements that can safely be read. Zero whenever the array is not backed by
|
||||
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
|
||||
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
|
||||
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
|
||||
/// </summary>
|
||||
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
|
||||
|
||||
public T this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
|
||||
return this.Buffer[index];
|
||||
}
|
||||
}
|
||||
|
||||
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
|
||||
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
|
||||
|
||||
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ public readonly unsafe struct WrappedPointer<T>(T* pointer)
|
||||
|
||||
public bool IsNull => this.Pointer is null;
|
||||
|
||||
/// <summary>
|
||||
/// True when the pointer is non-null and plausibly dereferenceable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
|
||||
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
|
||||
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
|
||||
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
|
||||
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
|
||||
/// </remarks>
|
||||
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
|
||||
|
||||
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
|
||||
|
||||
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
|
||||
|
||||
@@ -35,21 +35,28 @@ public sealed class CharacterSelectService(
|
||||
{
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null)
|
||||
gameContext.Pointer->World is null ||
|
||||
gameContext.Pointer->Character is null)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var availableCharsContext = this.gameContextService.GetAvailableChars();
|
||||
if (availableCharsContext.IsNull)
|
||||
if (!availableCharsContext.IsValid ||
|
||||
!availableCharsContext.Pointer->IsValid)
|
||||
{
|
||||
scopedLogger.LogError("Available characters context is not initialized");
|
||||
scopedLogger.LogError(
|
||||
"Available characters context is not initialized (array@0x{Array:X8} buffer=0x{Buffer:X8} capacity={Capacity} size={Size})",
|
||||
(nuint)availableCharsContext.Pointer,
|
||||
availableCharsContext.IsValid ? (nuint)availableCharsContext.Pointer->Buffer : 0,
|
||||
availableCharsContext.IsValid ? availableCharsContext.Pointer->Capacity : 0,
|
||||
availableCharsContext.IsValid ? availableCharsContext.Pointer->Size : 0);
|
||||
return default;
|
||||
}
|
||||
|
||||
var currentUuid = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString();
|
||||
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Size);
|
||||
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Count);
|
||||
foreach (var charContext in *availableCharsContext.Pointer)
|
||||
{
|
||||
var name = new string(charContext.Name);
|
||||
@@ -196,7 +203,9 @@ public sealed class CharacterSelectService(
|
||||
|
||||
// Find target character index
|
||||
uint targetIdx = 0xFFFF;
|
||||
var charCount = ctx.Pointer->Chars.Size;
|
||||
// Count (not Size) so a not-yet-allocated roster yields zero iterations
|
||||
// instead of indexing through a null buffer.
|
||||
var charCount = ctx.Pointer->Chars.Count;
|
||||
|
||||
for (uint i = 0; i < charCount; i++)
|
||||
{
|
||||
@@ -406,7 +415,8 @@ public sealed class CharacterSelectService(
|
||||
}
|
||||
|
||||
var availableCharsContext = this.gameContextService.GetAvailableChars();
|
||||
if (availableCharsContext.IsNull)
|
||||
if (!availableCharsContext.IsValid ||
|
||||
!availableCharsContext.Pointer->IsValid)
|
||||
{
|
||||
scopedLogger.LogError("Available characters context is not initialized");
|
||||
return default;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Daybreak.Shared.Attributes;
|
||||
|
||||
namespace Daybreak.Configuration.Options;
|
||||
|
||||
[OptionsName(Name = "Downloads")]
|
||||
internal sealed class DownloadOptions
|
||||
{
|
||||
[JsonPropertyName(nameof(HeaderTimeout))]
|
||||
[OptionName(
|
||||
Name = "Header Timeout",
|
||||
Description = "Amount of seconds Daybreak will wait for a download to respond before giving up on the attempt"
|
||||
)]
|
||||
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
|
||||
public double HeaderTimeout { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName(nameof(ChunkTimeout))]
|
||||
[OptionName(
|
||||
Name = "Chunk Timeout",
|
||||
Description = "Amount of seconds Daybreak will wait for the next chunk of a download before giving up on the attempt"
|
||||
)]
|
||||
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
|
||||
public double ChunkTimeout { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName(nameof(Retries))]
|
||||
[OptionName(
|
||||
Name = "Retries",
|
||||
Description = "Amount of times Daybreak will retry a failed download before reporting it as failed"
|
||||
)]
|
||||
[OptionRange<int>(MinValue = 0, MaxValue = 10)]
|
||||
public int Retries { get; set; } = 3;
|
||||
|
||||
[JsonPropertyName(nameof(RetryDelay))]
|
||||
[OptionName(
|
||||
Name = "Retry Delay",
|
||||
Description = "Amount of seconds Daybreak will wait before retrying a failed download"
|
||||
)]
|
||||
[OptionRange<double>(MinValue = 0, MaxValue = 60)]
|
||||
public double RetryDelay { get; set; } = 2;
|
||||
}
|
||||
@@ -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 <value></c>,
|
||||
/// <c>-password <value></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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,65 +8,76 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Executables</h3>
|
||||
</div>
|
||||
<div class="executables-list">
|
||||
@foreach (var executable in this.ViewModel.Executables)
|
||||
{
|
||||
<div class="executable-row">
|
||||
<div class="executable-label">
|
||||
<FluentLabel>Path</FluentLabel>
|
||||
</div>
|
||||
<div class="executable-field">
|
||||
<FluentTextField Value="@executable.Path"
|
||||
ReadOnly="true"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Placeholder="Path">
|
||||
<div class="executable-field-controls"
|
||||
slot="end">
|
||||
@if (executable.Validating)
|
||||
{
|
||||
<div class="loading-circle">
|
||||
<SpinnerWidget IsLoading="true" />
|
||||
@if (executable.UpdateProgress is string progress)
|
||||
{
|
||||
<div class="update-progress-text">@progress</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (executable.Valid)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is valid" />
|
||||
}
|
||||
else if (executable.NeedsUpdate)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
|
||||
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
|
||||
Color="Color.Neutral"
|
||||
Title="Update executable" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is invalid" />
|
||||
}
|
||||
@if (this.ViewModel.Executables.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No executables configured"
|
||||
ActionText="Add executable"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
|
||||
Disabled="@(!this.ViewModel.AddButtonEnabled)"
|
||||
OnAction="@this.ViewModel.CreateExecutable" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="executables-list">
|
||||
@foreach (var executable in this.ViewModel.Executables)
|
||||
{
|
||||
<div class="executable-row">
|
||||
<div class="executable-label">
|
||||
<FluentLabel>Path</FluentLabel>
|
||||
</div>
|
||||
<div class="executable-field">
|
||||
<FluentTextField Value="@executable.Path"
|
||||
ReadOnly="true"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Placeholder="Path">
|
||||
<div class="executable-field-controls"
|
||||
slot="end">
|
||||
@if (executable.Validating)
|
||||
{
|
||||
<div class="loading-circle">
|
||||
<SpinnerWidget IsLoading="true" />
|
||||
@if (executable.UpdateProgress is string progress)
|
||||
{
|
||||
<div class="update-progress-text">@progress</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (executable.Valid)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is valid" />
|
||||
}
|
||||
else if (executable.NeedsUpdate)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
|
||||
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
|
||||
Color="Color.Neutral"
|
||||
Title="Update executable" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is invalid" />
|
||||
}
|
||||
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Modify path" />
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Remove executable" />
|
||||
</div>
|
||||
</FluentTextField>
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Modify path" />
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Remove executable" />
|
||||
</div>
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
|
||||
{
|
||||
this.Executables.Remove(executable);
|
||||
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
|
||||
this.RefreshView();
|
||||
}
|
||||
|
||||
public async Task CreateExecutable()
|
||||
|
||||
@@ -9,151 +9,161 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Launch Configurations</h3>
|
||||
</div>
|
||||
<div class="launch-configs-list">
|
||||
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
|
||||
{
|
||||
var config = this.ViewModel.LaunchConfigurations[i];
|
||||
var index = i;
|
||||
<div class="launch-configs-row" id="launch-config-@config.Identifier">
|
||||
<div class="launch-config-reorder">
|
||||
<div class="launch-config-reorder-label">
|
||||
<FluentLabel>Reorder</FluentLabel>
|
||||
@if (this.ViewModel.LaunchConfigurations.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No launch configurations yet"
|
||||
ActionText="Add launch configuration"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
|
||||
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="launch-configs-list">
|
||||
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
|
||||
{
|
||||
var config = this.ViewModel.LaunchConfigurations[i];
|
||||
var index = i;
|
||||
<div class="launch-configs-row" id="launch-config-@config.Identifier">
|
||||
<div class="launch-config-reorder">
|
||||
<div class="launch-config-reorder-label">
|
||||
<FluentLabel>Reorder</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-reorder-buttons">
|
||||
@if (index > 0)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move up"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
|
||||
</FluentButton>
|
||||
}
|
||||
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move down"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
|
||||
</FluentButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-reorder-buttons">
|
||||
@if (index > 0)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move up"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
|
||||
<div class="launch-config-identifier">
|
||||
<div class="launch-config-identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-identifier-field">
|
||||
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
|
||||
Placeholder="Launch config identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
|
||||
Title="Delete launch configuration" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-name">
|
||||
<div class="launch-config-name-label">
|
||||
<FluentLabel>Name</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-name-field">
|
||||
<FluentTextField Value="@config.Name"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Custom name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-color">
|
||||
<div class="launch-config-color-label">
|
||||
<FluentLabel>Accent Color</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-color-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.AvailableColors"
|
||||
SelectedOption="@(config.Color ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div class="color-option">
|
||||
@if (!string.IsNullOrWhiteSpace(context))
|
||||
{
|
||||
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
|
||||
}
|
||||
<span>@this.ViewModel.GetColorDisplayName(context)</span>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-executable">
|
||||
<div class="launch-config-executable-label">
|
||||
<FluentLabel>Executable</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-executable-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div title="@(context)">
|
||||
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-credentials">
|
||||
<div class="launch-config-credentials-label">
|
||||
<FluentLabel>Credentials</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-credentials-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
|
||||
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-args">
|
||||
<div class="launch-config-args-label">
|
||||
<FluentLabel>Custom Arguments</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-args-field">
|
||||
<FluentTextField Value="@config.Arguments"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Launch arguments" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-steam">
|
||||
<div class="launch-config-steam-label">
|
||||
<FluentLabel>Steam support</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-steam-field">
|
||||
<FluentCheckbox Value="@config.SteamSupport"
|
||||
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods">
|
||||
<div class="launch-config-custom-mods-label">
|
||||
<FluentLabel>Custom mod loadout</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods-field">
|
||||
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
|
||||
<FluentButton Appearance="Appearance.Neutral"
|
||||
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
|
||||
Disabled="@(!config.CustomModLoadoutEnabled)"
|
||||
Title="Manage custom mods for this launch configuration">
|
||||
Manage Mods
|
||||
</FluentButton>
|
||||
}
|
||||
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move down"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
|
||||
</FluentButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-identifier">
|
||||
<div class="launch-config-identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-identifier-field">
|
||||
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
|
||||
Placeholder="Launch config identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
|
||||
Title="Delete launch configuration" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-name">
|
||||
<div class="launch-config-name-label">
|
||||
<FluentLabel>Name</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-name-field">
|
||||
<FluentTextField Value="@config.Name"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Custom name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-color">
|
||||
<div class="launch-config-color-label">
|
||||
<FluentLabel>Accent Color</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-color-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.AvailableColors"
|
||||
SelectedOption="@(config.Color ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div class="color-option">
|
||||
@if (!string.IsNullOrWhiteSpace(context))
|
||||
{
|
||||
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
|
||||
}
|
||||
<span>@this.ViewModel.GetColorDisplayName(context)</span>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-executable">
|
||||
<div class="launch-config-executable-label">
|
||||
<FluentLabel>Executable</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-executable-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div title="@(context)">
|
||||
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-credentials">
|
||||
<div class="launch-config-credentials-label">
|
||||
<FluentLabel>Credentials</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-credentials-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
|
||||
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-args">
|
||||
<div class="launch-config-args-label">
|
||||
<FluentLabel>Custom Arguments</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-args-field">
|
||||
<FluentTextField Value="@config.Arguments"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Launch arguments" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-steam">
|
||||
<div class="launch-config-steam-label">
|
||||
<FluentLabel>Steam support</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-steam-field">
|
||||
<FluentCheckbox Value="@config.SteamSupport"
|
||||
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods">
|
||||
<div class="launch-config-custom-mods-label">
|
||||
<FluentLabel>Custom mod loadout</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods-field">
|
||||
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
|
||||
<FluentButton Appearance="Appearance.Neutral"
|
||||
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
|
||||
Disabled="@(!config.CustomModLoadoutEnabled)"
|
||||
Title="Manage custom mods for this launch configuration">
|
||||
Manage Mods
|
||||
</FluentButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -178,6 +178,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
inMultiLineConstexpr = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -189,6 +190,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
inSkipBlock = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -211,6 +213,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
ParseEnumMembersFromLine(trimmed.Substring(0, closeBraceIdx), currentEnum);
|
||||
}
|
||||
|
||||
inEnum = false;
|
||||
var node = namespaceStack.Peek();
|
||||
node.Enums.Add(currentEnum);
|
||||
@@ -231,11 +234,11 @@ internal static class CppHeaderParser
|
||||
int closeBraces = CountChar(lineWithoutComments, '}');
|
||||
int prevDepth = structBraceDepth;
|
||||
structBraceDepth += openBraces - closeBraces;
|
||||
|
||||
|
||||
// Debug: Track brace changes for AgentLiving
|
||||
if (currentStruct.Name == "AgentLiving" && (openBraces > 0 || closeBraces > 0))
|
||||
{
|
||||
DebugLines.Add($"[BRACE] line {i+1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
|
||||
DebugLines.Add($"[BRACE] line {i + 1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
|
||||
}
|
||||
|
||||
// If we hit the closing brace of the struct
|
||||
@@ -252,8 +255,8 @@ internal static class CppHeaderParser
|
||||
pathParts.Add(n.Name);
|
||||
pathParts.Reverse();
|
||||
currentStruct.DebugNamespacePath = string.Join(".", pathParts);
|
||||
|
||||
DebugLines.Add($"[STRUCT-END] line {i+1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
|
||||
|
||||
DebugLines.Add($"[STRUCT-END] line {i + 1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
|
||||
node.Structs.Add(currentStruct);
|
||||
currentStruct = null;
|
||||
inStruct = false;
|
||||
@@ -278,6 +281,7 @@ internal static class CppHeaderParser
|
||||
if (field is not null)
|
||||
currentStruct.Fields.Add(field);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -295,7 +299,7 @@ internal static class CppHeaderParser
|
||||
var trailingCommentIdx = trimmed.IndexOf("//");
|
||||
if (trailingCommentIdx > 0)
|
||||
trimmedWithoutTrailingComment = trimmed.Substring(0, trailingCommentIdx).TrimEnd();
|
||||
|
||||
|
||||
if (trimmedWithoutTrailingComment.Contains("(") && (trimmedWithoutTrailingComment.Contains(")") || trimmedWithoutTrailingComment.Contains("{")))
|
||||
continue;
|
||||
|
||||
@@ -339,6 +343,7 @@ internal static class CppHeaderParser
|
||||
};
|
||||
currentStruct.Fields.Add(field);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -354,6 +359,7 @@ internal static class CppHeaderParser
|
||||
var child = current.GetOrCreateChild(part);
|
||||
namespaceStack.Push(child);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -366,13 +372,14 @@ internal static class CppHeaderParser
|
||||
freeBraceDepth--;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (namespaceStack.Count > 1)
|
||||
{
|
||||
// Debug: Track where namespace was popped
|
||||
var poppedNode = namespaceStack.Pop();
|
||||
poppedNode.DebugPopLine = i + 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -390,6 +397,7 @@ internal static class CppHeaderParser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -436,6 +444,7 @@ internal static class CppHeaderParser
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -448,6 +457,7 @@ internal static class CppHeaderParser
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -455,17 +465,18 @@ internal static class CppHeaderParser
|
||||
if (OutOfLineMethodRegex.IsMatch(trimmed))
|
||||
{
|
||||
int braces = CountChar(trimmed, '{') - CountChar(trimmed, '}');
|
||||
DebugLines.Add($"[OUT-OF-LINE] line {i+1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
|
||||
DebugLines.Add($"[OUT-OF-LINE] line {i + 1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
|
||||
if (braces > 0)
|
||||
{
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
else if (trimmed.Contains("::") && trimmed.Contains("(") && trimmed.Contains("{"))
|
||||
{
|
||||
DebugLines.Add($"[UNMATCHED-METHOD] line {i+1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
|
||||
DebugLines.Add($"[UNMATCHED-METHOD] line {i + 1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
|
||||
}
|
||||
|
||||
// ── Track function declarations with body on separate line ─
|
||||
@@ -492,6 +503,7 @@ internal static class CppHeaderParser
|
||||
inMultiLineConstexpr = true;
|
||||
multiLineBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -501,7 +513,7 @@ internal static class CppHeaderParser
|
||||
{
|
||||
var structName = structMatch.Groups[1].Value;
|
||||
var baseType = structMatch.Groups[2].Success ? structMatch.Groups[2].Value : null;
|
||||
DebugLines.Add($"[STRUCT-START] line {i+1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
|
||||
DebugLines.Add($"[STRUCT-START] line {i + 1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
|
||||
|
||||
// Skip some special cases
|
||||
if (structName.StartsWith("__") || structName == "Packet")
|
||||
@@ -512,6 +524,7 @@ internal static class CppHeaderParser
|
||||
inSkipBlock = true;
|
||||
skipBlockBraceCount = braces;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -530,7 +543,7 @@ internal static class CppHeaderParser
|
||||
if (structNobraceMatch.Success)
|
||||
{
|
||||
var structName = structNobraceMatch.Groups[1].Value;
|
||||
|
||||
|
||||
// Skip forward declarations (just struct Name;)
|
||||
if (trimmed.EndsWith(";"))
|
||||
continue;
|
||||
@@ -559,8 +572,10 @@ internal static class CppHeaderParser
|
||||
i = j;
|
||||
break;
|
||||
}
|
||||
|
||||
break; // unexpected content
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -593,9 +608,11 @@ internal static class CppHeaderParser
|
||||
{
|
||||
ParseEnumMembersFromLine(rest, enumDef);
|
||||
}
|
||||
|
||||
currentEnum = enumDef;
|
||||
inEnum = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -637,14 +654,18 @@ internal static class CppHeaderParser
|
||||
{
|
||||
ParseEnumMembersFromLine(rest, enumDef);
|
||||
}
|
||||
|
||||
currentEnum = enumDef;
|
||||
inEnum = true;
|
||||
}
|
||||
|
||||
i = j;
|
||||
break;
|
||||
}
|
||||
|
||||
break; // unexpected content
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -794,7 +815,7 @@ internal static class CppHeaderParser
|
||||
int slashSlash = line.IndexOf("//");
|
||||
if (slashSlash >= 0)
|
||||
line = line.Substring(0, slashSlash);
|
||||
|
||||
|
||||
// Remove /* */ style comments (simple - doesn't handle nested)
|
||||
int blockStart = line.IndexOf("/*");
|
||||
while (blockStart >= 0)
|
||||
@@ -806,7 +827,7 @@ internal static class CppHeaderParser
|
||||
line = line.Substring(0, blockStart);
|
||||
blockStart = line.IndexOf("/*");
|
||||
}
|
||||
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Emit
|
||||
var sb = new StringBuilder(65536);
|
||||
EmitHeader(sb, totalExports, skippedExports);
|
||||
|
||||
|
||||
// Add diagnostic comment about parsed structs
|
||||
sb.AppendLine(" // ═══════════════════════════════════════════════════");
|
||||
sb.AppendLine(" // Parsed structs diagnostic:");
|
||||
@@ -246,6 +246,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
sb.AppendLine($" // {diag}");
|
||||
}
|
||||
|
||||
sb.AppendLine(" // ═══════════════════════════════════════════════════");
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -270,31 +271,33 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
EmitConstantsNode(sb, grandchild, typeMap, namespaceClassNames, 4);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, 4);
|
||||
}
|
||||
|
||||
sb.AppendLine("}"); // Close GWCA class
|
||||
sb.AppendLine("}"); // Close Daybreak.API.Interop namespace
|
||||
|
||||
|
||||
// Emit structs and enums into GuildWars namespace for consumer code compatibility
|
||||
// Consumer code uses `using Daybreak.API.Interop.GuildWars;` to access types
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace Daybreak.API.Interop.GuildWars");
|
||||
sb.AppendLine("{");
|
||||
var emittedNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
|
||||
// Collect inline array types needed by struct fields (for non-blittable array fields)
|
||||
var inlineArrayTypes = new HashSet<(string csType, int size)>();
|
||||
CollectInlineArrayTypes(headerRoot, typeMap, inlineArrayTypes);
|
||||
|
||||
|
||||
// Emit manually-defined helper structs first (TLink, etc.)
|
||||
EmitManualHelperStructs(sb, 4, emittedNames);
|
||||
|
||||
|
||||
// Emit inline array types for non-blittable arrays (e.g., enum arrays)
|
||||
EmitInlineArrayTypes(sb, 4, inlineArrayTypes, emittedNames);
|
||||
|
||||
|
||||
// Emit enums first - they're often referenced by structs (e.g. Attribute enum vs Attribute struct)
|
||||
EmitEnumsToGuildWarsNamespace(sb, headerRoot, 4, emittedNames);
|
||||
EmitStructsToGuildWarsNamespace(sb, headerRoot, typeMap, 4, emittedNames, inlineArrayTypes);
|
||||
@@ -551,6 +554,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
else
|
||||
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{innerPad}}}");
|
||||
}
|
||||
else
|
||||
@@ -666,10 +670,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Only add structs that can actually be emitted
|
||||
if (!CanEmitStruct(structDef))
|
||||
continue;
|
||||
|
||||
|
||||
// Determine the C# name, handling collisions with namespace classes or enums
|
||||
var csName = SanitizeIdentifier(structDef.Name);
|
||||
|
||||
|
||||
// Check if name collides with a namespace class, enum, or ANOTHER struct already using this name
|
||||
if (namespaceClassNames.Contains(structDef.Name))
|
||||
{
|
||||
@@ -693,21 +697,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
else
|
||||
csName += "_";
|
||||
}
|
||||
|
||||
|
||||
usedGuildWarsNames.Add(csName);
|
||||
|
||||
|
||||
// Use a qualified key that includes the C++ path to distinguish structs with the same name
|
||||
// from different namespaces (e.g., GW::Attribute vs GW::SkillbarMgr::Attribute)
|
||||
var mapKey = structDef.Name;
|
||||
var fqCsType = "global::Daybreak.API.Interop.GuildWars." + csName;
|
||||
var simpleStructKey = "struct " + structDef.Name;
|
||||
|
||||
|
||||
if (typeMap.ContainsKey(mapKey))
|
||||
{
|
||||
// Use qualified key to distinguish this struct from enum or another struct
|
||||
// Include the csPath to make it unique (e.g., "struct GW.Attribute" vs "struct GW.SkillbarMgr.Attribute")
|
||||
mapKey = "struct " + csPath.Replace("GWCA.", "") + "." + structDef.Name;
|
||||
|
||||
|
||||
// Also add simple "struct X" key if not already taken (for field type resolution)
|
||||
// This allows MapCppFieldTypeToCs to find the struct without knowing the full path
|
||||
if (!typeMap.ContainsKey(simpleStructKey))
|
||||
@@ -715,7 +719,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
typeMap[simpleStructKey] = fqCsType;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Structs go into GuildWars namespace (flat), not nested GWCA classes
|
||||
typeMap[mapKey] = fqCsType;
|
||||
}
|
||||
@@ -745,19 +749,20 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
private static void CollectStructDiagnostics(ConstantsNode node, string path, List<string> diagnostics)
|
||||
{
|
||||
var currentPath = string.IsNullOrEmpty(path) ? node.Name : path + "." + node.Name;
|
||||
|
||||
|
||||
// Show namespace pop line info
|
||||
if (node.DebugPopLine > 0)
|
||||
{
|
||||
diagnostics.Add($"[NAMESPACE] {currentPath} popped at line {node.DebugPopLine}");
|
||||
}
|
||||
|
||||
|
||||
foreach (var structDef in node.Structs)
|
||||
{
|
||||
var (canEmit, reason) = CanEmitStructWithReason(structDef);
|
||||
var status = canEmit ? "OK" : $"SKIP: {reason}";
|
||||
diagnostics.Add($"{currentPath}.{structDef.Name}: {structDef.Fields.Count} fields [{status}]");
|
||||
}
|
||||
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
CollectStructDiagnostics(child, currentPath, diagnostics);
|
||||
@@ -772,7 +777,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Skip structs with no fields (usually forward declarations that got parsed)
|
||||
if (structDef.Fields.Count == 0)
|
||||
return (false, "no fields");
|
||||
|
||||
|
||||
// Check for mixed offset/no-offset fields (can't use Explicit layout if not all have offsets)
|
||||
bool hasAnyOffset = structDef.Fields.Any(f => f.Offset.HasValue);
|
||||
bool allHaveOffsets = structDef.Fields.All(f => f.Offset.HasValue);
|
||||
@@ -788,7 +793,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
return (false, $"template param T in field {field.Name}");
|
||||
// Skip structs with complex template containers we can't represent
|
||||
// Note: TLink<T> is handled separately in MapCppFieldTypeToCs (8-byte linked list node)
|
||||
if (cppType.Contains("TList<") ||
|
||||
if (cppType.Contains("TList<") ||
|
||||
cppType.Contains("PrioQ<") || cppType.Contains("PrioQLink<") ||
|
||||
cppType.Contains("BaseArray<"))
|
||||
return (false, $"complex template in field {field.Name}: {cppType}");
|
||||
@@ -815,9 +820,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
if (cppType.Contains("EquipmentVTable") || cppType.Contains("VTable"))
|
||||
return (false, $"vtable in field {field.Name}: {cppType}");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
|
||||
private static bool CanEmitStruct(CppStructDef structDef) => CanEmitStructWithReason(structDef).canEmit;
|
||||
|
||||
/// <summary>
|
||||
@@ -907,7 +913,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Start with the node's own name as the path base (GWCA for headerRoot)
|
||||
EmitStructsRecursive(sb, node, node.Name, typeMap, indent, emittedNames, inlineArrayTypes);
|
||||
}
|
||||
|
||||
|
||||
private static void EmitStructsRecursive(StringBuilder sb, ConstantsNode node, string currentPath, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames, HashSet<(string csType, int size)> inlineArrayTypes)
|
||||
{
|
||||
foreach (var structDef in node.Structs.OrderBy(s => s.Name, StringComparer.Ordinal))
|
||||
@@ -915,13 +921,13 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Skip structs that can't be emitted
|
||||
if (!CanEmitStruct(structDef))
|
||||
continue;
|
||||
|
||||
|
||||
// Get the C# name from typeMap (which includes collision-resolution suffixes like "Struct")
|
||||
// Try direct name first, then qualified struct key (for collision cases)
|
||||
string? fqCsName;
|
||||
var directLookup = typeMap.TryGetValue(structDef.Name, out fqCsName);
|
||||
var containsGuildWars = fqCsName?.Contains("GuildWars.") ?? false;
|
||||
|
||||
|
||||
if (!directLookup || !containsGuildWars)
|
||||
{
|
||||
// If direct lookup failed or returned an enum (not in GuildWars namespace),
|
||||
@@ -929,22 +935,22 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
var qualifiedKey = "struct " + currentPath.Replace("GWCA.", "") + "." + structDef.Name;
|
||||
typeMap.TryGetValue(qualifiedKey, out fqCsName);
|
||||
}
|
||||
|
||||
|
||||
if (fqCsName is null)
|
||||
continue;
|
||||
|
||||
|
||||
// Extract just the struct name from the fully-qualified name
|
||||
// e.g. "global::Daybreak.API.Interop.GuildWars.ItemStruct" -> "ItemStruct"
|
||||
var csName = fqCsName.Substring(fqCsName.LastIndexOf('.') + 1);
|
||||
|
||||
|
||||
// Skip duplicates (including if an enum with the same name was emitted)
|
||||
if (emittedNames.Contains(csName))
|
||||
continue;
|
||||
|
||||
|
||||
emittedNames.Add(csName);
|
||||
EmitStruct(sb, structDef, typeMap, indent, inlineArrayTypes, csName);
|
||||
}
|
||||
|
||||
|
||||
// Recurse into children, appending the child's name to the current path
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
@@ -959,7 +965,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
private static void EmitManualHelperStructs(StringBuilder sb, int indent, HashSet<string> emittedNames)
|
||||
{
|
||||
var pad = new string(' ', indent);
|
||||
|
||||
|
||||
// TLink<T> - doubly-linked list node used in Agent and other structs
|
||||
// C++ definition: struct TLink { TLink* prev_link; T* next_node; }
|
||||
// Size: 8 bytes (2 pointers on x86)
|
||||
@@ -1003,16 +1009,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
if (!CanEmitStruct(structDef))
|
||||
continue;
|
||||
|
||||
|
||||
foreach (var field in structDef.Fields)
|
||||
{
|
||||
if (field.ArraySize is null)
|
||||
continue;
|
||||
|
||||
|
||||
var size = ParseArraySize(field.ArraySize);
|
||||
if (size <= 0)
|
||||
continue;
|
||||
|
||||
|
||||
var csType = MapCppFieldTypeToCs(field.CppType, typeMap);
|
||||
if (!IsBlittableForFixed(csType))
|
||||
{
|
||||
@@ -1020,7 +1026,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
CollectInlineArrayTypes(child, typeMap, inlineArrayTypes);
|
||||
@@ -1034,16 +1040,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
private static void EmitInlineArrayTypes(StringBuilder sb, int indent, HashSet<(string csType, int size)> inlineArrayTypes, HashSet<string> emittedNames)
|
||||
{
|
||||
var pad = new string(' ', indent);
|
||||
|
||||
|
||||
foreach (var (csType, size) in inlineArrayTypes.OrderBy(x => x.csType).ThenBy(x => x.size))
|
||||
{
|
||||
// Generate a name like "AttributeArray12" or "SkillIDArray8"
|
||||
var simpleName = GetSimpleTypeName(csType);
|
||||
var arrayTypeName = $"{simpleName}Array{size}";
|
||||
|
||||
|
||||
if (!emittedNames.Add(arrayTypeName))
|
||||
continue;
|
||||
|
||||
|
||||
sb.AppendLine($"{pad}/// <summary>");
|
||||
sb.AppendLine($"{pad}/// Inline array of {size} {simpleName} elements.");
|
||||
sb.AppendLine($"{pad}/// </summary>");
|
||||
@@ -1067,10 +1073,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
var isPointer = csType.EndsWith("*");
|
||||
if (isPointer)
|
||||
csType = csType.TrimEnd('*');
|
||||
|
||||
|
||||
var lastDot = csType.LastIndexOf('.');
|
||||
var name = lastDot >= 0 ? csType.Substring(lastDot + 1) : csType;
|
||||
|
||||
|
||||
// Append Ptr for pointer types to make valid identifier
|
||||
return isPointer ? name + "Ptr" : name;
|
||||
}
|
||||
@@ -1092,30 +1098,30 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
EmitEnumsRecursive(sb, node, indent, emittedNames);
|
||||
}
|
||||
|
||||
|
||||
private static void EmitEnumsRecursive(StringBuilder sb, ConstantsNode node, int indent, HashSet<string> emittedNames)
|
||||
{
|
||||
var pad = new string(' ', indent);
|
||||
|
||||
|
||||
foreach (var enumDef in node.Enums.OrderBy(e => e.Name, StringComparer.Ordinal))
|
||||
{
|
||||
// Skip anonymous enums
|
||||
if (enumDef.Name is null)
|
||||
continue;
|
||||
|
||||
|
||||
// Skip duplicates (including if a struct with the same name was emitted)
|
||||
if (emittedNames.Contains(enumDef.Name))
|
||||
continue;
|
||||
|
||||
|
||||
emittedNames.Add(enumDef.Name);
|
||||
|
||||
|
||||
// Determine base type and map C++ types to C#
|
||||
var baseType = MapCppEnumBaseType(enumDef.UnderlyingType ?? "int");
|
||||
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{pad}public enum {SanitizeIdentifier(enumDef.Name)} : {baseType}");
|
||||
sb.AppendLine($"{pad}{{");
|
||||
|
||||
|
||||
var innerPad = new string(' ', indent + 4);
|
||||
foreach (var member in enumDef.Members)
|
||||
{
|
||||
@@ -1124,9 +1130,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
else
|
||||
sb.AppendLine($"{innerPad}{SanitizeIdentifier(member.Name)},");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{pad}}}");
|
||||
}
|
||||
|
||||
|
||||
// Recurse into children
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
@@ -1141,19 +1148,19 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
EmitTypeAliasesRecursive(sb, node, typeMap, indent, emittedNames);
|
||||
}
|
||||
|
||||
|
||||
private static void EmitTypeAliasesRecursive(StringBuilder sb, ConstantsNode node, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames)
|
||||
{
|
||||
var pad = new string(' ', indent);
|
||||
|
||||
|
||||
foreach (var alias in node.TypeAliases.OrderBy(a => a.AliasName, StringComparer.Ordinal))
|
||||
{
|
||||
// Skip duplicates
|
||||
if (emittedNames.Contains(alias.AliasName))
|
||||
continue;
|
||||
|
||||
|
||||
emittedNames.Add(alias.AliasName);
|
||||
|
||||
|
||||
var innerType = alias.TemplateArg.Replace("::", ".").Trim();
|
||||
// Handle pointer types (e.g., "Item *" -> "nint")
|
||||
if (innerType.EndsWith("*"))
|
||||
@@ -1170,10 +1177,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Unmapped struct type - use nint as fallback
|
||||
innerType = "nint";
|
||||
}
|
||||
|
||||
|
||||
sb.AppendLine($"{pad}public unsafe struct {alias.AliasName} {{ public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<{innerType}> Value; }}");
|
||||
}
|
||||
|
||||
|
||||
// Recurse into children
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
@@ -1259,6 +1266,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Fallback for unmapped types
|
||||
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
|
||||
}
|
||||
|
||||
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
|
||||
}
|
||||
|
||||
@@ -1286,7 +1294,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
var parts = inner.Split(new[] { "::" }, StringSplitOptions.RemoveEmptyEntries);
|
||||
innerStripped = parts[parts.Length - 1];
|
||||
}
|
||||
|
||||
|
||||
var csInner = MapCppFieldTypeToCs(inner, typeMap);
|
||||
// Special case: void* and char* and wchar_t* map to nint
|
||||
if (csInner is "void" or "char" or "byte")
|
||||
@@ -1301,7 +1309,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Check if original type is from Constants namespace (typically enums)
|
||||
// In this case, prefer enum mapping over struct mapping
|
||||
var isFromConstantsNamespace = cppType.Contains("Constants::");
|
||||
|
||||
|
||||
// Strip namespace qualifiers (GW::, GW::Constants::, etc.)
|
||||
if (cppType.Contains("::"))
|
||||
{
|
||||
@@ -1357,6 +1365,11 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
"HMODULE" => "nint",
|
||||
"HANDLE" => "nint",
|
||||
"HWND" => "nint",
|
||||
// EGL handles: `using EGLSurface = void*;` aliases declared inside
|
||||
// GWCA/Managers/RenderMgr.h, which the header parser doesn't resolve.
|
||||
"EGLSurface" => "nint",
|
||||
"EGLContext" => "nint",
|
||||
"EGLDisplay" => "nint",
|
||||
"uintptr_t" => "nuint",
|
||||
"intptr_t" => "nint",
|
||||
"Vec2f" => "global::System.Numerics.Vector2",
|
||||
@@ -1420,6 +1433,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
result.Append(part.Substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
@@ -1649,7 +1663,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
_ => "nint",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a struct name to its C# type, handling name collisions with enums.
|
||||
/// </summary>
|
||||
@@ -1664,7 +1678,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Unmapped struct -> nint
|
||||
return "nint";
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a pointer inner type (the pointee) for struct or enum types,
|
||||
/// handling name collisions between structs and enums.
|
||||
@@ -1680,7 +1694,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
// Fall back to direct lookup result (could be an enum) or just the name
|
||||
return direct ?? name;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a pointer type used as a template argument (e.g., Agent* in Array<Agent*>).
|
||||
/// In unsafe C#, pointer types can be used as generic type arguments for unmanaged structs.
|
||||
@@ -1689,17 +1703,17 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
{
|
||||
// arg.Name is the inner type (e.g., "Agent" for Agent*)
|
||||
var innerName = arg.Name;
|
||||
|
||||
|
||||
// Check if the inner type is mapped (prefer struct mapping for GuildWars types)
|
||||
var resolved = ResolveStructOrEnumPointerInner(innerName, typeMap);
|
||||
if (resolved != innerName)
|
||||
return resolved + "*";
|
||||
|
||||
|
||||
// Check for primitives
|
||||
var primitive = MapPrimitiveType(innerName);
|
||||
if (primitive != innerName)
|
||||
return primitive + "*";
|
||||
|
||||
|
||||
// Special cases
|
||||
return innerName switch
|
||||
{
|
||||
@@ -1707,7 +1721,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
_ => "nint", // unmapped pointer -> nint
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps C/C++ primitive type names to C# primitive types.
|
||||
/// </summary>
|
||||
@@ -1890,6 +1904,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
child = new ConstantsNode(childName);
|
||||
this.Children[childName] = child;
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -1943,4 +1958,4 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
public string SourceType { get; set; } = ""; // e.g., "Array"
|
||||
public string? TemplateArg { get; set; } // e.g., "Buff"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
|
||||
{
|
||||
pos++; // skip Z - this terminates the function pointer, not the outer param list
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace Daybreak.Shared.Models.Guildwars;
|
||||
/// Definition of a Guild Wars skill. The static skill collections (per-campaign
|
||||
/// lists, <see cref="AllSkills"/>, individual <c>public static readonly Skill X</c>
|
||||
/// fields) are generated by <c>Tools/SkillUpdater</c> into <c>Skill.g.cs</c> from
|
||||
/// the official wiki. Re-run the tool with
|
||||
/// <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
|
||||
/// the GWToolbox API, with names and icons from the official wiki. Re-run the
|
||||
/// tool with <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
|
||||
/// </summary>
|
||||
public sealed partial class Skill : IIconUrlEntity
|
||||
{
|
||||
|
||||
+2244
-2308
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule Daybreak.wiki updated: 62452af26d...936d25cf80
+23
-20
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <GWCA/GameContainers/GamePos.h>
|
||||
#include <GWCA/GameContainers/List.h>
|
||||
#include <GWCA/GameContainers/Array.h>
|
||||
#include <GWCA/GameContainers/ObjectPool.h>
|
||||
@@ -68,8 +69,7 @@ namespace GW {
|
||||
};
|
||||
static_assert(sizeof(MapStaticData) == 0xA0, "struct MapStaticData has incorrect size");
|
||||
|
||||
// Those are planes that are blocked and can be unblocked at runtime. e.g., the gates in foundry
|
||||
// Those aren't in the dat file, but sent from the server
|
||||
// Planes blocked but unblockable at runtime (e.g. the Foundry gates); sent by the server, not in the dat file.
|
||||
typedef BaseArray<uint32_t> BlockedPlaneArray;
|
||||
static_assert(sizeof(BlockedPlaneArray) == 0xC, "struct BlockedPlaneArray has incorrect size");
|
||||
|
||||
@@ -123,9 +123,7 @@ namespace GW {
|
||||
};
|
||||
static_assert(sizeof(PathContext) == 0x94, "struct PathContext has incorrect size");
|
||||
|
||||
// The game can optionally load a DLL to do the path finding.
|
||||
// The DLL is named "PathEngine.dll", but not clear if it's a 3rd party or just their name
|
||||
// for development.
|
||||
// The game can optionally load "PathEngine.dll" for path finding; unclear if third party or their own dev name.
|
||||
struct PathEngineContext {
|
||||
/* +h0000 */ void **vtable;
|
||||
/* +h0004 */ uint32_t h0004;
|
||||
@@ -136,6 +134,102 @@ namespace GW {
|
||||
};
|
||||
static_assert(sizeof(PathEngineContext) == 0x18, "struct PathEngineContext has incorrect size");
|
||||
|
||||
// Point lights baked into the map, parsed from its "LITE" chunk by Engine\Map\MapLight.cpp.
|
||||
// Torches, braziers and campfires are these, not props with a glow texture.
|
||||
struct MapLightDescriptor {
|
||||
/* +h0000 */ Vec3f position;
|
||||
/* +h000C */ uint8_t red;
|
||||
/* +h000D */ uint8_t green;
|
||||
/* +h000E */ uint8_t blue;
|
||||
/* +h000F */ uint8_t h000F;
|
||||
/* +h0010 */ float intensity; // colour bytes are premultiplied by intensity / 255 when the light is built
|
||||
/* +h0014 */ float inner_range;
|
||||
/* +h0018 */ float outer_range;
|
||||
};
|
||||
static_assert(sizeof(MapLightDescriptor) == 0x1C, "struct MapLightDescriptor has incorrect size");
|
||||
|
||||
// Field layout taken from MapLightApplyDescriptor, which hands each field straight to a GrLight setter:
|
||||
// GrLightSetColour(light, &desc->red, desc->intensity), GrLightSetPosition(light, &desc->position, 0),
|
||||
// GrLightSetRange(light, desc->inner_range, desc->outer_range)
|
||||
struct MapLightContext {
|
||||
/* +h0000 */ Array<MapLightDescriptor> descriptors;
|
||||
/* +h0010 */ Array<uint32_t> lights; // HGrLight handles, parallel to descriptors (same count)
|
||||
};
|
||||
static_assert(sizeof(MapLightContext) == 0x20, "struct MapLightContext has incorrect size");
|
||||
|
||||
// Only the fields below are confirmed; the real object is at least 0x1A0 bytes, so no size assert.
|
||||
// Only present when MapContext::flags bit 1 is set.
|
||||
struct MapWaterContext {
|
||||
/* +h0000 */ uint32_t h0000[0x2b];
|
||||
/* +h00AC */ uint32_t scene_program; // the single handle water contributes to the scene program list
|
||||
/* +h00B0 */ uint32_t h00B0[0xc];
|
||||
/* +h00E0 */ uint32_t shader_programs[2]; // two of the four per-map shader programs, chosen by quality
|
||||
/* +h00E8 */ uint32_t h00E8;
|
||||
/* +h00EC */ float plane_z; // world height of the water plane
|
||||
/* +h00F0 */ uint32_t h00F0[0x2a];
|
||||
/* +h0198 */ uint32_t shader_programs2[2]; // the other two quality variants
|
||||
};
|
||||
|
||||
// === terrain textures / baked terrain shadows ===
|
||||
//
|
||||
// GW bakes the map's static terrain shadows into the terrain textures, and streams them in
|
||||
// per tile. The data lives in two layers, both reachable from the terrain texture context:
|
||||
// 1. a per-texel bitmask, entropy coded per tile and decoded on demand into a scratch
|
||||
// buffer (soft edges, detail) - TrnTexShadowDecompressTile;
|
||||
// 2. a per-block "this whole block is in shadow" bitmask, held in the map's own data and
|
||||
// tested first - a set bit fills the block flat with the shadow colour without ever
|
||||
// consulting layer 1 - TrnTexComposeTileLighting.
|
||||
// Clearing only layer 1 leaves the solid interiors of large shadows behind, which is what
|
||||
// makes layer 2 worth documenting.
|
||||
|
||||
// 32x32 blocks, one bit each, MSB first, 4 bytes per row; 0x80 bytes in total.
|
||||
// Quality 0 walks it as two 16-row halves (the second at +0x40), every other quality walks
|
||||
// all 32 rows in one pass - both reach exactly 0x80 bytes, so that is the whole mask.
|
||||
typedef uint8_t TerrainBlockShadowMask[0x80];
|
||||
|
||||
// One per terrain tile, indexed by `shadow_grid_width * tile_y + tile_x`.
|
||||
struct TerrainShadowRecord {
|
||||
/* +h0000 */ void* compressed_stream; // layer 1, entropy coded, decoded per tile
|
||||
/* +h0004 */ void* compressed_stream_end;
|
||||
/* +h0008 */ TerrainBlockShadowMask* block_mask; // layer 2, the map's own data - not scratch
|
||||
};
|
||||
static_assert(sizeof(TerrainShadowRecord) == 0xC, "struct TerrainShadowRecord has incorrect size");
|
||||
|
||||
struct TerrainTexTile {
|
||||
/* +h0000 */ uint32_t h0000[3];
|
||||
/* +h000C */ uint32_t tile_x;
|
||||
/* +h0010 */ uint32_t tile_y;
|
||||
/* +h0014 */ uint32_t state; // set to 2 once the tile's shadow layer has been decoded
|
||||
/* +h0018 */ uint32_t h0018; // zeroed alongside the state above
|
||||
};
|
||||
|
||||
// A single streamed terrain texture. Only the two fields below are confirmed, so no size assert.
|
||||
struct TerrainTexture {
|
||||
/* +h0000 */ uint32_t h0000[0x27];
|
||||
/* +h009C */ uint32_t stream_flags; // bit 0x800: decompress + upload still pending
|
||||
/* +h00A0 */ uint32_t h00A0[0x37];
|
||||
/* +h017C */ TerrainTexTile* tile; // tagged pointer: null, or bit 0 set, means no tile
|
||||
};
|
||||
|
||||
// The map-wide terrain texture context (`TrnTex`). Partially mapped, so no size assert.
|
||||
struct TerrainTexContext {
|
||||
/* +h0000 */ uint32_t h0000[0x2a];
|
||||
// The bound tested by the game itself before indexing, so a tile outside the grid is
|
||||
// simply "no baked shadow" rather than a read off the end.
|
||||
/* +h00A8 */ BaseArray<TerrainShadowRecord> shadow_records;
|
||||
// Row stride for the index above. The three fields at +0xA8/+0xB0/+0xB4 read equally well
|
||||
// as an Array<TerrainShadowRecord> whose m_param happens to be the width; naming it is
|
||||
// the more useful of two identical layouts.
|
||||
/* +h00B4 */ uint32_t shadow_grid_width;
|
||||
/* +h00B8 */ uint32_t h00B8[0x10b];
|
||||
// Scratch, shared by every tile this context streams: layer 1 is decoded here and
|
||||
// uploaded, then the buffer is reused for the next tile. 0x110 rows of 0x22 bytes =
|
||||
// 272x272 bits, one per terrain texel. A SET bit is shadow, a clear bit is lit.
|
||||
/* +h04E4 */ uint8_t decoded_shadow_tile[0x110][0x22];
|
||||
};
|
||||
// Self-check on the padding above; the real object is at least this big, not exactly.
|
||||
static_assert(sizeof(TerrainTexContext) == 0x2904, "struct TerrainTexContext has incorrect layout");
|
||||
|
||||
struct MapContext {
|
||||
/* +h0000 */ uint32_t map_type; // less than 4
|
||||
/* +h0004 */ Vec2f start_pos;
|
||||
@@ -149,8 +243,8 @@ namespace GW {
|
||||
/* +h0078 */ PathEngineContext* path_engine;
|
||||
/* +h007C */ PropsContext* props;
|
||||
/* +h0080 */ uint32_t h0080;
|
||||
/* +h0084 */ void* terrain;
|
||||
/* +h0088 */ uint32_t h0088;
|
||||
/* +h0084 */ void* terrain; // relationship to TerrainTexContext is unconfirmed
|
||||
/* +h0088 */ void* collision; // "Collision" map chunk (Engine\Map\Collision\CollApi.cpp)
|
||||
/* +h008C */ GW::Constants::MapID map_id;
|
||||
/* +h0090 */ uint32_t h0090;
|
||||
/* +h0094 */ uint32_t h0094;
|
||||
@@ -161,37 +255,31 @@ namespace GW {
|
||||
/* +h00A8 */ uint32_t h00A8;
|
||||
/* +h00AC */ uint32_t h00AC;
|
||||
/* +h00B0 */ uint32_t h00B0;
|
||||
/* +h00B4 */ uint32_t h00B4;
|
||||
/* +h00B8 */ uint32_t h00B8;
|
||||
/* +h00BC */ uint32_t h00BC;
|
||||
/* +h00C0 */ uint32_t h00C0;
|
||||
/* +h00C4 */ uint32_t h00C4;
|
||||
/* +h00C8 */ uint32_t h00C8;
|
||||
/* +h00CC */ uint32_t h00CC;
|
||||
/* +h00D0 */ uint32_t h00D0;
|
||||
/* +h00D4 */ uint32_t h00D4;
|
||||
/* +h00B4 */ Vec3f view_eye; // cached by GmWorldUpdateView
|
||||
/* +h00C0 */ Vec3f view_target;
|
||||
/* +h00CC */ Vec3f view_up;
|
||||
/* +h00D8 */ uint32_t h00D8;
|
||||
/* +h00DC */ uint32_t h00DC;
|
||||
/* +h00DC */ uint32_t view_flags; // bit 0: view has been updated this frame
|
||||
/* +h00E0 */ uint32_t h00E0;
|
||||
/* +h00E4 */ uint32_t h00E4;
|
||||
/* +h00E8 */ uint32_t h00E8;
|
||||
/* +h00EC */ uint32_t h00EC;
|
||||
/* +h00EC */ MapLightContext* lights; // "Light" map chunk
|
||||
/* +h00F0 */ uint32_t h00F0;
|
||||
/* +h00F4 */ uint32_t h00F4;
|
||||
/* +h00F8 */ uint32_t h00F8;
|
||||
/* +h00FC */ uint32_t h00FC;
|
||||
/* +h0100 */ uint32_t h0100;
|
||||
/* +h0104 */ uint32_t h0104;
|
||||
/* +h0108 */ uint32_t h0108;
|
||||
/* +h0108 */ uint32_t flags; // bit 1: map has water
|
||||
/* +h010C */ uint32_t h010C;
|
||||
/* +h0110 */ uint32_t h0110;
|
||||
/* +h0114 */ uint32_t h0114;
|
||||
/* +h0118 */ uint32_t h0118;
|
||||
/* +h011C */ uint32_t h011C;
|
||||
/* +h0120 */ uint32_t h0120;
|
||||
/* +h0120 */ void* shore; // "Shore" map chunk, only loaded when flags bit 1 is set
|
||||
/* +h0124 */ uint32_t h0124;
|
||||
/* +h0128 */ uint32_t h0128;
|
||||
/* +h012C */ uint32_t h012C;
|
||||
/* +h012C */ MapWaterContext* water;
|
||||
/* +h0130 */ void* zones;
|
||||
/* +h0134 */ uint32_t h0134;
|
||||
};
|
||||
|
||||
@@ -38,9 +38,7 @@ namespace GW {
|
||||
/* +h0148 */ GW::Array<LoginCharacter> chars;
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API void* GetAvailableChars();
|
||||
}
|
||||
|
||||
+1
-2
@@ -29,8 +29,7 @@ namespace GW {
|
||||
/* +h0010 */ TradePlayer player;
|
||||
/* +h0024 */ TradePlayer partner;
|
||||
|
||||
// bool GetPartnerAccepted();
|
||||
// bool GetPartnerOfferSent();
|
||||
// bool GetPartnerAccepted(); bool GetPartnerOfferSent();
|
||||
|
||||
bool GetIsTradeOffered() const { return (flags & TRADE_OFFER_SEND) != 0; }
|
||||
bool GetIsTradeInitiated() const { return (flags & TRADE_INITIATED) != 0; }
|
||||
|
||||
+3
-3
@@ -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
@@ -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
|
||||
|
||||
}
|
||||
@@ -15,8 +15,4 @@ namespace GW {
|
||||
static_assert(sizeof(ObjectPool) == 0xC, "struct ObjectPool has incorrect size");
|
||||
}
|
||||
|
||||
// The functions used to allocate looks like:
|
||||
// void *__thiscall ObjectPool::Alloc(ObjectPool *pool, int32_t typesize, const char *typename);
|
||||
// So, the typename & typesize are passed at every allocs, similar to `MemAlloc`.
|
||||
// It's worth nothing that the minimum typesize is 4, though Guild Wars doesn't assert it, it just
|
||||
// ends up crashing. This is due to the freeList pointer lists that are 4 bytes each.
|
||||
// ObjectPool::Alloc(pool, typesize, typename) passes type info per alloc; minimum typesize is 4 (freeList pointers) or it crashes.
|
||||
|
||||
+32
-14
@@ -6,6 +6,7 @@
|
||||
#include <GWCA/Constants/Constants.h>
|
||||
|
||||
#include <GWCA/GameEntities/Item.h>
|
||||
#include <GWCA/Utilities/Export.h>
|
||||
|
||||
namespace GW {
|
||||
typedef uint32_t AgentID;
|
||||
@@ -90,19 +91,12 @@ namespace GW {
|
||||
/* +h0109 */ uint8_t offhand_item_type; // Offhand item type for stance/animation
|
||||
/* +h010A */ uint16_t offhand_item_id; // Offhand item id for stance/animation
|
||||
|
||||
inline uint32_t GetType() {
|
||||
return vtable->GetType(this);
|
||||
}
|
||||
inline bool RedrawEquipmentSlot(uint32_t slot) {
|
||||
if (!(slot < _countof(items) && items[slot].model_file_id))
|
||||
return false;
|
||||
return vtable->EquipItem(this, 0, slot), true;
|
||||
}
|
||||
inline bool UndrawEquipmentSlot(uint32_t slot) {
|
||||
if (!(slot < _countof(items) && items[slot].model_file_id))
|
||||
return false;
|
||||
return vtable->RemoveItem(this, 0, slot), true;
|
||||
}
|
||||
// These three call through the game's vtable (see Source/Agent.cpp) -- kept
|
||||
// out of line so the wasm table-adoption dance stays an implementation
|
||||
// detail rather than something every caller's translation unit compiles.
|
||||
GWCA_API uint32_t GetType();
|
||||
GWCA_API bool RedrawEquipmentSlot(uint32_t slot);
|
||||
GWCA_API bool UndrawEquipmentSlot(uint32_t slot);
|
||||
};
|
||||
static_assert(sizeof(NPCEquipment) == 0x10C);
|
||||
|
||||
@@ -145,6 +139,30 @@ namespace GW {
|
||||
struct AgentGadget;
|
||||
struct AgentLiving;
|
||||
|
||||
// Bits in GW::Agent::name_properties, which AvAgent.cpp recomputes name tag visibility from.
|
||||
enum NameTagFlags : uint32_t {
|
||||
// In the mouse pick list; cleared wholesale whenever that list is rebuilt.
|
||||
NameTagFlags_Picked = 0x8,
|
||||
// Moused-over agent: underlines the tag, glows the model, draws the selection decal.
|
||||
NameTagFlags_Highlighted = 0x10,
|
||||
// Within name tag draw distance (1500 gwinches from the camera).
|
||||
NameTagFlags_InRange = 0x20,
|
||||
// The evaluated target - manual target, else auto target.
|
||||
NameTagFlags_EvaluatedTarget = 0x80,
|
||||
// The manual target, while a different auto target exists.
|
||||
NameTagFlags_ManualTarget = 0x100,
|
||||
// Name tags globally suppressed (cutscenes, /hideui); refcounted by the client.
|
||||
NameTagFlags_Suppressed = 0x200,
|
||||
// Agent::type passes the persistent filter from the Guild Wars name tag options.
|
||||
NameTagFlags_PassesFilter = 0x400,
|
||||
// Dropped item reserved for another player, so it never gets a distance-based tag.
|
||||
NameTagFlags_NotOwnedByPlayer = 0x800,
|
||||
// Agent::type passes the transient filter, bound to the "show item names" key.
|
||||
NameTagFlags_PassesTransientFilter = 0x1000,
|
||||
// Name tag disabled for this agent regardless of any filter.
|
||||
NameTagFlags_Disabled = 0x20000
|
||||
};
|
||||
|
||||
struct Agent {
|
||||
/* +h0000 */ uint32_t* vtable;
|
||||
/* +h0004 */ uint32_t h0004;
|
||||
@@ -165,7 +183,7 @@ namespace GW {
|
||||
/* +h004C */ float rotation_angle; // Rotation in radians from East (-pi to pi)
|
||||
/* +h0050 */ float rotation_cos; // cosine of rotation
|
||||
/* +h0054 */ float rotation_sin; // sine of rotation
|
||||
/* +h0058 */ uint32_t name_properties; // Bitmap basically telling what the agent is
|
||||
/* +h0058 */ NameTagFlags name_properties; // Bitmap basically telling what the agent is
|
||||
/* +h005C */ uint32_t ground;
|
||||
/* +h0060 */ uint32_t h0060;
|
||||
/* +h0064 */ Vec3f terrain_normal;
|
||||
|
||||
+1
-2
@@ -65,8 +65,7 @@ namespace GW {
|
||||
float GetYaw() const { return yaw; }
|
||||
float GetPitch() const { return pitch; }
|
||||
|
||||
/// \brief This is not the FoV that GW uses to render
|
||||
/// see GW::Render::GetFieldOfView()
|
||||
// rief Not the FoV GW renders with -- see GW::Render::GetFieldOfView()
|
||||
float GetFieldOfView() const { return field_of_view; }
|
||||
|
||||
bool IsCameraUnlocked() const { return camera_mode == 3; }
|
||||
|
||||
+5
-3
@@ -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
@@ -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
@@ -24,8 +24,7 @@ namespace GW {
|
||||
/* +h0014 */ GW::Constants::Profession primary;
|
||||
/* +h0018 */ GW::Constants::Profession secondary;
|
||||
/* +h001C */ uint8_t default_level;
|
||||
// +h001D uint8_t padding;
|
||||
// +h001E uint16_t padding;
|
||||
// +h001D uint8_t padding; +h001E uint16_t padding;
|
||||
/* +h0020 */ wchar_t *name_enc;
|
||||
/* +h0024 */ uint32_t *model_files;
|
||||
/* +h0028 */ uint32_t files_count; // length of ModelFile
|
||||
|
||||
+125
-120
@@ -3,136 +3,141 @@
|
||||
#include <GWCA/GameContainers/Array.h>
|
||||
|
||||
namespace GW {
|
||||
struct PathingTrapezoid { // total: 0x30/48
|
||||
/* +h0000 */ uint32_t id;
|
||||
union {
|
||||
/* +h0004 */ PathingTrapezoid* adjacent[4];
|
||||
struct {
|
||||
/* +h0004 */ PathingTrapezoid* top_left;
|
||||
/* +h0008 */ PathingTrapezoid* top_right;
|
||||
/* +h000C */ PathingTrapezoid* bottom_left;
|
||||
/* +h0010 */ PathingTrapezoid* bottom_right;
|
||||
};
|
||||
};
|
||||
/* +h0014 */ uint16_t portal_left;
|
||||
/* +h0016 */ uint16_t portal_right;
|
||||
/* +h0018 */ float XTL;
|
||||
/* +h001C */ float XTR;
|
||||
/* +h0020 */ float YT;
|
||||
/* +h0024 */ float XBL;
|
||||
/* +h0028 */ float XBR;
|
||||
/* +h002C */ float YB;
|
||||
};
|
||||
static_assert(sizeof(PathingTrapezoid) == 48, "struct PathingTrapezoid has incorrect size");
|
||||
struct PathingTrapezoid { // total: 0x30/48
|
||||
/* +h0000 */ uint32_t id;
|
||||
union {
|
||||
/* +h0004 */ PathingTrapezoid* adjacent[4];
|
||||
struct {
|
||||
/* +h0004 */ PathingTrapezoid* top_left;
|
||||
/* +h0008 */ PathingTrapezoid* top_right;
|
||||
/* +h000C */ PathingTrapezoid* bottom_left;
|
||||
/* +h0010 */ PathingTrapezoid* bottom_right;
|
||||
};
|
||||
};
|
||||
/* +h0014 */ uint16_t portal_left;
|
||||
/* +h0016 */ uint16_t portal_right;
|
||||
/* +h0018 */ float XTL;
|
||||
/* +h001C */ float XTR;
|
||||
/* +h0020 */ float YT;
|
||||
/* +h0024 */ float XBL;
|
||||
/* +h0028 */ float XBR;
|
||||
/* +h002C */ float YB;
|
||||
};
|
||||
static_assert(sizeof(PathingTrapezoid) == 48, "struct PathingTrapezoid has incorrect size");
|
||||
|
||||
struct Node {
|
||||
/* +h0000 */ uint32_t type; //XNode = 0, YNode = 1, SinkNode = 2
|
||||
/* +h0004 */ uint32_t id;
|
||||
};
|
||||
struct Node {
|
||||
/* +h0000 */ uint32_t type; //XNode = 0, YNode = 1, SinkNode = 2
|
||||
/* +h0004 */ uint32_t id;
|
||||
};
|
||||
|
||||
struct XNode : Node { // type = 0
|
||||
/* +h0008 */ Vec2f pos;
|
||||
/* +h0010 */ Vec2f dir;
|
||||
/* +h0018 */ Node *left;
|
||||
/* +h001C */ Node *right;
|
||||
};
|
||||
static_assert(sizeof(XNode) == 32, "struct XNode has incorrect size");
|
||||
struct XNode : Node { // type = 0
|
||||
/* +h0008 */ Vec2f pos;
|
||||
/* +h0010 */ Vec2f dir;
|
||||
/* +h0018 */ Node* left;
|
||||
/* +h001C */ Node* right;
|
||||
};
|
||||
static_assert(sizeof(XNode) == 32, "struct XNode has incorrect size");
|
||||
|
||||
struct YNode : Node { // type = 1
|
||||
/* +h0008 */ Vec2f pos;
|
||||
/* +h0010 */ Node *above;
|
||||
/* +h0014 */ Node *below;
|
||||
};
|
||||
static_assert(sizeof(YNode) == 24, "struct YNode has incorrect size");
|
||||
struct YNode : Node { // type = 1
|
||||
/* +h0008 */ Vec2f pos;
|
||||
/* +h0010 */ Node* above;
|
||||
/* +h0014 */ Node* below;
|
||||
};
|
||||
static_assert(sizeof(YNode) == 24, "struct YNode has incorrect size");
|
||||
|
||||
struct SinkNode : Node { // type = 2
|
||||
/* +h0008 */ PathingTrapezoid *trapezoid;
|
||||
};
|
||||
static_assert(sizeof(SinkNode) == 12, "struct SinkNode has incorrect size");
|
||||
struct SinkNode : Node { // type = 2
|
||||
/* +h0008 */ PathingTrapezoid* trapezoid;
|
||||
};
|
||||
static_assert(sizeof(SinkNode) == 12, "struct SinkNode has incorrect size");
|
||||
|
||||
struct Portal { // total: 0x14/20
|
||||
/* +h0000 */ uint16_t portal_plane;
|
||||
/* +h0002 */ uint16_t neighbor_plane;
|
||||
/* +h0004 */ uint32_t flags; // 0x4 => "Not used for path finding"
|
||||
/* +h0008 */ Portal *pair;
|
||||
/* +h000C */ uint32_t count;
|
||||
/* +h0010 */ PathingTrapezoid **trapezoids;
|
||||
};
|
||||
static_assert(sizeof(Portal) == 20, "struct Portal has incorrect size");
|
||||
struct Portal { // total: 0x14/20
|
||||
/* +h0000 */ uint16_t portal_plane;
|
||||
/* +h0002 */ uint16_t neighbor_plane;
|
||||
/* +h0004 */ uint32_t flags; // 0x4 => "Not used for path finding"
|
||||
/* +h0008 */ Portal* pair;
|
||||
/* +h000C */ uint32_t count;
|
||||
/* +h0010 */ PathingTrapezoid** trapezoids;
|
||||
};
|
||||
static_assert(sizeof(Portal) == 20, "struct Portal has incorrect size");
|
||||
|
||||
struct PathingMap { // total: 0x54/84
|
||||
/* +h0000 */ uint32_t zplane; // ground plane = UINT_MAX, rest 0 based index
|
||||
/* +h0004 */ uint32_t h0004;
|
||||
/* +h0008 */ void *allocatedBuffer; // All following data are stored in a buffer allocated with a single MemAlloc. This is the pointer.
|
||||
/* +h000C */ uint32_t h0010_count; // count of number h0010
|
||||
/* +h0010 */ uint32_t *h0010; // elements of this array are 8 bytes
|
||||
/* +h0014 */ uint32_t trapezoid_count;
|
||||
/* +h0018 */ PathingTrapezoid* trapezoids;
|
||||
/* +h001C */ uint32_t sink_node_count;
|
||||
/* +h0020 */ SinkNode *sink_nodes;
|
||||
/* +h0024 */ uint32_t x_node_count;
|
||||
/* +h0028 */ XNode *x_nodes;
|
||||
/* +h002C */ uint32_t y_node_count;
|
||||
/* +h0030 */ YNode *y_nodes;
|
||||
/* +h0034 */ uint32_t portal_trapezoids_count;
|
||||
/* +h0038 */ PathingTrapezoid **portal_trapezoids;
|
||||
/* +h003C */ uint32_t portal_count;
|
||||
/* +h0040 */ Portal *portals;
|
||||
/* +h0044 */ Node *root_node;
|
||||
/* +h0048 */ BaseArray<Vec2f> dat_vectors; // this is an array of vectors read from the dat file. When reading the xnodes or ynodes from the dat file, an index will be used to get the pos.
|
||||
};
|
||||
static_assert(sizeof(PathingMap) == 84, "struct PathingMap has incorrect size");
|
||||
struct PathingMap { // total: 0x54/84
|
||||
/* +h0000 */ uint32_t zplane; // ground plane = UINT_MAX, rest 0 based index
|
||||
/* +h0004 */ uint32_t h0004;
|
||||
/* +h0008 */ void* allocatedBuffer; // All following data are stored in a buffer allocated with a single MemAlloc. This is the pointer.
|
||||
/* +h000C */ uint32_t h0010_count; // count of number h0010
|
||||
/* +h0010 */ uint32_t* h0010; // elements of this array are 8 bytes
|
||||
/* +h0014 */ uint32_t trapezoid_count;
|
||||
/* +h0018 */ PathingTrapezoid* trapezoids;
|
||||
/* +h001C */ uint32_t sink_node_count;
|
||||
/* +h0020 */ SinkNode* sink_nodes;
|
||||
/* +h0024 */ uint32_t x_node_count;
|
||||
/* +h0028 */ XNode* x_nodes;
|
||||
/* +h002C */ uint32_t y_node_count;
|
||||
/* +h0030 */ YNode* y_nodes;
|
||||
/* +h0034 */ uint32_t portal_trapezoids_count;
|
||||
/* +h0038 */ PathingTrapezoid** portal_trapezoids;
|
||||
/* +h003C */ uint32_t portal_count;
|
||||
/* +h0040 */ Portal* portals;
|
||||
/* +h0044 */ Node* root_node;
|
||||
/* +h0048 */ BaseArray<Vec2f> dat_vectors; // this is an array of vectors read from the dat file. When reading the xnodes or ynodes from the dat file, an index will be used to get the pos.
|
||||
};
|
||||
static_assert(sizeof(PathingMap) == 84, "struct PathingMap has incorrect size");
|
||||
|
||||
struct PropByType {
|
||||
uint32_t object_id;
|
||||
uint32_t prop_index;
|
||||
};
|
||||
struct PropByType {
|
||||
uint32_t object_id;
|
||||
uint32_t prop_index;
|
||||
};
|
||||
|
||||
struct PropModelInfo {
|
||||
/* +h0000 */ uint32_t h0000;
|
||||
/* +h0004 */ uint32_t h0004;
|
||||
/* +h0008 */ uint32_t h0008;
|
||||
/* +h000C */ uint32_t h000C;
|
||||
/* +h0010 */ uint32_t h0010;
|
||||
/* +h0014 */ uint32_t h0014;
|
||||
};
|
||||
static_assert(sizeof(PropModelInfo) == 0x18, "struct PropModelInfo has incorrect size");
|
||||
// Per-model collision bounds, shared by every prop using that model. Offsets confirmed against
|
||||
// the client's prop ray-pick, which builds a cylinder as
|
||||
// radius = MapProp::scale * bounding_radius
|
||||
// z = MapProp::position.z - bounds_z_offsets[n] * MapProp::scale
|
||||
struct PropModelInfo {
|
||||
/* +h0000 */ uint32_t h0000;
|
||||
/* +h0004 */ const wchar_t* model_file_name; // hash-encoded; FileHashToFileId() turns it into a file id
|
||||
/* +h0008 */ float bounding_radius; // model space; scale by MapProp::scale for world units
|
||||
/* +h000C */ float bounds_z_offsets[2];
|
||||
/* +h0014 */ uint32_t h0014;
|
||||
};
|
||||
static_assert(sizeof(PropModelInfo) == 0x18, "struct PropModelInfo has incorrect size");
|
||||
|
||||
struct RecObject {
|
||||
/* +h0000 */ void* vtable;
|
||||
/* +h0004 */ uint32_t ref_count;
|
||||
/* +h0008 */ uint32_t accessKey; // This is used by the game to make sure the data from the DAT matches the data in game
|
||||
/* +h000c */ uint32_t standalone;
|
||||
/* +h0010 */ uint32_t file_id;
|
||||
/* +h0014 */ uint32_t stream_id;
|
||||
/* +h0018 */ uint32_t flags;
|
||||
/* +h001c */ uint32_t opened;
|
||||
/* +h0020 */ uint32_t ref_count2;
|
||||
};
|
||||
static_assert(sizeof(RecObject) == 0x24, "struct RecObject has incorrect size");
|
||||
struct RecObject {
|
||||
/* +h0000 */ void* vtable;
|
||||
/* +h0004 */ uint32_t ref_count;
|
||||
/* +h0008 */ uint32_t accessKey; // This is used by the game to make sure the data from the DAT matches the data in game
|
||||
/* +h000c */ uint32_t standalone;
|
||||
/* +h0010 */ uint32_t file_id;
|
||||
/* +h0014 */ uint32_t stream_id;
|
||||
/* +h0018 */ uint32_t flags;
|
||||
/* +h001c */ uint32_t opened;
|
||||
/* +h0020 */ uint32_t ref_count2;
|
||||
};
|
||||
static_assert(sizeof(RecObject) == 0x24, "struct RecObject has incorrect size");
|
||||
|
||||
struct MapProp { // total: 0x54/84
|
||||
/* +h0000 */ uint32_t h0000[5];
|
||||
/* +h0014 */ uint32_t uptime_seconds; // time since spawned
|
||||
/* +h0018 */ uint32_t h0018;
|
||||
/* +h001C */ uint32_t prop_index;
|
||||
/* +h0020 */ Vec3f position;
|
||||
/* +h002C */ uint32_t model_file_id;
|
||||
/* +h0030 */ uint32_t h0030[2];
|
||||
/* +h0038 */ float rotation_angle;
|
||||
/* +h003C */ float rotation_cos;
|
||||
/* +h003C */ float rotation_sin;
|
||||
/* +h0040 */ uint32_t h0034[5];
|
||||
/* +h0058 */ RecObject* interactive_model;
|
||||
/* +h005C */ uint32_t h005C[4];
|
||||
/* +h006C */ uint32_t appearance_bitmap; // Modified when animation changes
|
||||
/* +h0070 */ uint32_t animation_bits;
|
||||
/* +h0064 */ uint32_t h0064[5];
|
||||
/* +h0088 */ PropByType* prop_object_info;
|
||||
/* +h008C */ uint32_t h008C;
|
||||
};
|
||||
struct MapProp { // total: 0x90/144
|
||||
/* +h0000 */ uint32_t h0000[5];
|
||||
/* +h0014 */ uint32_t uptime_seconds; // time since spawned
|
||||
/* +h0018 */ uint32_t h0018;
|
||||
/* +h001C */ uint32_t prop_index;
|
||||
/* +h0020 */ Vec3f position;
|
||||
/* +h002C */ uint32_t model_file_id;
|
||||
/* +h0030 */ uint32_t h0030[2];
|
||||
/* +h0038 */ float rotation_angle;
|
||||
/* +h003C */ float rotation_cos;
|
||||
/* +h0040 */ float rotation_sin;
|
||||
/* +h0044 */ uint32_t h0044[3];
|
||||
/* +h0050 */ float scale;
|
||||
/* +h0054 */ PropModelInfo* model_info;
|
||||
/* +h0058 */ RecObject* interactive_model;
|
||||
/* +h005C */ uint32_t h005C[4];
|
||||
/* +h006C */ uint32_t appearance_bitmap; // Modified when animation changes
|
||||
/* +h0070 */ uint32_t animation_bits;
|
||||
/* +h0074 */ uint32_t h0074[5];
|
||||
/* +h0088 */ PropByType* prop_object_info;
|
||||
/* +h008C */ uint32_t h008C;
|
||||
};
|
||||
|
||||
static_assert(sizeof(MapProp) == 0x90, "struct MapProp has incorrect size");
|
||||
static_assert(sizeof(MapProp) == 0x90, "struct MapProp has incorrect size");
|
||||
|
||||
typedef Array<PathingMap> PathingMapArray;
|
||||
typedef Array<PathingMap> PathingMapArray;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
@@ -25,14 +25,18 @@ namespace GW {
|
||||
struct Module;
|
||||
extern Module AgentModule;
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable: 4200) // nonstandard extension: zero-sized array in struct
|
||||
#endif
|
||||
struct AgentEffect {
|
||||
uint32_t effect_type;
|
||||
uint32_t data_size; // usually 0x34, detemines length of data in bytes
|
||||
uint32_t data[];
|
||||
};
|
||||
#ifdef _MSC_VER
|
||||
#pragma warning(pop)
|
||||
#endif
|
||||
|
||||
enum class WorldActionId : uint32_t {
|
||||
InteractEnemy,
|
||||
@@ -58,106 +62,107 @@ namespace GW {
|
||||
Include_Enemy = 0x000008,
|
||||
Include_SpiritPet = 0x000010,
|
||||
Accept_ActiveState = 0x000020,
|
||||
Include_Minion = 0x000040,
|
||||
Include_NPCMinipet = 0x000080,
|
||||
Accept_Player = 0x000100,
|
||||
Type_Gadget = 0x000200,
|
||||
Type_Item = 0x000400,
|
||||
Exclude_DeadAlly = 0x000800,
|
||||
Exclude_DeadNeutral = 0x001000,
|
||||
Exclude_DeadEnemy = 0x002000,
|
||||
Exclude_DeadSpiritPet = 0x004000,
|
||||
Exclude_DeadMinion = 0x008000,
|
||||
Exclude_DeadNPCMinipet = 0x010000,
|
||||
Exclude_UsedCorpse = 0x020000,
|
||||
Exclude_AliveAlly = 0x040000,
|
||||
Exclude_AliveNeutral = 0x080000,
|
||||
Exclude_AliveEnemy = 0x100000,
|
||||
Exclude_AliveSpiritPet = 0x200000,
|
||||
Exclude_AliveMinion = 0x400000,
|
||||
Exclude_AliveNPCMinipet = 0x800000,
|
||||
Exclude_BeingObserved = 0x2000000,
|
||||
ZeroPriority = 0x4000000,
|
||||
NPCMinipet_ZeroPriority = 0x8000000,
|
||||
};
|
||||
Include_Minion = 0x000040,
|
||||
Include_NPCMinipet = 0x000080,
|
||||
Accept_Player = 0x000100,
|
||||
Type_Gadget = 0x000200,
|
||||
Type_Item = 0x000400,
|
||||
Exclude_DeadAlly = 0x000800,
|
||||
Exclude_DeadNeutral = 0x001000,
|
||||
Exclude_DeadEnemy = 0x002000,
|
||||
Exclude_DeadSpiritPet = 0x004000,
|
||||
Exclude_DeadMinion = 0x008000,
|
||||
Exclude_DeadNPCMinipet = 0x010000,
|
||||
Exclude_UsedCorpse = 0x020000,
|
||||
Exclude_AliveAlly = 0x040000,
|
||||
Exclude_AliveNeutral = 0x080000,
|
||||
Exclude_AliveEnemy = 0x100000,
|
||||
Exclude_AliveSpiritPet = 0x200000,
|
||||
Exclude_AliveMinion = 0x400000,
|
||||
Exclude_AliveNPCMinipet = 0x800000,
|
||||
Exclude_BeingObserved = 0x2000000,
|
||||
ZeroPriority = 0x4000000,
|
||||
NPCMinipet_ZeroPriority = 0x8000000,
|
||||
};
|
||||
|
||||
inline AgentTargetFlags operator|(AgentTargetFlags a, AgentTargetFlags b) {
|
||||
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
|
||||
}
|
||||
inline AgentTargetFlags operator&(AgentTargetFlags a, AgentTargetFlags b) {
|
||||
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
|
||||
}
|
||||
inline AgentTargetFlags operator~(AgentTargetFlags a) {
|
||||
return static_cast<AgentTargetFlags>(~static_cast<uint32_t>(a));
|
||||
}
|
||||
inline AgentTargetFlags operator|(AgentTargetFlags a, AgentTargetFlags b) {
|
||||
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) | static_cast<uint32_t>(b));
|
||||
}
|
||||
inline AgentTargetFlags operator&(AgentTargetFlags a, AgentTargetFlags b) {
|
||||
return static_cast<AgentTargetFlags>(static_cast<uint32_t>(a) & static_cast<uint32_t>(b));
|
||||
}
|
||||
inline AgentTargetFlags operator~(AgentTargetFlags a) {
|
||||
return static_cast<AgentTargetFlags>(~static_cast<uint32_t>(a));
|
||||
}
|
||||
|
||||
namespace TargetFilter {
|
||||
const AgentTargetFlags Enemies = static_cast<AgentTargetFlags>(
|
||||
Include_Enemy | Exclude_DeadEnemy);
|
||||
namespace TargetFilter {
|
||||
const AgentTargetFlags Enemies = static_cast<AgentTargetFlags>(
|
||||
Include_Enemy | Exclude_DeadEnemy);
|
||||
|
||||
const AgentTargetFlags Allies = static_cast<AgentTargetFlags>(
|
||||
Include_Ally | Accept_Player | Exclude_DeadAlly);
|
||||
const AgentTargetFlags Allies = static_cast<AgentTargetFlags>(
|
||||
Include_Ally | Accept_Player | Exclude_DeadAlly);
|
||||
|
||||
const AgentTargetFlags Corpses = static_cast<AgentTargetFlags>(
|
||||
Include_Enemy | Exclude_AliveEnemy | Exclude_UsedCorpse);
|
||||
const AgentTargetFlags Corpses = static_cast<AgentTargetFlags>(
|
||||
Include_Enemy | Exclude_AliveEnemy | Exclude_UsedCorpse);
|
||||
|
||||
const AgentTargetFlags Items = static_cast<AgentTargetFlags>(
|
||||
Type_Item);
|
||||
const AgentTargetFlags Items = static_cast<AgentTargetFlags>(
|
||||
Type_Item);
|
||||
|
||||
const AgentTargetFlags Gadgets = static_cast<AgentTargetFlags>(
|
||||
Type_Gadget);
|
||||
const AgentTargetFlags Gadgets = static_cast<AgentTargetFlags>(
|
||||
Type_Gadget);
|
||||
|
||||
const AgentTargetFlags AnyLiving = static_cast<AgentTargetFlags>(
|
||||
Include_Ally | Include_Neutral | Include_Enemy |
|
||||
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
|
||||
Accept_Player |
|
||||
Exclude_DeadAlly | Exclude_DeadNeutral | Exclude_DeadEnemy |
|
||||
Exclude_DeadSpiritPet | Exclude_DeadMinion | Exclude_DeadNPCMinipet);
|
||||
const AgentTargetFlags AnyLiving = static_cast<AgentTargetFlags>(
|
||||
Include_Ally | Include_Neutral | Include_Enemy |
|
||||
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
|
||||
Accept_Player |
|
||||
Exclude_DeadAlly | Exclude_DeadNeutral | Exclude_DeadEnemy |
|
||||
Exclude_DeadSpiritPet | Exclude_DeadMinion | Exclude_DeadNPCMinipet);
|
||||
|
||||
const AgentTargetFlags Any = static_cast<AgentTargetFlags>(
|
||||
Include_Ally | Include_Neutral | Include_Enemy |
|
||||
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
|
||||
Accept_Player | Accept_HasQuest |
|
||||
Type_Gadget | Type_Item);
|
||||
}
|
||||
const AgentTargetFlags Any = static_cast<AgentTargetFlags>(
|
||||
Include_Ally | Include_Neutral | Include_Enemy |
|
||||
Include_SpiritPet | Include_Minion | Include_NPCMinipet |
|
||||
Accept_Player | Accept_HasQuest |
|
||||
Type_Gadget | Type_Item);
|
||||
}
|
||||
|
||||
|
||||
namespace Agents {
|
||||
typedef HookCallback<const GW::AgentLiving*, const AgentEffect*> AgentEffectCallback;
|
||||
namespace Agents {
|
||||
typedef HookCallback<const GW::AgentLiving*, const AgentEffect*> AgentEffectCallback;
|
||||
|
||||
// === Dialogs ===
|
||||
// Same as pressing button (id) while talking to an NPC.
|
||||
GWCA_API bool SendDialog(uint32_t dialog_id);
|
||||
// Dialogs -- same as pressing button (id) while talking to an NPC.
|
||||
GWCA_API bool SendDialog(uint32_t dialog_id);
|
||||
|
||||
// === Agent Array ===
|
||||
// === Agent Array ===
|
||||
|
||||
// Get Agent ID of currently observed agent
|
||||
GWCA_API uint32_t GetObservingId();
|
||||
// Get Agent ID of client's logged in player
|
||||
GWCA_API uint32_t GetControlledCharacterId();
|
||||
// Get Agent ID of current target
|
||||
GWCA_API uint32_t GetTargetId();
|
||||
// Get Agent ID of current evaluated target - either auto target or actual target
|
||||
GWCA_API uint32_t GetEvaluatedTargetId();
|
||||
// Get Agent ID of currently observed agent
|
||||
GWCA_API uint32_t GetObservingId();
|
||||
// Get Agent ID of client's logged in player
|
||||
GWCA_API uint32_t GetControlledCharacterId();
|
||||
// Get Agent ID of current target
|
||||
GWCA_API uint32_t GetTargetId();
|
||||
// Get Agent ID of current evaluated target - either auto target or actual target
|
||||
GWCA_API uint32_t GetEvaluatedTargetId();
|
||||
|
||||
// Returns Agentstruct Array of agents in compass range, full structs.
|
||||
GWCA_API AgentArray* GetAgentArray();
|
||||
// Returns Agentstruct Array of agents in compass range, full structs.
|
||||
GWCA_API AgentArray* GetAgentArray();
|
||||
|
||||
// Get AgentArray Structures of player or target.
|
||||
GWCA_API Agent *GetAgentByID(uint32_t id);
|
||||
// Get agent that we're currently observing
|
||||
inline Agent *GetObservingAgent() { return GetAgentByID(GetObservingId()); }
|
||||
// Get Agent of current target
|
||||
inline Agent *GetTarget() { return GetAgentByID(GetTargetId()); }
|
||||
// Get Agent of current evaluated target - either auto target or actual target
|
||||
inline Agent *GetEvaluatedTarget() { return GetAgentByID(GetEvaluatedTargetId()); }
|
||||
// Get AgentArray Structures of player or target.
|
||||
GWCA_API Agent* GetAgentByID(uint32_t id);
|
||||
// Get agent that we're currently observing
|
||||
inline Agent* GetObservingAgent() { return GetAgentByID(GetObservingId()); }
|
||||
// Get Agent of current target
|
||||
inline Agent* GetTarget() { return GetAgentByID(GetTargetId()); }
|
||||
// Get Agent of current evaluated target - either auto target or actual target
|
||||
inline Agent* GetEvaluatedTarget() { return GetAgentByID(GetEvaluatedTargetId()); }
|
||||
|
||||
GWCA_API Agent *GetPlayerByID(uint32_t player_id);
|
||||
GWCA_API Agent* GetPlayerByID(uint32_t player_id);
|
||||
|
||||
// Get Agent of current logged in character
|
||||
GWCA_API AgentLiving* GetControlledCharacter();
|
||||
// Get Agent of current logged in character
|
||||
GWCA_API AgentLiving* GetControlledCharacter();
|
||||
|
||||
GWCA_API bool GetAgentMatchesFlags(const Agent*, AgentTargetFlags flags = TargetFilter::Any);
|
||||
GWCA_API bool GetAgentMatchesFlags(const Agent*, AgentTargetFlags flags = TargetFilter::Any);
|
||||
|
||||
GWCA_API bool RefreshAgentNameTag(const Agent*);
|
||||
|
||||
// Whether we're observing someone else
|
||||
GWCA_API bool IsObserving();
|
||||
@@ -167,8 +172,7 @@ namespace GW {
|
||||
|
||||
GWCA_API uint32_t GetAmountOfPlayersInInstance();
|
||||
|
||||
// Returns array of alternate agent array that can be read beyond compass range.
|
||||
// Holds limited info and needs to be explored more.
|
||||
// Alternate agent array readable beyond compass range; holds limited info and needs more exploration.
|
||||
GWCA_API MapAgentArray* GetMapAgentArray();
|
||||
GWCA_API uint32_t CountAllegianceInRange(GW::Constants::Allegiance allegiance, float sqr_range);
|
||||
|
||||
@@ -187,8 +191,7 @@ namespace GW {
|
||||
GWCA_API bool ChangeTarget(const Agent *agent);
|
||||
GWCA_API bool ChangeTarget(AgentID agent_id);
|
||||
|
||||
// Move to specified coordinates.
|
||||
// Note: will do nothing if coordinate is outside the map!
|
||||
// Move to specified coordinates. Does nothing if the coordinate is outside the map.
|
||||
GWCA_API bool Move(float x, float y, uint32_t zplane = 0);
|
||||
GWCA_API bool Move(GamePos pos);
|
||||
|
||||
@@ -205,18 +208,9 @@ namespace GW {
|
||||
// Might be bugged, avoid to use.
|
||||
GWCA_API wchar_t* GetAgentEncName(const Agent* agent);
|
||||
GWCA_API wchar_t* GetAgentEncName(uint32_t agent_id);
|
||||
|
||||
GWCA_API void RegisterAgentEffectCallback(
|
||||
HookEntry* entry,
|
||||
const AgentEffectCallback& callback,
|
||||
int altitude = -0x8000);
|
||||
GWCA_API void RemoveFrameUIMessageCallback(
|
||||
HookEntry* entry);
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API bool SendDialog(uint32_t dialog_id);
|
||||
|
||||
|
||||
+1
-4
@@ -25,8 +25,7 @@ namespace GW {
|
||||
GWCA_API float GetFieldOfView();
|
||||
GWCA_API float GetYaw();
|
||||
|
||||
// ==== Camera patches ====
|
||||
// Unlock camera & return the new state of it
|
||||
// Camera patches -- unlock the camera and return its new state.
|
||||
GWCA_API bool UnlockCam(bool flag);
|
||||
GWCA_API bool GetCameraUnlock();
|
||||
|
||||
@@ -34,9 +33,7 @@ namespace GW {
|
||||
GWCA_API bool SetFog(bool flag);
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API void* GetCamera();
|
||||
GWCA_API bool SetMaxDist(float dist);
|
||||
|
||||
+4
-2
@@ -12,15 +12,19 @@ namespace GW {
|
||||
|
||||
namespace Chat {
|
||||
typedef uint32_t Color;
|
||||
#ifdef _MSC_VER
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable: 4200)
|
||||
#endif
|
||||
struct ChatMessage {
|
||||
uint32_t channel;
|
||||
uint32_t unk1;
|
||||
FILETIME timestamp;
|
||||
wchar_t message[0];
|
||||
};
|
||||
#ifdef _MSC_VER
|
||||
#pragma warning(pop)
|
||||
#endif
|
||||
|
||||
const size_t CHAT_LOG_LENGTH = 0x200;
|
||||
struct ChatBuffer {
|
||||
@@ -105,9 +109,7 @@ namespace GW {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API void* GetChatLog();
|
||||
GWCA_API bool AddToChatLog(wchar_t* message, uint32_t channel);
|
||||
|
||||
+1
-5
@@ -20,9 +20,7 @@ namespace GW {
|
||||
extern Module EffectModule;
|
||||
|
||||
namespace Effects {
|
||||
// Returns current level of intoxication, 0-5 scale.
|
||||
// If > 0 then skills that benefit from drunk will work.
|
||||
// Important: requires SetupPostProcessingEffectHook() above.
|
||||
// Current intoxication level, 0-5; above 0 makes drunk-benefit skills work. Requires SetupPostProcessingEffectHook().
|
||||
GWCA_API uint32_t GetAlcoholLevel();
|
||||
|
||||
// Have fun with this ;))))))))))
|
||||
@@ -59,9 +57,7 @@ namespace GW {
|
||||
GWCA_API Buff *GetPlayerBuffBySkillId(Constants::SkillID skill_id);
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API uint32_t GetAlcoholLevel();
|
||||
GWCA_API void GetDrunkAf(float intensity, uint32_t tint);
|
||||
|
||||
+55
-4
@@ -9,15 +9,65 @@ namespace GW {
|
||||
extern Module EventMgrModule;
|
||||
|
||||
namespace EventMgr {
|
||||
// Index into the game's m_handler array (EvtDispatch.cpp asserts id < EVENTS, which is 0x2f).
|
||||
// Names below are read off Gw.exe: EvtOs.cpp translates each window message into one of these,
|
||||
// EvtQueue.cpp drains them into the dispatcher, and every id the game itself registers a handler
|
||||
// for was matched to that handler. Ids absent here are dispatched by the game but not yet identified.
|
||||
enum class EventID {
|
||||
kRecvPing = 0x8,
|
||||
kSendFriendState = 0x26,
|
||||
kRecvFriendState = 0x2c,
|
||||
kAppActivated = 0x0, // WM_ACTIVATEAPP; packet is one dword, non-zero when activated
|
||||
kFrameTick = 0x1, // once per frame from the pump, packet is the elapsed milliseconds
|
||||
kRenderFinished = 0x2, // once per frame after the render, packet is one dword
|
||||
kCharTyped = 0x3, // WM_CHAR: {character, repeat, modifier keys}
|
||||
kCursorVisibility = 0x5, // packet is one dword, non-zero to show the cursor
|
||||
kShutdown = 0x6, // posting this latches the event queue closed, so nothing dispatches after it
|
||||
kScreenSizeChanged = 0x7, // {new width, new height, old width, old height}, raised inside the queue drain
|
||||
kDriverError = 0x8, // the game's own handler logs "Driver internal error encountered." and quits
|
||||
kRecvPing = kDriverError, // legacy GWCA name for 0x8; the game does not use this id for pings
|
||||
kDpiChanged = 0xa, // WM_DPICHANGED: {x dpi, y dpi}
|
||||
kGamepadAttached = 0xb, // the four below come from the same controller poll in EvtOs.cpp
|
||||
kGamepadState = 0xc, // one dword
|
||||
kGamepadAxis = 0xd, // four dwords
|
||||
kGamepadButtonDown = 0xe, // {button, state}
|
||||
kGamepadButtonUp = 0xf, // {button, state}
|
||||
kCharTypedInjected = 0x13, // same shape as kCharTyped, from the game's own WM_APP+3 injection
|
||||
kKeyDown = 0x1a, // {key, repeat, modifier keys}; the engine records the key as held here
|
||||
kKeyUp = 0x1b, // same shape; the engine clears the held bit here
|
||||
kFocusLost = 0x1c, // WM_KILLFOCUS, no packet
|
||||
kMouseMoveRelative = 0x1d, // {button, 0, x, y, button mask, modifier keys} while the mouse is captured
|
||||
kMouseButtonDown = 0x1e, // same shape as kMouseMoveRelative
|
||||
kMouseLeave = 0x1f, // WM_MOUSELEAVE, no packet
|
||||
kMouseMove = 0x20, // same shape as kMouseMoveRelative, only sent when the position changed
|
||||
kMouseButtonUp = 0x21, // same shape; also synthesised for every held button when capture is lost
|
||||
kMouseWheel = 0x22, // same shape, with the wheel delta in place of the button
|
||||
kWindowMoved = 0x23, // WM_MOVE: {x, y}
|
||||
// A shared channel: eight of the game's own subsystems register here, so filter on FriendEventType.
|
||||
kFriend = 0x24,
|
||||
kService = 0x25, // same packet shape as kFriend; FrApi forwards it as UI message 0x50
|
||||
kServiceAlt = 0x26, // same again, forwarded as UI message 0x51
|
||||
kFocusGained = 0x27, // WM_SETFOCUS, no packet
|
||||
kWindowResized = 0x28, // WM_SIZE: {width, height}
|
||||
kTouchDown = 0x2c, // the three touch ids carry {index, 0, x, y, touch mask}
|
||||
kTouchMove = 0x2d,
|
||||
kTouchUp = 0x2e, // also synthesised for every held touch when the sequence is cancelled
|
||||
|
||||
// AddHandler rejects 0x2a outright, and the dispatcher drops anything >= 0x2f, so 0x2f and
|
||||
// 0x30 are engine-internal: 0x30 is what makes the queue raise kScreenSizeChanged.
|
||||
kReserved = 0x2a,
|
||||
|
||||
kNone = 0xffff
|
||||
};
|
||||
|
||||
// First dword of a kFriend packet, which is what the game's own handler switches over.
|
||||
// These are the message ids FriendApi.cpp sends to the login frame, so they share that numbering.
|
||||
enum class FriendEventType {
|
||||
kStatusChanged = 0x26,
|
||||
kLocationChanged = 0x28,
|
||||
kFriendAddedOrRemoved = 0x2c
|
||||
};
|
||||
|
||||
// (status, event_id, packet_bytes, packet_bytes_len). Altitude <= 0 runs before the game handles it, > 0 after.
|
||||
typedef HookCallback<EventID, void*,uint32_t> EventCallback;
|
||||
|
||||
GWCA_API void RegisterEventCallback(
|
||||
HookEntry *entry,
|
||||
EventID event_id,
|
||||
@@ -28,6 +78,7 @@ namespace GW {
|
||||
HookEntry *entry,
|
||||
EventID event_id = EventID::kNone);
|
||||
|
||||
GWCA_API bool SendEventMessage(EventID, void*);
|
||||
// Took a length all along; the two-parameter declaration this replaces never had a definition to link against.
|
||||
GWCA_API bool SendEventMessage(EventID event_id, void* packet_bytes, uint32_t packet_bytes_len);
|
||||
};
|
||||
}
|
||||
|
||||
+3
-2
@@ -27,14 +27,15 @@ namespace GW {
|
||||
HookEntry* entry);
|
||||
GWCA_API bool AddFriend(const wchar_t* name, const wchar_t* alias = nullptr);
|
||||
GWCA_API bool AddIgnore(const wchar_t* name, const wchar_t* alias = nullptr);
|
||||
// Fire-and-forget, like AddFriend: true means the request was queued (it
|
||||
// may need the Friends window to finish opening across a few game ticks
|
||||
// first), not that the row is gone by the time this returns.
|
||||
GWCA_API bool RemoveFriend(Friend* _friend);
|
||||
GWCA_API bool ChangeFriendType(Friend* _friend, FriendType type);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API void* GetFriendList();
|
||||
GWCA_API void* GetFriendByIndex(uint32_t index);
|
||||
|
||||
+1
-4
@@ -16,8 +16,7 @@ namespace GW {
|
||||
|
||||
GWCA_API void ClearCalls();
|
||||
|
||||
// force_enqueue = false; will check if we're already in the game thread, and run immediately if we are.
|
||||
// force_enqueue = true; enqueue the function for the next loop regardless
|
||||
// force_enqueue = false runs immediately if already on the game thread; true always enqueues for the next loop.
|
||||
GWCA_API void Enqueue(std::function<void ()> f, bool force_enqueue = false);
|
||||
|
||||
typedef HookCallback<> GameThreadCallback;
|
||||
@@ -31,9 +30,7 @@ namespace GW {
|
||||
GWCA_API bool IsInGameThread();
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
typedef void(__cdecl* GW_GameThreadCallback)();
|
||||
typedef void(__cdecl* GW_GameThreadHookCallback)(GW::HookStatus* status);
|
||||
|
||||
@@ -21,9 +21,7 @@ namespace GW {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API void* GetPlayerGuild();
|
||||
GWCA_API void* GetCurrentGH();
|
||||
|
||||
@@ -97,9 +97,7 @@ namespace GW {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
// Lookups
|
||||
GWCA_API void* GetSalvageSessionInfo();
|
||||
|
||||
+3
-4
@@ -125,6 +125,8 @@ namespace GW {
|
||||
// Get the district number you are in.
|
||||
GWCA_API int GetDistrict();
|
||||
|
||||
GWCA_API size_t GetDistrictCount();
|
||||
|
||||
// Get time, in ms, since the instance you are residing in has been created.
|
||||
GWCA_API uint32_t GetInstanceTime();
|
||||
|
||||
@@ -140,8 +142,7 @@ namespace GW {
|
||||
|
||||
GWCA_API Constants::Language LanguageFromDistrict(const GW::Constants::District _district);
|
||||
|
||||
// Returns array of icons (res shrines, quarries, traders, etc) on mission map.
|
||||
// Look at MissionMapIcon struct for more info.
|
||||
// Array of icons (res shrines, quarries, traders...) on the mission map; see MissionMapIcon.
|
||||
GWCA_API MissionMapIconArray* GetMissionMapIconArray();
|
||||
|
||||
// Returns pointer of collision trapezoid array.
|
||||
@@ -164,9 +165,7 @@ namespace GW {
|
||||
GWCA_API bool CancelEnterChallenge();
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// C Interop API
|
||||
// ============================================================
|
||||
extern "C" {
|
||||
GWCA_API void* GetMissionMapContext();
|
||||
GWCA_API void* GetWorldMapContext();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user